When a request fails, the API returns the error envelope and the SDK throws. This page is the complete, source-traced map of error codes, statuses, and triggers from apps/api/src/error-map.ts and the per-package error constants.
The error envelope#
A failed request returns:
{ "ok": false, "error": "Invalid email or password", "code": "unauthorized" }erroris a human-readable message. For most 403s it is deliberately the generic string"forbidden"- the API never leaks why a request was denied beyond the machine code.codeis a stable machine-readable string. Switch oncode, not onerror.
The root app.notFound (unknown path) and app.onError (unhandled exception) return { ok: false, error } without a code at 404 / 500 respectively.
How the SDK throws#
The SDK core throws a GatekeeperError on any ok !== true response or non-2xx status. Its shape:
| Field | Source | Notes |
|---|---|---|
status | HTTP status | e.g. 401, 429 |
message | the envelope error string | human-readable |
code | the envelope code | stable machine code; may be absent on raw 404/500 |
retryAfter | the Retry-After header | parsed on 429s; seconds |
In TypeScript it is an Error subclass (instanceof GatekeeperError); in Python it is an Exception subclass. Catch it and branch on code or status.
Status to code#
fail() derives the default code from the status via codeForStatus() (STATUS_TO_CODE in error-map.ts):
| Status | Code | Class |
|---|---|---|
| 400 | bad_request | client error / validation |
| 401 | unauthorized | bad or missing credential |
| 402 | plan_limit | entitlement / quota cap reached |
| 403 | forbidden | authenticated but not permitted |
| 404 | not_found | resource absent or hidden |
| 409 | conflict | state conflict |
| 413 | payload_too_large | body over the limit |
| 429 | rate_limited | rate-limit tier exceeded |
| 503 | service_unavailable | dependency fault, fail-closed |
Status class reference#
400 bad_request#
The default for any error string not explicitly mapped, plus validation failures. Services convert unexpected thrown exceptions into a generic string, which also falls through to 400.
401 unauthorized#
A credential was presented and it was bad (invalid, revoked, expired), or required and absent.
| Error string | Source constant |
|---|---|
Invalid email or password | ERROR_MESSAGES.AUTH.INVALID_CREDENTIALS |
Invalid refresh token | ERROR_MESSAGES.AUTH.INVALID_REFRESH_TOKEN |
Refresh token expired | ERROR_MESSAGES.AUTH.REFRESH_TOKEN_EXPIRED |
Invalid API key | API_KEY_ERRORS.INVALID_KEY |
API key has been revoked | API_KEY_ERRORS.REVOKED |
API key has expired | API_KEY_ERRORS.EXPIRED |
Invalid payment webhook signature | PAYMENT_ERRORS.INVALID_WEBHOOK_SIGNATURE |
Payment webhook context does not match provider config | PAYMENT_ERRORS.WEBHOOK_CONTEXT_MISMATCH |
Stale payment webhook | PAYMENT_ERRORS.STALE_WEBHOOK |
invalid platform service account key | PLATFORM_SERVICE_ACCOUNT_ERRORS.INVALID_KEY |
platform service account revoked | PLATFORM_SERVICE_ACCOUNT_ERRORS.REVOKED |
platform service account expired | PLATFORM_SERVICE_ACCOUNT_ERRORS.EXPIRED |
The decision engine also emits 401 directly for UNAUTHENTICATED (no credential where one is required) and INVALID_CREDENTIAL (presented credential failed to resolve).
402 plan_limit#
An entitlement cap was reached. Route handlers set this explicitly when an entitlement check fails (team members, API keys, or webhooks limit reached). The decision engine maps ENTITLEMENT_EXCEEDED and QUOTA_EXCEEDED to 402.
403 forbidden#
The caller is authenticated but not permitted. The message is almost always the literal "forbidden".
| Error string | Source constant |
|---|---|
forbidden (generic guard rejection) | error-map.ts literal |
platform permission denied | PLATFORM_SERVICE_ACCOUNT_ERRORS.FORBIDDEN |
Engine deny reasons that resolve to 403: NOT_A_MEMBER, INSUFFICIENT_ROLE, PERMISSION_DENIED, PLATFORM_PERMISSION_DENIED. Routes that minted a key token from a scopeless key also return 403 explicitly. Note that a NOT_A_MEMBER 403 is remapped to 404 on routes with hideNotFound.
404 not_found#
A resource is absent, or hidden to avoid leaking its existence to non-members.
| Error string | Source constant |
|---|---|
User not found or suspended | ERROR_MESSAGES.USER.NOT_FOUND |
API key not found | API_KEY_ERRORS.NOT_FOUND |
Tenant not found | QUOTA_ERRORS.TENANT_NOT_FOUND |
Role not found | PERMISSIONS_ERRORS.ROLE_NOT_FOUND |
Webhook endpoint not found | WEBHOOK_ERRORS.ENDPOINT_NOT_FOUND |
Payment provider config not found | PAYMENT_ERRORS.PROVIDER_CONFIG_NOT_FOUND |
Payment plan mapping not found | PAYMENT_ERRORS.PLAN_MAPPING_NOT_FOUND |
Payment customer not found | PAYMENT_ERRORS.CUSTOMER_NOT_FOUND |
Unknown plan | PAYMENT_ERRORS.UNKNOWN_PLAN |
job not found | JOB_ERRORS.NOT_FOUND |
platform service account not found | PLATFORM_SERVICE_ACCOUNT_ERRORS.NOT_FOUND |
A hideNotFound route additionally turns a NOT_A_MEMBER 403 into this 404.
409 conflict#
A state conflict - the request is well-formed but collides with existing state.
| Error string | Source constant |
|---|---|
Email already registered | ERROR_MESSAGES.AUTH.EMAIL_ALREADY_REGISTERED |
Email already verified | ERROR_MESSAGES.AUTH.EMAIL_ALREADY_VERIFIED |
Email verification link has already been used | ERROR_MESSAGES.AUTH.VERIFICATION_TOKEN_ALREADY_USED |
Reset link has already been used | ERROR_MESSAGES.AUTH.PASSWORD_RESET_TOKEN_ALREADY_USED |
Magic link has already been used | ERROR_MESSAGES.AUTH.MAGIC_LINK_ALREADY_USED |
MFA is already enabled. Disable first to re-enroll | ERROR_MESSAGES.AUTH.MFA_ALREADY_ENABLED |
User is already a member of this tenant | ERROR_MESSAGES.AUTH.ALREADY_MEMBER |
Idempotency key already used for a different usage event | QUOTA_ERRORS.IDEMPOTENCY_CONFLICT |
Role name already exists | PERMISSIONS_ERRORS.DUPLICATE_ROLE_NAME |
Role is in use | PERMISSIONS_ERRORS.ROLE_IN_USE |
Payment webhook event payload changed for the same event id | PAYMENT_ERRORS.WEBHOOK_PAYLOAD_MISMATCH |
Payment provider config is disabled | PAYMENT_ERRORS.PROVIDER_CONFIG_DISABLED |
Payment provider operation is unsupported | PAYMENT_ERRORS.UNSUPPORTED_OPERATION |
413 payload_too_large#
The request body exceeds the limit. In practice the payment webhook ingest route caps the body at 256 KiB.
429 rate_limited#
A rate-limit tier was exceeded. The response carries a Retry-After header (seconds), which the SDK parses into GatekeeperError.retryAfter. The engine emits this for the RATE_LIMITED deny reason.
503 service_unavailable#
A dependency faulted and the request was rejected fail-closed. This is never a verdict about the caller; it is retryable.
| Error string | Source constant |
|---|---|
Payment credential key must be 32 random bytes | PAYMENT_ERRORS.INVALID_CREDENTIAL_KEY |
Payment store operation failed | PAYMENT_ERRORS.STORE_ERROR |
Payment provider request failed | PAYMENT_ERRORS.PROVIDER_ERROR |
job store error | JOB_ERRORS.STORE_ERROR |
platform service account store error | PLATFORM_SERVICE_ACCOUNT_ERRORS.STORE_ERROR |
The decision engine maps every operational fault reason (identity, membership, permission, entitlement, quota, or rate-limiter backend unavailable, plus an internal try/catch) to 503. A backend fault never silently becomes an allow.
Mapping mechanics#
Domain error strings are exact-matched in ERROR_STATUS (error-map.ts); anything unlisted defaults to 400. Because the map keys off each package's exported error constant, renaming a constant is a compile error here rather than a silent status change.