Errors & conventions
The single protocol error shape, status-code semantics, idempotency, ID prefixes, and the money rule shared by every hosted REST endpoint.
The error shape
Every error response — on every route — has exactly one shape:
{
"reason_codes": ["SCREAMING_SNAKE_CASE", "…"],
"error_human": "one-line, non-load-bearing explanation"
}reason_codes is the stable, machine-keyable vocabulary; agents and clients branch on
the codes. error_human is for humans and may change — never parse it. Checkout/policy
codes come from the SDK reason-code registry; transport and
auth codes are the API-plane additions below.
API-plane reason codes
| Code | Typical status | Meaning |
|---|---|---|
UNAUTHENTICATED | 401 | No API key presented. |
INVALID_API_KEY | 401 | Unknown or revoked key. |
INVALID_SESSION | 401 | Bad/expired management session. |
INSUFFICIENT_SCOPE | 403 | Key lacks a required scope. |
LIVE_KEY_REQUIRED | 403 | Live-only route, test key. |
TEST_MODE_RAIL_FORBIDDEN | 403 | Test-only route, live key. |
VALIDATION_FAILED | 400 | Request body failed validation. |
BAD_REQUEST | 400 | Body was not valid JSON. |
PAYLOAD_TOO_LARGE | 413 | Body exceeded 256 KB. |
RATE_LIMITED | 429 | Too many requests (e.g. sign-in). |
NOT_FOUND | 404 | No such resource / no route. |
DATABASE_UNAVAILABLE | 503 | Dependency down (readiness). |
INTERNAL_ERROR | 500 | Unexpected server error. |
Status-code semantics
Status codes carry meaning — the table on each endpoint page is authoritative, but the defaults are:
| Status | Meaning |
|---|---|
200 | OK (read or idempotent write). |
201 | Created (a new row / version). |
204 | Done, no body (soft-delete). |
304 | Not modified (manifest ETag match). |
400 | Malformed body / failed validation. |
401 / 403 | Auth / scope / mode failure. |
404 | Not found (also: cross-mode access). |
409 | Uniqueness conflict (slug / SKU taken). |
410 | Gone (expired magic link). |
422 | Semantically rejected (e.g. config invalid on publish). |
429 | Rate limited. |
503 | Not ready (dependency down). |
Money
Money is an exact decimal string on the wire and in storage — never a float, never a
number. Prices are stored as numeric(20,8) verbatim and round-tripped byte-for-byte.
{ "price": { "amount": "25.00", "currency": "USD" } }A price amount must match ^\d{1,12}(\.\d{1,8})?$ (≤ 8 decimal places). A malformed amount
is rejected with PRICE_AMOUNT_INVALID.
Idempotency
The reporting path is idempotent by resource key, so a self-hosted SDK can retry safely:
POST /v1/ingest/sessionupserts onsession.id.POST /v1/ingest/orderis idempotent onsession.id; a replay returns the original201with"replayed": trueand runs the reputation delta at most once.
For interactive writes (merchant/product/policy create), idempotency comes from
uniqueness constraints — re-creating a taken slug or SKU returns 409, not a duplicate.
The CORS preflight advertises an Idempotency-Key header for clients that send one.
ID prefixes
IDs are opaque, roughly time-sortable, prefixed strings (<prefix>_<base32>):
| Prefix | Resource |
|---|---|
mrch_ | Merchant |
prod_ | Product |
pol_ | Policy version |
ord_ | Order |
rcpt_ | Receipt |
evt_ | Webhook event |
wha_ | Webhook delivery attempt |
key_ | API key |
proj_ / org_ | Project / org |