Flags: a decision
A feature flag is a decision evaluated against user state. Targeting is a set of conditions on that state, checked the moment the flag is asked.
At a glance
- Host
https://api.prodantix.com- Read
GET /v1/flags,GET /v1/flags/snapshot,GET /v1/flags/memberships- Write
upsertFlag,archiveFlagover GraphQL, or a change request under review- Live updates
flagChangeon the project room- Streaming
flagChangepushed to server SDKs over Socket.IO- Auth
- Project key to read, access token to write
- Real-time targeting on live user state
- Decided the moment you ask, not read from a stored list
- Rollouts and experiments measured on the same source of truth
How it works
A flag is evaluated against the user state currently on file for a distinct id, in the same order everywhere: a disabled flag is off, every prerequisite must hold, every targeting condition must match, and then the rollout percentage decides by a stable bucket of the distinct id, so a user keeps their answer across reads and SDKs. Evaluation is server-side by default, so a rule change takes effect without a client release.
Where to reach it
| Surface | Operation |
|---|---|
| REST | GET /v1/flags · GET /v1/flags/snapshot · GET /v1/flags/memberships |
| GraphQL | flags, flagHistory, flagChangeRequests, flagRollouts, promotionDiff queries · upsertFlag, archiveFlag, proposeFlagChange, approveFlagChange, rejectFlagChange, startFlagRollout, cancelFlagRollout, rollbackFlagRollout, promoteFlag, setFlagReviewRequired mutations |
| Realtime | flagChange on the project room and on the flags room server SDKs join · flagRollout, flagChangeRequest on the project room |
| MCP | prodantix.flags.list · .upsert · .archive · .history · .change_requests · .rollouts · .propose |
Example
curl "https://api.prodantix.com/v1/flags?distinct_id=u_8f3a" \
-H "Authorization: Bearer $PRODANTIX_KEY"const flags = await prodantix.flags.all({ distinctId: 'u_8f3a' });
if (flags['new-checkout']) {
// ...
}final flags = await prodantix.flags.all(distinctId: 'u_8f3a');
if (flags['new-checkout'] == true) {
// ...
}flags = prodantix.flags.all(distinct_id="u_8f3a")
if flags.get("new-checkout"):
...Evaluating locally
For latency-sensitive paths, fetch the whole ruleset once and evaluate in-process. Snapshot responses carry Cache-Control: public, max-age=30, stale-while-revalidate=300. A snapshot also names the realtime gateway in socketUrl; a server SDK subscribes there and refetches the snapshot when a flag changes, so a change reaches it in under a second instead of at the next poll.
{
"flags": [
{
"key": "new-checkout",
"enabled": true,
"rolloutPercentage": 50,
"targeting": [
{ "attribute": "plan", "op": "in", "value": ["pro", "scale"] }
]
}
],
"generatedAt": "2026-09-04T09:00:00Z",
"socketUrl": "https://ws.prodantix.com"
}Variants and exposure
A typed flag assigns each user a variation. GET /v1/flags answers with a variants map beside flags, the assigned variation key and value per flag, and the SDKs expose it as getVariant. Every per-flag read records one $feature_flag_called event carrying $feature_flag and $feature_flag_response, once per user, flag and response, which is what an experiment attributes on.
{
"flags": { "new-checkout": true },
"variants": { "new-checkout": { "key": "treatment", "value": "b" } }
}Cohort conditions
A targeting condition can test cohort membership. It names the reserved attribute $cohort, the operator in_cohort or not_in_cohort, and a cohort id. Membership is read from the cohort store at decision time, so the flag follows the cohort as it is recomputed, and a flag cannot name an archived cohort. A local evaluator reads GET /v1/flags/memberships?distinct_id=… once per distinct id, and only when a flag in the snapshot names a cohort.
{ "attribute": "$cohort", "op": "in_cohort", "value": "<cohort id>" }Prerequisites
A flag can require other flags to serve a named variation first. Prerequisites are resolved against the same snapshot, before targeting and rollout; an unsatisfied one answers false with the reason prerequisite, and the off variation for a typed flag. A flag holds at most ten prerequisites, a cycle is refused at save, and a flag cannot be archived while another depends on it.
{
"key": "new-checkout",
"enabled": true,
"rolloutPercentage": 100,
"targeting": [],
"prerequisites": [{ "flagKey": "checkout-v2", "variationKey": "true" }]
}Two recipes fall out of this. A mutual-exclusion layer is a string flag whose variations partition the audience; each experiment flag requires one variation, so no user is in two experiments of the layer at once.
{ "key": "layer", "type": "string", "variations": [
{ "key": "a", "value": "a", "weight": 34 },
{ "key": "b", "value": "b", "weight": 33 },
{ "key": "c", "value": "c", "weight": 33 }
] }
{ "key": "exp-pricing", "prerequisites": [{ "flagKey": "layer", "variationKey": "a" }] }
{ "key": "exp-onboard", "prerequisites": [{ "flagKey": "layer", "variationKey": "b" }] }
{ "key": "exp-search", "prerequisites": [{ "flagKey": "layer", "variationKey": "c" }] }A holdout is a boolean flag rolled out to a slice of the audience; every experiment flag requires it to be false, so the slice never sees any experiment and stays a clean baseline.
{ "key": "holdout", "enabled": true, "rolloutPercentage": 5, "targeting": [] }
{ "key": "exp-pricing", "prerequisites": [{ "flagKey": "holdout", "variationKey": "false" }] }
{ "key": "exp-onboard", "prerequisites": [{ "flagKey": "holdout", "variationKey": "false" }] }Rollouts and guardrails
A progressive rollout moves a flag from one variation to another on a schedule. Each step names a time and the share the target variation holds from then on; the scheduler applies the latest due step once a minute and checks the guardrail every five. A guardrail names a metric and the direction that counts as worse. When the target variation is worse with a probability of at least 95 percent, over at least minExposures exposures, the rollout restores its start weights and notifies the project. While a rollout runs the flag is locked against edits that would move the weights; cancelFlagRollout keeps the weights where they are, rollbackFlagRollout restores the start.
{
"variation": "true",
"fromVariation": "false",
"steps": [
{ "at": "2026-09-05T09:00:00Z", "percentage": 10 },
{ "at": "2026-09-06T09:00:00Z", "percentage": 50 },
{ "at": "2026-09-08T09:00:00Z", "percentage": 100 }
],
"guardrail": {
"metric": { "kind": "conversion", "event": "checkout_failed" },
"worseWhen": "higher",
"minExposures": 100
}
}mutation StartRollout($projectId: String!, $rollout: String!) {
startFlagRollout(projectId: $projectId, key: "new-checkout", rollout: $rollout) {
id
status
currentStep
nextStepAt
}
}Change requests
A project can require review. Once setFlagReviewRequired is on, a change that reaches users becomes a request instead of a write; disabling a flag, and editing its description or intent, still apply directly. proposeFlagChange carries the change as JSON, one of upsert, archive, restore or start_rollout. approveFlagChange applies it and must come from someone other than the requester; rejectFlagChange closes it. A request records the flag version it was written against, and approving it after the flag moved on fails with a conflict rather than overwriting the newer write. A flag holds one pending request at a time.
{
"kind": "upsert",
"flag": {
"key": "new-checkout",
"enabled": true,
"rolloutPercentage": 50,
"targeting": [{ "attribute": "plan", "op": "eq", "value": "pro" }]
}
}mutation RequireReview($projectId: String!) {
setFlagReviewRequired(projectId: $projectId, required: true)
}
mutation Propose($projectId: String!, $change: String!) {
proposeFlagChange(projectId: $projectId, key: "new-checkout", change: $change, comment: "Half of pro") {
id
status
baseUpdatedAt
}
}
mutation Approve($projectId: String!, $id: String!) {
approveFlagChange(projectId: $projectId, id: $id, comment: "Looks right") {
id
status
appliedAt
}
}Promotion
A flag defined in a test project is promoted to the live project of the same slug. promotionDiff lists the fields that would change, with the live value beside the one the promotion writes; promoteFlag writes them. Cohorts are matched by name across the two projects and every prerequisite must already exist in the target. enabled is never copied, so a promoted flag lands off until someone switches it on. Under review, a promotion becomes a change request like any other write.
query Diff($from: String!, $to: String!) {
promotionDiff(fromProjectId: $from, toProjectId: $to, key: "new-checkout") {
exists
diff {
field
from
to
}
}
}
mutation Promote($from: String!, $to: String!) {
promoteFlag(fromProjectId: $from, toProjectId: $to, key: "new-checkout") {
applied
changeRequestId
}
}History
Every write keeps the previous version. flagHistory lists versions newest first with who wrote each one, how (changedVia is one of user, experiment, rollout, guardrail, promotion, change_request) and which fields changed. Pass before with the last writtenAt to page further back.
query History($projectId: String!) {
flagHistory(projectId: $projectId, key: "new-checkout", limit: 20) {
writtenAt
changedByEmail
changedVia
changedFields
}
}