Every error from a Rekey API or SDK has the same shape:
{
"success": false,
"error": {
"code": "TENANT_NOT_FOUND",
"message": "Tenant \"ckxxx\" not found.",
"fix": "List tenants with GET /api/v1/admin/tenants to see valid ids.",
"requestId": "9f1c0a3e-8b47-4d21-9f0e-2c5a7b13d840"
}
}code, message, fix and requestId are always present. Two fields are optional and appear only where they apply:
issues— onVALIDATION_ERROR(400): an array of{ path, message }naming each field that failed, capped at 10 entries. This is what tells you which key a.strict()endpoint refused.retryAfterSeconds— on the retryable codes listed below.details— on the few codes whose repair needs data the message cannot carry, such asDEVICE_LIMIT_REACHED(the devices to release) andDEPENDENCY_UNAVAILABLE(details.reasonwhen the database is busy rather than down). Documented per code.
There is also a docs field in the envelope type, reserved for a per-code documentation URL. No error the API emits sets it today, so do not branch on its presence — this page is the reference it would point at.
Read error.fix first. It is the most actionable field. We treat the absence of a fix on any new error as a bug.
Every response also carries an X-Request-Id header (and, on errors, error.requestId) — grep the server logs for it to find the matching backtrace. It is a UUID, unique per request across restarts and replicas. If you send your own X-Request-Id, Rekey echoes it back so a trace spans your proxy and the API; the value is clamped to 64 characters of [A-Za-z0-9._:@+=/-] first, so anything longer or stranger comes back trimmed.
Retryable errors carry a Retry-After header and error.retryAfterSeconds (same value, in seconds): RATE_LIMITED (429), TOO_MANY_FAILED_ATTEMPTS / MFA_TOO_MANY_ATTEMPTS (429), IDEMPOTENCY_KEY_IN_FLIGHT (409), EMAIL_RATE_LIMITED (429), EMAIL_SEND_IN_FLIGHT (409), DEPENDENCY_UNAVAILABLE (503), PASSWORD_VERIFY_BUSY (503), and USAGE_RECORD_BUSY (503). Honour it before retrying; nothing else in this document is worth retrying without a code change.
None of the SDKs retry for you, deliberately: a retry inside a server action or a request handler holds that request open and adds load to a server that is already shedding it, and the caller cannot see or cancel the wait. @rekey.dev/nextjs exposes classifySignInError(err) (from @rekey.dev/nextjs/server or the dependency-free @rekey.dev/nextjs/errors), which separates these from a wrong password and hands back retryAfterSeconds; elsewhere read err.retryAfterSeconds directly.
error.code is one of the codes listed below. Fastify's internal FST_ERR_* identifiers are normalised away before the response is written (FST_ERR_VALIDATION and FST_ERR_CTP_INVALID_JSON_BODY → BAD_REQUEST, FST_ERR_CTP_INVALID_MEDIA_TYPE → UNSUPPORTED_MEDIA_TYPE, and so on) — if you ever see one, that's a bug. Write your client so an unrecognised code degrades to "handle by status class" rather than throwing: a 4xx thrown by a plugin with a code of its own can still reach you verbatim, and this list grows.
- Service code throws
RekeyError({ statusCode, code, message, fix, docs? }). - Fastify's global error handler (
lib/error.ts) turns it into the envelope above. @rekey.dev/nodedecodes the envelope into a typedRekeyErrorwitherr.code,err.fix,err.docs.
When you write a new error path:
- Code —
SCREAMING_SNAKE_CASE. Stable across versions; clients switch on it. Include the resource:TENANT_NOT_FOUND,APPLICATION_SLUG_TAKEN,API_KEY_LIMIT_REACHED. - Message — one sentence, includes the offending value when safe (
Slug "myapp" is not URL-safe.). - Fix — concrete remediation. Imperative voice. If there's a CLI command or an API call that resolves it, name it. Avoid vague phrasing ("check your config").
- Status code — match HTTP semantics. 401 for auth, 403 for authorized-but-not-allowed, 404 for missing, 409 for conflicts, 400 for validation, 402 for payment/quota, 429 for rate-limit, 5xx for server problems.
Bad error:
throw new Error('not found');Good error:
throw new RekeyError({
statusCode: 404,
code: 'PLAN_NOT_FOUND',
message: `Plan "${slug}" not found in application "${appId}".`,
fix: 'Run `rekey plans list` to see available plans, or create one with POST /api/v1/admin/applications/:id/plans.',
});The complete list of codes the API emits today, grouped by domain. This list is the spec for client compatibility — add new codes here when you introduce them.
| Code | HTTP | When | How to handle |
|---|---|---|---|
INTERNAL_ERROR |
500 | Unhandled server-side exception (message is opaque to clients in production). | Don't retry blindly. Grep the server logs for requestId. |
DEPENDENCY_UNAVAILABLE |
503 | A backing service this deployment owns could not serve the request. Without details: it is unreachable, and the message names which (PostgreSQL or Redis). With details.reason, the database is reachable but busy: pool_busy (every connection in the API instance's pool stayed checked out past the wait) or transaction_timeout (a transaction ran past its time limit and was rolled back); /health/ready checks the database through the same connection pool, so it can report db ok during a transaction timeout, and can report db: unreachable while the pool is saturated even though the database is up. Never carries a host, port, or connection string. |
Honour Retry-After. Without a reason, GET /health/ready reports db and redis separately; restore the failed one. pool_busy that clears within seconds without a restart was load, not an outage; if it persists, raise DATABASE_POOL_SIZE or reduce load. transaction_timeout that persists: look for long-running queries or lock waits. |
ROUTE_NOT_FOUND |
404 | No route matches the method + path. | Check the path against /docs (OpenAPI). Usually a typo or a missing trailing segment. |
METHOD_NOT_ALLOWED |
405 | The path exists but not for this verb (e.g. GET on an MCP JSON-RPC endpoint). |
Read the Allow header. |
BAD_REQUEST |
400 | The route's JSON-schema validation failed (the message names the field and the constraint, e.g. body/amount must be <= 100000000000), or the JSON body didn't parse. Also the catch-all for any Fastify-native 4xx without a code of its own. |
Compare the request body/query against the route schema in /docs. Money fields cap at 10^11 minor units and counts at the int4 range — a value past those is refused here rather than 500-ing at the database. |
VALIDATION_ERROR |
400 | A handler's own schema parse failed — as opposed to the route-level one that raises BAD_REQUEST. Carries an issues array of { path, message } (max 10). This is what the config PATCH endpoints answer for an unrecognised key: auth-config, billing-config, portal, access, application-roles and usage-meters all reject keys they don't know rather than accepting the request and ignoring them, so a typo like mfaa for mfa can no longer report success while changing nothing. |
Read issues — it names the offending keys verbatim (Unrecognized key(s) in object: 'mfaa'). |
INVALID_BODY |
400 | The JSON body contains a NUL byte (\u0000), anywhere — nested objects, array entries, or object keys. Postgres cannot store it, so it is refused at the edge instead of surfacing as a 500. The guard runs ahead of routing, so a NUL body on a path that doesn't exist answers 400 rather than 404; a clean body on an unknown path still 404s. |
Strip \u0000 from the value before sending. It is almost always a truncation bug or a probe, not data you meant to store. |
INVALID_QUERY |
400 | Same, for a %00 sequence in the URL's query string. |
Remove the %00 from the request URL. |
UNSUPPORTED_MEDIA_TYPE |
415 | The request body's Content-Type isn't JSON on an endpoint that only takes JSON. Form-encoded bodies are accepted only on the MCP OAuth endpoints, where RFC 6749/7662 require them. |
Send Content-Type: application/json. A form-encoded body used to parse and then fail validation as a missing field — this is that mistake, reported honestly. |
PAYLOAD_TOO_LARGE |
413 | The request body is over the limit for that endpoint, rejected by the HTTP layer before any handler runs. Most routes carry the global 1 MiB cap; the credential routes (sign-in, sign-up, magic link, password reset, MFA, passkeys, OAuth token and introspection) carry much smaller ones sized to their own schemas, so a body no route would have accepted is never parsed. The response names no number. Not to be confused with METADATA_TOO_LARGE (400) — see the note under Auth — end-user sessions & passwords. |
Send only the fields the route's schema documents. |
SSRF_BLOCKED |
400 | A server-side fetch target (e.g. a webhook URL) resolved to a private/loopback address. | Use a publicly routable URL. |
RATE_LIMITED |
429 | A request-rate limiter tripped: the deployment-wide limiter (one bucket per secret API key, operator, signed-in end user, or else client IP; budgets in rate-limits.md), the tighter per-identity cap on auth endpoints, the per-Application ceiling across auth endpoints, or, for auth traffic that carries no visitor address (a secret-key call without X-Rekey-Client-Ip), the per-Application cap on failed attempts, which past its limit refuses only sign-in and MFA attempts for an account that already failed in the window. |
Back off for Retry-After seconds. x-ratelimit-limit / -remaining / -reset are on every response, so pace ahead of the limit rather than discovering it. |
TOO_MANY_FAILED_ATTEMPTS |
429 | Credential lockout, not request-rate: 10 failed password sign-ins for one (Application, email) inside 15 minutes locks that account for 15 minutes — end-user and operator sign-in both. Even the correct password is refused for the window. | Wait for Retry-After seconds, then retry. Don't loop. A successful sign-in clears the counter. |
The two 429s are different systems and you handle them differently. RATE_LIMITED means you are sending too fast — back off and continue. TOO_MANY_FAILED_ATTEMPTS means one account has too many failed passwords — retrying with the same credentials won't help until the window ends, and if it wasn't your user typing, someone is guessing at that account.
Selected mutating routes accept an Idempotency-Key header for safe blind retries — see "Idempotent requests" in concepts.md for the full semantics (scoping, replay, 24 h TTL).
| Code | HTTP | When | How to handle |
|---|---|---|---|
IDEMPOTENCY_KEY_INVALID |
400 | The header is empty, longer than 200 chars, or sent more than once. | Send one header with a stable unique string (a UUID works well). |
IDEMPOTENCY_KEY_REUSED |
409 | The key was already used for a different request (method, path, or body hash doesn't match the original). Also POST /usage/record when the body idempotencyKey was already used on that meter for that subject with a different quantity, or with an occurredAt in another UTC month (a retry with a regenerated timestamp in the same month replays the original); the original record stands and nothing new is recorded. |
A key identifies one logical operation. Reuse it only for byte-identical retries; mint a fresh key for new operations. |
IDEMPOTENCY_KEY_IN_FLIGHT |
409 | A request with this key is still executing (concurrent duplicate). | Honour Retry-After (1 s) and retry with the same key — you'll get the stored response once the original finishes. |
| Code | HTTP | When | How to handle |
|---|---|---|---|
ADMIN_AUTH_MISSING |
401 | /api/v1/admin/* called without Authorization: Bearer <SUPER_ADMIN_KEY>. |
Send the deployment's SUPER_ADMIN_KEY as a bearer token. |
ADMIN_AUTH_INVALID |
401 | The presented admin key doesn't match SUPER_ADMIN_KEY. |
Check the env value on the deployment you're actually targeting. |
PANEL_URL_NOT_CONFIGURED |
503 | Operator MCP consent needs PANEL_URL, which has no default — a default would send your operators to Rekey's panel. |
Set PANEL_URL to your panel origin on the API and restart. |
ADMIN_IP_NOT_ALLOWED |
403 | This deployment sets ADMIN_IP_ALLOWLIST and the request came from an address outside it. Raised before the key is examined, so it says nothing about whether the key was valid. |
Call from an allowed address, or add yours to ADMIN_IP_ALLOWLIST and restart the API. Behind a proxy, the address checked is the one the proxy vouches for: set API_PROXY_SECRET and have the proxy send it as X-Rekey-Proxy-Secret, with API_PROXY_HOPS set to how many proxies append to X-Forwarded-For (1 for Traefik alone, 2 with a CDN in front). Without that the API has only the proxy's own address and answers ADMIN_IP_UNVERIFIABLE instead. (TRUSTED_PROXIES covers the separate case of the panel and portal calling by fixed address; see rate-limits.md.) |
ADMIN_IP_UNVERIFIABLE |
403 | This deployment sets ADMIN_IP_ALLOWLIST, but the request arrived through a proxy the API cannot identify, so the only address it has is the proxy's, shared by everyone behind it. Matching it would admit them all, so the request is refused. |
Set API_PROXY_SECRET and have the proxy send it as X-Rekey-Proxy-Secret (see DEPLOY.md), or clear ADMIN_IP_ALLOWLIST, then restart the API. |
TENANT_NOT_FOUND |
404 | Tenant id doesn't exist (admin routes, workspace lookups). | List tenants with GET /api/v1/admin/tenants. |
TENANT_QUOTA_EXCEEDED |
403 | An action would put the workspace over a limit set in Tenant.limits (see concepts.md → Workspace limits). Three actions raise it: creating a new end-user (maxActiveEndUsers), and creating, promoting or re-enabling a production application (maxProductionApps). Existing end-users are unaffected (sign-in never returns this), and production applications already running are never taken offline by it. |
Read the fix on the response: it differs per action and names the applications currently holding the production slots. Free capacity (erase end-users; disable a production application), or raise the ceiling. On a self-hosted deployment a super-admin sets it via PUT /api/v1/admin/tenants/:id/limits. On Rekey Cloud the production-application ceiling comes from the workspace's plan: rekey.dev/pricing lists what each plan includes, and rekey.dev/contact is where to ask for more. |
INVALID_TENANT_LIMITS |
400 | The PUT /api/v1/admin/tenants/:id/limits body has an unknown key or an out-of-range value. Unknown keys are rejected rather than ignored, so a typo can't silently leave a workspace uncapped. |
Send only documented limit keys; {} clears every limit. |
| Code | HTTP | When | How to handle |
|---|---|---|---|
APPLICATION_DISABLED |
403 | The application is disabled. Every end-user-facing request is refused on both the secret-key and publishable-key surfaces; the hosted portal answers as though the slug does not exist. Also returned (409) when promoting a disabled application. Nothing has been deleted and no session was revoked. | A workspace operator re-enables it in Panel → Application → Settings, or DELETE /api/v1/tenant/applications/:id/disable. Re-enabling a production application needs a free production slot. |
ALREADY_PROMOTED |
409 | POST /api/v1/tenant/applications/:id/promote on an application already in the production environment. Promotion is one-way and happens once. |
Nothing to do. There is no way to move an application back out of production. |
ENTITLEMENT_OVERRIDE_INVALID |
400 | A per-subscription entitlement override would have been stored and then silently ignored, or worse, at resolve time: a malformed KIND:key; a CREDIT/LICENSE/USAGE key the plan does not carry (only FEATURE overrides may introduce a new entitlement); a value of the wrong shape for its kind; a quantity that is not a whole number in 0…2147483647 (the columns are 32-bit, and a fraction or a larger number is refused by the database at the next renewal rather than here); or a quantity its kind cannot carry — CREDIT must be positive, a SEATS licence needs at least one seat, and a USAGE row with no included units must carry a creditsPerUnit, since a quota of zero with no price reads as uncapped rather than as zero; or a quantity on a licence that has no seats, since a licence quantity means seats and no Rekey code reads one on a PERPETUAL or TIMED row. |
Read the fix, which names which it was. For a key the plan lacks, add the entitlement first via PUT /api/v1/tenant/applications/:id/plans/:slug/entitlements, then override its quantity. |
PLAN_TRIAL_MATERIALISES_ENTITLEMENTS |
400 | A plan that grants a CREDIT or LICENSE entitlement was given a non-zero trialDays, or such an entitlement was added to a plan that already carries one. A trial hands those over on day 0, before any payment, and neither has an inverse. |
Sell the trial on a plan whose entitlements are FEATURE or USAGE only, or drop trialDays from the plan. |
TENANT_ROLE_INSUFFICIENT on /promote or /disable |
403 | The three application-lifecycle routes require the workspace OWNER. An ADMIN role and an APP_ADMIN grant are both insufficient — unlike every other application mutation, which the ordinary write grant covers. |
Ask the workspace owner to promote, disable or re-enable the application. |
API_KEY_MISSING |
401 | Public-API call had no Authorization: Bearer <secretKey> header. |
Send the Application secret key (rp_live_… / rp_test_…). |
API_KEY_INVALID |
401 | Bearer credential is unknown / revoked / expired / wrong type (e.g. a public key or admin key sent to the public API). Single code on purpose — refusing to identify the kind of mistake stops credential-probing. | Mint a fresh secret key and check you're using the right credential type (see api-keys.md). |
API_KEY_SCOPE_INSUFFICIENT |
403 | The key's scopes don't cover this endpoint. |
Mint a key with the needed scope (or *). |
IP_NOT_ALLOWED |
403 | The key has an IP allowlist and your address isn't on it. | Update the key's allowlist in the panel, or call from an allowed address. |
PUBLISHABLE_KEY_INVALID |
401 | The presented publishable key (rp_pub_…) is unknown or rotated out past its grace window. |
Use the current publishable key from Panel → Application. If you just rotated, redeploy clients with the new key. |
ORIGIN_NOT_ALLOWED |
403 | A publishable-key request came from a browser origin not on the Application's CORS allowlist (or with no Origin). |
Call from an allowlisted origin, or add it in Panel → Application → Access. |
PUBLIC_KEY_ROTATION_IN_GRACE |
409 | Rotate-public-key called while a previous key is still in its grace window (only one previous-key slot exists). | Wait for the window to end, or pass force: true to drop the previous key now (leaked-key path). |
API_KEY_LIMIT_REACHED |
400 | Application already has 25 active keys. | Revoke unused keys before creating more. |
API_KEY_NOT_FOUND |
404 | Key id doesn't exist or belongs to a different Application. | List the Application's keys to find the right id. |
API_KEY_EXPIRY_IN_PAST |
400 | expiresAt on key creation is already in the past. |
Pass a future timestamp or omit it. |
API_KEY_SCOPE_UNKNOWN |
400 | Key creation named a scope no route enforces. Checked on create only; stored keys are unaffected. | Use only *, auth:read, auth:write, billing:read, billing:write, webhooks:read, contacts:write, credits:grant, email:send, contacts:read, or omit scopes for *. details.unknown lists the rejected entries. |
SIGNUP_REQUIRES_SECRET_KEY |
403 | Sign-up was called with a publishable key on an Application whose signup posture is secret_only. |
Call sign-up server-side with a secret key; keep the publishable key for browser sign-in. |
| Code | HTTP | When | How to handle |
|---|---|---|---|
APPLICATION_NOT_FOUND |
404 | Application id or slug doesn't exist (or isn't in your workspace). | Verify the id/slug; list applications for your tenant. |
APPLICATION_SLUG_INVALID |
400 | Slug fails the URL-safe regex. | Lowercase alphanumerics and hyphens. |
APPLICATION_SLUG_TAKEN |
409 | Slug already exists. | Pick another slug. |
PORTAL_NOT_FOUND |
404 | Hosted-portal config lookup for a slug/domain with no portal enabled. | Enable billing on the Application first (the Portal tab is hidden until you do), then turn it on in Panel → Application → Billing → Portal, or check the URL. |
PORTAL_DOMAIN_TAKEN |
409 | The custom portal hostname is already claimed by another Application. | Pick a different hostname. |
| Code | HTTP | When | How to handle |
|---|---|---|---|
REPORTING_TIMEZONE_UNSUPPORTED |
400 | PATCH /tenant/applications/:id/settings: the zone is valid IANA but the database's zone list does not have it (a legacy alias such as Asia/Calcutta or Europe/Kiev). |
Send the current name (Asia/Kolkata, Europe/Kyiv). |
ANALYTICS_BUSY |
503 | Dashboard numbers are computed at most two at a time per API process, and this request could not start within 3 seconds, or another request is computing the same numbers and they were not ready in time. | Retry after the Retry-After seconds. |
ANALYTICS_RANGE_INVALID |
400 | /analytics/users: from after to, to after today, a day that does not exist, range=custom without both days, or from/to with a preset. |
Use range=7d|30d|90d|12m, or range=custom with from and to as YYYY-MM-DD. |
ANALYTICS_RANGE_TOO_LONG |
400 | /analytics/users: longer than 366 days, or longer than the 63 days the live path answers when the daily rollup cannot serve the request. |
Shorten the range, or drop the filters that force the live path (see the fix). |
ANALYTICS_FILTER_UNSUPPORTED |
400 | /analytics/users: a section this deployment does not serve. |
Ask for the sections the fix lists. |
ANALYTICS_TIMEOUT |
503 | The dashboard query exceeded its statement budget. On /analytics/users it is reported inside the section instead. |
Retry in a minute; on /analytics/users, narrow the range or remove a filter. |
| Code | HTTP | When | How to handle |
|---|---|---|---|
AUTH_METHOD_DISABLED |
400 | Sign-up/sign-in via a method (password, magic_link) not enabled in Application.authConfig.methods. |
Enable the method in the panel, or use an enabled one. |
SIGNUP_DISABLED |
403 | Public sign-up is off for this Application (invite-only posture). | Create the user as an operator (panel End-users page, or POST /api/v1/tenant/applications/:id/end-users) or import it with POST /api/v1/users/import, or set signupMode to public or secret_only. |
SIGNUP_EMAIL_DOMAIN_NOT_ALLOWED |
403 | The address's domain is refused by authConfig.signupRestrictions: blocked, not on a non-empty allow list, or a disposable inbox with blockDisposable on. Raised by password sign-up, magic-link consume for a new address and a first OAuth sign-in; the magic-link request itself stays silent. Existing users still sign in. |
Ask the person to sign up with a different address. The message never names the allowed domains, so it is safe to show. Operators change the rules in Panel → Application → Auth → Sign-up email rules, or create the user with POST /api/v1/tenant/applications/:id/end-users, which is never checked. |
PASSWORD_TOO_SHORT |
400 | Password below authConfig.passwordMinLength. |
Surface the minimum to the user. |
PASSWORD_BREACHED |
400 | Password appears in known breach corpora (k-anonymity check). | Ask the user for a different password. |
EMAIL_ALREADY_EXISTS |
409 | Sign-up hit the (applicationId, email) unique constraint (also operator sign-up). |
Route the user to sign-in / password reset instead. |
INVALID_CREDENTIALS |
401 | Sign-in failed (wrong email or wrong password — never disclosed which), or wrong currentPassword on change. |
Show a generic "email or password is incorrect". |
EMAIL_NOT_VERIFIED |
403 | The Application sets authConfig.requireEmailVerification and this end-user's address is still unconfirmed. Raised wherever a session would be minted — sign-up (the account IS created, and the verification mail IS sent; only the session is refused), sign-in, MFA verification, org switching and refresh. Always after a credential verified, so it is neither an account-existence oracle nor a failed attempt against the lockout. |
Tell the user to click the link in their verification email, and offer a "send it again" button wired to POST /api/v1/auth/resend-verification — that route takes { email } and needs no session, because this refusal is what denies them one. (POST /api/v1/auth/send-verification is the authenticated sibling and is not reachable here.) Never show "wrong password". |
END_USER_BANNED |
403 | An operator banned this end-user from the Application. Returned only after the credential checks out (a wrong password on a banned account is still INVALID_CREDENTIALS), and on any live access token. On refresh, a token the ban revoked answers REFRESH_TOKEN_REVOKED; only a session minted by a sign-in that raced the ban gets END_USER_BANNED. The operator's reason is never included. The OAuth/OIDC surface answers invalid_grant / invalid_token, as for END_USER_ERASED. |
Show the person a suspended-account message with your support contact. Do not retry: only an operator can lift the ban. |
BAN_REASON_INVALID |
400 | An operator ban request sent a reason that is empty after trimming or longer than 500 characters. The reason is stored for your team and is never shown to the end-user. |
Send a reason of 1 to 500 characters. |
END_USER_ERASED |
410 | The account was permanently erased on a GDPR data-subject request and can no longer authenticate. The per-Application OAuth/OIDC surface enforces the same rule in the OAuth dialect instead: invalid_grant at the token endpoint, invalid_token at /userinfo and the MCP endpoint. |
Not recoverable — create a fresh account if the person returns. |
METADATA_KEY_RESERVED |
400 | A metadata write named the reserved oidc key from an end-user session or a publishable key. That namespace holds the OIDC identity claims the Application asserts about the user, so only the operator may write it. |
Store your own profile fields under any other key. To set claims, use a secret key or PATCH /api/v1/tenant/applications/:id/end-users/:endUserId. |
METADATA_TOO_LARGE |
400 | EndUser.metadata would exceed 16KB serialized. Enforced on every writer — sign-up, the self-service PATCH (measured after the merge) and the operator end-user routes. |
Keep large values (files, documents, long text) in your own storage and store a reference here. |
END_USER_UPDATE_INVALID |
400 | The body of PATCH /api/v1/users/me named a field that is not self-service. The allowlist is closed and holds exactly one key, metadata; anything else — email, role, password, erasure state — is refused loudly rather than dropped silently, so an integrator finds out at once instead of shipping a call that appears to work. |
Send { "metadata": { … } } only. Email, role and password changes have their own routes. |
END_USER_NOT_FOUND |
404 | EndUser id unknown, or belongs to a different Application than the calling secret key. | Verify the id and that you're using the right Application's key. |
USER_TOKEN_MISSING |
401 | Per-user endpoint called without the X-Rekey-User-Token header. |
Pass the user's access JWT as the per-user argument in the SDK. |
USER_TOKEN_INVALID |
401 | User JWT malformed, expired, or signed with a different secret. | Refresh the session (auth.refresh) and retry once. |
USER_TOKEN_WRONG_APPLICATION |
401 | JWT issued by a different Application than the calling secret key. The cross-tenant guard. | You're mixing credentials from two Applications — fix your config. |
IMPERSONATION_SESSION_ENDED |
401 | The presented token is an operator impersonation token whose impersonation_audits row has been ended (POST /api/v1/tenant/applications/:id/end-users/:euid/impersonate/end), or that names no live row at all. Impersonation is revocable: ending the row invalidates the token immediately, ahead of its 5-minute expiry. |
Mint a fresh token via the impersonate route if the session is still needed. |
IMPERSONATION_ACTION_FORBIDDEN |
403 | An impersonating operator tried a credential-changing route — password change, MFA setup/disable, passkey enrolment or removal. Those changes outlive the 5-minute token permanently and the user cannot tell who made them, so they are refused for impersonated sessions regardless of who holds the token. Reads, billing, organizations and profile edits are unaffected. | Ask the user to perform the action themselves, or act through the operator panel. |
REFRESH_TOKEN_INVALID |
401 | Refresh token unknown to the server (or not valid for session refresh). | Send the user through sign-in again. |
REFRESH_TOKEN_REUSED |
401 | Refresh token already used. Treat as a compromise signal — all sessions for the user are revoked as a precaution. | Force re-authentication; investigate where the old token leaked. |
REFRESH_TOKEN_RACED |
401 | The token was rotated moments ago by another request (within REFRESH_TOKEN_REUSE_WINDOW_SECONDS, 15 by default) and the replacement has not been used yet: two tabs or two server instances refreshing at once, or a retry after a lost response. Nothing was revoked and nothing was issued. Recorded as a user.refresh_token_raced (or operator.refresh_token_raced) security event. |
Do not retry with this token: presenting it again after the window is REFRESH_TOKEN_REUSED. Use the replacement the other request stored (re-read the cookie or token store); if this client never received it, sign in again. Other devices are unaffected. |
DEVICE_FINGERPRINT_REQUIRED |
400 | authConfig.deviceBinding is required and a primary sign-in (password, sign-up, OAuth, magic link, passkey) carried no device. Checked before an account is created, so a sign-up refused this way leaves nothing behind. Refresh is never gated. |
Send device: { fingerprint, label? }. See devices.md. |
DEVICE_LIMIT_REACHED |
403 | A new (or released) device would exceed the end-user's max_devices entitlement. No token was issued. details carries { limit, devices: [{ id, label, firstSeenAt, lastSeenAt }] }, the active devices filling the cap. On refresh, the presented token is NOT spent; a refusal that lands after the rotation (a race with another sign-in of the same user) is REFRESH_TOKEN_REVOKED instead. |
Offer the user the list to release (via a session without device, your backend's POST /api/v1/devices/:id/release, or an operator), or upgrade the plan. |
DEVICE_BLOCKED |
403 / 409 | 403: sign-in from a fingerprint an operator blocked. 409: an end-user tried to release a blocked device. | Only an operator can unblock (POST …/devices/:id/unblock). |
DEVICE_NOT_FOUND |
404 | No device with that id belongs to that end-user in this Application; a device from another user or Application looks the same as a typo. | List the user's devices. |
SESSION_DEVICE_RELEASED |
401 | A refresh or organization switch on a session bound to a device that has since been released. A re-mint never brings a released device back. | Sign the user in again from this device. |
REFRESH_TOKEN_DEVICE_MISMATCH |
401 | A refresh chain bound to one device was refreshed with a different fingerprint. Treated as a compromise signal: every session for the user is revoked. | Sign the user in again from this device. |
REFRESH_TOKEN_REVOKED |
401 | The token was explicitly revoked — sign-out, sign-out-everywhere, an operator ending the session, a device release or block, or the family being burned by a REFRESH_TOKEN_REUSED elsewhere. Also returned when a refresh spent the token and the device was then refused (DEVICE_LIMIT_REACHED or DEVICE_BLOCKED won a race with the rotation): details.reason carries that code, plus limit and devices for a limit. Distinct from _REUSED: this token was never presented twice, it was invalidated by something else. |
Send the user through sign-in again. Not on its own a compromise signal. |
REFRESH_TOKEN_EXPIRED |
401 | Refresh token past its window (END_USER_REFRESH_TOKEN_TTL_DAYS, 30 by default). |
Send the user through sign-in again. |
USER_TOKEN_INVALID (revoked) |
401 | Either the access token predates the user's last password change or reset, sign-out everywhere or refresh-token reuse (every session ended), or its own session was revoked or its device released or blocked (only that session ended; the user's others are unaffected). The message says which. Same code as an unverifiable token, so existing SDKs refresh on it. | Refresh; if the refresh token is gone too, sign in again. |
REFRESH_TOKEN_WRONG_APPLICATION |
401 | Refresh token belongs to a different Application. | Fix the credential mix-up. |
METADATA_TOO_LARGE (400) and PAYLOAD_TOO_LARGE (413) are different errors and want different handling. The 413 is the HTTP layer refusing an over-sized request, before any handler runs, on every endpoint (1 MiB in general, and a good deal less on the credential routes); the 400 is one handler refusing a metadata object that would push a single EndUser row past 16KB. A client switching on "too large" must switch on the code, not the phrase: the 413 says split this request, the 400 says this field will never fit, move the value out of it. On sign-up the two meet: a metadata blob a little over 16KB earns the 400, and one many times over it is refused as a 413 before the handler ever runs, because the route's own body cap is smaller than the global one. The name is also the one code in this document that breaks the resource-prefix convention above (its peers are END_USER_*, COUPON_*) — it shipped that way in 2.0.0-rc.1, which is published, so it stays. Nothing about it is deprecated.
| Code | HTTP | When | How to handle |
|---|---|---|---|
PASSWORD_HASH_UNSUPPORTED |
400 | A row's passwordHash is not a well-formed argon2id PHC string within the parameter budget (m ≤ 262144 KiB, t ≤ 10, p ≤ 8) or a bcrypt hash at cost 12 or below. The ceilings exist because sign-in honours whatever the stored hash asks for, and an unbounded import would let one tenant make sign-in a memory and CPU sink. The whole batch is refused before any write. |
Send hashes as the old system stores them, or omit passwordHash and let the user reset. |
IMPORT_DUPLICATE_EMAIL |
400 | The same address (case-insensitively) appears twice in one batch. | Send each address once. |
PASSWORD_VERIFY_BUSY |
503 | A password check against an imported bcrypt hash (one not yet upgraded to argon2id) arrived while every bcrypt worker was busy and the wait queue was full. Answered by sign-in, password change and step-up. The password was not checked, so nothing was counted toward lockout. | Honour Retry-After and retry. Sustained, it means a burst of sign-ins against imported accounts. |
| Code | HTTP | When | How to handle |
|---|---|---|---|
PROFILE_SCHEMA_INVALID |
400 | PUT .../profile-schema sent fields that do not parse: a bad or reserved key (constructor, prototype), an unknown type, a select without options, options on another type, or a duplicate key. details.issues lists each. |
Fix the fields details.issues names. |
PROFILE_FIELD_KEY_IMMUTABLE |
409 | The new schema removes or retypes a field that users have answered. details.fields counts the answers per key. |
Keep the key and type and change the label instead. |
PROFILE_OPTION_IN_USE |
409 | The new schema removes a select option that users picked. details.options lists each key, option and user count. |
Keep the option, or change those users' answers first. |
PROFILE_SCHEMA_CHANGED |
409 | The PUT named a version that is no longer current: someone saved the fields since you read them. details.expected and details.current give both. |
Read the fields again, reapply your change, and send the new version. |
PROFILE_FIELD_UNKNOWN |
400 | A profile write names a key the schema does not define. details.unknown and details.defined list the keys. |
Define the field first, or drop it from the request. |
PROFILE_FIELD_READ_ONLY |
403 | The signed-in user tried to set a writableBy: "server" field. details.fields names them. |
Set it with a secret key (rekey.users.updateProfile) or as an operator. |
PROFILE_FIELD_INVALID |
400 | An answer is not the shape its field takes. details.issues says what each field expected. |
Send the value in the field type's shape; null clears an answer. |
PROFILE_TOO_LARGE |
400 | The user's answers would exceed 16 KB after the merge. | Shorten answers or clear ones you no longer need. |
PROFILE_INCOMPLETE |
409 | onboarding/complete was called with required fields unanswered. details.missing lists their keys. Only complete returns it: onboarding/skip needs no answers, and nothing else is gated on onboarding. |
Answer those fields, then call it again. To let the user move on without answering, call onboarding/skip instead. |
| Code | HTTP | When | How to handle |
|---|---|---|---|
PASSWORD_RESET_TOKEN_INVALID |
401 | Reset token unknown. | Ask the user to request a new reset. |
PASSWORD_RESET_TOKEN_USED |
401 | Reset token already consumed. Treat as a compromise signal. | Issue a fresh reset; consider notifying the user. |
PASSWORD_RESET_TOKEN_EXPIRED |
401 | Reset token past its 1-hour window. | Issue a fresh reset. |
PASSWORD_RESET_TOKEN_WRONG_APPLICATION |
401 | Reset token from a different Application. | Fix the credential mix-up. |
EMAIL_ALREADY_VERIFIED |
400 | POST /auth/send-verification (the authenticated route) for an already-verified address. Never raised by POST /auth/resend-verification, which answers 200 whatever the address's state — telling an anonymous caller "that one is already confirmed" is an account oracle. |
Nothing to do — treat as success in UI. |
EMAIL_VERIFICATION_TOKEN_INVALID / EMAIL_VERIFICATION_TOKEN_USED / EMAIL_VERIFICATION_TOKEN_EXPIRED / EMAIL_VERIFICATION_TOKEN_WRONG_APPLICATION |
401 | Verification token unknown / consumed / past 24 h / cross-Application. | Re-send the verification email. |
EMAIL_VERIFICATION_STALE |
401 | Token was issued for a different email than is currently on the account. | Re-send verification for the current address. |
MAGIC_LINK_INVALID / MAGIC_LINK_USED / MAGIC_LINK_EXPIRED / MAGIC_LINK_WRONG_APPLICATION |
401 | End-user magic-link token unknown / consumed / expired / cross-Application. | Request a new magic link. |
MAGIC_LINK_STALE |
401 | Magic-link token issued for a different email than is currently on the account. | Request a new magic link. |
EMAIL_EVENT_UNKNOWN |
404 | Tenant email-template route given an unknown event key. | Use one of the documented email event keys. |
EMAIL_FROM_NAME_INVALID |
400 | PATCH .../email-sender or PUT .../email-credentials given a fromName over 120 characters, or containing a line break or other control character (which would start a new mail header). |
Send a single-line display name, or null to clear it. |
EMAIL_REPLY_TO_INVALID |
400 | PATCH .../email-sender given a replyTo that is not a single email address. |
Send one address, or null to clear it. |
EMAIL_SUPPORT_EMAIL_INVALID |
400 | PATCH .../email-sender given a supportEmail that is not a single email address. |
Send one address, or null to clear it. |
AUTH_URL_INVALID |
400 | A resetUrl / verifyUrl / signInUrl you passed isn't a parseable absolute URL. (signInUrl is the magic-link destination; not to be confused with the React <SignIn> magicLinkUrl prop, which is a form action.) |
Pass an absolute URL on an origin this Application has registered. |
AUTH_URL_NOT_ALLOWED |
400 | The URL parsed but is either not http(s), carries credentials (https://user@host, refused even on an allowed origin or portal page), or its origin is not one this Application declared. The allowed set is authConfig.appUrl plus authConfig.redirectUrls, by origin, plus, while the hosted portal is on, this Application's own portal pages: <PUBLIC_PORTAL_URL>/<slug>/... on the shared host (path-scoped: another app's slug, dot segments, encoded slashes, backslashes and empty segments are refused) and the whole origin of its verified custom portal domain. A freshly created Application with no portal has none of these, so the first requestPasswordReset({ resetUrl }) of a new integration lands here. Checked before the address is looked up, so the answer is the same for a known and an unknown address. |
Add the origin to the Application's redirect URLs (Panel → Application → Authentication → Methods) or set its App URL. The link is emailed carrying a live login token, so an unrecognised destination is refused rather than sent. |
EMAIL_VERIFICATION_URL_REQUIRED |
409 | An auth-config update would require a verified email while this Application has no appUrl, no http(s) redirect URL and the deployment sets no DEFAULT_APP_URL. No verification link could be built, so every new sign-up would be stranded. Also raised when such an update clears the last URL while verification is required. |
Set the Application URL or an http(s) redirect URL (Panel → Application → Authentication → Methods, or appUrl / redirectUrls in the same update), then require a verified email. |
A suppressed recipient is not an error: the send answers 202 with status: "suppressed". A one-click unsubscribe suppresses notification mail only; built-in and critical mail still go to that address.
| Code | HTTP | When | How to handle |
|---|---|---|---|
EMAIL_TRANSPORT_NOT_CUSTOM |
403 | Publish, send or test send on an Application that uses the shared pool or has no provider. Custom templates only go out through the Application's own Resend key or SMTP server, and never fall back to the pool. | Connect your own Resend or SMTP in Panel → Application → Email → Settings. |
EMAIL_TEMPLATE_NOT_FOUND |
404 | No custom template with that key in this Application, or the pinned version does not exist (the message names the latest). |
Check the key; omit version to send the latest. |
EMAIL_TEMPLATE_NOT_PUBLISHED |
409 | The template has never been published. Drafts are never sent. | Publish it in the panel, then send again. |
EMAIL_SENDER_DOMAIN_MISMATCH |
409 | The Application has no From address, or its domain changed since this version was published. | Set the From address, or review the template and publish it again. |
EMAIL_VARIABLES_INVALID |
400 | A variable is undeclared, missing while required, too long, the wrong type, a url that is not https or not on the template's link domains, or a date that is not ISO 8601. details.issues lists every problem as { path, message }. Also returned by the draft preview when a value passed in variables breaks the same rules (required aside). |
Fix each listed variable. Nothing was sent. |
EMAIL_RECIPIENT_NOT_END_USER |
403 | The Application has "Only send to end users" on and to is not one of its end users. |
Send to an end user's address, or turn the setting off. |
EMAIL_RATE_LIMITED |
429 | The workspace sent its daily allowance (Tenant.limits.emailSendDailyCap, else EMAIL_SEND_DAILY_CAP, default 1000 per UTC day) or this recipient's hourly allowance (emailSendRecipientHourlyCap, else EMAIL_SEND_RECIPIENT_HOURLY_CAP, default 10; +tags count as one address). Counted across every Application in the workspace. Carries retryAfterSeconds. |
Wait for Retry-After. Nothing was sent, so the same idempotency key still works. |
EMAIL_IDEMPOTENCY_KEY_REUSED |
409 | The idempotency key was already used for a send with a different template, recipient, version or variables. | Use a new key for a different email. |
EMAIL_SEND_IN_FLIGHT |
409 | A send with this key started and has not finished. Carries retryAfterSeconds. |
Retry with the same key after Retry-After. If it persists past a minute the first attempt was interrupted; check Email → Logs before sending with a new key. |
EMAIL_SEND_OUTCOME_UNKNOWN |
409 | The first send with this idempotency key started more than five minutes ago and never recorded an outcome (the process stopped, or the log write failed), so it is not known whether the provider accepted it. details.id is the email log row, now unknown. |
Check the recipient or your provider's log. If it was not delivered, send again with a new idempotency key. |
EMAIL_DELIVERY_FAILED |
502 | The Application's own provider refused the message or timed out. details.id is the email log row. A repeat with the same idempotency key returns this same failure and sends nothing. |
Fix the provider credentials or From address, then send with a new idempotency key. |
EMAIL_TEMPLATE_INVALID |
400 | Publish found a problem in the draft: an undeclared variable, a non-url variable where it decides a link or image address's scheme or host (href, src, srcset, background, action, formaction, poster, xlink:href, CSS url()), or url variables with no link domains. details.issues lists them. |
Fix the draft and publish again. |
EMAIL_TEMPLATE_KEY_TAKEN |
409 | Create with a key the Application already uses. | Pick another key or edit the existing template. |
EMAIL_TEMPLATE_LIMIT_REACHED |
409 | The Application already holds 200 custom templates. | Delete one you no longer send. |
EMAIL_ADDRESS_SUPPRESSED |
409 | A test send to an address on the suppression list (also the built-in template test send). | Remove the address from Email → Suppressions, or test from another address. |
A publishable-key subscribe, or a secret-key subscribe that names the visitor in X-Rekey-Client-Ip, answers 202 {status:"received"} whatever happened to the address, so none of the outcomes below that depend on who is already on a list reach a browser.
| Code | HTTP | When | How to handle |
|---|---|---|---|
LIST_NOT_FOUND |
404 | No list with that id (operator routes) or key in this Application, or it is archived. On the public routes a secret key also gets details.available, the live keys; a publishable key gets the same code for a list whose Public capture is off or whose Application has no browser origins. |
Use a key or id that exists. For a browser, turn on Public capture (it needs an origin in Access). |
LIST_KEY_TAKEN |
409 | Create with a key the Application already uses, including an archived list's key. | Pick another key, or restore the archived list with DELETE .../lists/:listId/archive. |
CONTACT_LIST_QUOTA_EXCEEDED |
403 | Creating or restoring a list would pass the workspace's maxContactLists (Tenant.limits). Archived lists do not count. |
Archive a list you no longer use, or raise the limit (a super-admin on self-host; the plan on Rekey Cloud). |
LIST_CAPTURE_UNPROTECTED |
409 | Turning on publicCapture for a list on an Application with no browser origins (corsOrigins). |
Add the site that hosts the form in Panel, Access, then turn Public capture on. |
CONTACT_CONSENT_REQUIRED |
400 | Subscribe to a list whose lawful basis is consent without consent: { granted: true, version }. details.currentVersion is the version to send. |
Show the list's consent text and send the version GET /api/v1/lists/:key returned. |
CONTACT_CONSENT_STALE |
409 | The consent.version sent is not the list's current one: the text changed after the form loaded. details.currentVersion. |
Re-fetch the list, show the new text, send its version. |
CONTACT_FIELDS_INVALID |
400 | A field is unknown, missing while required, the wrong type, too long, not one of a select's options, or fields is over 8 KB. details.issues lists each as { path, message }. |
Fix each listed field. Nothing was stored. |
CONTACT_EMAIL_DOMAIN_NOT_ALLOWED |
403 | The address is at a disposable email domain and the list blocks them (blockDisposable, on by default). |
Use a permanent address, or turn blockDisposable off for the list. |
CONTACT_QUOTA_EXCEEDED |
403 | A secret-key subscribe with no visitor address would add a new contact past the workspace's maxContacts. A publishable key, or a secret key naming the visitor, is never told: it gets its usual 202, nothing is stored, and the operator sees an app.contact_quota_reached security event (at most one an hour). |
Erase contacts you no longer need, or raise the limit. Existing contacts still join and leave lists. |
CONTACT_CURSOR_INVALID |
400 | GET /api/v1/lists/:key/members got a cursor it did not issue. |
Pass nextCursor from the previous page unchanged, or omit it to start over. |
LIST_MEMBER_NOT_FOUND |
404 | An operator unsubscribe named a member id that is not on this list. | Take the memberId from GET .../lists/:listId/members. |
CONTACT_NOT_FOUND |
404 | Erase named a contact id that is not in this Application. | Use contactId from a secret-key subscribe response, or data.contact.id from a contact.* webhook. |
CONTACTS_RATE_LIMITED |
429 | A browser subscribe (publishable key, or secret key naming the visitor in X-Rekey-Client-Ip) over 5 a minute or 30 an hour from one address, 120 a minute on one list, or the workspace's contactCaptureDailyCap; or a secret key with no visitor address over 1200 a minute on one list. Carries retryAfterSeconds. |
Wait for Retry-After. Nothing was stored. |
| Code | HTTP | When | How to handle |
|---|---|---|---|
MFA_NOT_ENABLED |
403 | MFA endpoints called but two-factor is not enabled for the Application. | Enable MFA in authConfig first. |
MFA_NOT_INITIATED |
400 | /mfa/setup-confirm called before /mfa/setup. |
Call setup first; confirm with the code from the authenticator. |
STEP_UP_REQUIRED |
401 | A privileged self-service action was attempted without re-proving identity. End-user: enrolling a passkey (publishable callers only — secret-key callers are exempt). Operator: enrolling a passkey, and re-running /tenant/auth/mfa/setup or /tenant/auth/mfa/disable while MFA is enrolled. |
Send password with the account password, or code with a current authenticator or unused backup code. While an operator has MFA enrolled, the MFA routes accept only code — the password is deliberately refused, since someone holding a stolen session and the password is who the second factor exists to stop. |
STEP_UP_UNAVAILABLE |
400 | The account has neither a password nor enrolled MFA, so there is no second factor to confirm with. The access token is its only credential. | Have the user set a password or enroll MFA first, then retry. |
MFA_CODE_INVALID |
401 (sign-in verify, or re-enrolling MFA from a browser) / 422 (setup-confirm) | TOTP or backup code didn't verify, or was not sent when re-running /auth/mfa/setup over a completed enrollment. |
Prompt the user to retry; codes are time-based. |
MFA_CHALLENGE_INVALID |
401 | MFA challenge token invalid or past its 5-minute lifetime. | Restart sign-in to get a new challenge. |
MFA_CODE_REUSED |
401 (sign-in verify, step-up) / 422 (setup-confirm) | The TOTP code matched, but it (or a later code) was already accepted for this user. Every TOTP code works once, including the one that confirmed enrolment. | Wait for the authenticator to show the next code (up to 30 seconds) and send that, or send an unused backup code at sign-in. Not counted toward the MFA lockout. |
MFA_BACKUP_CODE_USED |
401 (sign-in verify) | The code is a backup code this account was issued and has already spent. Each backup code works once. Only sign-in verify names it; step-up and disable keep their usual refusal. | Send a different unused backup code, or the current authenticator code. Counted toward the MFA lockout like any wrong code. |
MFA_CHALLENGE_USED |
401 | The MFA challenge token already completed a sign-in. Each token completes one. | Sign in again to get a new challenge token. |
MFA_CHALLENGE_WRONG_APPLICATION |
401 | Challenge token issued under a different Application. | Fix the credential mix-up. |
MFA_TOO_MANY_ATTEMPTS |
429 | MFA verification throttled after repeated failures. | Wait for Retry-After, then retry. |
| Code | HTTP | When | How to handle |
|---|---|---|---|
WEBAUTHN_NOT_CONFIGURED |
400 | The Application (or the panel) has no WebAuthn RP config — ceremonies can't run. | Configure authConfig.webauthn (or PANEL_WEBAUTHN_RP_ORIGINS for operators). |
WEBAUTHN_CHALLENGE_INVALID |
401 | Challenge unknown, expired, already used, or for a different ceremony. | Restart the ceremony from start…. |
WEBAUTHN_ALREADY_REGISTERED |
409 | This passkey credential is already registered (end-user). | Treat as success or prompt for a different authenticator. |
WEBAUTHN_AUTH_INVALID |
400/401 | Authentication response malformed (400) or didn't verify (401). | Restart authentication. |
WEBAUTHN_REGISTRATION_FAILED |
401 | Registration response didn't verify. | Restart registration. |
PASSKEY_ALREADY_REGISTERED |
409 | Operator-side duplicate authenticator. | Same as above, operator panel flows. |
PASSKEY_AUTHENTICATION_FAILED |
401 | Operator passkey authentication didn't verify. | Restart the ceremony. |
PASSKEY_REGISTRATION_FAILED |
400 | Operator passkey registration didn't verify. | Restart the ceremony. |
PASSKEY_RESPONSE_INVALID |
400 | Operator passkey response missing a credential id. | Send the full WebAuthn response object. |
PASSKEY_UNKNOWN |
401 | No operator account matches that passkey. | Sign in another way and (re-)register the passkey. |
PASSKEY_NOT_FOUND |
404 | Passkey id isn't registered to this account (delete). | List passkeys to get a valid id. |
| Code | HTTP | When | How to handle |
|---|---|---|---|
OAUTH_PROVIDER_UNKNOWN |
404 | Provider name isn't one Rekey knows. | Use a supported provider slug. |
OAUTH_PROVIDER_NOT_CONFIGURED |
400 | Application (or operator panel) has no client id/secret for this provider. | Configure the provider's credentials first. |
OAUTH_NOT_CONFIGURED |
503 | Operator OAuth redirect base not configured on the deployment. | Set the panel OAuth env config. |
OAUTH_NO_EMAIL |
400 | Provider returned no email — can't create/sign in a user. | Ask the user to use a provider account with an email, or another method. |
OAUTH_EMAIL_NOT_VERIFIED |
401 | Provider didn't verify the email — auto-linking to an existing account is refused. | User must verify the email at the provider, or sign in with the original method and link explicitly. |
OAUTH_IDENTITY_TAKEN |
409 | This provider account is already linked to a different user. | Unlink it from the other account first. |
OAUTH_IDENTITY_WRONG_APPLICATION |
401 | Provider account already linked under a different Application. | Fix the credential mix-up. |
OAUTH_UNLINK_WOULD_LOCK_OUT |
409 | Unlinking would leave the account with no sign-in method. | Add a password or another provider before unlinking. |
INVALID_REDIRECT_URI |
400 | MCP OAuth dynamic registration: redirect_uris empty, >20 entries, or a URI fails validation. |
Register 1–20 valid redirect URIs. |
CLIENT_REGISTRATION_DISABLED |
403 | POST /oauth/register on an Application with authConfig.dynamicClientRegistration = false, or on the operator MCP authorization server with OPERATOR_MCP_DYNAMIC_REGISTRATION=disabled. The endpoint exists and the deployment is real; the operator has closed registration, and registration_endpoint is absent from the discovery documents. |
Ask the operator to register your redirect URIs and issue a client_id, or to re-open registration. |
MCP_NOT_FOUND |
404 | No MCP server at this path — the Application is missing, or neither mcpEnabled nor (for the shared OAuth endpoints) oidcEnabled is set. |
Check the Application slug in the MCP URL, and the toggle. |
OIDC_NOT_FOUND |
404 | No OpenID Provider at this path (/.well-known/openid-configuration, /oauth/userinfo). The Application is missing or oidcEnabled is off. |
Set authConfig.oidcEnabled = true, and check the slug. |
OPERATOR_MCP_UNAUTHORIZED |
401 | Operator MCP called without a PAT (rp_op_…) or OAuth access token. |
Pass a valid operator credential. |
MCP_GRANT_INVALID |
400 | Operator-MCP consent POST (/oauth/grant) body didn't parse. |
Send the OAuth params the authorize redirect carried, plus tenant_id and approve. |
MCP_GRANT_INVALID_CLIENT |
400 | Consent named an unknown client_id, or a redirect_uri that client never registered. Deliberately not a redirect — we don't bounce to an unvalidated URI. |
The client must complete RFC 7591 registration first. |
MCP_GRANT_PKCE_REQUIRED |
400 | code_challenge_method wasn't S256. |
Use PKCE with S256; plain is not accepted. |
TENANT_MEMBERSHIP_REQUIRED |
403 | Operator-MCP consent chose a workspace the signed-in operator doesn't belong to. | Pick a workspace from your memberships. |
OAUTH_CLIENT_NOT_FOUND |
404 | Operator-facing OAuth-client management named a client id this Application doesn't have. | List the registered clients to get a current id. |
OAUTH_TOKEN_EXCHANGE_FAILED |
502 | The upstream identity provider refused the authorization-code exchange. The provider's own error code is forwarded in the message; its free-text error_description deliberately is not. |
invalid_grant means the code was already used, expired, or was issued for a different client or redirect URI — start the sign-in again. Otherwise check client id, redirect URI and issuer against what the provider expects. |
OAUTH_ID_TOKEN_INVALID |
502 | The OIDC issuer described an identity in a way that does not bind it to this exchange: an ID token with no sub, an iss that is not the configured issuer, an aud without this client id, an azp naming another party when the token has several audiences, or an exp in the past (a minute of clock skew is allowed). Also raised when a userinfo response carries no sub. Refused rather than used, because an empty subject would fold every user of the provider onto one account. |
Check the issuer URL and client id configured for the provider against what the identity provider issues. |
INVALID_GRANT_REQUEST |
400 | Operator-MCP OAuth: unknown client_id, or a redirect_uri not registered for it. From POST /oauth/authorize/grant also a scope nothing can grant, or a bad organization_id. When the client and redirect_uri were valid, details carries oauth_error and the registered redirect_uri. |
Register the client and its exact redirect URI at POST /oauth/register. A hosted authorize page redirects the browser only when details is present, with error=<oauth_error> and the original state; without it, show the error on your own page. |
SESSION_HANDOFF_FORBIDDEN |
403 | A publishable key tried to hand off a session. Publishable keys ship in browsers, so this is refused by key kind, not by scope. | Call it from your server with the Application secret key. |
OIDC_ASSERTION_INVALID |
401 | An operator ID-Token assertion (one-click handoff) failed validation, or was replayed. These are single-use and short-lived. | Start the sign-in again from the site that sent the operator here. |
OIDC_ASSERTION_NOT_CONFIGURED |
404 | This deployment doesn't accept ID-Token assertions. | Set OPERATOR_OIDC_ISSUER and OPERATOR_OIDC_CLIENT_ID to opt in. |
| Code | HTTP | When | How to handle |
|---|---|---|---|
BILLING_DISABLED |
403 | Billing endpoints called but billing isn't enabled for the Application. | Enable billing in the panel. |
CHECKOUT_RETURN_URL_INVALID |
400 | Checkout's successUrl or cancelUrl parsed but is not http(s) (javascript:, data:, …). Nothing was created. |
Pass an absolute https URL on an origin the Application registered. |
CHECKOUT_RATE_LIMITED |
429 | Too many checkouts started: 10 an hour or 30 a day by this end-user, CHECKOUT_LIMIT_PER_IP_HOUR (default 20) from this client address, or CHECKOUT_LIMIT_PER_APP_HOUR (default 1000) for the Application. Applies to every provider and both checkout pages. A checkout that failed does not count. |
Retry after the time the fix names; finish or abandon an open checkout instead of starting new ones. |
CHECKOUT_EMBEDDED_NOT_READY |
409 | The Application has the Rekey checkout page on for this checkout's payment mode, its failure behaviour is "refuse", and a readiness check failed. Nothing was created at the provider and nothing was reserved. | details.check names the failed check. The operator sees its message and repair in Panel → Application → Billing → Setup → Checkout page and in the app.checkout_embedded_refused security event; the buyer is not shown them. |
CHECKOUT_EMBEDDED_FELL_BACK |
warning, 200 | Not an error: a warnings[] entry. The Rekey checkout page is on, a readiness check failed, and url is the provider's page instead. check names the check. |
Follow the fix. The Checkout page tab under Panel → Application → Billing lists every check. |
CHECKOUT_READINESS_FAILED |
409 | Switching a payment mode to the Rekey checkout page while one of its readiness checks FAILs. details.checks lists all of them. |
Fix each FAIL (each carries its own fix), then switch again. Switching back to the provider's page is never refused. |
CHECKOUT_SESSION_NOT_FOUND |
404 | No Rekey checkout page exists for this token (unknown, malformed, or the Application is disabled). | Start the checkout again from the app. |
CHECKOUT_SESSION_COMPLETE / CHECKOUT_SESSION_EXPIRED |
409 | The checkout page's session is already paid, or can no longer be paid (24 hours passed, or it was replaced). | Return to the app. |
CHECKOUT_MODE_MISMATCH |
409 | The checkout was started on sandbox credentials and the provider's credentials are now live, or the reverse. The session is expired and stays refused. | Start the checkout again so it runs on the current credentials. |
CHECKOUT_CONFIRMATION_REFUSED |
409 | The checkout page reported an approval the provider does not confirm for this checkout. PayPal: another subscription or order, a subscription id on a one-time checkout or the reverse, another plan, buyer, amount or currency, or not approved yet. Razorpay: the handler's signature does not verify with the key secret, it names another subscription or order or the wrong kind of id, Razorpay's record of the payment is for another order, amount or currency, or Rekey could not read the Application's Razorpay credentials. Stripe: another Checkout Session, Application, buyer, plan or amount, or the session is not complete yet. Nothing was activated or captured. |
PayPal, when it is only not approved yet: approve again with the PayPal button on the page, or continue on PayPal; otherwise return to the app and start the checkout again. Razorpay: do not pay again; a payment Razorpay took completes from its webhook within a few minutes, or Razorpay refunds it. Stripe, when it is only not complete yet: pay again with the form on the page, or continue on Stripe. |
CHECKOUT_PAYMENT_IN_PROGRESS |
409 | This buyer's earlier subscription checkout for the same plan was approved at PayPal or Razorpay, or completed at Stripe (including a bank debit still settling), and is waiting for the provider's webhook. A second checkout would create a second subscription. | Wait a few minutes for the subscription to activate (GET /billing/subscription); do not start another checkout. |
CHECKOUT_PAYMENT_STATUS_UNAVAILABLE |
503 | PayPal, Razorpay or Stripe could not be asked whether an earlier open subscription checkout for this plan was paid, so a new one was not started. | Retry in a minute. |
CHECKOUT_CONFIRMATION_LIMIT |
409 | Five approvals for this checkout were refused (another subscription or order id, the wrong kind of id, another plan or buyer, another amount or currency, or for Razorpay a signature that does not verify), so it takes no more. Not-yet-approved answers, checks Rekey could not run because the Application's credentials cannot be read, and Stripe refusals from the page-load return path do not count. | Start the checkout again from the app. |
CHECKOUT_EMBEDDED_UNSUPPORTED |
400 | The Rekey checkout page was asked for a flow the provider cannot take there. | Use the provider's page for that purchase (mode: 'redirect'). |
CHECKOUT_FALLBACK_UNAVAILABLE |
409 | The checkout page asked for the provider's own page for this order and none was recorded, or Stripe could not open one, or a Stripe checkout expires in under half an hour. | Try again in a moment, or start the checkout again from the app. |
CHECKOUT_RETURN_URL_UNREGISTERED |
warning, 200 | Not an error: a warnings[] entry on a checkout that went through. successUrl or cancelUrl is on an origin the Application has not registered (App URL, redirect URLs, or its hosted portal). The next minor release refuses this with 400 CHECKOUT_RETURN_URL_NOT_ALLOWED. |
Add the origin to the Application's redirect URLs (Panel → Application → Auth) or set it as the Application URL. See billing.md. |
BILLING_ORGANIZATION_REQUIRED |
400 | The Application bills per organization (billingSubject: 'org') but checkout had no organizationId. Also an external billing subscription.activated for a new subscription id (or one naming a different plan or subscriber) with no subscriber.organizationId: recorded on the receipt, left unprocessed and answered 500, so the sender retries. A renewal under the subscription id Rekey already holds keeps that row's organization and need not name it. |
Pass the active org's id to createCheckout; read billingConfig.billingSubject from applications.me(). For an external event, re-send it under the same eventId with subscriber.organizationId. |
PROVIDER_CANCEL_FAILED |
502 | Deleting an end-user was refused because the payment provider would not cancel their still-active subscription. The user was NOT deleted. | Cancel it in the provider dashboard, or fix the stored credentials, then retry. To satisfy an erasure request without waiting on the provider, use the erase endpoint — it tombstones the user and does not block. |
BILLING_PROVIDER_SWITCH_BLOCKED |
409 | Checkout named a provider other than the one this buyer is bound to, anywhere in the Application. A subscription cannot move between processors, so this would create a second one and bill twice. One-off purchases (credit packs, perpetual licences) neither trigger this nor cause it. Only raised when the bound provider is still enabled; when it is not, BILLING_BOUND_PROVIDER_UNAVAILABLE is raised instead, whether or not the caller named one. |
Check out through the bound provider, which is provider on the active subscription, or cancel that subscription, let it terminate, and start a new one elsewhere. A checkout that names no provider is pinned to the bound one instead of refused. When the binder is a PENDING checkout rather than a paid subscription the message says so and there is nothing to cancel: finish it, or let it expire. |
BILLING_SUBSCRIPTION_SUBJECT_CONFLICT |
409 | Checkout, or an operator or free-tier grant: the buyer already holds a live subscription to this plan billed to a different subject — their personal account when the checkout names an organization, or another organization. A subscription to one plan is stored once per buyer, so this checkout or grant would MOVE the existing one rather than start a second: the current holder would lose the entitlement while the provider kept charging for it. An external billing subscription.activated hitting the same conflict is not answered with this status: it is acknowledged, recorded on the receipt as BILLING_SUBSCRIPTION_SUBJECT_CONFLICT, and applies nothing. |
Cancel the existing subscription and let it terminate before starting one for a different billing subject, or bill the two subjects on separate plans. GET /billing/subscription (with organizationId for an org's) says which subject holds it. |
BILLING_FREE_TIER_ALREADY_CLAIMED |
409 | POST /billing/subscribe for a free plan that grants credits or a licence, naming a beneficiary other than the one this end-user already claimed it for. Such a plan is claimable once per person across their personal account and every organization they administer, and the claim survives cancellation. Reactivating for the beneficiary that holds the claim is allowed and issues nothing new. Free plans with only FEATURE or USAGE entitlements are not limited. |
An operator can grant the plan to the other beneficiary (POST /api/v1/tenant/applications/:id/end-users/:euid/subscriptions with organizationId), or the buyer can purchase a plan for it through checkout. |
BILLING_BOUND_PROVIDER_UNAVAILABLE |
409 | The buyer is bound to a provider that is no longer configured or enabled for the Application, so no checkout can be issued for them. Answered before the caller's own provider is judged, because it does not depend on what they asked for. Distinct from BILLING_PROVIDER_NOT_AVAILABLE, which is about a provider the CALLER named. Also returned where an Application has NO enabled providers at all and the buyer is bound to one of them; that case previously answered 400 BILLING_CREDENTIALS_NOT_CONFIGURED, which named a provider unrelated to this buyer. |
Depends on which state it is, and the message says which. DISABLED: re-enable that provider, or cancel the subscription there, let it terminate, and buy again through an enabled one. DELETED: re-add the credentials, which restores both cancellation and checkout because a re-added credential is enabled unless you say otherwise. While they are deleted, any buyer whose row carries a providerSubId (a paid subscription or a trial alike) can neither buy nor cancel, because cancellation dials the processor; cancelling in the processor's own dashboard leaves the subscription live in Rekey. Three binder states get three wordings on both this and BILLING_PROVIDER_SWITCH_BLOCKED: a paid subscription, a trial (which has not paid, though it is cancellable while the credentials exist), and an unfinished checkout. The last is identified by status = PENDING AND providerSubId = null, not by status alone: Stripe's paused and every unrecognised status also map to PENDING while keeping the id, and those rows are paid and cancellable. A genuine unfinished checkout has nothing provider-side to dial, so it stays cancellable whatever the credentials say, and its text says no completed payment has been RECORDED rather than that nothing was charged, because PENDING means no completion has been applied, which a lost webhook also produces. Note that completions do not consult enabled, so a checkout opened at a provider that is later DISABLED still completes; only DELETED credentials refuse it. |
BILLING_PROVIDER_NOT_AVAILABLE |
400 | Checkout requested a provider that isn't enabled for this Application. | Omit provider (geo router picks) or use one from billing.getProviders(). |
BILLING_CREDENTIALS_MODE_CONTRADICTED |
400 | The submitted mode disagrees with what the credential itself says (e.g. an sk_live_… key sent as mode: test). The key decides — the provider SDK authenticates with the key, not with our label. |
Send the credentials for the mode you meant, or omit mode and let it be read from the key. |
BILLING_CREDENTIALS_NOT_CONFIGURED |
400/404/503 | No credentials for the provider. 400 on checkout and on webhook auto-registration; 404 when an operator route addresses credentials that were never stored (e.g. setting the mode on them); 503 when a provider webhook arrives for an Application with no BYO credentials / webhook secret. There is no stub fallback in any environment. | Set the provider credentials + webhook secret in the panel. |
BILLING_CREDENTIALS_INVALID |
400 | Submitted credentials fail shape validation (wrong fields for the provider). | Match the documented per-provider body shape. |
BILLING_CREDENTIALS_DECRYPT_FAILED |
500 | Stored credentials can't be decrypted (e.g. ENCRYPTION_KEY changed). |
Re-save the credentials; keep ENCRYPTION_KEY stable. |
BILLING_CREDENTIALS_PROVIDER_MISMATCH / BILLING_CREDENTIALS_SHAPE_INVALID |
500 | Stored credentials are internally inconsistent. | Re-save the provider credentials. |
BILLING_WEBHOOK_AUTOCONFIG_UNSUPPORTED |
400 | Automatic webhook registration isn't supported for this provider. | Create the webhook in the provider dashboard and paste the secret. |
BILLING_WEBHOOK_BASE_NOT_PUBLIC |
400 | Webhook auto-config needs a public base URL but the deployment's base is private/localhost. | Use a public URL (or a tunnel in dev). |
BILLING_WEBHOOK_REGISTRATION_FAILED |
502 | The provider's API rejected webhook registration. | Read the message; fix credentials/permissions at the provider, retry. |
BILLING_PROVIDER_ERROR |
502 | A call to the payment provider's API failed: checkout, plan registration, or subscription cancellation. Most often the stored credentials are wrong or for the other mode (live keys with mode=test). Branch on this for "the provider, not the caller, is at fault": before 2.0.0-rc.3 these surfaced as 500 INTERNAL_ERROR or as the provider's own 401 relabelled BAD_REQUEST, so there was no billing-specific code to switch on. |
Operator responses carry the provider's own message; end-user responses deliberately do not (it can contain credential fragments); those are in the server log against the requestId. Re-check the Application's billing credentials + mode, then retry. On plan creation the plan already exists, inactive with registration FAILED: details carries planId, planSlug and registrationStatus, and the retry is POST /api/v1/tenant/applications/:id/plans/:slug/register, not a second create (which answers PLAN_SLUG_TAKEN). |
BILLING_PROVIDER_UNKNOWN |
400 | A provider name that isn't in the module registry at all (distinct from BILLING_PROVIDER_NOT_AVAILABLE, which is a real provider this Application hasn't enabled). |
Use a registered provider name. |
BILLING_PROVIDER_INBOUND_ONLY |
400 | An outbound call (checkout, plan registration) reached the external billing provider, which only receives events. The router never picks it, so this means a caller named it explicitly, a row bound to it was asked to do something only a hosted provider can, or it is the only enabled provider and a checkout or GET /billing/trial-eligibility needed a hosted one. From trial eligibility the message says no trial can be started through Rekey, since that is the question asked. |
Do it in your billing system, which then posts the resulting event (a trial it runs arrives as subscription.activated with trialEndsAt). For self-serve checkout or trials, connect a hosted provider. |
SUBSCRIPTION_MANAGED_EXTERNALLY |
409 | A cancel through Rekey (portal, operator, MCP) for a subscription an external billing system created. Rekey cannot stop the money there, and cancelling only the entitlement would leave the buyer paying for nothing. | Cancel in the billing system that sold it; it posts subscription.canceled and Rekey mirrors the change. |
BILLING_DISCOUNT_UNSUPPORTED |
400 | Checkout carried a couponCode, but the provider it routed to cannot apply a discount on that flow (PayPal and Razorpay: recurring subscriptions). Nothing is charged and nothing is redeemed. |
Retry without the coupon, or pass an explicit provider that supports it — see capabilities.discounts in GET /billing/providers. |
SUBSCRIPTION_NOT_FOUND |
404 | Subscription id unknown in this Application/workspace. | List subscriptions to get a valid id. |
SUBSCRIPTION_CHECKOUT_UNFINISHED |
409 | POST /billing/subscription/cancel named, by subscriptionId, a checkout that was never completed (PENDING with no provider subscription). It bills nothing and grants nothing. |
Leave it; a new checkout for the same plan reuses it. GET /billing/subscriptions lists what can be cancelled. |
BILLING_CHECKOUT_RACE |
409 | Two checkouts for the same account raced and another one bound the subscription to a provider first. Serialised deliberately so concurrent checkouts cannot reach two processors. | Retry. The retry is sent to the same provider as the checkout that won. |
BILLING_ORGANIZATION_NOT_ACCEPTED |
400 | Checkout named an organization, but this Application bills individual users (billingSubject: user). |
Drop the organization from the call, or switch the Application to organization billing. |
BILLING_PROVIDER_REFUSED |
502 | The provider rejected the operation. The provider's own error name is forwarded when it sends one. | INSTRUMENT_DECLINED means the customer's payment method was declined and they should use another. Otherwise check the Application's billing credentials and retry; the full provider response is in the server log. |
BILLING_TRIAL_NOT_APPLICABLE |
400 | The plan carries a trial but the purchase is one-off, so there is no subscription for the trial to convert into. | Remove the trial from the plan, or make it a subscription plan. |
BILLING_TRIAL_UNSUPPORTED |
400 | The plan offers a free trial and the routed provider cannot start a subscription in one. | Route the plan to a provider that supports trials, or remove the trial. |
BILLING_SUBJECT_CHANGE_BLOCKED |
409 | Switching an Application between user-billing and organization-billing while live subscriptions exist on the current subject. Blocked because the change would strand them. | Cancel or migrate the named subscriptions first, then change the subject. |
SUBSCRIPTION_PERIOD_END_IN_PAST |
400 | An operator-granted subscription was given a period end earlier than now. | Pass a future end date, or omit it for an open-ended grant. |
An unapplied payment is money that arrived without Rekey being able to attribute it to a subscription. Each one is a case an operator resolves: refund it, or extend the payer's subscription.
| Code | HTTP | When | How to handle |
|---|---|---|---|
UNAPPLIED_PAYMENT_NOT_FOUND |
404 | No unapplied payment with that id in this Application. | Reload the list. The id may belong to another Application, or the case may have been removed with its payment. |
UNAPPLIED_PAYMENT_ALREADY_RESOLVED |
409 | The case was already resolved. Refused rather than repeated, because resolving twice would refund or grant twice. | Reload the list to see the current state. |
UNAPPLIED_PAYMENT_UNATTRIBUTED |
409 | Rekey could not work out which customer the payment came from, so it cannot extend anyone. | Find the payer in the provider dashboard using the charge id, then either refund them there and dismiss the case, or extend their subscription from their end-user page. |
UNAPPLIED_PAYMENT_NO_SUBSCRIPTION |
409 | The payer has no subscription to extend. | Refund the payment instead, or create a subscription for them first and then extend it. |
BILLING_PAYMENT_NOT_REFUNDABLE |
409 | The payment has no provider charge id, so Rekey has nothing to ask the provider to refund. For a Stripe invoice it also means the invoice was paid partly or wholly outside Stripe, or by several separate payments. | Refund it in the provider dashboard directly, then dismiss the case with a note saying so. |
BILLING_REFUND_UNSUPPORTED |
409 | Rekey cannot issue refunds through this provider. | Refund it in the provider dashboard directly, then dismiss the case with a note saying so. |
BILLING_REFUND_AMOUNT_EXCEEDED |
400 | The requested amount is more than the un-refunded remainder of the payment. | Lower the amount, or leave it blank to refund everything that remains. |
BILLING_PAYMENT_ALREADY_REFUNDED |
409 | The provider has already refunded this charge in full. | Nothing to do: the buyer has their money. Resolve the case as refunded. |
BILLING_REFUND_ALREADY_REQUESTED |
409 | This exact refund was already sent to the provider. Refused because a second one would pay the buyer twice. | Check the payment in the provider dashboard for the outcome. |
BILLING_REFUND_WINDOW_CLOSED |
409 | The provider will not refund a payment this old (Razorpay caps at 6 months). | The provider cannot move this money back. Settle with the buyer another way, or extend their entitlements instead and resolve the case that way. |
BILLING_REFUND_REJECTED |
502 | The provider refused the refund. A charge outside its window, or one whose funding source is gone, lands here. | Check the charge in the provider dashboard; it has to be settled with the buyer another way. |
| Code | HTTP | When | How to handle |
|---|---|---|---|
PLAN_NOT_FOUND |
404 | Plan slug unknown for this Application. | GET /billing/plans for the plans on sale; the operator sees every plan in Panel → Plans or GET /api/v1/tenant/applications/:id/plans. |
PLAN_SLUG_INVALID |
400 | Plan slug fails the regex (lowercase alphanumerics + - + _). |
Fix the slug. |
PLAN_SLUG_TAKEN |
409 | Plan slug already exists for this Application. The message says whether that plan is archived or failed provider registration (which leaves it inactive but not archived). | Pick another slug — or, if the existing plan failed provider registration, repair it in place (PATCH .../plans/:slug, then POST .../plans/:slug/register). The fix says which. |
PLAN_AMOUNT_INVALID |
400 | Plan amount is negative. (Amount is the smallest currency unit — integers only.) | Pass an integer ≥ 0. |
PLAN_INACTIVE |
400 | Checkout requested for a deactivated plan. | Point checkout at an active plan, or reactivate this one (Panel → Plans, or PATCH /api/v1/tenant/applications/:id/plans/:slug with { "active": true }). A plan whose provider registration failed or never finished cannot be reactivated that way (PLAN_NOT_REGISTERED_WITH_PROVIDER); register it with POST .../plans/:slug/register, which puts it back on sale. The fix says which applies. |
PLAN_NOT_REGISTERED_WITH_PROVIDER |
409 | The plan has no price at the payment provider — its registration was refused, or it predates the registration column. Raised at checkout, and when an operator tries to activate such a plan. | Operator: fix the provider credentials if they were the cause, then POST /api/v1/tenant/applications/:id/plans/:slug/register. |
PLAN_PRICE_IMMUTABLE |
409 | PATCH tried to change amount/currency/interval on a plan already registered with a provider. |
A provider price object cannot be re-priced. Retire the plan ({"active": false}) and create a replacement. Price edits are accepted only while the plan is unregistered. |
PLAN_CREDITS_AMOUNT_REQUIRED |
400 | CREDIT-kind plan missing a positive credit amount. | Set creditsAmount. |
PLAN_LICENSE_KIND_REQUIRED / PLAN_LICENSE_DURATION_REQUIRED / PLAN_LICENSE_SEATS_REQUIRED |
400 | LICENSE-kind plan missing its kind / TIMED duration / SEATS count. | Fill the license config for the chosen kind. |
PLAN_USAGE_CONFIG_REQUIRED / PLAN_USAGE_METER_UNKNOWN |
400 | USAGE-kind plan missing usage config, or referencing a meter that doesn't exist. | Create the meter first; reference its slug. |
PLAN_ENTITLEMENT_INVALID |
400 | Entitlement row fails validation for its kind. | Match the entitlement schema for the kind. |
PLAN_ENTITLEMENT_NOT_FOUND |
404 | Entitlement id not on this plan. | List the plan's entitlements. |
DEFAULT_PLAN_NOT_FOUND |
400 | Setting an Application's free-tier defaultPlanSlug to a plan that doesn't exist or isn't active. |
Pass an existing active plan slug, or null to clear it. |
BILLING_FREE_PLAN_NOT_FREE |
409 | The free tier must cost nothing: setting defaultPlanSlug to a plan with a nonzero amount or a pricePerUnitCents, or POST /billing/subscribe on an Application whose default plan charges money. |
Nominate a plan with amount 0 and no pricePerUnitCents, or null for no free tier. Sell a priced plan through POST /billing/checkout. |
NO_BILLING_PROVIDER |
— | Checkout blocker, not an HTTP error: appears in a plan's checkout.blockers[] when the Application has no provider configured, so nothing can be bought. |
Connect Stripe, PayPal or Razorpay on the Billing tab. Plans created before that need registering afterwards. |
PLAN_NOT_REGISTERED |
— | Checkout blocker: the plan has no provider price behind it, so a buyer sent to checkout is refused. Plans register when they are created, so this is a plan that existed before the credentials did. | Repair it in place with POST /api/v1/tenant/applications/:id/plans/:slug/register. |
PLAN_REGISTRATION_FAILED |
— | Checkout blocker: the provider refused to register the plan, so it has no price. The provider's objection is carried in the message. | Fix what the provider objected to, then retry with POST /api/v1/tenant/applications/:id/plans/:slug/register. |
PROVIDER_INBOUND_ONLY |
— | Checkout blocker: the only provider configured is the external billing provider, which receives events and cannot host a checkout. Reported on every plan, since it is a property of the provider rather than of the plan. | Sell through your own billing system and let it post subscription.activated, or connect a hosted provider for self-serve checkout. The HTTP refusal for the same cause is BILLING_PROVIDER_INBOUND_ONLY. |
| Code | HTTP | When | How to handle |
|---|---|---|---|
COUPON_NOT_FOUND |
404 | Code unknown for this Application (or no active auto-apply match). | Check the code; list coupons in the panel. |
COUPON_CODE_INVALID |
400 | Create — code fails the regex (alphanumerics + _-, ≤ 40 chars). |
Fix the code. |
COUPON_AMOUNT_INVALID |
400 | Create — amountOff < 0, or PERCENT > 10000 (basis points). |
Fix the amount. |
COUPON_CODE_TAKEN |
409 | Create — (applicationId, code) collision. |
Pick another code. |
COUPON_INACTIVE |
400 | Validate — active === false. |
Surface "coupon not valid" to the user. |
COUPON_NOT_YET_STARTED / COUPON_EXPIRED |
400 | Validate — outside the startsAt/endsAt window. |
Surface to the user. |
COUPON_NOT_APPLICABLE |
400 | Validate — plan slug not in the coupon's planSlugs. |
Surface to the user. |
COUPON_CURRENCY_REQUIRED |
400 | Create: an AMOUNT coupon was sent without currency. Validate and checkout: an AMOUNT coupon created before currency was required has none, so it is refused rather than applied to a plan in any currency. |
On create, send the 3-letter ISO 4217 code of the plans it should discount. At checkout, surface to the user; the operator deactivates the coupon and creates a replacement with a currency. |
COUPON_CURRENCY_INVALID |
400 | Create: currency is not an ISO 4217 currency code. |
Send a 3-letter ISO 4217 code such as USD. |
COUPON_CURRENCY_MISMATCH |
400 | Validate — AMOUNT coupon in a different currency than the plan. | Surface to the user. |
COUPON_REDEMPTION_LIMIT_REACHED / COUPON_USER_LIMIT_REACHED |
400 | Validate — total / per-user redemption cap hit. COUPON_REDEMPTION_LIMIT_REACHED also fires at checkout when every remaining redemption is reserved by a checkout already in progress: the global limit counts recorded redemptions plus in-flight checkouts, so a one-use coupon cannot be discounted by five concurrent buyers. Reservations expire within 30 minutes, so an abandoned checkout frees its slot. |
Surface to the user. If the coupon has redemptions left on paper, another buyer is mid-checkout — retrying shortly may succeed. |
COUPON_NO_DISCOUNT |
400 | Checkout — the discount rounds down to zero at this price (a small PERCENT coupon on a cheap plan). | Use a coupon worth at least one unit of the plan currency. Nothing is redeemed. |
COUPON_FULL_DISCOUNT_UNSUPPORTED |
400 | Checkout — the coupon covers 100% of a ONE-TIME purchase, and no provider will check out a zero-value order. | Use a smaller coupon, or grant the credits/licence directly. (A 100% coupon on a recurring plan is fine.) |
COUPON_CHECKOUT_ALREADY_OPEN |
409 | Checkout — this end-user already has a live checkout holding this coupon's reserved slot. One buyer cannot hold two reservations against the same code; the discount already minted is still payable. | Finish or abandon the open checkout — the reservation frees itself within 24 hours. Do not advise a different coupon; the one they have still works. |
COUPON_DISCOUNT_EXCEEDS_PRICE |
500 | Checkout — the discount came out larger than the plan. This is a Rekey bug; the coupon service is supposed to clamp it. | Report it with the coupon code and plan slug. |
COUPON_PROVIDER_REJECTED |
502 | Checkout — the payment provider refused to create the ad-hoc discount object (currency or amount restrictions on the operator's account). Nothing is charged and nothing is redeemed. | Retry without the coupon, or check the provider account. Previously surfaced as an opaque 500. |
| Code | HTTP | When | How to handle |
|---|---|---|---|
USAGE_METER_NOT_FOUND |
404 | Meter slug unknown for this Application. | Create the meter, or check the slug. |
USAGE_METER_INACTIVE |
400 | Recording against a deactivated meter. | Reactivate or use another meter. |
USAGE_METER_SLUG_INVALID |
400 | Meter slug fails the regex. | Fix the slug. |
USAGE_METER_SLUG_TAKEN |
409 | Meter slug already exists. | Pick another slug. |
USAGE_QUOTA_EXCEEDED |
402 | Record would exceed the subject's included quota (record-time hard cap). | Upsell: prompt an upgrade or credit purchase; don't retry the same record. |
USAGE_SUBJECT_AMBIGUOUS |
400 | Both endUserId and organizationId passed. |
Pass at most one. |
USAGE_CHARGE_TOO_LARGE |
400 | Quantity multiplied by the meter's creditsPerUnit exceeds the maximum chargeable amount. Refused at the edge rather than overflowing the ledger. |
Record the usage in smaller batches, or lower the meter price. |
USAGE_RECORD_BUSY |
503 | A capped record waited more than 2s for another record of the same subject and meter, so it gave up. Nothing was recorded or charged. | Retry after Retry-After. Safe to retry, with or without an idempotency key. |
USAGE_IDEMPOTENCY_KEY_REUSED |
409 | The record's idempotency key was already spent against the credit ledger. Keys are namespaced per meter, so this means a direct credits call already used it. | Use a fresh idempotency key for this usage record. |
USAGE_METER_PRICE_INVALID |
400 | creditsPerUnit is not a non-negative whole number of credits (or null). |
Use a whole number like 1, or null to stop charging for the meter. |
USAGE_NEGATIVE_ON_PRICED_METER |
400 | A negative quantity was recorded on a priced meter. Corrections that move money have to be auditable, so they can't ride in as negative usage. | Refund the credits with an explicit credits adjustment, which records who did it and why. |
USAGE_OCCURRED_AT_IN_FUTURE |
400 | occurredAt is in the future. |
Record usage as it happens, or omit occurredAt to use the current time. |
USAGE_OCCURRED_AT_TOO_OLD |
400 | occurredAt predates the current quota period, so it cannot be attributed to it. |
Record usage within the calendar month it happened in. Backfilling an earlier period is an operator correction, not a record. |
| Code | HTTP | When | How to handle |
|---|---|---|---|
CREDITS_SUBJECT_REQUIRED |
400 | Neither endUserId nor organizationId given. |
Pass exactly one subject. |
CREDITS_AMOUNT_INVALID |
400 | Consume amount not a positive integer (or grant amount zero). | Whole credits only. |
CREDITS_INSUFFICIENT |
402 | Balance below the requested drawdown (atomic guard — never overspends). | Prompt a credit-pack purchase; don't retry the same consume. |
| Code | HTTP | When | How to handle |
|---|---|---|---|
LICENSE_NOT_FOUND |
404 | License id unknown (operator routes). | List licenses for the Application. |
LICENSE_ACTIVATION_NOT_FOUND |
404 | No activation with that id under that license (operator release route). | List the license's activations. |
LICENSE_REVOKED |
409 | Key rotation attempted on a revoked license. | Issue a new license instead. |
LICENSE_EXPIRES_AT_REQUIRED |
400 | TIMED license created without expiresAt. |
Set the expiry. |
LICENSE_SEATS_REQUIRED |
400 | SEATS license created without a seat count. | Set the seats. |
Note: the public licenses.verify endpoint never throws for an invalid key: it returns 200 with { ok: false, reason: 'unknown' | 'wrong_application' | 'revoked' | 'expired' | 'seats_exhausted' | 'suspended' }. suspended means an operator banned the end-user who holds the key; organization-pooled licences are not affected. Branch on result.ok.
| Code | HTTP | When | How to handle |
|---|---|---|---|
ORGANIZATIONS_NOT_ENABLED |
400 | Org endpoints called, or a custom organization role authored, while orgs are off for the Application. | Enable organizations in the panel (Application → Auth), PATCH /tenant/applications/:id/auth-config with organizationsEnabled: true, or the update_auth_config MCP tool. |
ORGANIZATION_NOT_FOUND |
404 | Org id unknown in this Application. | Check the id via organizations.listMine. |
ORGANIZATION_NOT_MEMBER |
403 | Caller isn't a member of the org. | Only members can read/act; invite them first. |
ORGANIZATION_ROLE_INSUFFICIENT |
403 | Caller's role tier can't perform this action. Compared on baseRole, not on the role name, so a custom role named studio-lead on tier MEMBER is refused exactly like MEMBER. |
Requires an OWNER- or ADMIN-tier role (per action). |
ORGANIZATION_ALREADY_MEMBER |
409 | Inviting/adding someone already in the org. | Treat as success in UI. |
ORGANIZATION_MEMBER_NOT_FOUND |
404 | Target user isn't a member. | Refresh the member list. |
ORGANIZATION_LAST_OWNER |
409 | Demote/remove attempt against the only OWNER-tier member. Counted across every role whose baseRole is OWNER, not just the built-in OWNER. |
Promote another member to an OWNER-tier role first. |
ORGANIZATION_OWNER_CANNOT_LEAVE |
409 | An OWNER tried to leave (payment + benefits are tied to the owner). | Transfer ownership first. |
ORGANIZATION_SLUG_INVALID / ORGANIZATION_SLUG_TAKEN |
400 / 409 | Slug fails the regex / collides. | Fix or change the slug. |
ORGANIZATION_INVITATION_NOT_FOUND |
404 | Invitation token/id unknown. | Re-issue the invitation. |
ORGANIZATION_INVITATION_EXPIRED / ORGANIZATION_INVITATION_NOT_USABLE |
400 | Invitation expired / revoked / already accepted. | Re-issue the invitation. |
ORGANIZATION_INVITATION_WRONG_APPLICATION |
401 | Invitation belongs to a different Application. | Fix the credential mix-up. |
ORGANIZATION_INVITATION_EMAIL_MISMATCH |
403 | The signed-in user's email is not the address the invitation names. Invite links travel by email or chat and can be forwarded, so acceptance is bound to the invited address. | Sign in as the invited address, or have an OWNER / ADMIN re-invite the address in hand. |
Per (organization, end-user). See the two-axis table in auth.md → Roles for why these are distinct from the application roles below.
| Code | HTTP | When | How to handle |
|---|---|---|---|
ORGANIZATION_ROLE_UNKNOWN |
400 | A role name was assigned that this Application's catalog doesn't define. | GET /users/me/organizations/roles lists the assignable names. |
ORGANIZATION_ROLE_NOT_FOUND |
404 | Editing/deleting a role name that doesn't exist. | List the catalog to confirm the name. |
ORGANIZATION_ROLE_NAME_INVALID |
400 | Name fails the regex (lowercase, 2–40 chars, alphanumeric edges). | Fix the name. |
ORGANIZATION_ROLE_NAME_TAKEN |
409 | Role name collision within the Application. | Pick another name. |
ORGANIZATION_ROLE_NAME_RESERVED |
409 | Tried to create OWNER, ADMIN or MEMBER (case-insensitive). |
Those three are built in; pick a distinct name and set baseRole to the tier you wanted. |
ORGANIZATION_ROLE_BUILT_IN_IMMUTABLE |
400 | Re-tiering or deleting a built-in. | Built-ins are permanent. Create a custom role instead. |
ORGANIZATION_ROLE_IS_DEFAULT |
400 | Deleting the Application's default organization role. | Mark another role default first. |
ORGANIZATION_ROLE_IN_USE |
400 | Deleting a role that memberships or pending invitations still reference, with no reassignTo. |
Pass reassignTo, which moves holders and drops the role in one transaction. |
ORGANIZATION_ROLE_REASSIGN_SELF / ORGANIZATION_ROLE_REASSIGN_TARGET_UNKNOWN |
400 | Reassign target is the role being deleted / unknown. | Pick a valid target role. |
ORGANIZATION_ROLE_DISABLED |
403 / 400 | 403 on an organization-scoped request from someone whose role has been disabled. 400 when a disabled role is assigned, or an invitation naming one is redeemed. The role stays on the membership, so re-enabling restores the holder. | Ask an operator to re-enable the role, or an OWNER to move you to another one. |
ORGANIZATION_ROLE_RETIER_ORPHANS_OWNERS |
409 | Disabling a custom role, or moving it off the OWNER tier, when some organization's only owner holds it. Neither a re-tier nor a disable changes a membership row, so the per-member last-OWNER guard never runs and this is the only thing standing between the edit and an ownerless organization. | Give those organizations another OWNER-tier member first, or create a second OWNER-tier role and move them to it. |
ORGANIZATION_ROLE_REASSIGN_DEMOTES_OWNER |
400 | Bulk-reassigning an OWNER-tier role to a lower tier, which would leave organizations with no owner. The per-member last-OWNER guard does not sit on this path. | Pick a target whose baseRole is also OWNER, or move those members individually first. |
NO_ORGANIZATION_ROLES |
500 | Application has no organization roles at all (invariant breach). | Contact support. The three built-ins are seeded with the Application. |
One value per (Application, end-user), governing EndUser.role. Served at
/tenant/applications/:id/application-roles; the former /end-user-roles path
still works. The END_USER_ROLE_* codes below are unchanged. They are wire
contract that integrations match on, and were left alone deliberately when the
route was renamed.
| Code | HTTP | When | How to handle |
|---|---|---|---|
END_USER_ROLE_NOT_FOUND / END_USER_ROLE_UNKNOWN |
404 / 400 | Role id/name doesn't exist in this Application. | List roles first. |
END_USER_ROLE_NAME_INVALID |
400 | Name fails the regex (lowercase, 2–40 chars, alphanumeric edges). | Fix the name. |
END_USER_ROLE_NAME_TAKEN |
409 | Role name collision. | Pick another name. |
END_USER_ROLE_IN_USE / END_USER_ROLE_IS_DEFAULT |
400 | Deleting a role users still hold / the default role. | Reassign users first; change the default first. |
END_USER_ROLE_REASSIGN_SELF / END_USER_ROLE_REASSIGN_TARGET_UNKNOWN |
400 | Reassign target is the deleted role itself / unknown. | Pick a valid target role. |
NO_END_USER_ROLES |
500 | Application has no roles defined at all (invariant breach). | Contact support — seed roles are created with the Application. |
Two directions — see the "Webhooks — two directions" section of billing.md for the distinction.
| Code | HTTP | When | How to handle |
|---|---|---|---|
WEBHOOK_RAW_BODY_MISSING |
400 | Internal — fastify-raw-body wasn't configured for the route. |
Server bug; report it. |
WEBHOOK_SIGNATURE_MISSING |
401 | Inbound provider webhook (Stripe) arrived with no stripe-signature header. |
Point the provider at the correct Rekey endpoint; don't proxy through anything that strips headers. |
WEBHOOK_SIGNATURE_INVALID |
401 | Provider webhook signature didn't validate against the configured secret. | The webhook secret in the panel must match the provider dashboard's signing secret. |
WEBHOOK_VERIFICATION_UNAVAILABLE |
503 | PayPal's signature-verification call did not answer in time, so Rekey could not tell whether the signature was good. Deliberately not WEBHOOK_SIGNATURE_INVALID — telling PayPal a signature was bad when the real fault is that we could not reach PayPal to ask is how an endpoint gets disabled for an outage. Still fail-closed: the event is not processed. |
Transient; PayPal retries. If it persists, check PayPal's status and this deployment's egress to api-m.paypal.com. |
WEBHOOK_PAYLOAD_INVALID |
400 | PayPal webhook body isn't a recognisable event; or an external billing event fails envelope validation (the message names the first failing field). Stored nowhere. | Check the provider configuration; for the external provider, fix the field named and resend. |
WEBHOOK_SIGNATURE_STALE |
401 | An external billing event's t is more than five minutes from the server clock. The HMAC may well be correct; the delivery is refused so a captured body cannot be replayed later. |
Sign at send time with the current unix time; synchronise the sending host's clock. |
WEBHOOK_APPLICATION_MISMATCH |
400 | An inbound provider event named a different Application than the credential that signed it. Nothing is applied and the reason is recorded on the event's receipt. Expected when two Applications share one Stripe account: each endpoint receives the other's events (see docs/billing.md, Applications sharing one Stripe account). | Point the provider at the route for the Application whose webhook secret signs its events. |
WEBHOOK_MODE_MISMATCH |
409 | A provider event that states its mode (Stripe livemode) contradicted the test/live mode of the credential that verified it. Recorded on the receipt's processingError and not applied; the receipt stays unprocessed, so the provider's retry is re-attempted. |
Save credentials in the event's mode, or the signing secret of the endpoint in the credential's own mode. The provider's next retry then applies the event. |
BILLING_REFUND_EXCEEDS_PAYMENT |
200 (receipt note) | Not an HTTP error. An inbound payment.refunded would take the payment's refunded total past its amount. Recorded on the receipt's processingError; the payment is unchanged. |
Check the refund in your billing system: each payment.refunded carries the amount of that one refund, not a running total. |
WEBHOOK_ENDPOINT_NOT_FOUND |
404 | Outbound-webhook endpoint id unknown in this Application. | List the Application's webhook endpoints. |
WEBHOOK_ENDPOINT_LIMIT_REACHED |
400 | The Application already has 100 webhook endpoints, the most one may have. Every event fans out to at most 100 endpoints, so another would never receive anything. | Delete endpoints you no longer use, or subscribe one endpoint to more event types. |
WEBHOOK_URL_UNSAFE |
400 | Outbound webhook URL points at a private/loopback address (SSRF guard). | Use a publicly routable HTTPS URL. |
WEBHOOK_PROVIDER_UNKNOWN |
404 | The /webhooks/billing/<provider> path segment isn't a registered provider. |
Fix the webhook URL at the provider. |
WEBHOOK_APPLICATION_UNRESOLVED |
401 | An inbound provider webhook arrived on a URL with no Application slug, so it can't be attributed. | Point the provider at the per-Application endpoint (…/webhooks/billing/<provider>/<appSlug>). |
WEBHOOK_DELIVERY_NOT_FOUND |
404 | Manual retry targeted a delivery id that isn't on this endpoint (or already succeeded). | List deliveries and retry a PENDING/FAILED row. |
| Code | HTTP | When | How to handle |
|---|---|---|---|
TENANT_SESSION_MISSING |
401 | Tenant route called without an Authorization bearer. |
Sign in to the panel / send the operator access token. |
TENANT_SESSION_INVALID |
401 | Operator JWT bad / expired / signed wrong, or the account no longer exists. | Refresh or sign in again. |
TENANT_MEMBERSHIP_REVOKED |
403 | Operator was removed from the workspace after the JWT (or PAT) was issued. | Ask a workspace OWNER/ADMIN to re-invite. |
TENANT_ROLE_INSUFFICIENT |
403 | Operator's role can't perform this action (e.g. MEMBER trying to invite). | Requires a higher workspace role. |
NO_TENANT_MEMBERSHIPS |
403 | Operator account exists but isn't a member of any workspace. | Accept an invitation or create a workspace. |
NOT_A_MEMBER |
403 | switch-workspace targeted a tenant the user doesn't belong to. | Pick a workspace from the memberships list. |
TENANT_USER_NOT_FOUND |
404 | Operator account lookup miss. | Check the id. |
WORKSPACE_NAME_INVALID |
400 | Workspace name not 2–80 characters. | Fix the name. |
WORKSPACE_CREATION_DISABLED |
403 | This deployment sets WORKSPACE_CREATION=disabled, so a signed-in operator cannot create additional workspaces. Creation only — switching, listing and renaming are unaffected. |
Ask the deployment administrator to create the workspace, or set WORKSPACE_CREATION=open (the default) and restart the API. |
INVITE_TARGET_ALREADY_MEMBER |
409 | Inviting someone already in the workspace. | Treat as success in UI. |
INVITATION_NOT_FOUND / INVITATION_REVOKED / INVITATION_ALREADY_ACCEPTED / INVITATION_EXPIRED / INVITATION_NOT_USABLE |
404 / 400 | Invitation lookup or consumption failures. | Re-issue the invitation. |
INVITATION_EMAIL_MISMATCH |
403 | Invitation was issued to a different email than the accepting account. Also returned for a workspace-bound operator invite (rp_opinv_…) accepted by another address. |
Sign in with the invited address. |
MEMBERSHIP_NOT_FOUND |
404 | Member-management target id is wrong. | Refresh the member list. |
CANNOT_REMOVE_LAST_OWNER |
400 | Remove or demote attempt against the only OWNER of a workspace. | Promote someone else first. |
APP_ACCESS_DENIED |
403 | A MEMBER's per-application grant (APP_VIEWER / APP_BILLING) doesn't allow this action on the granted Application. | Ask an OWNER/ADMIN to raise the grant (PUT /tenant/workspace/members/:id/grants). Note: an app the member holds NO grant for returns 404 APPLICATION_NOT_FOUND, not 403. |
APP_GRANT_MEMBER_ONLY |
400 | Tried to set a per-application grant on an OWNER/ADMIN membership. | Grants only scope MEMBER roles — OWNER/ADMIN already have full access. Demote to MEMBER first if scoping is wanted. |
APP_GRANT_NOT_FOUND |
404 | Deleting a grant that doesn't exist on that membership. | List grants via GET /tenant/workspace/members/:id/grants. |
MAGIC_LINK_TOKEN_INVALID / MAGIC_LINK_TOKEN_USED / MAGIC_LINK_TOKEN_EXPIRED |
401 | Operator magic-link token failures (note: end-user codes drop the _TOKEN). |
Request a new link. |
OPERATOR_NOT_FOUND |
404 | Granting workspace access named an email with no operator account. This route grants access to an existing operator; it does not create one. | Have them register first, or send a workspace invitation instead. |
| Code | HTTP | When | How to handle |
|---|---|---|---|
OPERATOR_TOKEN_INVALID |
401 | PAT missing, invalid, revoked, or expired. | Mint a new PAT in the panel. |
OPERATOR_SCOPE_INSUFFICIENT |
403 | PAT lacks the scope this action requires (e.g. keys:mint). |
Mint a PAT with the right scope — default-deny by design. |
OPERATOR_SCOPE_UNKNOWN |
400 | Operator token (PAT) creation named a scope that does not exist. Nothing is minted. | Use only read, applications:write, keys:mint, or omit scopes for a read-only token. details.unknown lists the rejected entries and details.valid the allowed ones. |
OPERATOR_TOKEN_EXPIRY_IN_PAST |
400 | expiresAt already passed. |
Pass a future timestamp or omit. |
OPERATOR_TOKEN_LIMIT_REACHED |
400 | Operator already has the max active PATs. | Revoke unused tokens first. |
OPERATOR_TOKEN_NOT_FOUND |
404 | PAT id unknown. | List your tokens. |
Gates who may create an operator account on this deployment — OPERATOR_SIGNUP_MODE is open, invite, or closed.
| Code | HTTP | When | How to handle |
|---|---|---|---|
OPERATOR_SIGNUP_CLOSED |
403 | OPERATOR_SIGNUP_MODE=closed — no new operator accounts at all. |
Sign in with an existing account, or ask the deployment administrator to open sign-up. |
OPERATOR_INVITE_REQUIRED |
403 | OPERATOR_SIGNUP_MODE=invite and the sign-up carried no inviteKey. |
Pass an invite key minted by the deployment administrator. |
OPERATOR_INVITE_INVALID |
403 | The presented invite key is unknown or revoked. | Check the key, or ask for a fresh one. |
OPERATOR_INVITE_EXPIRED |
403 | The invite key is past its expiresAt. |
Ask for a fresh key. |
OPERATOR_INVITE_USED |
409 | Each invite key creates exactly one operator; this one already did. | Ask for a fresh key. |
OPERATOR_INVITE_NOT_FOUND |
404 | Admin invite management — no invite with that id. | Check the id against GET /api/v1/admin/operator-invites. |
OPERATOR_INVITE_ALREADY_USED |
409 | Revoking an invite that has already created an operator. | Manage the resulting operator account instead. |
OPERATOR_INVITE_EXPIRY_IN_PAST |
400 | Minting an invite with an expiresAt already in the past. |
Pass a future timestamp, or omit it for a non-expiring key. |
OPERATOR_INVITE_EMAIL_MISMATCH |
403 | Sign-up (password or OAuth) presented an invite key bound to a workspace for a different email. Nothing was created and the key is still usable. | Sign up with the email address the invite was sent to. |
OPERATOR_INVITE_TENANT_REQUIRED |
400 | Minting an invite with email or role but no tenantId. Those two only mean something on a workspace-bound key. |
Pass tenantId with them, or drop both for a key that creates a new workspace. |
OPERATOR_INVITE_EMAIL_REQUIRED |
400 | Minting an invite with tenantId but no email. A workspace-bound key is redeemable only by one address. |
Pass email with tenantId. |
WORKSPACE_NAME_REQUIRED |
400 | Operator sign-up with no workspaceName and no invite key bound to an existing workspace, so there is a workspace to create and nothing to call it. |
Pass workspaceName (1 to 120 characters). |
Raised locally by the SDK before any network call:
| Code | When | How to handle |
|---|---|---|
CONFIG_MISSING_API_URL |
Constructor — apiUrl was empty. |
Set REKEY_URL. |
CONFIG_INVALID_SECRET_KEY |
Constructor — secretKey didn't start with rp_. |
Use the Application secret key, not the admin key or public key. |
Add new codes here when you introduce them. The list is the spec for client compatibility.