Every API needs three error decisions made deliberately, not by whichever engineer ships the first 500 handler: the error body's shape (what fields carry code, message, and details), the code taxonomy (stable machine-readable strings versus human prose), and status-code semantics (when 400 differs from 422 or 409). Skip any one and integrators will reverse-engineer their own rules.
Quick Answer: Lock down three contracts before you ship an error response — a consistent body shape (
code+message+details), a stable machine-readable code taxonomy kept separate from human-readable text, and disciplined status-code semantics (400malformed,422semantically invalid,409conflicting state).
Most API programs write a beautiful spec for the happy path and let the failure path emerge from whatever the framework's default exception handler happens to serialize. That's backwards. As the complete guide to API product design lays out, an API is a product surface, and the error path is the part of that surface your integrators hit most often while debugging — which makes it the worst place to leave undesigned.
The Error Body Shape Contract: What Fields Every Response Must Carry
A well-designed error body always separates three things: a stable machine code for programmatic branching, a human-readable message for logs and UIs, and a details object for field-level or contextual data. Bake this shape in from day one, because retrofitting it after integrators have started pattern-matching on message strings breaks their code.
Without a deliberate shape, you get drift: one endpoint returns {"error": "Not found"}, another returns {"message": "invalid input", "status": 400}, and a third leaks a raw stack trace into production. None of these give an integrator anything reliable to branch on, so they write brittle string-matching logic against your prose — and now every copy edit you make is a breaking change you didn't know you were shipping.
A minimal, defensible error body needs at least three fields:
code— a stable, namespaced machine string (e.g.,invalid_email_format), never just the HTTP status number restatedmessage— a human-readable sentence, safe to log or surface to a developer, not written for end usersdetails— an array or object carrying field-level errors, a request ID, or a link to the relevant reference doc
The difference between a body that gives integrators nothing and one that gives them everything is stark once you put them side by side:
| Bad error body | Good error body |
|---|---|
{"error": "Bad Request"} | {"code": "invalid_email_format", "message": "The email field must be a valid address.", "details": [{"field": "email", "issue": "format"}], "request_id": "7f9e2c"} |
| Message doubles as the only signal; rewording it breaks client logic | Machine code is stable; message can be reworded freely |
| No field-level attribution on multi-field forms | Pinpoints exactly which field failed, and why |
| No trace ID for support escalation | Carries a request_id for support and observability |
The bad example forces every client to string-match on prose, which breaks the moment someone edits the copy for clarity. The good example gives the client a stable hook to branch on while leaving the message free to evolve — that separation is the entire point of the contract.
The RFC 7807 problem+json Pattern
If you'd rather point engineering at an existing standard than invent your own field names, RFC 7807 ("Problem Details for HTTP APIs," an IETF standard later folded into the broader HTTP semantics work) defines a application/problem+json media type with type, title, status, detail, and instance fields:
{
"type": "https://api.example.com/errors/invalid-email",
"title": "Invalid email format",
"status": 422,
"detail": "The email field must be a valid address.",
"instance": "/requests/7f9e2c"
}
It's the same idea wearing different names: type functions as a URI-based version of code, title and detail split the message into a short and long form, and instance is your request_id. Adopting it isn't mandatory, but it saves your team from bikeshedding field names that someone else already reviewed and standardized.
Two shape decisions are easy to skip and expensive to retrofit later. First, decide whether the error object sits at the top level of the response or nested under an error key — mixing the two across endpoints forces every client to write two parsers instead of one. Second, decide early whether message is localized. If you plan to support non-English integrators, add a locale field or a stable code-to-translation lookup on day one; bolting internationalization onto a message field that clients already log verbatim is a much bigger migration than adding it upfront.
Deciding this shape is squarely a product decision, not an implementation detail engineering should improvise mid-sprint — it's exactly the kind of decision that belongs in the PM's job of specifying an API contract before a single handler gets written.
The Code Taxonomy Contract: Stable Machine Codes vs. Human Strings
A code taxonomy is the enumerated, versioned list of every machine-readable error code your API can return, and it must be treated as a first-class part of your public contract — with the same backward-compatibility discipline as a field name. Human-readable messages, by contrast, are free text you can rewrite anytime without breaking anyone downstream.
Conflating the two is the single most common error-design mistake. If message is the only stable signal a client has, then rewriting a sentence for clarity becomes a breaking change your client teams never signed up for. The fix is to make code boring, stable, and grep-able, and let message carry all the personality and iteration.
Build the taxonomy in four steps:
- Inventory every distinct failure mode per resource, not per endpoint — the same failure (an invalid email) should get one code no matter which route triggers it, which is much easier to see once you've done the resource-noun exercise from API resource modeling around nouns
- Group codes into a shallow namespace (
resource_action_reason, e.g.invoice_payment_declined,user_email_already_exists) so they're self-documenting in logs - Publish the enumerated list in your API reference and version it the same way you'd version a schema field
- Set an explicit policy: adding a new code is additive and safe; renaming or removing one is a breaking change that needs a deprecation window
Different taxonomy styles trade off differently, and the choice is worth making consciously rather than by default:
| Approach | Example | Pros | Cons |
|---|---|---|---|
| Namespaced string enum | invoice_payment_declined | Human-legible in logs and tickets; greppable; stable | Requires discipline to keep the namespace consistent across teams |
| Numeric/opaque enum | 4021 | Compact on the wire | Meaningless without a lookup table; easy to typo or collide |
| Reused HTTP status only | 422 | Zero extra design work | Too coarse — dozens of distinct failures collapse into one status |
URI-referenced type (RFC 7807 type) | https://api.example.com/errors/payment-declined | Doubles as a documentation link | Heavier to generate and keep in sync per error |
Namespaced string enums win for most teams because they're readable without tooling and stable enough to build client logic against — the worst outcome is a taxonomy so opaque that support engineers have to look up what 4021 means every time a ticket comes in.
Deprecating a code is where taxonomies actually get tested. Renaming payment_declined to card_declined because a new payment method exposed a gap in the original name feels like a small cleanup, but any client with an if (code === "payment_declined") branch silently breaks the moment you ship it. Treat it like any other breaking change: introduce the new code alongside the old one, document both as valid for a deprecation window, emit both in parallel if your framework allows it, and only remove the old code on a major version boundary you've communicated in advance.
The Status-Code Semantics Contract: 400 vs 422 vs 409
Reserve 400 for malformed requests the server can't parse, 422 for well-formed requests that fail validation or business rules, and 409 for requests that conflict with a resource's current state. Blur these lines and clients must parse your message text to learn what actually happened, which defeats the entire purpose of having status codes.
The confusion is understandable: 422 Unprocessable Entity wasn't in the original HTTP/1.1 specification. It was introduced in RFC 4918 (WebDAV) and later adopted broadly across REST APIs — Rails, Stripe, and the JSON:API specification all use it — as the de facto standard for "I understood you, but you're wrong."
Public guidelines still disagree on the edges. Microsoft's REST API Guidelines lean toward 400 for most validation failures, while Google's API Improvement Proposals (AIP-193) push error detail into a structured google.rpc.Code payload rather than relying on status-code granularity alone. Zalando's RESTful API Guidelines split the difference, endorsing 422 specifically for semantic validation errors distinct from malformed syntax.
A working default that resolves most of that ambiguity:
| Status | Meaning | Example trigger | Client action |
|---|---|---|---|
400 Bad Request | Request is malformed or unparseable | Invalid JSON, missing required field, wrong content type | Fix the request shape before retrying |
422 Unprocessable Entity | Request is well-formed but violates a validation or business rule | Invalid email format, password too short, quantity exceeds stock | Fix the field value(s) named in details |
409 Conflict | Request conflicts with the resource's current state | Duplicate unique key, optimistic-lock version mismatch, resource already finalized | Re-fetch current state, then decide whether to retry |
Picking a stance and documenting it matters more than which stance you pick — a team that consistently uses 400 for everything is more predictable than one that mixes conventions endpoint by endpoint.
409 Conflict deserves special attention because it's the code most often misused as a generic "something's wrong" bucket. It should fire only when the request would be valid in isolation but collides with the resource's current state — a duplicate signup with an already-registered email, a PATCH carrying a stale If-Match version header, or an attempt to cancel an order that already shipped.
In each case, the client's correct next move is to re-fetch the resource and re-decide, not to retry the identical request. That's exactly why bundling 409 with 422 — where retrying with corrected input is the right move — confuses client-side retry logic.
When Status Codes Aren't Enough (GraphQL and RPC)
This entire axis evaporates outside REST. GraphQL conventionally returns 200 with an errors array regardless of failure type, and many RPC frameworks fold errors into the response payload rather than the transport status. If you're still choosing among architectures using a REST vs. RPC vs. GraphQL decision framework, factor this in: the moment you leave REST, the taxonomy contract from the previous section has to carry the entire semantic load that status codes used to share.
Error Messages Are a UX Surface, Not an Afterthought
Treat every error message as product copy an integrator will read at 2 a.m. while production is down — vague or condescending text extends their outage and erodes trust in your platform. Specific, actionable messages that name the exact field and the exact fix are a documented driver of developer experience, the same way clear UI copy reduces support tickets for end users.
It helps to think about the integrator the same way you'd think about any other user. An integrator hitting your error endpoint is mid-way through their own job-to-be-done — get unblocked, ship their feature — and a cryptic message just stacks a second job on top of it: reverse-engineer what you actually meant. Mapped onto a customer journey emotion curve, the error response sits at the lowest trough of the integration experience; a message that names the field, the rule violated, and the fix is one of the cheapest levers available to flatten that dip.
Five rules for writing the message field:
- Name the exact field or parameter — never "one of your inputs" or "invalid request"
- State the rule that was violated, not just that something failed
- Suggest the fix, or link to the relevant reference doc via
details - Never leak internals — no stack traces, no SQL fragments, no secrets — in
messageordetails - Keep the tone plain and neutral; no jokes, no blame, no exclamation points
Stripe's public API error documentation is widely cited as a reference point for this kind of actionable messaging, precisely because it consistently names the field and the fix rather than just the failure. Postman's annual State of the API survey has also repeatedly found that unclear documentation and error handling rank among developers' top integration frustrations — which is a fair proxy for how much a bad error message actually costs you in support load and abandoned integrations.
Specifying the Failure Contract Alongside the Success Response
Most spec tools give the 200 response a first-class field and bury errors in a shared appendix or a footnote, which is exactly how the three contracts above end up decided informally, endpoint by endpoint, by whoever writes the exception handler that week. The fix isn't a better error-writing checklist — it's making the failure contract impossible to skip while you're specifying the endpoint in the first place.
This is precisely the gap Prodinja's API Designing tool is built to close: it lets you define the error response body alongside the success response, endpoint by endpoint, so code, message, and details get the same design attention as the 200 response. The taxonomy and status-code decisions from this article become part of the artifact engineering actually builds from, instead of a hallway conversation someone has to reconstruct after the fact.
Key Takeaways
- Design the body shape before the first exception handler ships — commit to
code,message,details(or RFC 7807'stype/title/detail) as your minimum viable error contract. - Treat the code taxonomy as a versioned contract — namespaced, stable machine strings that survive message rewrites; adding a code is safe, renaming or removing one is breaking.
- Reserve status codes for what they actually mean —
400malformed,422semantically invalid,409conflicting state — and don't let one status absorb everything. - Write error messages as product copy — name the field, state the rule, suggest the fix, and never leak internals into
messageordetails. - GraphQL and RPC APIs lose the status-code axis entirely, so the code taxonomy carries even more weight when status codes stop differentiating failure types.
- Specify the failure contract in the same artifact as the success contract, so engineering isn't left guessing at the shape of your
4xxresponses after the happy path already shipped.
Frequently Asked Questions
What is an API error contract?
An API error contract is the documented, versioned agreement covering three things: the JSON shape of an error body, the enumerated list of machine-readable codes, and the status codes each failure maps to. It's a contract in the same sense as your success schema — integrators build logic against it and reasonably expect it not to change without notice.
Should I use RFC 7807 problem+json for my API?
Yes, if you don't already have a house standard — RFC 7807 gives you a reviewed, widely adopted type/title/status/detail/instance shape instead of inventing field names from scratch. It's a specification you extend, not a rigid mandate, and adapting it is faster than debating naming conventions internally for weeks.
What's the difference between 422 and 400 in REST APIs?
400 means the server couldn't even understand or parse the request; 422 means the request was well-formed but failed a validation or business rule once parsed. Use 400 for malformed JSON or a missing required field, and 422 for something like an invalid email format or a quantity that exceeds available stock.
Should API error codes be strings or numbers?
Use namespaced strings, such as invoice_payment_declined, rather than numeric enums. Strings are self-documenting in logs and support tickets, while numeric codes require a lookup table that teams rarely keep updated and that invites collisions across services that don't share a registry.
How many distinct error codes should an API expose?
Start with one code per distinct failure mode per resource, not per endpoint — the same invalid_email_format code should fire whether the email is submitted at signup or during a profile update. Most mature APIs settle into dozens, not hundreds, of codes once duplicate codes across endpoints get merged.