The Error Model: Structure Over Apology
A failing response is still a response, and clients write code against it. A stable error model — machine-readable code, human message, request id, structured details — is a contract clause, not a courtesy.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
Errors are read by programs first
The instinct behind {"error": "something went wrong"} is honest — the server genuinely does not know what to say — but the consequence is that every client now knows *less* than the server did. The client cannot distinguish "your request was malformed, do not retry" from "we had a hiccup, retry in a second" from "you are over quota until midnight". All three demand different client code, and the contract just collapsed them into one string.
What follows is predictable and worse than the original failure: clients start parsing message text. if (msg.includes("duplicate")) ships in a partner integration, works for a year, and breaks the day a copywriter improves the wording. Now your error message text is load-bearing — the least stable field in the response has become the most depended-upon. This is What an API Contract Actually Is applied to failures: if you do not give clients a designed field to branch on, they will branch on whatever you gave them.
The error model is therefore a schema, designed once and reused by every endpoint. Its stability requirements are *stricter* than the success path's: success responses evolve per resource, but error handling is written once per client and shared across every call site.
- `code` — stable, machine-readable, documented, enumerable:
rate_limited,card_declined,project_not_found. The field clients branch on. Never renamed, only extended. - `message` — human-readable, for logs and debugging. Explicitly documented as changeable text that clients must not parse.
- `details` — structured, code-specific payload: which field failed validation, which limit was hit, when to retry (see Validation Errors: Feedback, Not Verdicts).
- `request_id` — the correlation handle. Echoed from the request or generated at the edge; the one field support and on-call actually use (see Request IDs: The Contract's Correlation Clause).
- `doc_url` (optional) — a stable link to the error's documentation; cheap for you, minutes saved for every integrator.
One shape, everywhere — read as an exchange
The HTTP status code and the body code do different jobs, and the model needs both. The status is for generic machinery — proxies, caches, SDK retry layers — which knows nothing about your domain but understands "client error" vs "server error" vs "back off" (see Status Codes Clients Can Branch On). The body code is for domain logic: the same 409 might mean duplicate_email on one endpoint and stale_version on another, and the client handles those very differently.
The exchange below shows the division of labor. A payment fails; the 402 tells the retry wrapper not to bother; card_declined tells the checkout flow which UX path to take; details.decline_reason feeds the message shown to the buyer; request_id ties the whole thing to a trace the provider can find. Four consumers of one error, each served by a designed field.
POST /v1/charges HTTP/1.1
Authorization: Bearer <token>
Idempotency-Key: 8c41…
Content-Type: application/json
{
"amount": 4999,
"currency": "EUR",
"source": "card_7x2…"
}HTTP/1.1 402 Payment Required
Request-Id: req_01J8…
Content-Type: application/json
{
"error": {
"code": "card_declined",
"message": "The card was declined.",
"details": { "decline_reason": "insufficient_funds" },
"request_id": "req_01J8…",
"doc_url": "https://api.example.com/docs/errors#card_declined"
}
}The shape is easy; the discipline is the product
Designing the envelope takes an afternoon. The work is holding the line afterwards: every endpoint, every framework default, every dependency's exception must be translated into the model before it leaves the process. The moment one endpoint leaks a stack trace, an HTML 500 page from a proxy, or a raw database error, clients need a second error handler — and you need to guess which one they wrote.
The leak is also a security matter: raw errors carry table names, file paths, library versions and occasionally query fragments. The error boundary that translates everything into the model is the same boundary that keeps internals internal. What the client needs is "what happened to *my request* and what can *I* do" — never "what happened inside your process".
1POST /users → 500 "Internal Server Error" (HTML from proxy)2POST /payments → 400 { "error": "something went wrong" }3GET /projects/9 → 200 { "success": false,4 "msg": "SQLSTATE 23505: duplicate key5 users_email_key" }6# clients parse messages, leak internals, and7# cannot tell retryable from permanent1ANY endpoint, ANY failure →2HTTP <status>3{4 "error": {5 "code": "duplicate_email", # stable, documented6 "message": "…may change…", # display only7 "details": { "field": "email" },8 "request_id": "req_01J8…"9 }10}11# unknown internal failure? code: "internal",12# details omitted, full context in server logs under request_idThe left side is not three mistakes — it is one missing decision surfacing three ways. Every unhandled path takes whatever shape the nearest framework layer produces. The envelope plus a translation boundary makes "what does failure look like" a decision made once.
Key points
- Error responses are parsed by programs; a designed
codefield is the only alternative to clients parsing message text. - Status code and body code do different jobs: status for generic machinery (retry layers, proxies), body code for domain logic.
- Messages are for humans and must be documented as changeable; the day a client parses one, your copy is load-bearing.
- A
request_idin every error turns support tickets and incident timelines from archaeology into lookup. - One envelope across every endpoint and failure path — including the ones your framework handles for you — or clients write N error handlers.
- Never leak internals: stack traces, SQL state and file paths belong in your logs under the request id, not in the response.
Progressive depth
Overview
An error response is the part of the contract clients write the most code against. It needs a machine-readable code, a human message, and a request_id — a free-text string is not an error model.
Practical
Use status codes for the category (4xx client, 5xx server, 409 conflict, 429 limited) and a stable code registry for the cause (ALREADY_MEMBER, LAST_OWNER). Field-level details[] for validation; Retry-After for anything retryable (An Error Taxonomy Clients Can Branch On, Validation Errors: Feedback, Not Verdicts, Retryability: Telling Clients What To Do Next).
Advanced
Errors evolve too: adding a code is additive, renaming one is breaking, and clients must be told to fall back on the status category for codes they do not know — the same forward-compatibility rule as Enum Evolution: The New Value That Broke Old Clients.
Internals
Map internal exceptions to public codes at one boundary (the API layer), never letting a database constraint name or a stack trace reach the wire; log the internal cause with the request_id so the public code stays stable while the diagnosis stays precise (Request IDs: The Contract's Correlation Clause, API Logging Without Leaking).
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → API: ships success-path JSON carefully and lets failures fall through to framework defaults.
- 2Clients → API: meet four different error shapes; the pragmatic ones branch on HTTP status plus message substrings.
- 3Team → copy edit: "improves" an error message; a partner's
includes("duplicate")check silently stops matching. - 4Partner → production: duplicate signups start creating support tickets instead of inline errors; nobody connects it to the copy change.
- 5Provider → support: tickets arrive as "it failed" with no request id; each one costs a log-diving session to even locate.
- Client retry logic guesses: permanent failures get retried (wasted load, duplicate side effects) and transient ones surface to users as hard errors.
- Message text becomes unchangeable because unknown clients parse it — the provider loses control of its own copy.
- Support cost scales with traffic: unfindable failures mean every ticket starts with "can you tell us roughly when this happened?"
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Define one error envelope (`code`, `message`, `details`, `request_id`) and enforce it with a translation boundary that catches every path out of the process — framework errors, proxy timeouts and dependency exceptions included.
- • Document the error codes per endpoint as part of the contract, with the same review discipline as response fields; codes are append-only (see [[backward-compatibility]]).
- • Return the request id in every response, success and failure, and log everything you withheld from the client under that id.
- • State in the docs, explicitly, that `message` text is not stable — an explicit non-guarantee is the only defense against it becoming one.
- • Count error responses that bypass the envelope (log a metric in the translation boundary's catch-all path); nonzero means clients are meeting raw internals.
- • Track error responses by `code` per endpoint; a spike in `internal` is an incident, a spike in a 4xx code is a client integration or docs problem.
- • Watch support tickets that arrive without request ids — it usually means an error path (often the edge or a proxy) is not carrying the envelope.
- • New error codes are additive — but only if clients were told from day one to treat unknown codes by falling back on the status class (the enum-evolution rule applied to errors, see [[enum-evolution]]).
- • The `details` object can grow new fields per code freely; renaming or removing existing ones is a breaking change like any other.
- • Messages and `doc_url` targets change freely — that is exactly why the contract routes stability through `code` instead.
- • The translation boundary is real code that must be maintained and that can itself fail; its catch-all path needs the most care and gets the least testing by default.
- • Stable codes are commitments: `card_declined` shipped once is yours forever, so codes need naming review, which slows shipping the first error slightly.
- • Withholding internals from responses makes provider-side logging non-optional — the debugging detail has to live somewhere findable.
Misconceptions
409 can be duplicate_email or stale_version; a client that only sees 409 must guess which recovery to run. Status and body code are layers, not alternatives.code: "duplicate_email" reveals a fact the attacker can learn anyway by trying to log in; SQLSTATE 23505 on users_email_key reveals your schema. Structure lets you say exactly as much as you mean to.