Appearance
Errors
Every non-2xx response carries a flat envelope:
json
{
"code": "invalid_request",
"message": "email is required",
"fields": { "email": "must be a valid email address" },
"requestId": "req_9f7a32b5"
}code— machine-readable. Stable. Branch on this.message— human-readable. May change. Don't string-match.fields— present on validation errors. Maps dotted field paths to per-field messages.requestId— server-generated. Include this when reporting an issue.
HTTP status conventions
| Status | When |
|---|---|
200 | Success with a body. |
201 | Resource created. |
202 | Approval-gated mutation diverted to a proposal — see Location header. |
204 | Success, no body. |
303 | Magic-link redirect. |
304 | If-None-Match matched; resource unchanged. |
400 | Bad request — code: invalid_request (with fields) or a more specific code. |
401 | Not authenticated. |
403 | Authenticated but not authorized. |
404 | Not found, or cross-tenant access (collapsed from 403 to avoid leaking URL existence). |
409 | Conflict — see code-specific table below. |
410 | Gone — terminal state on a one-shot resource (invitation, proposal, magic-link). |
412 | If-Match precondition failed. |
429 | Rate limit exceeded — see Retry-After. |
500 | Server error — code: internal. |
503 | Temporary unavailability — most commonly ruleset_not_cached on a fresh env. |
Cross-tenant access (e.g. asking for an environment in an org you're not a member of) always collapses to 404, never 403. URL existence shouldn't leak.
Code catalog
General
| Code | Status | When |
|---|---|---|
invalid_request | 400 | Bad shape, missing required field, type mismatch, invalid enum. fields carries the per-field messages. |
unauthorized | 401 | No / invalid credentials. |
forbidden | 403 | Authenticated but not authorized for this resource. |
not_found | 404 | Resource doesn't exist (or cross-tenant access). |
precondition_failed | 412 | If-Match ETag didn't match the current row. |
internal | 500 | Server error. The response includes a requestId — bring it when reporting. |
ruleset_not_cached | 503 | Environment has no materialized ruleset yet (only happens on a brand-new env that hasn't been written to). Retry after the first write. |
Authentication
| Code | Status | When |
|---|---|---|
magic_link_invalid | redirect | Token unknown or malformed. |
magic_link_used | redirect | Token already redeemed. |
magic_link_expired | redirect | Token past its 15-minute TTL. |
no_invitation | redirect | Magic-link sign-in attempted for an unknown email on a hosted instance with no pending invitation. |
already_claimed | 409 | POST /auth/install-claim against an instance that already has at least one org. |
slug_unavailable | 409 | Org slug already in use (signup or org create). |
Authorization
| Code | Status | When |
|---|---|---|
insufficient_role | 403 | Session caller's org role is below the endpoint's minimum. |
scope_denied | 403 | Bearer token's scope doesn't admit the requested env / resource / action. |
token_action_denied | 403 | Bearer token's actions list doesn't include the required verb. |
token_env_out_of_scope | 403 | Bearer token's environments scope doesn't include this env. |
token_resource_out_of_scope | 403 | Bearer token's resources scope doesn't match this primitive's key. |
token_ip_denied | 403 | Bearer token has an IP allowlist and the source IP isn't on it. |
token_rate_limit | 429 | Per-token bucket exhausted. Retry-After carries seconds. |
approval_not_supported | 403 | Approval-required token attempted a mutation that can't be diverted to a proposal (e.g. metadata patch, mixed-dimension state replace). |
Members
| Code | Status | When |
|---|---|---|
last_owner_cannot_demote_or_remove | 409 | Demoting or removing the last active owner. Promote someone else to owner first. |
already_member | 409 | Inviting an email that's already an org member. |
invitation_pending | 409 | A still-fresh pending invitation exists for the same (org, email). |
Invitations
| Code | Status | When |
|---|---|---|
invitation_used | 410 | Invitation already accepted. |
invitation_expired | 410 | Invitation past its 7-day TTL. |
invitation_email_locked | 410 | The email matches a soft-deleted user. Operator must hard-delete to free the email. |
Segments
| Code | Status | When |
|---|---|---|
in_use | 409 | Deleting a segment that's referenced from another flag, config, or segment. fields lists referrers as <resourceType>.<resourceKey>. |
Proposals
| Code | Status | When |
|---|---|---|
version_drift | 409 | Apply attempted after the env's version moved. Body carries {liveVersion, proposedVersion}; re-propose against the new state. |
proposal_gone | 410 | Apply or cancel attempted on a proposal that's already applied, cancelled, or expired. |
Confirmation
| Code | Status | When |
|---|---|---|
invalid_confirmation | 400 | A destructive endpoint (e.g. DELETE /orgs/{slug}) requires the caller to type the resource's slug as a confirmation token; mismatched or missing returns this. fields.confirm is populated. |
Rate-limit headers
Every authenticated bearer response with status 2xx, 304, or 429 carries:
X-RateLimit-Limit— requests per minute cap.X-RateLimit-Remaining— requests remaining in the current bucket.X-RateLimit-Reset— integer seconds until the bucket fully refills.
On 429, also Retry-After: <seconds> — the load-bearing "when can I retry" signal.
Two buckets gate every request: per-token (the key's rateLimitPerMin) and per-org aggregate. Headers reflect the more restrictive bucket; on 429, the bucket that denied.
Session-authenticated responses don't carry rate-limit headers.