Error Response Design
Error responses are the most undervalued part of an API. Developers spend 80% of their integration time dealing with errors — parsing them, debugging them, and figuring out what went wrong. A well-designed error response turns a 30-minute debugging session into a 30-second fix.
The goal is simple: when something goes wrong, tell the developer exactly what happened, why it happened, and how to fix it. Most APIs fail at all three.
Anatomy of a Good Error Response
A complete error response has five components:
- HTTP status code — the coarse category of the error (4xx client error, 5xx server error)
- Error code — a machine-readable string that identifies the specific error
- Message — a human-readable explanation of what went wrong
- Details — field-specific or context-specific information
- Request ID — a unique identifier for debugging with support
{
"error": {
"status": 422,
"code": "invalid_parameter",
"message": "The email address is not valid.",
"details": [
{
"field": "email",
"code": "invalid_format",
"message": "Must be a valid email address. Received: 'not-an-email'"
}
],
"request_id": "req_abc123def456",
"doc_url": "https://docs.example.com/errors/invalid_parameter"
}
}
This response tells the developer everything they need: the HTTP status (422), the error type (invalid parameter), the specific field (email), what is wrong (invalid format), what was received, and where to find documentation.
HTTP Status Codes
Use status codes correctly. They are the first signal a client receives, often before parsing the response body.
Client Errors (4xx)
400 Bad Request — malformed syntax, cannot parse the request
401 Unauthorized — no authentication credentials provided
403 Forbidden — authenticated but not authorized for this action
404 Not Found — resource does not exist
409 Conflict — request conflicts with current state (duplicate, version mismatch)
422 Unprocessable — request is valid JSON but semantically incorrect
429 Too Many Requests — rate limit exceeded
Server Errors (5xx)
500 Internal Server Error — unexpected server failure
502 Bad Gateway — upstream service returned an invalid response
503 Service Unavailable — server is temporarily overloaded or in maintenance
504 Gateway Timeout — upstream service did not respond in time
Common Mistakes
Using 200 for errors:
{
"success": false,
"error": "User not found"
}
This forces clients to parse the response body to determine if the request succeeded. Use 404 instead.
Using 500 for client errors:
HTTP/1.1 500 Internal Server Error
{"error": "Invalid email address"}
An invalid email is a client error (4xx), not a server error (5xx). Returning 500 for client mistakes confuses monitoring systems and makes it impossible to distinguish actual server failures from bad input.
RFC 7807: Problem Details
RFC 7807 (updated by RFC 9457) defines a standard format for HTTP error responses. It provides a consistent structure that tools and libraries can parse automatically.
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Insufficient Funds",
"status": 403,
"detail": "Your account balance of $5.00 is insufficient for a $20.00 transfer.",
"instance": "/transfers/txn_abc123",
"balance": 500,
"required": 2000,
"currency": "usd"
}
The standard fields:
| Field | Purpose |
|---|---|
type |
URI identifying the error type (machine-readable) |
title |
Short human-readable summary |
status |
HTTP status code |
detail |
Human-readable explanation of this specific occurrence |
instance |
URI identifying this specific occurrence |
The format is extensible — you can add custom fields (balance, required, currency above) for error-specific context. The Content-Type header should be application/problem+json.
Stripe's Error Format
Stripe's error format is the most widely emulated in the industry:
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "Your card has insufficient funds.",
"param": "source",
"charge": "ch_abc123",
"doc_url": "https://stripe.com/docs/error-codes/card-declined"
}
}
Key design decisions:
typegroups errors into categories:api_error,card_error,invalid_request_error,authentication_errorcodeis the specific machine-readable error within the categoryparamidentifies which request parameter caused the errordoc_urllinks directly to documentation for this error codechargeincludes the ID of the related resource for debugging
Every field serves a purpose. The type is for routing in client code (different handling for card errors vs API errors). The code is for specific handling (show a different message for card_declined vs expired_card). The message is for displaying to end users.
Consistent Error Structure
Every endpoint in your API should return errors in the same format. Inconsistent error structures force consumers to write different error-handling code for different endpoints.
Bad — different formats for different endpoints:
{
"error": "User not found"
}
{
"errors": ["Invalid email", "Name too short"]
}
{
"status": "error",
"reason": "rate_limited"
}
Good — same format everywhere:
{
"error": {
"code": "resource_not_found",
"message": "User not found.",
"request_id": "req_abc123"
}
}
{
"error": {
"code": "validation_error",
"message": "Request validation failed.",
"details": [
{"field": "email", "code": "invalid_format", "message": "Must be a valid email address."},
{"field": "name", "code": "too_short", "message": "Must be at least 2 characters."}
],
"request_id": "req_def456"
}
}
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Retry after 30 seconds.",
"retry_after": 30,
"request_id": "req_ghi789"
}
}
The structure is always error.code, error.message, error.request_id. Additional fields vary by error type but the core structure is identical.
Request IDs for Debugging
Every API response — success or error — should include a request ID. This is a unique identifier that correlates a client's request with server-side logs.
HTTP/1.1 500 Internal Server Error
X-Request-ID: req_abc123def456
{
"error": {
"code": "internal_error",
"message": "An unexpected error occurred. Please contact support with request ID: req_abc123def456",
"request_id": "req_abc123def456"
}
}
When a developer contacts support, they provide the request ID. Your support team searches server logs for that ID and finds the exact stack trace, the exact request payload, and the exact database query that failed.
Without request IDs, debugging is "it broke yesterday around 3 PM" — searching through millions of log lines for a needle in a haystack.
Include the request ID in both the response header (X-Request-ID) and the response body. Headers are visible in browser dev tools and HTTP clients. The body is visible when logging response data.
Security: What Not to Expose
Error responses should help developers without helping attackers.
Never Expose Stack Traces
{
"error": "NullPointerException at com.example.UserService.getUser(UserService.java:42)\n at com.example.api.UserController.get(UserController.java:15)"
}
Stack traces reveal your technology stack, file structure, library versions, and internal logic. In production, return a generic message and log the stack trace server-side, correlated with the request ID.
Never Expose SQL Queries
{
"error": "ERROR: relation \"users\" does not exist\nSELECT * FROM users WHERE email = 'jane@example.com' AND password_hash = 'abc123'"
}
This reveals your database schema, table names, and potentially sensitive query parameters.
Be Careful with Existence Disclosure
POST /login
{"email": "jane@example.com", "password": "wrong"}
{"error": "Invalid password for jane@example.com"}
This confirms that jane@example.com has an account. Instead:
{
"error": {
"code": "invalid_credentials",
"message": "Invalid email or password."
}
}
Do not distinguish between "user not found" and "wrong password" in authentication endpoints.
Common Pitfalls
- Returning 200 for errors — wrapping errors in a 200 response with a
success: falsefield. Use proper HTTP status codes. - Generic messages — returning "Bad Request" without specifying what is bad. Every error should identify the problematic field or parameter.
- Inconsistent formats — different error shapes from different endpoints or different teams. Define one error format and enforce it across the entire API.
- Missing request IDs — without request IDs, debugging production errors requires timestamp-based log searching, which is unreliable.
- Exposing internals — stack traces, SQL queries, internal service names, and file paths in error responses. Log these server-side; do not return them to clients.
- Forgetting rate limit errors — 429 responses should include
Retry-Afterheader and information about the rate limit window. - Not testing error paths — testing only the happy path. Error responses need the same quality assurance as success responses.
Key Takeaways
- A good error response includes five components: HTTP status code, machine-readable error code, human-readable message, field-specific details, and a request ID.
- RFC 7807 Problem Details provides a standard format. Use it or design something equally structured. Consistency matters more than the specific format.
- Stripe's error format is the industry gold standard: type for category, code for specifics, param for the problematic field, and doc_url for documentation.
- Every endpoint must return errors in the same structure. Inconsistent error formats create unnecessary integration complexity.
- Include request IDs in every response (header and body). They are the single most valuable debugging tool for production APIs.
- Never expose stack traces, SQL queries, or internal implementation details in production error responses. Log them server-side and reference them by request ID.