Every API invents its own error shape — and that's the problem
Ask five backends how they report an error and you'll get five JSON shapes: { "error": "..." }, { "message": "...", "code": 42 }, { "errors": [ ... ] }, a bare string, or an HTML page where JSON was promised. Clients then write bespoke parsing for every service they call. Problem Details, defined by RFC 9457, fixes this with one standard, machine-readable JSON error format for HTTP APIs.
RFC 9457 was published in 2023 and supersedes the older RFC 7807. By 2026 it is the default recommendation in most REST style guides — the argument over "how should errors look" now has a published answer. If you already follow JSON API best practices and HTTP status codes, this is the missing piece: the *body* that accompanies a 4xx or 5xx.
The media type and the five base fields
A Problem Details response is served with the content type application/problem+json (there is an XML variant, application/problem+xml, but JSON is what everyone uses). The body is a JSON object with five standard, all-optional members:
{
"type": "https://api.example.com/problems/insufficient-funds",
"title": "Insufficient funds",
"status": 403,
"detail": "Your account balance is 30 but the transfer requires 50.",
"instance": "/accounts/12345/transfers/98765"
}- type — a URI that identifies the *kind* of problem. It is the primary key clients switch on. When dereferenceable, it should point to human-readable documentation for that error type. Defaults to
"about:blank"when omitted. - title — a short, human-readable summary of the problem type. It should stay the same for a given
typeand not change from occurrence to occurrence. - status — the HTTP status code, duplicated in the body so it survives proxies and logs that drop the response line.
- detail — a human-readable explanation *specific to this occurrence*. This is where the changing specifics go (the actual balance, the offending field).
- instance — a URI identifying the specific occurrence, useful for correlating with logs or a support ticket.
The golden rule: `type` and `title` describe the class of error; `detail` and `instance` describe this one event. Clients branch on type; humans read detail.
Extension members: where your domain data goes
The five fields are rarely enough — a validation error needs to say *which fields* failed. RFC 9457 explicitly allows extension members: any additional top-level keys you like. This is how you carry structured, actionable detail without breaking the standard.
{
"type": "https://api.example.com/problems/validation-error",
"title": "Your request body is invalid",
"status": 422,
"detail": "2 fields failed validation.",
"instance": "/orders",
"errors": [
{ "field": "email", "message": "must be a valid email address" },
{ "field": "quantity", "message": "must be >= 1" }
],
"traceId": "0af7651916cd43dd8448eb211c80319c"
}A client that understands your API reads errors and highlights form fields; a generic client still gets a sensible title and status. Adding a traceId extension is one of the highest-leverage habits in API design — it lets a user paste one value into a support request and lets you find the exact log line.
What RFC 9457 changed over RFC 7807
If you already emit RFC 7807, you barely have to move — 9457 is a compatible clarification, not a rewrite. The meaningful additions:
- A registry for common problem
typeURIs, so unrelated APIs can share well-known types instead of each minting their own. - Clarified guidance for reporting multiple problems in one response (typically an extension array like
errorsabove). - Expanded advice for non-dereferenceable
typeURIs — atypeis an identifier first; it does not have to resolve to a live page.
Producing and consuming it
On the server, most frameworks now ship first-class support — ASP.NET Core, Spring, and many Node frameworks emit application/problem+json from a single exception handler, so you map an exception type to a problem type once and every error endpoint is consistent. The key discipline is to route *all* errors through that one handler so you never leak a stack trace or a raw framework error page.
On the client, detection is a content-type check:
async function call(url: string, init?: RequestInit) {
const res = await fetch(url, init);
if (res.ok) return res.json();
const isProblem = res.headers
.get("content-type")
?.startsWith("application/problem+json");
const body = isProblem ? await res.json() : { title: res.statusText };
throw new ApiError(body.type ?? "about:blank", body.title, res.status, body);
}Because the shape is predictable, one ApiError class handles every endpoint of every RFC 9457 service you call.
Design tips that keep it clean
- Never put secrets or stack traces in `detail`. It is user-facing. Put internal diagnostics behind the
traceIdin your logs. - Keep `title` stable per `type`. If
titlechanges per request, you've put occurrence data in the wrong field — move it todetail. - Version your `type` URIs` like documentation, not like code. They are stable identifiers; don't churn them.
- Validate your own error bodies. Generate a JSON Schema for your Problem Details shape and validate responses in tests, so a malformed error can't slip out. When debugging, format the payload to read nested
errorsarrays.
For neighboring topics see JSON API best practices, HTTP status codes explained, and the 429 rate-limit response, which pairs naturally with a rate-limit-exceeded problem type.