Quick answer: Use RFC 9457 Problem Details: a JSON object with
type,title,status,detail, andinstance, served withContent-Type: application/problem+jsonand the matching HTTP status code. Add anerrorsarray for field-level validation failures and a stable machine-readablecodefor anything a client needs to branch on. Never include stack traces.
A mobile developer on your team shows you their error handling: a chain of if (message.includes("not found")) checks. It worked until someone fixed the capitalization in one message and the app started showing a generic failure dialog for every missing record. That is what {"error": "something"} costs you. An error response is a contract, and this post is about writing one clients can keep.
Explore a valid sample in the JSON Viewer to check its fields and nesting. The viewer does not perform the protocol-specific validation discussed in this guide.
Why {"error": "message"} Is Not Enough
The single-string error is the default in most tutorials and it fails in four predictable ways:
- Clients cannot branch on it. "Card declined" and "Card expired" need different UI, and string matching is the only option.
- It cannot carry more than one problem. A form with three invalid fields produces three round trips.
- It cannot be localized without the server knowing every language the client supports.
- It drifts. Every endpoint invents its own shape:
errorhere,messagethere,errors[]somewhere else. Clients end up with a normalizer that knows all of them.
Fix all four by giving the error body a schema and sticking to it everywhere, including the responses your framework generates for you when a route does not exist. The general shape of request and response bodies is covered in JSON in REST APIs; this post is only about the unhappy path.
RFC 9457 Problem Details: The Standard Error Format
RFC 9457 (which obsoleted RFC 7807 in 2023 without changing the wire format) defines a small JSON object called a problem detail. A minimal example:
HTTP/1.1 402 Payment Required
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Insufficient funds",
"status": 402,
"detail": "Your balance is 30.00 but the transfer requires 50.00.",
"instance": "/transfers/9c4e1a"
}
| Member | Type | Role | Example |
|---|---|---|---|
type | string (URI) | Identifies the error category. Defaults to about:blank. Should resolve to docs. | "https://api.example.com/errors/insufficient-funds" |
title | string | Short human-readable summary, the same for every occurrence of this type. | "Insufficient funds" |
status | number | The HTTP status code, repeated for clients that lose the transport layer. | 402 |
detail | string | Explanation specific to this occurrence. For humans, not for parsing. | "Balance is 30.00, transfer is 50.00." |
instance | string (URI) | Identifies this specific occurrence, often a request or trace ID path. | "/requests/7f1c3e" |
Every member is optional, but type plus title is the useful minimum. The key design idea is that type is the thing clients switch on. It is a URI, which sounds heavyweight, but it does two jobs: it is globally unique without a registry, and if you make it resolve to a documentation page, a developer who sees it in a log can click it.
The RFC also allows extension members: any additional keys you need. This is where field errors, balances, retry hints, and request IDs go:
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Insufficient funds",
"status": 402,
"detail": "Your balance is 30.00 but the transfer requires 50.00.",
"instance": "/transfers/9c4e1a",
"balance": 30.00,
"required": 50.00,
"requestId": "req_01J2X8K7P4"
}
Serve it with Content-Type: application/problem+json. The +json suffix means any JSON parser reads it, while clients that recognize the type know to look for these members. The JSON Content-Type guide covers the suffix family and how to make body parsers accept it.
Field-Level Validation Errors
Validation is the one case where a single detail is never enough. Put each failure in an errors array with a pointer to the field, a stable code, and a message:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/validation",
"title": "Request validation failed",
"status": 422,
"detail": "2 fields are invalid.",
"instance": "/users",
"errors": [
{
"pointer": "/email",
"code": "format",
"message": "Must be a valid email address."
},
{
"pointer": "/address/postalCode",
"code": "required",
"message": "Postal code is required for AU addresses."
}
]
}
pointer is a JSON Pointer (RFC 6901) into the request body: /address/postalCode means the postalCode key inside the address object, and /items/2/sku means the third item's SKU. Clients can map it to a form field mechanically. A dotted path like address.postalCode is a common alternative; pick one and never mix them. The syntax is explained in JSONPath vs JSON Pointer.
Return all validation failures in one response rather than stopping at the first one. It is the difference between a form that lights up every bad field at once and one that nags the user five times.
Error Codes vs Messages
The single most useful rule: every error carries two strings with different contracts.
| Code | Message | |
|---|---|---|
| Purpose | Machine branching | Human display |
| Stability | Never changes once published | Can be reworded any time |
| Localization | Never translated | Translate via Accept-Language |
| Example | validation_failed | Email address is not valid. |
| Who reads it | Client code, dashboards, alerts | End users, developers in logs |
In a Problem Details body the type URI plays the code role at the top level, and a code extension plays it inside each field error. Use snake_case or kebab-case codes consistently; the key naming conventions post explains how to choose and stick with one. Document every code, because the list is part of your public API.
For localization, read the Accept-Language request header and translate title, detail, and message on the server, or ship only codes and let the client translate. Either works; what does not work is a client that parses English prose.
What Stripe and GitHub Do
Two widely imitated APIs predate RFC 9457 and use their own envelopes. They are worth knowing because the ideas transfer.
Stripe-style: a single error object with a type (broad category), a code (specific condition), a human message, and a param naming the offending field. Their public docs show bodies in this shape:
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "Your card has insufficient funds.",
"param": "payment_method"
}
}
GitHub-style: a top-level message, an optional errors array with resource, field, and code, and a documentation_url:
{
"message": "Validation Failed",
"errors": [
{ "resource": "Issue", "field": "title", "code": "missing_field" }
],
"documentation_url": "https://docs.github.com/rest/issues/issues#create-an-issue"
}
Notice what both share with the RFC: a stable code separate from the message, field-level detail, and a link to documentation. If you are starting fresh, use the RFC shape so clients can reuse generic libraries; if you are maintaining one of these styles, keep it consistent rather than migrating halfway.
Implementing a JSON Error Handler in Express
One error middleware at the end of the chain turns every thrown error into a problem detail. Define a small error class so route code can throw with a type and status:
class ProblemError extends Error {
constructor({ status, type, title, detail, errors }) {
super(detail || title);
Object.assign(this, { status, type, title, detail, errors });
}
}
// In a route
app.post("/transfers", async (req, res, next) => {
try {
const result = await transfer(req.body);
res.status(201).json(result);
} catch (err) {
next(err);
}
});
// Last middleware: four arguments make it an error handler
app.use((err, req, res, next) => {
const status = err.status || 500;
const body = {
type: err.type || "about:blank",
title: err.title || (status === 500 ? "Internal Server Error" : err.message),
status,
detail: status === 500 ? undefined : err.detail,
instance: req.originalUrl,
requestId: req.id,
};
if (err.errors) body.errors = err.errors;
if (status === 500) console.error(req.id, err); // stack goes to logs only
res.status(status).type("application/problem+json").json(body);
});
// Unknown routes get the same shape
app.use((req, res) => {
res.status(404).type("application/problem+json").json({
type: "https://api.example.com/errors/not-found",
title: "Not Found",
status: 404,
instance: req.originalUrl,
});
});
Two details matter. res.type() is called before res.json() so the helper does not overwrite the media type. And the 500 branch replaces detail with undefined, which JSON.stringify drops entirely; the null vs undefined post explains why that is the behaviour you want here.
Implementing Problem Details in FastAPI
FastAPI already returns a detail array for validation errors. Override its handler to reshape that into the RFC format and set the media type:
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
app = FastAPI()
PROBLEM = "application/problem+json"
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
errors = [
{
"pointer": "/" + "/".join(str(p) for p in e["loc"] if p != "body"),
"code": e["type"],
"message": e["msg"],
}
for e in exc.errors()
]
return JSONResponse(
status_code=422,
media_type=PROBLEM,
content={
"type": "https://api.example.com/errors/validation",
"title": "Request validation failed",
"status": 422,
"detail": f"{len(errors)} field(s) are invalid.",
"instance": str(request.url.path),
"errors": errors,
},
)
@app.exception_handler(StarletteHTTPException)
async def http_handler(request: Request, exc: StarletteHTTPException):
return JSONResponse(
status_code=exc.status_code,
media_type=PROBLEM,
content={
"type": "about:blank",
"title": exc.detail,
"status": exc.status_code,
"instance": str(request.url.path),
},
)
The loc tuple from Pydantic already looks like a path (("body", "address", "postalCode")), so joining it with slashes produces a JSON Pointer with almost no work.
Common Mistakes in JSON Error Responses
Retry-related errors need an operation contract as well as a body format. The guide to JSON API idempotency mistakes covers payload mismatches, concurrent requests, stored outcomes, expiration, and duplicate downstream effects.
- 200 OK with an error body. Some APIs return 200 and
{"success": false}. Every HTTP client, cache, and monitoring tool now believes the request worked. Use the real status code; the body elaborates, it does not replace it. - HTML error pages from a JSON API. A proxy or framework default returns a 502 page as
text/html. The client callsresponse.json()and gets an unexpected token < error instead of the real failure. Configure the gateway to return JSON for errors too. - Leaking internals. Stack traces, SQL fragments, file paths, and library versions belong in server logs keyed by a request ID. Return the ID, not the trace.
- Different shapes for 4xx and 5xx. Clients write one parser. Make the shape identical and vary the members.
- Changing a message without versioning. Not a problem if clients branch on codes. A guaranteed outage if they do not, which is one more reason to ship codes from day one.
- Invalid JSON in error paths. Error handlers are tested less than success paths. A hand-built template with a trailing comma will pass code review and fail in production. Capture a real error response and run it through JSON Prettifier once; it takes seconds and the Validate button finds the comma.
A Checklist for Your Error Contract
- One schema for every error, including framework-generated 404s and 405s.
- Correct HTTP status code, repeated in the
statusmember. - A stable
typeURI orcodethat clients branch on. - A human
titleanddetailthat you are free to change. - An
errorsarray with pointers for validation failures. - A request or trace ID for support, and nothing from the stack trace.
Content-Type: application/problem+json.- Every code documented at the URL the
typepoints to.