Every /v1 endpoint returns the same envelope shape, and every failure carries a stable machine-readable code alongside its HTTP status. The SDKs unwrap the envelope for you and raise a single typed error, so you never branch on raw JSON.
The envelope#
Success and failure are distinguished by a top-level ok boolean.
// success
{ "ok": true, "data": <T> }
// failure
{ "ok": false, "error": "<human message>", "code": "<machine code>" }A paginated list nests its items under data:
{ "ok": true, "data": { "items": [ ... ], "nextCursor": "<opaque>" | null } }The error type#
A non-ok response becomes a GatekeeperError (TypeScript) or GatekeeperError exception (Python). Both carry the same four fields:
| Field | TypeScript | Python | Meaning |
|---|---|---|---|
| HTTP status | status: number | status: int | The response status code. |
| machine code | code?: string | code: Optional[str] | Stable code from the table below. |
| message | message: string | message: str | Human-readable, deliberately generic for auth failures. |
| retry hint | retryAfter?: number | retry_after: Optional[float] | Seconds to wait, parsed from the Retry-After header (present on 429). |
import { GatekeeperError } from '@orkait/sdk';
try {
await usage.checkAndRecord({ tenantId, service: 'api', action: 'call' });
} catch (err) {
if (err instanceof GatekeeperError) {
if (err.code === 'rate_limited' && err.retryAfter) {
await sleep(err.retryAfter * 1000);
} else if (err.code === 'plan_limit') {
// 402 - quota or entitlement exceeded
showUpgradePrompt();
} else if (err.status === 401) {
await refreshSession();
}
}
throw err;
}Error codes#
The API maps every error to one of these codes. The first nine are the shared /v1 vocabulary; conflict and payload_too_large are HTTP-layer additions. The status-to-code mapping is single-sourced in the API, so a code always pairs with the same status.
| Code | HTTP | Meaning |
|---|---|---|
bad_request | 400 | Malformed input, missing required field, or a domain error with no more specific status (the default). |
unauthorized | 401 | Missing, invalid, or expired credential (bad password, invalid / expired / revoked API key, expired refresh token). |
plan_limit | 402 | Quota exceeded or a plan entitlement check failed. |
forbidden | 403 | Authenticated but not allowed: not a member, insufficient role, permission denied, platform permission denied. |
not_found | 404 | Resource does not exist - or is hidden from a non-member when existence hiding is on. |
conflict | 409 | State collision: email already registered, duplicate role name, idempotency-key reuse with a different payload, role in use. |
payload_too_large | 413 | Request body exceeds the size cap enforced before any handler parses it. |
rate_limited | 429 | A rate-limit tier was exceeded. Carries a Retry-After header. |
service_unavailable | 503 | A backend dependency could not answer. Fail-closed and retryable - never a verdict about the caller. |
Why auth messages are vague#
For authorization failures the human message is intentionally generic - most 403 responses say only "forbidden". The detail lives in the structured code and in server-side audit logs, never in a string an attacker can probe by trial and error. Branch on status and code, not on the message text.
Related#
- Architecture - where these statuses come from in the pipeline
- Pagination - the list envelope and cursor loop
- Rate limits - the
429path andRetry-After