Skip to main content

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, archiveFlag over GraphQL, or a change request under review
Live updates
flagChange on the project room
Streaming
flagChange pushed 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

SurfaceOperation
RESTGET /v1/flags · GET /v1/flags/snapshot · GET /v1/flags/memberships
GraphQLflags, flagHistory, flagChangeRequests, flagRollouts, promotionDiff queries · upsertFlag, archiveFlag, proposeFlagChange, approveFlagChange, rejectFlagChange, startFlagRollout, cancelFlagRollout, rollbackFlagRollout, promoteFlag, setFlagReviewRequired mutations
RealtimeflagChange on the project room and on the flags room server SDKs join · flagRollout, flagChangeRequest on the project room
MCPprodantix.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"

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.

JSON
{
  "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.

JSON
{
  "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.

JSON
{ "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.

JSON
{
  "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.

JSON
{ "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.

JSON
{ "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.

JSON
{
  "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
  }
}
GraphQL
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.

JSON
{
  "kind": "upsert",
  "flag": {
    "key": "new-checkout",
    "enabled": true,
    "rolloutPercentage": 50,
    "targeting": [{ "attribute": "plan", "op": "eq", "value": "pro" }]
  }
}
GraphQL
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.

GraphQL
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.

GraphQL
query History($projectId: String!) {
  flagHistory(projectId: $projectId, key: "new-checkout", limit: 20) {
    writtenAt
    changedByEmail
    changedVia
    changedFields
  }
}