Skip to content

Conventions

These conventions apply to every Management API endpoint.

All endpoints live under /api/v1. Breaking changes ship under a new version prefix; additive changes (new fields, new endpoints) may appear within v1.

Send request bodies as JSON with Content-Type: application/json. Requests with another content type are rejected with 415 unsupported_media_type. Responses are always JSON.

Organizations, projects, flags, environments, and segments are addressed by their key or slug — the same stable identifier you see in the dashboard:

GET /api/v1/orgs/{org}/projects/{project}/flags/{flag}

Nested resources without a user-facing key — targeting rules and variations — are addressed by their GUID id, returned when you create or list them.

List endpoints are cursor-paginated. Pass an opaque cursor query parameter to fetch the next page:

GET /api/v1/orgs/{org}/projects/{project}/flags?cursor=<next_cursor>

The response wraps items with the next cursor:

{
"items": [ /* … */ ],
"next_cursor": "eyJvIjoyNX0"
}

When next_cursor is null there are no more pages. Because cursors are offset-based, the final “next” page can legitimately come back empty — treat an empty page as the end.

Every error response uses one frozen JSON envelope:

{
"error": "validation_failed",
"message": "One or more fields are invalid.",
"docs_url": "https://featureflip.io/docs/management-api/errors/validation_failed",
"fields": {
"key": ["Key must be lowercase kebab-case."]
}
}
FieldTypeAlways presentDescription
errorstringYesStable machine-readable code (see Errors)
messagestringYesHuman-readable explanation
docs_urlstringYesLink to the docs page for this error code
fieldsobjectNoPer-field validation messages, present on validation_failed
retry_afterintegerNoSeconds to wait, present on rate_limited
did_you_meanarrayNoSuggested corrections for an unknown key/slug
next_actionsarrayNoSuggested follow-up requests ({ "method", "path" })
connection_idstringNoThe SSO connection to sign in with, present on sso_required
plan_limitobjectNoThe plan cap the request would exceed ({ "resource", "plan", "max" }), present on plan_limit_reached

See Errors for every code and how to resolve it.

Every response carries rate-limit headers:

HeaderDescription
X-RateLimit-LimitRequests allowed in the current window
X-RateLimit-RemainingRequests remaining in the window
X-RateLimit-ResetUnix time (seconds) when the window resets

Exceeding the limit returns 429 rate_limited with a retry_after field and a Retry-After header. Back off for that many seconds before retrying.

POST requests that create a resource accept an Idempotency-Key header:

Idempotency-Key: 6f9619ff-8b86-d011-b42d-00cf4fc964ff

Send a unique key (e.g. a UUID) per logical create. If a request with the same token and key is retried, the original result is replayed instead of creating a duplicate. If a second request with the same key arrives while the first is still in flight, it returns 409 idempotency_key_in_progress — retry after the first completes. Reusing a key for a request with a different body returns 409 idempotency_key_reuse.

Flags, flag environment configs, targeting, targeting rules, environments, segments and webhook subscriptions support optimistic concurrency with standard HTTP preconditions. A GET of one of these resources returns a strong ETag header, and so does a successful PUT:

ETag: "80857c9ec8c53314"

Treat the value as opaque. It covers everything in the GET body, _actions included, so it changes whenever any of it does, including changes made by other endpoints, and two tokens with different roles get different ETags for the same resource. Toggling a flag changes its environment config’s ETag and its targeting ETag. Archiving it, or setting its expiry, owner, permanence, removal PR or variations, changes the flag’s ETag. A webhook’s ETag changes when a failed delivery is counted, so while deliveries to it keep failing, a conditional PUT gets 412 if one fails between your GET and your PUT. GET it again and retry.

Send it back as If-Match on the PUT to make the write conditional:

If-Match: "80857c9ec8c53314"

If the resource still has that ETag, the write applies and the response carries the new one. If it doesn’t, you get 412 precondition_failed and nothing is saved: GET it again and retry. The body is checked first, so an invalid one is 400 validation_failed whatever ETag you send. If-Match is optional. Without it, or with If-Match: *, a PUT applies unconditionally, as it always has.

The ETag is compared against the whole resource. A change that lands while your PUT is being saved, to a part of the resource your PUT doesn’t write (a toggle, say), is kept alongside your write instead of refusing it, and the ETag in the response includes it. A change to anything your PUT writes is always refused with 412.

A GET evaluates If-Match too. It returns the resource only if that is still its ETag, and 412 otherwise. A GET that returns no ETag, such as a list, matches no tag.

The targeting ETag covers the environment’s whole rule set, so adding, deleting, editing or reordering any rule changes it. An environment in which the flag has never been configured has no config ETag yet.

Only these seven PUTs and GETs evaluate If-Match. Any other write answers a request carrying it with 400 bad_request, so a write is never applied without the precondition you asked for. The project, flag, environment, segment and SDK key in the path are looked up first, so one that doesn’t exist is still 404 not_found. If-Match: * is accepted everywhere.

Every resource response includes an _actions object describing which follow-up operations the current token may perform on that resource, and why not when it can’t:

"_actions": {
"update": { "allowed": true },
"delete": { "allowed": false, "reason": "requires Member role" }
}

Use _actions to drive UI affordances without hardcoding the permission model.