Every refusal on this API uses the same body, at every status:
One shape means you write one parser and one branch, rather than one per route.
Branch on code, never on message.code is part of this contract and is safe to switch on. message is English prose for a human reading a log. Its wording can change at any time and is not part of the contract. Matching on message text produces an integration that breaks on a copy edit.

Some refusals carry an extra key

Beside code and message, a refusal may add one of three optional keys. They exist to make a failure mechanical to act on rather than to read:
field appears only when ONE key is at fault. A request that omits several names them all in message and sends no field, because there is no single input for a client to point at.
Treat all three as optional. They are added where a route knows the answer, so a body carrying only code and message is not a malformed one. Branch on code first and read these for detail; field in particular lets a client point a validation message at the right input without parsing prose.

This shape covers everything

Including the responses that usually escape an API’s error format:
  • 404 for a path that is not mounted
  • 405 for a method the route does not accept
  • 401 from the authentication layer, before any handler runs
  • 429 from the rate limiter, before authentication runs
  • 500 when something failed on our side
There is no route on /api/v1 that answers a refusal in HTML, in plain text, or in a different JSON shape. You never need a fallback parser for a body you could not decode.

Statuses

Reading a 401: code says unauthorized, reason says what happened

In HTTP terms 401 means we do not know who you are and 403 means we do and you may not. The code on our 401 is unauthorized, which is the 403’s word. That is a compatibility decision, not an accident: renaming it would break every client already branching on it, so the accurate value is carried additively in reason.
The distinction is worth branching on: credential_missing is a configuration mistake you can fix in your client, while credential_invalid usually means a key was rotated or revoked and needs replacing. The message stays deliberately vague about which. It never says “this key was revoked” or “this key expired”, because that would let a probe learn which invented keys are real.
A client reading only code keeps working. reason is additive, so branching on it is optional. On a 403 the code is insufficient_scope, which already says what happened and needs no separate reason.

Every response carries a correlation id

This is on every response, success or failure, including the 401 and 429 that are answered before any handler runs. It is the same value that appears in our logs for that request.
Log the x-request-id alongside your own errors. When you report a problem, quoting it is the difference between support finding the exact request in seconds and asking you a series of narrowing questions.

Handling errors

Client mistakes are never 5xx

A malformed body, a bad cursor, an unknown field or an invalid value answers 4xx. It does not answer 500. This matters for your retry logic. A 5xx from this API means something failed on our side and retrying is reasonable. If client faults could also surface as 500, you could not distinguish “retry this” from “this will fail identically forever”, and a retry loop on a permanently malformed request is just load with no chance of success.

Unknown codes

New error codes can be added as the API grows. Handle the codes you care about explicitly and fall through to a default that logs code, message and x-request-id. Do not treat an unrecognised code as success.