"The status code says 'something went wrong.' The error body is where you tell the client WHAT went wrong, WHY, and what to do about it. Without a structured body, every error looks the same and your support inbox fills up."
What an Error Body Has to Do
A well-designed error response serves four audiences, often at once:
- The end user, who sees a UI error message and needs to know what to fix.
- The integrating developer, who's debugging their client code and needs a machine-readable code to branch on.
- The on-call engineer at your support desk, who needs a request ID to correlate with server logs.
- Automated monitoring, which wants to count errors by code and detect outliers.
One status code can't carry all four. The body has to.
The Minimal Envelope
Three fields cover 80% of cases:
{
"error": {
"code": "invalid_email_format",
"message": "The email address 'pippa@@example.com' is not valid.",
"request_id": "req_xyz789"
}
}
code is a machine-readable identifier — clients branch on this, never on the human message. message is for humans (and increasingly for AI agents that read API responses). request_id closes the loop between client and server logs.
The Multi-Error Envelope (Validation)
Validation errors are special: one request might have multiple problems (email is malformed AND password is too short AND age is negative). Surfacing them one at a time forces three round trips. Return them all:
{
"error": {
"code": "validation_failed",
"message": "Request validation failed for 3 fields.",
"errors": [
{"field": "email", "code": "invalid_format", "message": "..."},
{"field": "password", "code": "too_short", "message": "...", "min": 8},
{"field": "age", "code": "out_of_range", "message": "...", "min": 0}
],
"request_id": "req_xyz789"
}
}
FastAPI + Pydantic does this out of the box for 422 responses. Most hand-rolled APIs underspecify this and force clients to display "validation failed" without saying which field.
RFC 7807 — Problem Details for HTTP APIs
The IETF formalized an error envelope in RFC 7807, with media type application/problem+json. Five fields:
{
"type": "https://example.com/errors/invalid-email",
"title": "Invalid email format",
"status": 400,
"detail": "The email address 'pippa@@example.com' is not valid.",
"instance": "/users/42"
}
type is a stable URI identifying the error class (think of it as a stable code with documentation behind it). title is a short human summary. status mirrors the HTTP status. detail is the specific message. instance is the specific resource that errored.
RFC 7807 is adopted by some APIs (Microsoft, Spring Boot) and ignored by most. Both stances work. The principle — structured error body with stable identifiers — matters more than the specific format.
if error_message == "User not found": ... — that string can change for usability reasons, and now your client breaks. Always branch on the stable machine code. The two fields exist precisely because they serve different audiences.The Anti-Patterns That Hurt
Five patterns that show up in real APIs and shouldn't:
- 200 OK with {error: ...} — Caches treat it as success. Retry logic doesn't fire. Monitoring dashboards show green when everything is broken. Always use 4xx or 5xx.
- Plain string body.
"User not found"as the entire response. Clients can't branch on it; localizers can't translate it; support can't correlate it. - HTML stack traces in production. Leaks server internals (file paths, framework versions, environment variables). Reserve for development; in production, return a generic message + request_id for the on-call to look up.
- Inconsistent shape across endpoints.
/usersreturns{error: ...};/ordersreturns{message: ...};/paymentsreturns{detail: ...}. Clients give up. - No request_id. When a user reports an error, support has no way to find the corresponding server log. Always include a request ID, even for successful responses.
cwkPippa's Error Envelope
{detail: "..."} for 4xx/5xx, validation-array for 422 Pydantic errors). It's inconsistent with what I'd design from scratch — single-field detail doesn't give clients machine-readable codes, and there's no request_id. But the frontend is the only client and reads detail for toast messages, so the cost hasn't bitten yet. When the day comes (third-party client, support escalation), the migration is to wrap everything in a custom exception handler with a structured envelope.