# Flagraft API Reference --- ## Auth -- there is no login There is no session, no JWT, no OAuth. Every request carries an API key directly in the `Authorization` header: ``` Authorization: ff_a1b2c3d4e5f6a7b8c9d0e1f2 ``` The very first key (root admin) is created once via CLI, not via any API: ```bash pnpm admin:create-root-key # prints: ff_a1b2c3d4e5f6... -- save it, shown only once ``` Everything below uses `ROOT_KEY` as a placeholder for that value. --- ## The Full Flow -- Create and Evaluate a Flag ### Step 1 -- Create a project ```bash curl -X POST http://localhost:3000/api/v1/admin/projects \ -H "Authorization: $ROOT_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "My App", "slug": "my-app", "description": "optional" }' ``` **Body:** | Field | Required | Notes | | ------------- | -------- | -------------------------- | | `name` | yes | Display name | | `slug` | yes | URL-safe unique identifier | | `description` | no | | **Response (201):** Returns the created project with `id`. Also auto-creates `development` and `production` environments behind the scenes. --- ### Step 2 -- Check your environments (auto-created) ```bash curl http://localhost:3000/api/v1/admin/projects/$PROJECT_ID/environments \ -H "Authorization: $ROOT_KEY" ``` Returns the project's environments with their `id` and `slug`. You need the `slug` for enable/disable and strategy calls. --- ### Step 3 -- Create a project admin key (optional but recommended) The root key is a master key. For day-to-day ops, create a project-scoped admin key: ```bash curl -X POST http://localhost:3000/api/v1/admin/projects/$PROJECT_ID/keys \ -H "Authorization: $ROOT_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "admin", "description": "dev team key" }' ``` **Body:** | Field | Required | Notes | | --------------- | ------------------- | ----------------------- | | `type` | yes | `"admin"` or `"client"` | | `environmentId` | only for `"client"` | UUID of the environment | | `description` | no | | **Response (201):** Contains `key` (plaintext, shown once), `keyPrefix`, `id`. --- ### Step 4 -- Create a feature flag ```bash curl -X POST http://localhost:3000/api/v1/admin/projects/$PROJECT_ID/flags \ -H "Authorization: $ROOT_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "New Checkout Flow", "key": "new-checkout-flow" }' ``` **Body:** | Field | Required | Notes | | ------------- | -------- | --------------------------------------------------------- | | `name` | yes | Display name | | `key` | yes | Stable identifier used in code -- cannot be changed later | | `description` | no | | **Response (201):** Returns the flag. Also auto-creates `flag_environments` rows for all environments, all `enabled: false`. --- ### Step 5 -- Enable the flag in an environment ```bash curl -X POST \ "http://localhost:3000/api/v1/admin/projects/$PROJECT_ID/flags/new-checkout-flow/environments/production/enable" \ -H "Authorization: $ROOT_KEY" ``` To disable: ```bash curl -X POST \ "http://localhost:3000/api/v1/admin/projects/$PROJECT_ID/flags/new-checkout-flow/environments/production/disable" \ -H "Authorization: $ROOT_KEY" ``` No body needed. Uses the flag `key` and environment `slug` in the URL. --- ### Step 6 -- Create a client key for your app ```bash curl -X POST http://localhost:3000/api/v1/admin/projects/$PROJECT_ID/keys \ -H "Authorization: $ROOT_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "client", "environmentId": "$PRODUCTION_ENV_ID", "description": "production app" }' ``` Client keys are scoped to a specific environment. The key itself carries that scope -- the client never passes project/environment IDs in requests. --- ### Step 7 -- Evaluate flags (client API) Get all flags at once: ```bash curl "http://localhost:3000/api/v1/client/features?userId=usr_123" \ -H "Authorization: $CLIENT_KEY" ``` ```json { "features": [ { "name": "new-checkout-flow", "enabled": true }, { "name": "dark-mode", "enabled": false } ] } ``` This response carries an `ETag`. Send it back as `If-None-Match` and an unchanged result costs a `304` with no body instead of the full list: ```bash curl -i "http://localhost:3000/api/v1/client/features?userId=usr_123" \ -H "Authorization: $CLIENT_KEY" \ -H 'If-None-Match: "kP3nQ8..."' # HTTP/1.1 304 Not Modified ``` The tag covers the flag state and the query context together, so it changes as soon as either does. The official SDK does this automatically. Get one flag with reason: ```bash curl "http://localhost:3000/api/v1/client/features/new-checkout-flow?userId=usr_123" \ -H "Authorization: $CLIENT_KEY" ``` ```json { "name": "new-checkout-flow", "enabled": true, "reason": "default" } ``` Every query param becomes evaluation context. `userId`, `companyId`, `plan`, `tenant` -- whatever your app knows about the caller. Only keys registered as context fields are considered; unknown keys are ignored. `reason` is one of: - `strategy-match` -- a targeting strategy matched the context - `default` -- the flag is enabled with no strategies (on for everyone), or enabled but no strategy matched (off) - `disabled` -- the flag is off in this environment --- ### Bonus -- Target specific callers with a strategy First register the context field you want to target on: ```bash curl -X POST http://localhost:3000/api/v1/admin/projects/$PROJECT_ID/context-fields \ -H "Authorization: $ROOT_KEY" \ -H "Content-Type: application/json" \ -d '{ "key": "tenant", "type": "string" }' ``` Then set the flag's strategies for an environment. `PUT` replaces the whole ordered list. Each strategy is a set of constraints that AND together; multiple strategies OR together; a matched strategy serves the flag on. ```bash curl -X PUT \ "http://localhost:3000/api/v1/admin/projects/$PROJECT_ID/flags/new-checkout-flow/environments/production/strategies" \ -H "Authorization: $ROOT_KEY" \ -H "Content-Type: application/json" \ -d '{ "strategies": [ { "constraints": [ { "fieldKey": "tenant", "operator": "in", "values": ["phyg"] } ] } ] }' ``` Now `?tenant=phyg` returns `enabled: true, reason: strategy-match`, while any other tenant (or a missing `tenant`) returns `enabled: false, reason: default`. The flag must be enabled in the environment for strategies to apply -- a disabled environment always returns `reason: disabled`. **Operators by field type:** `string` -- `equals`, `in`, `notIn`, `startsWith`, `contains`, `regex`; `enum` -- `equals`, `in`, `notIn`; `boolean` -- `is`; `number` -- `eq`, `neq`, `lt`, `lte`, `gt`, `gte`; `version` -- `eq`, `gte`, `lte`, `satisfies`; `date` -- `before`, `after`. Constraints on `enum` fields are validated against the field's allowed values. A `regex` pattern is checked when it is saved. An invalid pattern, or one that can backtrack exponentially -- a repeated group that itself repeats, such as `(a+)+$` -- is rejected with a `400`, because evaluation runs the pattern against caller-supplied context on every request and a pattern like that would hang the server for everyone. Ordinary patterns are unaffected. --- ## Import and Export Two separate surfaces that share no endpoint and no request parameter: - **Native** -- `flagraft.export` v1. Round-trips losslessly. For backups, restores, and cloning a project into another project or another install. - **External tools** -- one route pair per tool. Unleash is the only one today. There is no `format` parameter and no format sniffing. Each endpoint accepts exactly one shape, and tells you which route wants the other one. | Method | Path | Auth | What it does | | ------ | ---------------------------------------------------- | ------------- | -------------------------------- | | `GET` | `/api/v1/admin/projects/:id/transfer/export` | admin | Native export | | `POST` | `/api/v1/admin/projects/:id/transfer/import` | project admin | Native import | | `GET` | `/api/v1/admin/projects/:id/transfer/export/unleash` | admin | Export in Unleash's import shape | | `POST` | `/api/v1/admin/projects/:id/transfer/import/unleash` | project admin | Import an Unleash export | Import needs the owner or admin role, because it rewrites flag state in every environment including protected ones -- the same rule `DELETE /flags/:key` follows. ### The native document ```json { "format": "flagraft.export", "version": 1, "exportedAt": "2026-09-12T05:30:00.000Z", "project": { "slug": "web", "name": "Web" }, "contextFields": [ { "key": "plan", "type": "enum", "description": "Billing plan", "enumValues": ["free", "pro", "enterprise"] } ], "flags": [ { "key": "new-checkout", "name": "New checkout", "description": "Rewritten checkout funnel", "environments": { "development": { "enabled": true, "strategies": [ { "constraints": [{ "fieldKey": "plan", "operator": "in", "values": ["pro"] }] } ] }, "production": { "enabled": false, "strategies": [] } } } ] } ``` Strategy order is array order. `project` and `exportedAt` are informational -- the import target is always the `projectId` in the URL, so a document exported from `web` imports into `mobile` with no editing. **Deliberately absent:** ids (per-install UUIDs, meaningless elsewhere), timestamps and authors, API keys, users and memberships, pending approval toggles, and project settings. Importing settings would let a file relax another install's safety rules. ### Export ``` GET /api/v1/admin/projects/:id/transfer/export?keys=a,b,c ``` `keys` is optional and exports a subset. The response is always an envelope: ```json { "document": {}, "warnings": [] } ``` Never a bare file. The Unleash export can drop strategies, and a bare download has nowhere to report that -- so both export routes return one shape. Scripts use `curl ... | jq .document > flags.json`. The native export never populates `warnings`. ### Import ``` POST /api/v1/admin/projects/:id/transfer/import ``` ```jsonc { "document": { /* flagraft.export v1 */ }, "onConflict": "skip", // "skip" (default) | "overwrite" "environmentMap": { "prod": "production" }, // optional "dryRun": false, } ``` - **One transaction.** It all lands or none of it does. A half-imported project is impossible rather than recoverable. - **`dryRun`** runs the identical code path and rolls back, returning the report it would have returned. It is not a separate validation pass, so the preview cannot drift from the real thing. - **`onConflict: "skip"`** (default) leaves an existing flag untouched and reports it. **`"overwrite"`** replaces the name, description, per-environment `enabled`, and the whole strategy list for each environment the document names. Environments the document is silent about are left alone -- silence is not an instruction to disable. - **Environments** match by slug, `environmentMap` first. Unmatched names are reported and their state and strategies skipped; the flag itself still imports. Missing environments are never created: one needs its own API keys and protection setting. - **Context fields** are created when missing and never modified when present. Every creation is listed in the report. ### Context fields on import An import creates the context fields the file names and never touches one that already exists. It is held to exactly the rules `POST /context-fields` enforces, because it writes to the same table: - keys must match `^[A-Za-z_][A-Za-z0-9_.-]*$` and be at most 64 characters - an `enum` field must carry a non-empty `enumValues`, and nothing else may carry one - a project may hold at most 25 context fields; an import that would take it over is refused with a `409` and writes nothing at all A native document that breaks the first two is rejected with a `400`. An Unleash export is not: its context names are free-form, so a name that is not a legal key here is dropped and reported, and the strategies referring to it fail the ordinary unknown-field check and are reported one by one -- the rest of the migration still lands. ### The fail-safe rule **If any part of a strategy cannot be represented, the whole strategy is dropped and reported.** A strategy is an AND of constraints, so dropping one constraint _widens_ who the flag is on for -- a strategy scoped to `plan in [enterprise]` that loses its only constraint becomes match-everybody. This applies identically to unsupported operators, unsupported Unleash strategy types, and constraints naming a field whose local type rejects the operator. Import errs narrow, and tells you exactly what to rebuild by hand. ### Protected environments When the project has `settings.security.requireApprovalInProd`, import **refuses to change `enabled` in protected environments** and reports those as `approval-required`. Strategies still import -- they are not behind the approval flow. Import never creates pending toggle rows: a bulk file is not a considered request by a named admin. ### The report Both import routes return the same shape. Native imports never produce `unsupported-*` warnings. ```jsonc { "report": { "dryRun": true, "source": "unleash", // "flagraft" | "unleash" "counts": { "flagsCreated": 12, "flagsUpdated": 3, "flagsSkipped": 1, "contextFieldsCreated": 4, "strategiesImported": 18, "strategiesSkipped": 2, }, "contextFieldsCreated": ["plan", "userId"], "unmatchedEnvironments": ["staging"], "flags": [ { "key": "new-checkout", "action": "created", "warnings": [] }, { "key": "old-banner", "action": "skipped", "reason": "exists (onConflict=skip)" }, { "key": "beta-search", "action": "created", "warnings": [ { "environment": "production", "kind": "unsupported-strategy", "detail": "Strategy dropped: flexibleRollout at 50% ...", }, ], }, ], }, "warnings": [], } ``` `action` is `created | updated | skipped`. `kind` is a closed set: | `kind` | Means | | ---------------------- | ---------------------------------------------------------------- | | `unsupported-strategy` | The source used a feature with no equivalent here | | `unsupported-operator` | Export only: Unleash has no operator for one of ours | | `constraint-rejected` | A constraint failed validation, so its strategy was dropped | | `unknown-environment` | No local environment matched that name | | `approval-required` | Protected environment behind the two-admin flow; state untouched | | `behaviour-change` | Imported, but matches slightly differently than it did | The top-level `warnings` array carries document-level notes from an adapter (archived features, dropped segments and tags). Per-flag notes live on the flag's own entry. ### Worked example -- clone a project ```bash # 1. Export from the source project. curl -s "http://localhost:3000/api/v1/admin/projects/$SRC/transfer/export" \ -H "Authorization: $ADMIN_KEY" | jq .document > flags.json # 2. See what it would do to the target project, without writing anything. curl -s -X POST "http://localhost:3000/api/v1/admin/projects/$DST/transfer/import" \ -H "Authorization: $ADMIN_KEY" -H "Content-Type: application/json" \ -d "$(jq -n --slurpfile d flags.json '{ document: $d[0], dryRun: true }')" | jq .report # 3. Happy with the report? Run it for real. curl -s -X POST "http://localhost:3000/api/v1/admin/projects/$DST/transfer/import" \ -H "Authorization: $ADMIN_KEY" -H "Content-Type: application/json" \ -d "$(jq -n --slurpfile d flags.json '{ document: $d[0] }')" | jq .report ``` --- ## Migrating from Unleash Accepts the document Unleash's `POST /api/admin/features-batch/export` produces (v5 / v6), which is also what its own importer takes. ```bash curl -s -X POST "http://localhost:3000/api/v1/admin/projects/$PROJECT_ID/transfer/import/unleash" \ -H "Authorization: $ADMIN_KEY" -H "Content-Type: application/json" \ -d "$(jq -n --slurpfile d unleash-export.json '{ document: $d[0], dryRun: true }')" | jq ``` Flagraft's model has no variants, no percentage rollouts and no segments. Unleash leans on all three, so the mapping is lossy by nature -- and everything lost is reported. Flagraft is deliberately lenient about the shape: Unleash's export differs between versions, and entries arrive missing `environment` or `strategyName` (some write `name` instead). Rather than reject the whole file over a few odd rows, those entries are skipped and named in the report so the rest of the migration still lands. ### Strategies with no environment An export taken from a single Unleash environment writes no `environment` on its strategies at all -- the environment appears only on the feature's `featureEnvironments` row. Such a strategy is placed in the one environment its flag has, or, if the flag has no state row, in the single environment the export describes. That is a lookup, not a guess, and the import reports how many strategies were placed this way and where. A strategy with no `environment` in an export that describes **several** environments is genuinely ambiguous, so it is **dropped** and reported. Placing it would switch targeting on somewhere the export never named. ### Top level | Unleash | Becomes | | ------------------------------------------------------- | --------------------------------------------------- | | `features[].name` | both `key` and `name` (Unleash has no display name) | | `features[].description` | `description` | | `features[].archived: true` | **skipped**, reported | | `features[].type`, `stale`, `impressionData`, `project` | ignored; no equivalent | | `featureEnvironments[]` | per-environment `enabled` | | `featureStrategies[]` | strategies, ordered by `sortOrder` | | `featureStrategies[].environment` missing | inferred when unambiguous; see above | | `contextFields[]`, `legalValues[].value` | context fields, `enumValues` | | `featureTags`, `tagTypes`, `segments`, `dependencies` | **dropped**, reported once each | | any `variants` | **dropped**, reported | ### Strategies | `strategyName` | Result | | ------------------------------------------------- | ----------------------------------------------------------------------- | | `default` | one strategy carrying the mapped constraints | | `flexibleRollout`, `parameters.rollout === "100"` | same as `default` | | `flexibleRollout` below 100 | **skipped** -- importing 50% as always-on would take it to 100% | | `userWithId` | constraint `userId in [...]`; the `userId` field is created as `string` | | `remoteAddress` | **skipped** -- Unleash matches CIDR, our `in` is exact equality | | `applicationHostname` | **skipped** -- no natural context field to land on | | `gradualRollout*` (legacy) | **skipped** | | anything else, including custom strategies | **skipped** | | any strategy with `disabled: true` | **skipped**, reported as intentionally off rather than unsupported | | any strategy referencing a `segment` | **skipped** -- a segment is a named constraint set we cannot store | ### Constraint operators | Unleash | Becomes | Needs field type | | ------------------------------------- | --------------------- | -------------------------------------------- | | `IN` | `in` | string, enum | | `NOT_IN` | `notIn` | string, enum | | `STR_CONTAINS` | `contains` | string | | `STR_STARTS_WITH` | `startsWith` | string | | `NUM_EQ` | `eq` | number | | `NUM_GT` `NUM_GTE` `NUM_LT` `NUM_LTE` | `gt` `gte` `lt` `lte` | number | | `DATE_AFTER` `DATE_BEFORE` | `after` `before` | date | | `SEMVER_EQ` | `eq` | version | | `STR_ENDS_WITH` | -- | no equivalent | | `SEMVER_GT` `SEMVER_LT` | -- | we have only `eq`, `gte`, `lte`, `satisfies` | | `inverted: true` | the negated operator | see below | An unmapped operator drops its whole strategy, per the fail-safe rule above. `caseInsensitive: true` is kept and reported as `behaviour-change`: the constraint becomes case-sensitive, which narrows who matches, and narrowing is the safe direction. #### Inverted constraints Unleash writes "is not one of" as an operator plus `inverted: true`. There is no NOT wrapper here, so the inversion is folded into the operator itself, which is exact: | Unleash, inverted | Becomes | | ---------------------------- | ---------------------------------------------------------------------------------------------------- | | `IN` | `notIn` | | `NOT_IN` | `in` | | `NUM_EQ` | `neq` | | `NUM_GT` / `NUM_GTE` | `lte` / `lt` | | `NUM_LT` / `NUM_LTE` | `gte` / `gt` | | `STR_CONTAINS` | **dropped** -- no `notContains` | | `STR_STARTS_WITH` | **dropped** -- no `notStartsWith` | | `SEMVER_EQ` | **dropped** -- version has no `neq` | | `DATE_AFTER` / `DATE_BEFORE` | **dropped** -- `before` and `after` are both strict here, so negating one loses the boundary instant | ### Context field typing Unleash context fields carry no type; Flagraft gates operators on one. So the type is inferred from how each field is used across the whole document, most specific first: 1. any `SEMVER_*` operator -> `version` 2. any `DATE_*` operator -> `date` 3. any `NUM_*` operator -> `number` 4. `legalValues` present -> `enum`, with those values 5. otherwise -> `string` Operator families beat `legalValues`: a field with legal values that is also compared with `NUM_GT` is being used as a number, and an enum would reject the numeric operator. Operators we cannot map at all are ignored when deciding -- the strategy using one is dropped anyway, so it should not drag the field's type with it. A field used with operators implying two different types becomes a `string` and is reported as `behaviour-change`; the constraints that then fail validation are dropped by the ordinary fail-safe rule. A field that already exists locally is never retyped. ### Exporting for Unleash ``` GET /api/v1/admin/projects/:id/transfer/export/unleash ``` Every collection Unleash's importer expects is present, empty where we have nothing. Each strategy goes out as `strategyName: "default"` with `sortOrder` from its position. Operators map back, with two that change shape and five that cannot travel: | Flagraft | Becomes | Note | | ----------------------------------- | ---------------------------------- | ----------------------------------------------------- | | `in` / `notIn` | `IN` / `NOT_IN` | | | `equals` | `IN` with one value | Unleash has no equality operator | | `is` (boolean) | `IN` with `"true"` / `"false"` | Unleash context values are strings throughout | | `contains` / `startsWith` | `STR_CONTAINS` / `STR_STARTS_WITH` | | | `eq` `gt` `gte` `lt` `lte` (number) | `NUM_*` | | | `before` / `after` | `DATE_BEFORE` / `DATE_AFTER` | | | `eq` (version) | `SEMVER_EQ` | | | `gte` / `lte` (version) | -- | Unleash has only the strict `SEMVER_GT` / `SEMVER_LT` | | `neq` | -- | no `NUM_NEQ` in Unleash | | `satisfies` | -- | no semver-range operator | | `regex` | -- | no regex operator | A strategy using one of the unmappable operators is omitted and named in `warnings` -- which is why export returns an envelope rather than a bare document. --- ## All Endpoints at a Glance | Method | Path | Auth | What it does | | -------- | ------------------------------------------------------------------------ | ------ | ----------------------------------------------- | | `POST` | `/api/v1/admin/projects` | root | Create project (auto-creates 2 envs) | | `GET` | `/api/v1/admin/projects` | admin | List projects | | `GET` | `/api/v1/admin/projects/:id` | admin | Get project | | `PATCH` | `/api/v1/admin/projects/:id` | admin | Update name / slug / description | | `DELETE` | `/api/v1/admin/projects/:id` | root | Delete project and all children | | `POST` | `/api/v1/admin/projects/:id/environments` | admin | Create environment | | `GET` | `/api/v1/admin/projects/:id/environments` | admin | List environments | | `DELETE` | `/api/v1/admin/projects/:id/environments/:envId` | admin | Delete environment | | `POST` | `/api/v1/admin/projects/:id/flags` | admin | Create flag (auto-creates flag_environments) | | `GET` | `/api/v1/admin/projects/:id/flags` | admin | List flags | | `GET` | `/api/v1/admin/projects/:id/flags/:key` | admin | Get flag | | `PATCH` | `/api/v1/admin/projects/:id/flags/:key` | admin | Update name / description | | `DELETE` | `/api/v1/admin/projects/:id/flags/:key` | admin | Delete flag | | `POST` | `/api/v1/admin/projects/:id/flags/:key/environments/:envSlug/enable` | admin | Enable flag in environment | | `POST` | `/api/v1/admin/projects/:id/flags/:key/environments/:envSlug/disable` | admin | Disable flag in environment | | `GET` | `/api/v1/admin/projects/:id/flags/:key/environments/:envSlug/strategies` | admin | List targeting strategies | | `PUT` | `/api/v1/admin/projects/:id/flags/:key/environments/:envSlug/strategies` | admin | Replace targeting strategies (ordered list) | | `GET` | `/api/v1/admin/projects/:id/transfer/export` | admin | Export flags as a flagraft.export document | | `POST` | `/api/v1/admin/projects/:id/transfer/import` | admin | Import a flagraft.export document (owner/admin) | | `GET` | `/api/v1/admin/projects/:id/transfer/export/unleash` | admin | Export in Unleash's import shape | | `POST` | `/api/v1/admin/projects/:id/transfer/import/unleash` | admin | Import an Unleash export (owner/admin) | | `GET` | `/api/v1/admin/projects/:id/context-fields` | admin | List context fields | | `POST` | `/api/v1/admin/projects/:id/context-fields` | admin | Create context field | | `PATCH` | `/api/v1/admin/projects/:id/context-fields/:fieldId` | admin | Update context field (key is immutable) | | `DELETE` | `/api/v1/admin/projects/:id/context-fields/:fieldId` | admin | Delete context field | | `POST` | `/api/v1/admin/projects/:id/keys` | admin | Create API key (plaintext returned once) | | `GET` | `/api/v1/admin/projects/:id/keys` | admin | List keys (prefix only, no plaintext) | | `DELETE` | `/api/v1/admin/projects/:id/keys/:keyId` | admin | Revoke key | | `GET` | `/api/v1/client/features` | client | Evaluate all flags (ETag / `If-None-Match`) | | `GET` | `/api/v1/client/features/:flagKey` | client | Evaluate one flag with reason | --- ## Key Tiers | Tier | Created by | Can do | | ------------- | --------------------- | -------------------------------------------------------- | | Root admin | CLI only | Everything, including create and delete projects | | Project admin | Root via API | Full CRUD within the scoped project, cannot touch others | | Client | Root or admin via API | Read-only flag evaluation for its scoped project + env |