🛡️Gatekeeper/ SDKs

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:

FieldTypeScriptPythonMeaning
HTTP statusstatus: numberstatus: intThe response status code.
machine codecode?: stringcode: Optional[str]Stable code from the table below.
messagemessage: stringmessage: strHuman-readable, deliberately generic for auth failures.
retry hintretryAfter?: numberretry_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.

CodeHTTPMeaning
bad_request400Malformed input, missing required field, or a domain error with no more specific status (the default).
unauthorized401Missing, invalid, or expired credential (bad password, invalid / expired / revoked API key, expired refresh token).
plan_limit402Quota exceeded or a plan entitlement check failed.
forbidden403Authenticated but not allowed: not a member, insufficient role, permission denied, platform permission denied.
not_found404Resource does not exist - or is hidden from a non-member when existence hiding is on.
conflict409State collision: email already registered, duplicate role name, idempotency-key reuse with a different payload, role in use.
payload_too_large413Request body exceeds the size cap enforced before any handler parses it.
rate_limited429A rate-limit tier was exceeded. Carries a Retry-After header.
service_unavailable503A 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.