How to Write API Errors a Developer Can Debug
An API error message has one job: give the person on the other end enough to fix the problem without opening a support ticket. Most of them fail at this so reliably that developers have learned to ignore the message text entirely and go straight to the status code, then to the logs, then to a colleague. The error string sits in the middle of the response like a note nobody reads.
This is a writing problem dressed up as an engineering one. The information needed to debug is almost always available at the moment the error is thrown. What goes wrong is the translation from internal state to a sentence a stranger can act on.
The generic string that helps no one
"Invalid request." Fourteen characters, zero information. The developer who sent the request knows it was invalid, because the server rejected it. What they do not know is which field, which rule, or which value crossed the line. A message that says only that something is wrong forces a binary search through the request body, toggling fields until the error changes.
The habit is easy to explain. A validation layer catches a failure, the framework's default handler formats it, and the default handler was written for the developer of the API, not the consumer. "Invalid request" is true. It is also useless.
The fix costs one line. Name the field. Name the constraint. A message reading "expires_at must be a future date; received 2023-04-01" ends the investigation in the time it takes to read it. That sentence fits in a response header and replaces an hour of guessing.
What the four hundred family owes the caller
Client errors carry a special obligation, because the caller can usually fix them without help. A 400, 401, 403, 404, 409, or 422 tells the developer the problem is on their side of the wire. Each one deserves a message that names the specific cause.
A 401 that says "Unauthorized" wastes a step. The developer needs to know whether the token was missing, malformed, expired, or revoked. Four different fixes follow from those four causes. A 403 that says "Forbidden" is even worse: the caller has valid credentials and no idea which permission is missing or which resource they cannot touch.
A 422 validation failure should return the field path and the violated rule, ideally as structured data alongside the human string. The string is for the person reading logs at midnight. The structure is for the client code that renders inline field errors. Both belong in the response.
Machine-readable fields belong beside the sentence
Good error bodies carry two parallel payloads. One is prose for a human. The other is a stable code for software. The prose changes as the API's wording is refined; the code must never change, because client code branches on it.
A typical shape: a top-level code like PAYMENT_DECLINED, a message like "The card was declined by the issuer," and a details object listing the parameter and the value. The developer logs the code, shows the message, and uses the parameter name to highlight the input. Each piece serves a different consumer.
The code is the contract. Treat it with the seriousness of a function signature. Renaming RATE_LIMITED to TOO_MANY_REQUESTS in a minor release will break every client that matches on the string, and the breakage will surface as a silent fallback, which is the worst kind.
A request identifier to bind the error to the trace
Every error response should carry a unique request identifier, and that identifier should appear in the server logs next to the stack trace. This single field converts a vague support conversation into a five-second lookup. The developer pastes the id, the engineer greps the logs, and the actual cause appears.
Without it, the support loop stalls. The developer describes the time and the endpoint. The engineer asks for the exact payload. The developer reconstructs it from memory, inaccurately. Two days pass. With the identifier, the correlation is exact.
The identifier does not need to be long. A compact UUID or a short opaque string is enough. What matters is that the same value appears in the response body and in the log line, character for character.
Leaking internals versus hiding them entirely
There is a real tension here. Error messages that expose stack traces, SQL fragments, or file paths help attackers map the system. Messages that expose nothing help no one. The resolution is a split audience: detailed diagnostics in the logs, a safe summary in the response, bound together by the request identifier.
A database constraint violation is the classic case. The response should not say "duplicate key value violates unique constraint users_email_key." That sentence reveals the table and the column name. The response should say "An account with this email already exists," which is accurate, actionable, and reveals nothing an attacker did not already guess by trying to register.
Some teams overcorrect and return "An error occurred" for every server-side failure. That is safe and useless. A generic 500 message is acceptable only when paired with a request identifier that support can trace. Otherwise the caller has nothing.
Rate limits need the reset time in the error
A 429 response that omits the retry window forces clients to guess. Guessing means they retry immediately, which deepens the rate limit, or they back off far too aggressively, which wastes capacity. The error should carry the number of seconds until the next permitted request, either in a Retry-After header or in the body.
The same principle applies to any transient failure. A 503 that says "Service unavailable" invites an immediate retry. A 503 that says "Service unavailable, retry after 30 seconds" tells the client exactly what to do. When the retry window is unknown, say so, and let the client apply its own backoff policy.
Status codes that contradict the message body
Nothing erodes trust faster than a response that says one thing in the status line and another in the body. A 200 with an error object inside is the most common offender: the HTTP layer reports success, the payload reports failure, and the client has to parse the body to know which is true. Pick one convention and hold it everywhere.
The reverse also happens. A client sends a malformed JSON body, the parser throws, and the API returns a 500 because the handler was not written to catch parse errors. The caller caused the problem and receives an error that suggests the server broke. A 400 with a message naming the malformed position serves everyone better.
Documentation that shows the actual failing request
An error reference page listing every code is necessary and rarely sufficient. What developers reach for is an example: the request that triggered the error, the response it produced, and the fix. A table of codes answers "what does this mean." A worked example answers "what do I do."
For each error family, the documentation should show a realistic request with a plausible failure, the exact response body, and a sentence on the resolution. This is more work than generating a table from the code constants. It is also what gets read.
Keep the examples current. An error page showing a response shape the API stopped returning two versions ago teaches the wrong lesson and erodes the documentation's credibility for every other page.
The test that separates a debug-ready error from a decorative one
Hand the error response to someone who has never seen the API and ask them to state the cause and the next action. If they can do both, the message works. If they ask a follow-up question, the message is missing something: a field name, a constraint, a reset time, a request identifier.
This test is cheap and brutal. It catches the "Invalid request" habit immediately, because the reader cannot state a cause. It catches the over-generic 500, because the reader cannot state an action. It catches the contradictory status code, because the reader hesitates over whether the request succeeded.
Run it once per error family before shipping. The cost is an afternoon. The return is every developer who integrates with the API and never has to file a ticket about a message they could not decode. That is the whole point of the error string: to make the next person's debugging session short. A message that forces a support round-trip has failed at the only thing it was written to do.

