Skip to content

Flags

A flag has identity at the project level (key, type, description) and state per environment (rules, default value). The CRUD endpoints split along the same line.

The Flag object (joined view, returned by env-scope reads):

json
{
  "id": "9f7a32b5-…",
  "projectId": "1234abcd-…",
  "envId": "5678ef01-…",
  "key": "new-onboarding",
  "type": "boolean",
  "description": "Show the new onboarding flow.",
  "defaultValue": false,
  "rules": [
    { "if": { "field": "plan", "$equals": "enterprise" }, "value": true }
  ],
  "createdAt": "2026-04-12T10:00:00Z",
  "updatedAt": "2026-04-29T14:33:00Z"
}

The ETag returned on single-flag GETs and PATCHes is GREATEST(flag.updatedAt, flag_env_state.updatedAt). Pass it back as If-Match: W/"<ETag>" for optimistic concurrency on PATCH and PUT /state requests.

For scoped bearer tokens with a narrow resource pattern, list endpoints filter rows server-side to scope-matching keys (rather than returning 403); single-key reads 403 with scope_denied.


List flags

http
GET /api/v1/envs/{envId}/flags

Bearer scope: read action. Session role: viewer+.

Response: 200 → Flag[] | 404 not_found

Get a flag

http
GET /api/v1/envs/{envId}/flags/{key}

Bearer scope: read action.

Response: 200 → FlagErrors: 403 scope_denied, 404 not_found.


Create a flag

http
POST /api/v1/projects/{id}/flags
Content-Type: application/json

{
  "key": "new-onboarding",
  "type": "boolean",
  "defaultValue": false,
  "rules": [],
  "description": "Show the new onboarding flow."
}

Bearer scope: write action + resource match. Session role: editor+.

The new flag is created at the project level and seeded into every existing environment with the supplied (rules, defaultValue) in one transaction. No partial fan-out is possible.

type is one of boolean or string. defaultValue must match the type. rules defaults to [] if omitted. See Concepts → Rules and contexts for rule shape.

Response: 201 → FlagProject (project-scope identity). Errors: 400 invalid_request, 403 scope_denied / approval_not_supported, 404 not_found, 409 key_collision.

Update flag metadata

http
PATCH /api/v1/projects/{id}/flags/{key}
Content-Type: application/json
If-Match: W/"<flagETag>"

{ "description": "Show the new onboarding flow (post-A/B)." }

Bearer scope: write action. Session role: editor+.

Updates eval-irrelevant project-scope fields only (currently: description). Doesn't rematerialize rulesets, doesn't change evaluation results. If-Match matches the project-scope flag.updatedAt (not the joined ETag).

Response: 200 → FlagProjectErrors: 400 invalid_request, 403 scope_denied / approval_not_supported, 404 not_found, 412 precondition_failed.

Delete a flag

http
DELETE /api/v1/projects/{id}/flags/{key}

Bearer scope: delete action. Session role: editor+.

Cascades to every environment's state in one transaction.

Response: 204 No ContentErrors: 403 scope_denied / approval_not_supported, 404 not_found.


Replace environment state

http
PUT /api/v1/envs/{envId}/flags/{key}/state
Content-Type: application/json
If-Match: W/"<envStateETag>"

{
  "defaultValue": true,
  "rules": [
    { "if": { "field": "plan", "$equals": "free" }, "value": false }
  ]
}

Bearer scope: write action. Session role: editor+.

Full-replace of (rules, defaultValue) for this environment. Both fields are required — pass rules: [] to clear all rules. defaultValue must match the flag's declared type.

If-Match matches the env-state's flag_env_state.updatedAt, not the joined-view ETag.

Approval-gated tokens with rules-only deltas are diverted into the proposal flow: the response is 202 with a Location: /api/v1/proposals/{id} header. A mixed-dimension change (both rules and defaultValue differ from existing) returns 403 approval_not_supported.

Response: 200 → Flag (joined view) | 202 → Proposal (approval-gated divert). Errors: 400 invalid_request, 403 scope_denied / approval_not_supported, 404 not_found, 412 precondition_failed.