An API’s errors are part of its interface. A client that gets a 200 with an error message in the body, or a 500 for a bad request, cannot handle failures correctly, and its developers will resent you. The rules are short: use the status code to say what kind of failure it was, use one consistent body shape to say what specifically went wrong, put detail in the log rather than the response, and never expose internals. This article gives the code-by-code guide and the body format that has become standard.
4xx or 5xx: whose fault is it?#
The single most important rule. A 4xx says the client sent something wrong and retrying the same request will fail again. A 5xx says the server failed and the same request might succeed later. Clients, proxies and monitoring all act on that distinction: retry logic, alerting, caching. Getting it wrong in either direction causes real damage — a 500 for invalid input pages the on-call engineer for a typo; a 400 for a database outage tells the client to give up.
The codes you need#
| Code | Use when |
|---|---|
| 200 | Success with a body |
| 201 | Something was created; include its location or the resource |
| 204 | Success with nothing to return, typically after DELETE |
| 400 | The request is malformed or fails validation |
| 401 | Not authenticated: no credentials, or bad ones |
| 403 | Authenticated, but not allowed to do this |
| 404 | The resource does not exist |
| 409 | Conflict with current state: duplicate, version mismatch |
| 422 | Well-formed but semantically invalid; many APIs use 400 for this too |
| 429 | Rate limited; include Retry-After |
| 500 | An unexpected server error |
| 503 | Temporarily unavailable; include Retry-After |
That is enough for almost any API. Exotic codes add ambiguity without adding information. Pick 400 or 422 for validation and use it everywhere.
One body shape#
RFC 9457, Problem Details, defines a JSON shape that every client library understands and that most frameworks can produce:
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Insufficient funds",
"status": 409,
"detail": "Balance is 20.00; the transfer requires 35.00.",
"instance": "/accounts/12345/transfers/987",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}
type: a stable identifier for this kind of error, so clients can branch on it without parsing text. A URL by convention, though it need not resolve.title: short and fixed per type.status: repeats the HTTP code, for clients that lose it.detail: a human-readable explanation of this occurrence.instance: the request path or resource this concerns.- Extra fields as needed: a trace id, a list of field errors.
Send it with Content-Type: application/problem+json. Use the same shape for every error the API emits, including 404s from the router and 500s from the exception handler. Clients then need one parser.
Validation errors#
Report every invalid field at once. A client that fixes one field, resubmits, and learns about the next one is being made to do your job:
{
"type": "https://api.example.com/errors/validation",
"title": "Validation failed",
"status": 400,
"errors": {
"email": ["Must be a valid email address."],
"quantity": ["Must be greater than zero."]
}
}
Field names in the error should match the field names in the request body, so a form can attach messages to inputs.
What to log, what to return#
Log everything: the exception, the stack trace, the request id, the user id, the input that caused it. Return only what the client can act on. In particular, never return:
- Stack traces or file paths.
- Database errors, SQL, table or column names.
- Internal service names or hostnames.
- Whether a username exists, on a login failure. “Invalid credentials” covers both cases.
The trace id is the bridge: the client shows it to the user, the user quotes it to support, support finds the full log entry. For 5xx responses, the detail field should say something like “An unexpected error occurred” and nothing more.
Retries and idempotency#
Clients retry 5xx and 429 responses; they must not retry 4xx. Make that safe by making the retried operations idempotent: a PUT applied twice has the same effect as once; a POST that creates something should accept an Idempotency-Key header so a retry does not create a duplicate. Include Retry-After on 429 and 503 so clients back off correctly rather than hammering.
Consistency#
Every endpoint in the API should fail the same way. That means one place in the code that turns exceptions into responses — middleware, an exception handler, a filter — rather than try/catch blocks in each handler with their own ideas. Frameworks provide this: exception handlers in ASP.NET Core, exception handlers in FastAPI, error middleware in Express, @ControllerAdvice in Spring. Use it, and write the mapping from your domain errors to status codes once.
Mistakes to avoid#
- 200 with an error in the body. Breaks every client’s success check and every proxy’s caching assumption.
- 500 for everything. Hides whether the client can fix it.
- Different shapes per endpoint. Forces clients to special-case.
- Human text as the only signal. Clients cannot branch on “Item not found” reliably; give them a type or code.
- HTML error pages from a JSON API. Usually a proxy or default handler in front of the application. Configure it to return JSON, or at least the right status.
- Leaking the existence of resources the caller may not see. A 403 on a private resource tells them it exists; a 404 does not. Decide which you want.
Questions people ask#
400 or 422 for validation?
Either, consistently. 422 signals “syntactically fine, semantically wrong” and some ecosystems prefer it; 400 is universally understood. Pick one for the whole API.
Should errors be localised?
The title and detail can be, based on Accept-Language. The type must not change, since clients branch on it.
What about GraphQL?
GraphQL returns 200 with an errors array by convention, which is a different contract. The principles — consistent shape, no internals, machine-readable codes — still apply inside that array.
Is Problem Details required?
No, but it is the standard, and the alternative is inventing an equivalent that no library understands. Use it unless you have a strong reason.
Where to go next#
- ASP.NET Core error handling — implementing this in .NET.
- Returning error messages from Spring Boot — the Java version.
- Build a REST API with FastAPI — a complete API that follows these rules.