The 4 HTTP Status Codes Every API Developer Gets Wrong (And What Actually Works)
I've reviewed hundreds of API designs over the past few years, and one pattern keeps showing up: developers consistently misuse the same handful of HTTP status codes. It's not laziness — it's that most of us learned from frameworks that picked sensible defaults for us, and we never stopped to question them.
Here are the four most commonly misused status codes, why the "obvious" choice is wrong, and what you should use instead.
1. 200 OK for Everything (Then an Error in the Body)
This is the granddaddy of all API mistakes. GraphQL made it famous, but I see it everywhere:
HTTP/1.1 200 OK
{
"success": false,
"error": "Invalid email address",
"code": "VALIDATION_ERROR"
}
Why it's wrong: HTTP status codes exist so that infrastructure can make decisions without parsing your body. Load balancers, CDNs, monitoring tools, and client libraries all look at the status code first. When your error responses return 200, you break:
- Retry logic (clients retry 5xx, not 2xx)
- Circuit breakers (they open on error rates, which you're hiding)
- Caching layers (they cache 200s aggressively)
- Health checks (your service looks healthy even when everything is failing)
- Log aggregation (your error rate metric becomes meaningless)
What to use instead:
| Scenario | Correct Code |
|---|---|
| Validation failure | 400 Bad Request |
| Missing resource | 404 Not Found |
| Auth required | 401 Unauthorized |
| Permission denied | 403 Forbidden |
| Business rule violation |
409 Conflict or 422 Unprocessable Entity
|
Here's what a proper error response looks like:
HTTP/1.1 422 Unprocessable Entity
{
"error": "validation_failed",
"message": "Email address is invalid",
"details": [
{
"field": "email",
"reason": "not_a_valid_email_format",
"value": "not-an-email"
}
]
}
Your monitoring dashboard will thank you.
2. 404 Not Found for Permission Denied
I see this one constantly in multi-tenant APIs. A user requests a resource that exists but doesn't belong to them, and the API returns:
HTTP/1.1 404 Not Found
{"error": "Resource not found"}
Why it's wrong: 404 tells the client "this thing doesn't exist." But it does exist — the user just isn't allowed to see it. The distinction matters:
- A 404 is safe to log as a dead link or a typo.
- A 403 is a security event. Someone tried to access something they shouldn't.
If you return 404 for permission errors, your security monitoring can't distinguish between "someone followed a broken link" and "someone is probing for resources they shouldn't see."
What to do:
# Instead of this:
if resource.owner_id != current_user.id:
raise HTTPException(status_code=404) # Wrong!
# Do this:
if resource.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="You don't have access to this resource")
# Or, to prevent information disclosure about what resources exist:
if not resource or resource.owner_id != current_user.id:
raise HTTPException(status_code=404) # Okay if you intentionally hide existence
The nuance: If you're deliberately preventing enumeration attacks — where an attacker probes IDs to discover what exists — 404 is acceptable as a security measure. But be intentional about it. Don't do it just because your ORM's get_or_404 helper made it the path of least resistance.
3. 500 Internal Server Error for User Mistakes
A client sends bad JSON, and your server logs this:
ERROR: JSONDecodeError at line 1 column 45
Then returns:
HTTP/1.1 500 Internal Server Error
Why it's wrong: 500 means "something went wrong on the server." But malformed input is a client problem. The server is working fine. You've just trained your on-call engineer to wake up for a problem the client caused.
Every major framework has this gotcha. Express, FastAPI, Rails — they all let unhandled parse errors bubble up to 500 by default.
What to do:
# FastAPI example
from fastapi import Request
from fastapi.responses import JSONResponse
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=400,
content={
"error": "invalid_request",
"message": "The request body could not be parsed",
"details": exc.errors()
}
)
The rule is simple: if the server could fix it by asking the client to send different data, it's a 4xx. If the server operator needs to fix something, it's a 5xx. Bad JSON falls squarely in the first category.
4. 201 Created When Nothing Was Created
I blame tutorial culture for this one. Every REST API tutorial shows:
POST /users → 201 Created
So developers return 201 for every POST. But sometimes a POST doesn't create anything new:
POST /api/orders/42/cancel
HTTP/1.1 201 Created ← wait, what?
Why it's wrong: 201 includes a Location header pointing to the new resource. If you're not returning a Location header that the client can GET, you probably shouldn't be using 201.
What to use instead:
| POST Result | Correct Code | Header |
|---|---|---|
| New resource created | 201 Created |
Location: /resource/123 |
| Action processed, nothing new | 200 OK |
— |
| Action accepted, processing later | 202 Accepted |
— |
| Action completed, see other resource | 303 See Other |
Location: /other-resource |
# Cancel an order — nothing is created
POST /api/orders/42/cancel
HTTP/1.1 200 OK
{"status": "cancelled", "refund_id": "ref_789"}
# Submit a report that'll be generated later
POST /api/reports
HTTP/1.1 202 Accepted
{"report_id": "rep_456", "status": "queued"}
A Quick Self-Audit for Your API
Pull your API logs from the last 24 hours and run this checklist:
Search for
200+"error"or"success": falsein the response body. That's your 200-for-errors problem.Search for all
500responses and check what caused them. How many were bad client input that should have been a 4xx?Search for
404and check how many are permission issues you're hiding. Are you leaking information about what resources exist?Search for
201and verify every single one returns aLocationheader. If not, it should probably be a 200 or 202.
I ran this audit on our own API last quarter and found that 22% of our 500s were actually 4xx errors in disguise. Fixing that one thing dropped our on-call alert noise by almost a quarter.
The Bigger Picture
HTTP status codes aren't pedantry. They're the interface between your API and everything that sits in front of it — proxies, caches, CDNs, monitoring, client SDKs. When you get them right, your infrastructure works with you instead of against you.
The next time you're about to type status_code=200, take two seconds to ask: "What actually happened here, and who needs to know about it?"
Your on-call engineer — and your users — will thank you.
What's the worst HTTP status code abuse you've seen in the wild? I once encountered an API that returned 418 I'm a Teapot for rate limiting. It was creative, but debugging it at 3 AM was not fun.
Top comments (0)