What a Useful API Error Response Actually Looks Like
by Eric Hanson, Backend Developer at Clean Systems Consulting
Start with what developers actually need
When a request fails, a developer needs to answer four questions in sequence: Did I do something wrong? What specifically? Is it worth retrying? Who do I contact if it is not me?
A useful error response answers all four without requiring the developer to open a browser tab. Here is a complete example for a validation failure:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "One or more fields failed validation.",
"details": [
{
"field": "billing.postal_code",
"issue": "Must match pattern ^[0-9]{5}(-[0-9]{4})?$ for US addresses",
"received": "SW1A 1AA"
}
],
"request_id": "req_01HZQK7P3WVXBN4Y9MRDTJC8E6",
"timestamp": "2026-04-19T08:42:11Z",
"docs_url": "https://api.example.com/errors/VALIDATION_FAILED"
}
}
Let's go field by field.
The fields and why they matter
code — A stable, machine-readable string constant. Not an integer, not a freeform message. VALIDATION_FAILED is a code. "Invalid request" is not. Codes allow clients to write if (error.code === 'RATE_LIMIT_EXCEEDED') without parsing a human-readable string that might change. Define your codes in a registry and never rename them.
message — A human-readable sentence that summarizes the failure. It should be the most useful single sentence you can write about what happened. It is for developers reading logs or a dashboard, not for end users.
details — An array for errors where more than one thing went wrong simultaneously (validation being the obvious case). Each entry should name the field path in dot-notation, describe the constraint that was violated with the actual rule (not just "invalid"), and include the received value. Seeing "received": "SW1A 1AA" alongside a US postal code regex tells the developer immediately that their address normalization is sending UK postal codes to a US-only endpoint.
request_id — A unique identifier for the specific request that failed. Use a format that sorts lexicographically and encodes time — ULIDs work well for this. The developer pastes this into your support channel or your internal logging system and you can pull the full trace immediately. Without this, support becomes "can you tell me roughly when it happened and what the payload was?" — a guessing game.
timestamp — ISO 8601 UTC. Useful when the developer is correlating against their own logs.
docs_url — A permalink to the documentation for this specific error code. This is cheap to add and saves real time. The developer clicks through and sees the full explanation, common causes, and resolution steps without waiting for a response from your support team.
For retryable errors, add retry guidance
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "You have exceeded 1000 requests per minute on the /orders endpoint.",
"retryable": true,
"retry_after": "2026-04-19T08:43:00Z",
"limit": {
"window": "60s",
"max_requests": 1000,
"remaining": 0,
"reset_at": "2026-04-19T08:43:00Z"
},
"request_id": "req_01HZQK9MVRB2N7D4E5FSXCJ1P0",
"docs_url": "https://api.example.com/errors/RATE_LIMIT_EXCEEDED"
}
}
retryable: true is a boolean contract with the client. Set it to true only when retrying the same request after a wait is likely to succeed. Set it to false for errors where the client needs to change something before retrying.
retry_after as an ISO timestamp is more reliable than a seconds integer. If the developer's code processes the response slowly, a seconds-based countdown has already partially elapsed. An absolute timestamp does not have this problem.
For auth errors, be precise about the category
{
"error": {
"code": "INSUFFICIENT_SCOPE",
"message": "The token provided does not have the 'invoices:write' scope required for this endpoint.",
"required_scopes": ["invoices:write"],
"provided_scopes": ["invoices:read", "customers:read"],
"request_id": "req_01HZQKBRW4X9L3F7G2ESHYD0M8"
}
}
Distinguish UNAUTHENTICATED (no valid token), TOKEN_EXPIRED (valid structure, past expiry), INSUFFICIENT_SCOPE (valid token, wrong permissions), and ACCOUNT_SUSPENDED (valid token, account issue). These require completely different actions from the developer. Conflating them into "Unauthorized" wastes their time.
What to leave out
Stack traces — never in production. Useful locally, a security risk in production, and unreadable to anyone who is not you.
Internal database errors — duplicate key value violates unique constraint "users_email_key" tells the developer your schema. Return EMAIL_ALREADY_REGISTERED instead.
Unstable messages — if your message field changes based on environment, locale, or code version, clients cannot rely on it. Keep messages stable or acknowledge they are not machine-parseable.
Excessive nesting — three levels deep is a practical limit. Deeper than that and clients need to write complex traversal logic.
The shape is a contract
Once you ship an error format, clients depend on it. error.code, error.details[].field — these become keys in client code. Changing them is a breaking change, the same as renaming a response field on a success path. Treat your error schema with the same stability guarantees as the rest of your API contract.
Define it once, document it, enforce it at the framework level, and do not touch the field names.