HTTP Status Codes That Tell the Truth
Most APIs lie about status codes. They return 200 with an error in the body. They return 500 for client mistakes. The right code in the right place is one of the cheapest ways to make an API honest.
Most APIs lie about status codes. They return 200 OK with an error in the body. They return 500 Internal Server Error for client mistakes. They return 403 Forbidden when they mean 401 Unauthorized, or vice versa, depending on which intern wrote the route.
HTTP status codes are not decoration. They are the first signal every consumer of your API uses to decide what to do next. Get them right, and your API is easier to integrate, easier to monitor, and easier to debug. Get them wrong, and every client has to write the same fragile logic to figure out what actually happened.
The minimum useful set
Most APIs need a small number of codes used consistently. Here are the ones that earn their place:
200 OK— request succeeded, here's the data.201 Created— request created a new resource, return it in the body.204 No Content— request succeeded, nothing to return (DELETE, idempotent updates).400 Bad Request— the request itself is malformed (bad JSON, missing required field).401 Unauthorized— the request lacks valid authentication.403 Forbidden— authentication succeeded but the user can't access this resource.404 Not Found— resource doesn't exist (or the requester shouldn't know it does).409 Conflict— request conflicts with current state (duplicate, version mismatch).422 Unprocessable Entity— request is well-formed but fails validation rules.429 Too Many Requests— rate limited, with a Retry-After header.500 Internal Server Error— something broke on our end. Bug in your code or infrastructure.502/503/504— upstream is broken (bad gateway, service unavailable, gateway timeout).
That is twelve codes. They cover 99% of what most APIs need. Resist the urge to invent more.
The 200-with-error anti-pattern
The most common lie:
HTTP/1.1 200 OK
{"success": false, "error": "user not found"}This forces every client to parse the body to know whether the request worked. It breaks every standard tool: curl shows green, monitoring sees no errors, retry libraries don't trigger. The right answer is a 4xx code with the same body — the client gets the error explicitly and tools behave correctly.
The pattern usually arises because someone wanted "consistent response shapes." Consistency is fine — return the same body shape on success and failure if you want — but the status code is a separate channel and it should be honest.
400 vs 422: a useful distinction
Both are client errors, but they mean different things:
400 Bad Request— the request couldn't even be parsed. JSON syntax error, missing Content-Type, malformed URL.422 Unprocessable Entity— the request parsed fine, but the data violates business rules. "Email address is invalid." "Quantity must be positive."
Mixing them up is forgivable. Most clients don't distinguish. But if you do split them, your error logs become much more useful — you can tell parser bugs (your fault, mostly) from validation rejections (often the user's fault).
401 vs 403: also useful
This pair confuses everyone, including the people writing the spec:
401 Unauthorizedmeans "I don't know who you are." Authentication failed or wasn't provided. The client should retry with credentials.403 Forbiddenmeans "I know who you are, but you can't do that." Authentication succeeded but authorization failed. Retrying with the same credentials won't help.
The HTTP spec calls 401 "Unauthorized" but it really means "Unauthenticated." This naming has caused more confusion than any other status code.
404 has a hidden meaning
Use 404 not just for things that don't exist, but also for things that exist but the requester shouldn't know about. If user A queries for user B's private project, the right answer is often 404, not 403. A 403 leaks information: "this resource exists, you just can't see it." A 404 says: "as far as you're concerned, there's nothing here."
This is sometimes called "404 over 403 for security." It's appropriate for authenticated multi-tenant APIs where each user only sees their own data.
429 with Retry-After is non-negotiable
If you rate-limit, return 429 with a Retry-After header indicating either a number of seconds or an HTTP date. Otherwise the client has no idea when to try again — they'll either retry immediately and get throttled again, or back off too long and feel like the API is broken.
Bonus: include rate-limit headers in every response, not just 429. X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. Clients that respect these never hit your 429.
500 vs 502/503/504
500 means your code is broken. The bug is yours.
502 means an upstream returned a bad response (bad gateway). 503 means your service can't currently handle the request (overloaded, in maintenance, dependency down). 504 means an upstream timed out.
The distinction matters because retrying makes sense for 502/503/504 (transient infrastructure issues) but rarely helps for 500 (real bug). Clients with retry logic — which is most of them — depend on this distinction to avoid hammering broken endpoints.
The error body
Pair every non-2xx code with a structured error body:
{
"error": "validation_failed",
"message": "Email is required",
"field": "email",
"request_id": "req_abc123"
}Four fields, all useful: a stable error code clients can match against, a human-readable message, an optional pointer to the offending field, and a request ID for support.
Avoid the temptation to return localized error messages by default — most APIs are read by developers, in English, in logs. Add localization later if you actually need it.
What this buys you
Honest status codes are the cheapest way to make an API feel professional. They make integration easier. They make monitoring meaningful — you can graph 5xx rate and trust that the graph reflects real problems. They make support easier because users hit useful errors instead of generic 200s with mysterious bodies.
And they signal something to careful integrators: this API was built by people who took it seriously. That signal is worth more than most marketing.
If you're building an API today and you remember nothing else from this post, remember: the status code is a load-bearing piece of the response, not decoration. Use it like one.