An error response has one job: let the caller decide what to do next. Most of them fail at it by being either too vague to act on or too specific to be safe.
The shape every API invents
Four styles turn up in the same codebase often enough to be a running joke:
{"error": "not found"}with status 404{"success": false, "message": "..."}with status 200{"errors": [{"field": "email", "msg": "invalid"}]}with status 400- An HTML error page, with status 500 and
Content-Type: text/html, because the framework's default handler caught it before your API layer did
The second one is the actively harmful one. A 200 tells every cache, proxy, retry policy and uptime monitor between you and the caller that the request succeeded, and they all act accordingly. The failure is then visible only to code that parses the body, which is precisely the work the status line exists to prevent. Your monitoring shows a healthy service while every request fails.
The fourth is the one that wastes the most time in the browser, because an HTML body where JSON was expected produces a parse error that names a character position rather than the actual problem.
problem+json in five fields
RFC 9457, published in July 2023 as the successor to RFC 7807, defines a single error body with the media type application/problem+json. Five members, all optional, all with a defined meaning:
| Member | Holds |
|---|---|
type | a URI identifying the problem class, defaults to about:blank |
title | a short, stable summary that does not change per occurrence |
status | the HTTP status code, repeated for clients that lost it |
detail | what went wrong this time, for a human reading it |
instance | a URI identifying this specific occurrence |
The division that matters is title against detail. title is the class of problem and should be identical for every occurrence, so it can be matched, counted and translated. detail is this occurrence and is free to name the field, the limit or the value. Clients switch on type, log instance, and show detail to a person.
Extension members are explicitly allowed, and this is what makes the format usable rather than academic. Add errors for per-field validation, balance and accounts for an insufficient-funds problem, traceId for correlation. The five standard members stay recognisable to a generic client while your own consumers get the structured detail they need.
Two rules people break in the first week. The type URI is an identifier that happens to be resolvable, so it must never be reused for a different meaning even if the documentation site is rebuilt. And the status member must equal the actual HTTP status; a body claiming 400 inside a 200 response is worse than no body at all.
Picking a status, and matching it
Problem details do not remove the need to choose a status, they just stop it being the only signal. A short, opinionated set covers most APIs:
| Situation | Status |
|---|---|
| Malformed JSON, wrong content type | 400 |
| Well-formed request, invalid values | 422 |
| No or invalid credentials | 401 plus WWW-Authenticate |
| Authenticated but not allowed | 403 |
| Conflicting state, duplicate key | 409 |
| Rate limited | 429 plus Retry-After |
| Unhandled failure on your side | 500, with nothing revealing in the body |
| Dependency down, deploy in progress | 503 plus Retry-After |
Three of these are routinely wrong in the wild. A 401 without a WWW-Authenticate header violates RFC 9110, which requires it, and it is the header that tells a client which scheme to use. A 429 without Retry-After guarantees a client retries immediately and makes the situation worse. And a 500 for a request the client got wrong sends the caller to your on-call channel for a problem they could have fixed themselves.
The boundary that generates the most argument is 400 against 422, and the practical line is whether the request could be parsed at all. Broken syntax is 400, semantically wrong content is 422. Whichever you choose, be consistent, document it, and remember that a client mostly needs to know whether retrying unchanged could ever help. Our HTTP status code reference has the full list with the pairs that are easy to confuse called out.
Many broken fields, one response
Validation is where a generic error shape either earns its place or does not. A form with eight fields must not take eight submissions to complete, so return everything that is wrong in one response:
- Status 422, media type
application/problem+json. titlea constant such as "Validation failed",detaila one-line summary.- An
errorsextension member: an array of objects, each with a pointer to the field and its owndetail.
Use a stable pointer format and never change it. JSON Pointer (/address/postcode) is the standard-friendly choice and works for nested bodies and arrays. A dotted path is fine if it matches the names in your schema. What does not work is a message with the field name embedded in prose, because the client ends up parsing English to decide which input to highlight.
Give each field error its own machine-readable code as well. "too_short" with a minLength value lets the client render its own message in its own language; a fixed English sentence does not. This is the same reason the top-level type URI exists: text is for people, codes are for code, and mixing them means every copy edit is a breaking change.
How much to say in an error
The tension is real. Too little and a correct client cannot tell what is wrong; too much and the error becomes a map of your internals.
The split that holds up: 4xx bodies may be specific, because the caller caused the problem and needs to know what to change. 5xx bodies should say that something failed, give a request id, and nothing else.
Never in a response body: stack traces, SQL statements, ORM exception text, file system paths, internal hostnames, library versions, or the values of other users' records. Framework debug pages are the usual leak, since they are on by default in development and get shipped by a missed environment variable, and they cheerfully print the environment along with the traceback.
One more that is easy to miss: do not let error messages confirm what exists. "No account with that email" and "wrong password" together turn a login form into a membership oracle. The same applies to a 404 against a 403 on a private resource, where returning 404 for anything the caller may not see is the standard defence.
If your errors are echoing input back to the caller, make sure they are encoded for wherever they end up. An error string containing user input, rendered into an admin dashboard, is a stored XSS with extra steps.
Request ids, retries and headers
The single highest-value extension member is a correlation id. Generate one per request, put it in the response body and in a header, log it with every line the request produces. Support tickets then start with an id that finds the trace in one query instead of a timestamp and a guess. Use the traceparent value if you already run W3C Trace Context, and otherwise any id that is unique and short enough to read over the phone.
Retry semantics belong in headers, not prose. Retry-After on 429 and 503 takes seconds or an HTTP date, and a client that respects it costs you far less than one guessing at a backoff. Pair rate limiting with your own limit headers so a well-behaved client can pace itself before it gets rejected at all; the header conventions and which ones are safe to invent are covered in our HTTP header reference.
Two more that get forgotten because they are not part of the body. Error responses need the same CORS headers as successful ones: if Access-Control-Allow-Origin is added by a middleware that only runs on the success path, the browser reports a CORS failure and the actual 500 never reaches your code, which is one of the confusions untangled in CORS errors explained. And error responses need explicit caching headers, because RFC 9111 allows a cache to store some error statuses heuristically when you send none, so a 404 can outlive the missing record.
Adding it to an API that already ships
You cannot change an error shape clients depend on, but you can grow into the new one.
Start at the edge: the framework's default handler for unhandled exceptions and for 404s, since nobody has written a client against those beyond the status code. Add the Content-Type and the five members while keeping any legacy fields you already emit alongside them; problem details permits extension members, so an existing message or code key can stay until it is unused. Then convert new endpoints as they are written, and old ones when their consumers move.
Content negotiation matters more than it looks. Return application/problem+json when the client accepts JSON, and keep serving the HTML error page to a browser asking for HTML, since a raw problem document in the address bar helps nobody.
Finally, test the errors like you test the happy path. Assert the status, the content type and the type URI, because the error path is the part of an API that changes without anyone noticing: a framework upgrade swaps the default handler, a proxy inserts its own 502 page, and the shape you documented quietly stops being the shape you send. When you are eyeballing a response by hand, our JSON formatter pins a parse error to the exact line, which is the fastest way to discover that what came back was not JSON at all.
Error format questions
What is application/problem+json?
It is the media type for a standard error body, defined in RFC 9457, carrying the members type, title, status, detail and instance plus any extensions you add. The point is that a client library can recognise the shape without knowing your API: it sees the content type, reads status and title, and only needs your documentation for the type URI. It applies to any HTTP API, is not tied to any framework, and Spring Boot, ASP.NET Core and several Node frameworks emit it out of the box.
Is RFC 9457 the same as RFC 7807?
It is the successor. RFC 7807 defined problem details in 2016 and RFC 9457 obsoleted it in July 2023, keeping the media type and all five members so existing clients keep working. The changes are clarifications rather than a redesign: sharper guidance on how type URIs should be defined and documented, and on how extension members behave. If your API already emits 7807 bodies, it emits 9457 bodies.
Should an API ever return 200 with an error in the body?
For a REST API, no. A 200 tells every cache, proxy, retry policy and monitoring dashboard between you and the client that the request succeeded, and they act on it. The client then has to parse the body to find out that it did not, which is exactly the work the status line exists to avoid. GraphQL is the deliberate exception, since a partial result with an errors array genuinely is a partial success, and even there a transport-level failure should still be a 4xx or 5xx.
How should an API return validation errors for several fields at once?
One response, status 422 or 400, with an extension member holding an array of per-field problems, each naming the field and what is wrong with it. RFC 9457 explicitly allows extension members for this. Returning only the first failure makes a form take five round trips to submit, and returning a flat string forces the client to parse prose. Use a stable pointer for the field, either a JSON Pointer such as /address/postcode or the dotted path your request schema uses, and keep it identical between versions.
What should the type URI in a problem response point to?
A stable URL documenting that error class, for humans. It is an identifier first and a link second, so it must never change once published, even if the documentation moves; keep a redirect rather than reusing the value for something else. When you have nothing specific to say, omit it, which means about:blank and tells the client that the status code carries the whole meaning. Do not put a per-request URL there, that is what instance is for.
How much detail is safe to put in an API error message?
Enough to fix a correct client, never enough to map your internals. "Field expiresAt must be an ISO 8601 timestamp" is useful; a stack trace, a SQL statement, a file path or an ORM exception is reconnaissance. The rule that scales: 4xx bodies may be specific, because the client caused the problem and needs to know what to change, while 5xx bodies should say only that something failed and give a request id that ties it to the detail in your logs.
Should error responses include a machine-readable error code?
Yes, and it should not be the HTTP status. A status of 403 covers dozens of distinct situations, and a client that has to string-match the message to distinguish "subscription expired" from "missing scope" will break on your next copy edit. Put a stable code in the type URI or a code extension member, treat it as part of the API contract, and never repurpose one. Human-readable text stays free to change; the code does not.
Should error responses be cached?
Only where you intend it. Under RFC 9111, several error statuses are heuristically cacheable when no explicit headers are sent, 404 and 410 among them, so an error can outlive its cause without anyone configuring anything. Send explicit Cache-Control on error responses: no-store for anything user-specific or transient, and a deliberate short max-age only if you actually want a 404 absorbed at the edge.