The Error Message That Tells the Developer Nothing at All
by Eric Hanson, Backend Developer at Clean Systems Consulting
The message you shipped
{ "message": "Something went wrong" }
This is not an edge case. It ships in production APIs every week. Sometimes it comes dressed up:
{ "error": "Internal server error", "status": 500 }
Or with false precision:
{ "code": 1042, "message": "Operation failed" }
The code number implies a lookup table exists somewhere. It usually does not, or the table is internal and the developer has no access to it. Either way, the developer is stuck.
Why this keeps happening
The person who wrote the error handler knew exactly what went wrong. The exception was right there in the stack trace. The context was obvious from the surrounding code. So they wrote a message that made sense to them in that moment and moved on.
The problem is that the message has to make sense to someone who has none of that context, months or years later, under pressure.
There is also a habit of treating error messages as defensive — vague to avoid leaking internals. That instinct is not wrong, but it gets applied too broadly. The result is that legitimate integrators are treated the same as potential attackers.
The anatomy of a useless message
A message is useless when it:
- Describes the category of failure without the cause ("Invalid input")
- Names the system that failed without saying why ("Database error")
- Gives a code without a stable reference ("Error 5023")
- Is accurate but not actionable ("Request could not be completed")
None of these tell the developer what to fix. They tell them to go look somewhere else — logs, documentation, support tickets — for the real answer.
Rewriting useless messages
Take a common validation failure:
Before:
{ "error": "Validation error" }
After:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "The 'interval' field must be one of: daily, weekly, monthly. Received: 'bi-weekly'."
}
}
The after version answers the question immediately. The developer does not need to look anything up.
A dependency timeout:
Before:
{ "error": "Service unavailable" }
After:
{
"error": {
"code": "DEPENDENCY_TIMEOUT",
"message": "The downstream inventory service did not respond within the 5000ms timeout. This is likely transient.",
"retryable": true
}
}
Now the developer knows it is not their fault, knows not to change their request, and knows to retry.
An authorization failure:
Before:
{ "error": "Forbidden" }
After:
{
"error": {
"code": "INSUFFICIENT_SCOPE",
"message": "This endpoint requires the 'orders:write' scope. The token provided has scopes: ['orders:read', 'products:read']."
}
}
The developer now knows exactly which scope to request. No support ticket needed.
The security objection
The common pushback: detailed messages expose internals. True, with caveats.
For authenticated API consumers — developers building on your platform — detailed errors are almost always the right call. They are already inside your trust boundary.
For unauthenticated or public-facing endpoints, be selective. You do not need to expose which specific database constraint failed on a signup endpoint. But you can still be specific without being insecure:
{
"error": {
"code": "EMAIL_ALREADY_REGISTERED",
"message": "An account with this email address already exists."
}
}
This is specific and actionable. Yes, it confirms whether an email is registered — but that same information is available via the "forgot password" flow. Obscuring it in the signup error does not meaningfully improve security while it definitely degrades the developer experience.
The question to ask: does this message help a legitimate developer more than it helps an attacker? For most API errors, the answer is yes.
Write the message for the person at 11pm
The heuristic that works: write the error message assuming the reader has no access to your codebase, your logs, or your documentation. They have only what you return. If that message does not give them a path forward, rewrite it until it does.
That constraint will surface every vague, defensive, or lazy message in your API. Fix them before you ship.