Your API rejects a request with 400 Bad Request and an empty body. The client has no idea which field was wrong, the developer integrating with you guesses for an hour, and your support inbox gets a ticket that takes three exchanges to resolve.
Error responses are the worst-specified part of most APIs. They are written last, tested least, and they are what a consumer spends the most time reading, because success is unremarkable and failure is where the work is. This post is how to design them, and the security line that makes it genuinely subtle rather than merely neglected.
Why it goes wrong
Success responses have a schema, an example in the documentation, and a test. Errors have an exception handler somebody wrote once that catches everything and returns a status code.
That handler is usually correct about the status and silent about everything else, because the person writing it was thinking about not leaking internals, which is a real concern. The result overcorrects into an API that refuses requests without saying why.
What a useful error contains
Four things. The first two are not negotiable, the third is what separates a good API from a tolerable one.
A stable machine-readable code. Not the HTTP status, which is too coarse, and not the prose message, which will be reworded. A string like slug_contains_invalid_character that a client can branch on and that never changes.
Which input was wrong. A field path. For a nested body, a pointer to the exact location. This single field eliminates most support conversations.
What would be valid. The constraint, stated. "must match [a-z0-9-]+" turns a guess into a fix.
A correlation id. So a user reporting a problem and the log line describing it can be connected without a database search.
{
"error": {
"code": "slug_contains_invalid_character",
"message": "Slug may contain lowercase letters, digits and hyphens only.",
"field": "/slug",
"constraint": "^[a-z0-9-]+$",
"request_id": "req_01JBX2..."
}
}
That is not a large amount of work and it is a different product from a bare 400.
The security line, drawn properly
The reason people write empty errors is a genuine one: verbose errors leak. The distinction that resolves it is about whose input the error describes.
Safe to state: what is wrong with the request the caller sent. They already have it. Telling them their slug contains a slash reveals nothing they did not supply. This covers nearly all validation.
Not safe: anything about the state of the system or of other users' data. The canonical case is authentication. user not found and incorrect password are two different answers, and together they are a user enumeration oracle. The correct response is one indistinguishable answer for both, and the same applies to password reset, invite acceptance, and any endpoint that reveals whether an identifier exists.
Also on the wrong side of the line: stack traces, SQL fragments, internal hostnames, framework versions, file paths, and the distinction between "you may not access this" and "this does not exist". That last one matters: returning 403 for an existing resource and 404 for a missing one tells an unauthorised caller which record ids are real. Return 404 for both when the caller has no right to know.
So the rule is not "be vague". It is: be precise about the caller's input, opaque about the system's state.
Status codes, briefly
Most of the argument about which 4xx to use does not matter. Two distinctions do.
401 versus 403. 401 means not authenticated, and it invites the client to authenticate and retry. 403 means authenticated and not permitted, where retrying is pointless. Clients build refresh logic on this, and swapping them causes infinite token refresh loops against an endpoint the user will never be allowed to call.
400 versus 422. Malformed, versus well-formed but semantically invalid. Worth distinguishing, because the client's remedy differs: one is a bug in their serialisation, the other is a bug in their data.
And do not return 200 with an error in the body. It defeats every generic retry, alerting and monitoring layer between you and the caller, all of which look at status codes.
The failure that hides this from you
An error nobody sees is an error nobody fixes, and clients are very good at hiding yours.
A frontend with a fallback renders the cached or committed version when a call fails, so the page looks correct and the failing request is invisible. A retry loop turns a hard failure into latency. A catch that logs and continues turns it into nothing at all.
This is why bad errors persist: the person who could fix the message never learns it was inadequate, because the symptom surfaces as a support ticket weeks later rather than as a broken page.
Two things counter it. Alert on your own 4xx rate by endpoint and by error code, because a spike in one validation error usually means the message is unclear rather than that users suddenly got worse. And make the client log the error code and request id whenever it swallows a failure, so the information survives the fallback.
Document them as part of the contract
Every error code your API can return belongs in your specification, with the condition that produces it. In OpenAPI that is a response schema per status, with the codes enumerated.
The test is whether a consumer can write exhaustive handling from the documentation alone, without triggering each error experimentally. Most APIs fail this, which is why most integrations handle errors by string-matching the message, which then breaks when someone improves the wording.
The concession
There is a cost to specificity beyond the writing: a detailed constraint in an error message is a commitment. Publish ^[a-z0-9-]+$ and you have made it part of your contract, and loosening it later is a silent behaviour change for clients that validate locally against it.
That is a real constraint and it argues for stating the rule at the right altitude, not for hiding it. "Lowercase letters, digits and hyphens" is durable. A regex is precise and brittle. Pick per field, and treat error codes as versioned surface, because they are.
The implication
The quality of an API is judged in its failure modes, because that is where its consumers spend their difficult hours. A bare 400 is not a security posture, it is a cost transferred to everyone integrating with you.
Say exactly what was wrong with what they sent. Say nothing about what exists on your side. The line is that simple, and almost every empty error body is on the wrong side of it for no benefit.