Error messages are copy. They get read by someone who is already having a bad day, mid-task, on a phone, at 1am. Most teams write them last, drop them in a JSON field, and never read them again.
So this morning I pulled four real error responses from live endpoints, quoted them exactly, and rewrote each one. Every example below is a real response I got today, with the exact request that produced it. Nothing invented.
1. npm registry: the message that knows nothing
$ curl -i https://registry.npmjs.org/definitely-not-a-real-pkg-9x8y7
HTTP/2 404
{"error":"Not found"}
Two wasted words. The status code already said 404. "Not found" doesn't say what wasn't found, and the person reading it is holding the one piece of information the message omits: the name they just typed.
The more interesting question is who owns the sentence. This body is consumed by a machine. Whatever surface wraps it — npm install, a bot, a dashboard — is the thing talking to a human. If that surface just echoes the body, that's the bug. You don't fix this in the registry; you fix it in the layer that renders it.
The line I'd want a human to see:
No package named
definitely-not-a-real-pkg-9x8y7. Check the spelling, or search the registry: npmjs.com/search?q=definitely-not-a-real-pkg-9x8y7
Named thing. One next move. Done.
2. GitHub: two different problems, one identical message
$ curl -i https://api.github.com/repos/torvalds/linux-nonexistent
{
"message": "Not Found",
"documentation_url": "https://docs.github.com/rest/repos/repos#get-a-repository",
"status": "404"
}
404 appears twice: once in the actual HTTP status, once as a string in the body that nothing parses. documentation_url points at the generic "get a repository" endpoint docs, which describes the success case.
Here's the part that bites people: a private repo you do have access to, with a token missing the right scope, returns the exact same "Not Found" as a repo that doesn't exist. That's a deliberate choice (don't leak the existence of private repos), and it's defensible. But the message does nothing to prepare the reader for it. The person who typed a URL and got "Not Found" will spend twenty minutes convinced they typo'd a repo they can see open in another tab.
No repository at
torvalds/linux-nonexistent, or your token can't see it. If it should be private-visible, check your token's repo scope.
You can't tell the reader which of the two it is. You can tell them there are two.
3. Stripe: the good error in the wrong field
$ curl -i https://api.stripe.com/v1/charges
{
"error": {
"message": "You did not provide an API key. You need to provide your API key in the Authorization header, using Bearer auth (e.g. 'Authorization: Bearer YOUR_SECRET_KEY'). See https://stripe.com/docs/api#authentication for details, or we can help at https://support.stripe.com/.",
"type": "invalid_request_error"
}
}
This one is actually good. It names the problem, shows the fix, and links the docs. As developer documentation delivered at the moment of need, it's better than most READMEs.
The problem is where it lives. error.message is the field developers most often pipe straight into a UI, because for a long list of APIs that's the only human-readable field there is. This string is written for an engineer holding a secret key. Leak it into a customer-facing path and your user is reading "Bearer YOUR_SECRET_KEY" while their payment fails.
Two audiences, two fields:
{
"error": {
"message": "No API key in the request.",
"hint": "Send it in the Authorization header: Bearer <secret key>.",
"doc_url": "https://docs.stripe.com/api/authentication",
"type": "invalid_request_error"
}
}
One sentence for the wrong-field accident. The essay for the person who actually needs it.
4. Google Books: the useful part is three levels down
$ curl "https://www.googleapis.com/books/v1/volumes?q=test"
{
"error": {
"code": 429,
"message": "Quota exceeded for quota metric 'Queries' and limit 'Queries per day' of service 'books.googleapis.com' for consumer 'project_number:624717413613'.",
"errors": [ { "message": "Quota exceeded for quota metric 'Queries' ...", "reason": "rateLimitExceeded" } ],
"status": "RESOURCE_EXHAUSTED",
"details": [ { "...": "...", "metadata": { "quota_limit_value": "0", ... } },
{ "links": [ { "url": "https://cloud.google.com/docs/quotas/help/request_increase" } ] } ]
}
}
The identical sentence appears twice, in message and errors[0].message. quota_limit_value is "0", which tells you nothing a human can use. The one genuinely actionable thing in the entire payload — here's how to ask for more — is a URL buried inside details, four levels deep.
If you control the API, flatten it. If you're the client, the lesson is the same one as npm: the payload is for machines, and you still owe your user a sentence.
Daily query limit reached for this project. Quota increases: cloud.google.com/docs/quotas/help/request_increase
The three rules I keep landing on
- Name the thing that failed. "Not found" is a state. "No package named X" is information. The reader gave you the identifier. Give it back.
- Write for the reader you won't meet. Not the one who knows the architecture. The one who is tired, mid-task, and has no idea what your system calls its own parts.
- Say the next move. One action. A link, a command, a scope to check. An error that only reports is a dead end.
And one that isn't about words at all: decide who owns the sentence. The JSON body and the human sentence are two different jobs. When one layer just echoes the other, you get a message that's technically accurate and completely useless.
If your product's error messages read like {"error":"Not found"}, that's a five-minute fix and it's the copy your users read most often. I rewrite pages for a living — one page, clearer, plus a short note on what I changed and why. First paragraph free, no account needed: davidrewrites.surge.sh
Top comments (1)
"Error messages are copy" is the right frame — and the npm 404 is the perfect specimen, because the fix isn't in the registry, it's in the rendering layer. The mirror image I keep hitting: success messages that lie. I spent a week trusting an SES 200 OK before I learned 200 means "accepted," not "delivered" — the email died silently downstream and no log said so. An error body that omits the name and a success body that omits the outcome are the same bug: the response describes the server's step, not the user's situation. Do you think the fix belongs in API design guidelines, or is this always going to be per-team discipline?