No matter how well I design an API’s success path, things will go wrong — a client will send bad data, a resource won’t exist, a payment will get declined, or my own system will hit an unexpected failure. How I design the API’s response to these situations matters just as much as how I design the success path, and honestly, I’ve come to believe it matters more. A confusing error response can waste hours of a developer’s time. In this guide, I’ll walk through exactly how I design error responses so they’re clear, consistent, and actually helpful.
What Makes a Good Error Response?
A good error response answers three questions immediately, without the developer having to guess or dig through documentation:
- What went wrong? (a human-readable explanation)
- Why did it go wrong? (a specific, machine-readable reason)
- What can be done about it? (guidance, or at least enough detail to fix the request)
If an error response can’t answer all three, I go back and improve it before shipping the endpoint.
Step 1: Choose the Right HTTP Status Code for Each Failure Type
Just like with success responses, I map specific failure situations to specific, correct status codes:
- 400 Bad Request — the request itself is malformed or fails validation (missing required field, wrong data type).
- 401 Unauthorized — the client isn’t authenticated at all (no valid token provided).
- 403 Forbidden — the client is authenticated, but doesn’t have permission to perform this action.
- 404 Not Found — the requested resource doesn’t exist.
- 409 Conflict — the request conflicts with the current state of the resource (like trying to cancel an order that’s already been delivered).
- 422 Unprocessable Entity — the request is well-formed and passes basic validation, but fails a business rule (like ordering more stock than is available).
- 429 Too Many Requests — the client has hit a rate limit.
- 500 Internal Server Error — something failed on my side, unrelated to the client’s request.
I take real care to distinguish 400 from 422. A missing required field is a 400 — the request itself is broken. Ordering 10 units of a product that only has 3 in stock is a 422 — the request is well-formed, but it violates a business rule. This distinction genuinely helps client applications decide how to react: a 400 usually means “fix your code,” while a 422 often means “show the user a specific message.”
Step 2: Design a Consistent Error Body Structure
Just like I use one consistent envelope for success responses, I use exactly one consistent structure for error responses across the entire API. My typical structure looks like this:
{
"error": {
"code": "insufficient_stock",
"message": "The requested quantity exceeds available stock for this product.",
"details": [
{
"field": "items[0].quantity",
"issue": "Requested 10, only 3 available."
}
],
"request_id": "req_abc123"
}
}
Every field here serves a specific purpose. code is a stable, machine-readable string the client’s code can check against (if (error.code === 'insufficient_stock')). message is a human-readable explanation, useful for logging or showing directly to a developer during integration — I try not to show raw API error messages directly to end users, since they’re written for developers, not customers. details gives field-level specifics when the error involves particular parts of the request. request_id matches the same ID I include in success responses, which makes it easy to trace a specific failing request through logs when someone reports a problem.
Step 3: Use Stable, Documented Error Codes — Not Just Messages
I never rely on the message string alone to communicate what went wrong, because message text can change over time (a typo fix, a wording improvement) and I don’t want that to silently break a client’s error-handling logic. Instead, I define a fixed set of code values for each type of failure, treat them like part of the API’s contract, and document every single one. A few examples from a typical order API:
validation_error— one or more fields failed basic validationresource_not_found— the requested resource doesn’t existinsufficient_stock— not enough inventory to fulfill the orderinvalid_state_transition— trying to move a resource into a state it can’t go to from its current staterate_limit_exceeded— too many requests in a given time window
Because these codes are documented and stable, a client application can build real logic around them — for example, automatically retrying on rate_limit_exceeded after a delay, but immediately surfacing insufficient_stock to the end user with a clear message.
Step 4: Design Field-Level Validation Errors Clearly
When a request fails because of specific field problems — the most common type of error in practice — I make sure every individual problem is listed, not just the first one found. Nothing is more frustrating than fixing one validation error, resubmitting, and immediately hitting a second one that could have been reported the first time. My validation error responses always list every failing field at once:
{
"error": {
"code": "validation_error",
"message": "The request failed validation.",
"details": [
{ "field": "customer_id", "issue": "This field is required." },
{ "field": "items", "issue": "Must contain at least one item." },
{ "field": "shipping_address.postal_code", "issue": "Invalid postal code format." }
],
"request_id": "req_def456"
}
}
Step 5: Never Leak Internal Details in Error Responses
This is a rule I take very seriously. Error responses should never expose internal implementation details — no raw database error messages, no stack traces, no internal file paths, no information about the internal architecture. Not only is this a security risk (it can hand an attacker useful information), it’s also just confusing and unhelpful to a legitimate developer. For unexpected server-side failures, I always return a generic 500 response like:
{
"error": {
"code": "internal_error",
"message": "Something went wrong on our end. Please try again, and contact support with the request ID if the problem continues.",
"request_id": "req_ghi789"
}
}
The real details of what failed go into my internal logging system, tied to that same request_id, where I can look them up — but they never go into the response body itself.
Step 6: Handle Authentication and Authorization Errors Carefully
For 401 and 403 errors, I’m deliberately a little less specific than I am with validation errors, for security reasons. If a client tries to access a resource that either doesn’t exist or that they don’t have permission to see, I sometimes return a 404 instead of a 403, so I’m not confirming to an unauthorized caller that the resource even exists. This decision depends on the sensitivity of the resource, but it’s something I consciously decide, rather than defaulting to whichever status code happens to be easiest to implement.
Step 7: Make Rate Limit Errors Actionable
When a client hits a rate limit, a bare 429 status code isn’t enough. I include headers and body fields that tell the client exactly how to recover:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
{
"error": {
"code": "rate_limit_exceeded",
"message": "You have exceeded the allowed number of requests. Please retry after 30 seconds.",
"request_id": "req_jkl012"
}
}
The Retry-After header lets well-behaved clients automatically wait the right amount of time before retrying, instead of guessing or hammering the API repeatedly.
Step 8: Keep Error Design Consistent With Standards Where It Helps
I don’t reinvent error formats from scratch every time. I often base my structure loosely on established patterns like RFC 7807 (Problem Details for HTTP APIs), which already solves a lot of these same questions in a well-tested way. I don’t follow it rigidly if it doesn’t fit my API’s needs, but borrowing from an established standard means developers who’ve worked with other APIs already have some intuition for how mine behaves.
A Worked Example: Error Response for “Cancel an Order”
Following our running Order example, imagine a client tries to cancel an order that’s already been delivered:
HTTP/1.1 409 Conflict
{
"error": {
"code": "invalid_state_transition",
"message": "This order cannot be cancelled because it has already been delivered.",
"details": [
{ "field": "status", "issue": "Current status is 'delivered', cancellation is only allowed from 'pending' or 'paid'." }
],
"request_id": "req_mno345"
}
}
This response tells the client exactly what happened, why, and what the valid states actually are — enough information to either fix the calling code or explain the situation to an end user.
Common Mistakes I See in Error Response Design
- Returning
200 OKwith an error message inside the body — this breaks basic HTTP semantics and confuses both tooling and developers. - Using the same generic error code for many different problems, making it impossible for client code to react differently to different failures.
- Only reporting the first validation error instead of all of them at once.
- Leaking stack traces or database errors directly in API responses.
- Inconsistent error body structure across different endpoints in the same API.
- Vague messages like “An error occurred” with no code, no details, and no request ID to trace the problem.
Final Thoughts
I’ve found that the quality of an API’s error handling is one of the clearest signals of how much care went into the overall design. Anyone can make the success path look decent in a demo. It’s the error path — the messy, unglamorous edge cases — that separates a genuinely well-designed API from one that just happens to work when everything goes right. I always design error responses with the same seriousness as success responses, because in real production use, errors happen constantly, and every one of them is an opportunity to either help a developer or waste their time.
