An integration partner's system kept retrying requests that could never succeed. Their payload was malformed — a missing required field — and our API was returning 500. Their retry logic, quite reasonably, treated 500 as "the server had a problem, try again," so it retried. Forever.
The fix was one line: return 400 instead. Their client saw a client error, stopped retrying, and logged it for a human. The bug was never in their code.
Status codes are not decoration. They are the part of your API that machines make decisions from, and getting them wrong produces exactly this kind of failure — where both sides behave correctly and the system still breaks.
The Distinction That Matters Most
Before any individual code: 4xx means the caller must change something, 5xx means the caller should try again.
Every retry library, every queue, every monitoring rule is built on that split. Get it wrong and you either produce infinite retry loops on unfixable requests, or you make clients give up on transient failures they should have retried through.
The most common version of this mistake is a catch-all error handler that turns everything into 500. Validation failures, missing records, permission denials — all 500, all retried, all polluting your error rate so the real 500s are invisible.
The Success Codes Worth Distinguishing
200 OK — the default for a successful GET, PATCH or PUT that returns a body.
201 Created — a POST that created something. Include a Location header pointing at the new resource. Clients use this to distinguish "created" from "already existed", which matters for idempotent endpoints.
202 Accepted — you took the work but have not done it. The correct code for anything queued: a bulk import, a video render, an email send. Return an ID and a status URL. Returning 200 for async work tells the client it finished, which it did not.
204 No Content — success, no body. Right for a DELETE. Do not send a body with it; some clients will choke.
The Client Errors People Confuse
400 Bad Request — malformed or invalid. The catch-all for "you sent something wrong".
401 Unauthorized — you are not authenticated. The name is a historical mistake; it means unauthenticated. Use it when there is no credential, or the credential is invalid or expired.
403 Forbidden — you are authenticated and still not allowed. Different action for the client entirely: 401 means "log in again", 403 means "stop trying".
Getting these two backwards is the most common status code bug I see, and it causes real damage — a client that receives 403 on an expired token will not refresh it, and a client that receives 401 on a permissions problem will log the user out repeatedly.
404 Not Found — no such resource. Also the right answer for a resource that exists but belongs to another tenant. Returning 403 there confirms the ID exists, which is a small information leak; 404 tells them nothing.
409 Conflict — the request is valid but conflicts with current state. A duplicate email on signup, a version mismatch on optimistic locking, cancelling an already-shipped order. Frequently returned as 400, which loses information the client could act on.
422 Unprocessable Content — syntactically fine, semantically invalid. Valid JSON, but quantity: -5. Honestly, 400 with a clear body is fine here; pick one convention and be consistent rather than agonising over the distinction.
429 Too Many Requests — rate limited. Always with Retry-After.
The Server Errors
500 Internal Server Error — you broke. This should be genuinely rare, and every one should be actionable. If your 500 rate is a steady 2%, you have taught yourself to ignore it.
502 Bad Gateway / 504 Gateway Timeout — an upstream failed or timed out. Usually emitted by your proxy rather than your app, and worth distinguishing in monitoring because they point at a different layer.
503 Service Unavailable — temporarily down, deliberately. Maintenance, or shedding load. Send Retry-After so clients back off intelligently instead of hammering you while you recover.
A Body That Clients Can Act On
The status code tells a machine what category of thing happened. The body tells it what specifically:
{
"error": {
"code": "insufficient_stock",
"message": "Only 3 units of SKU-8891 remain.",
"field": "items[0].qty",
"request_id": "req_01J8XK2M"
}
}
The stable machine-readable code is the important part. Clients write logic against it; they cannot write logic against a message you might reword. And request_id is what turns "your API errored yesterday" into a log lookup that takes seconds.
Pick one error shape and use it for every endpoint, including the ones your framework generates by default. Half-and-half is worse than either.
Where This Bites in Practice
Health checks. A health endpoint returning 200 because the process is running, while the database is unreachable, means your load balancer keeps sending traffic to a broken instance. Check dependencies, return 503 when they fail.
Webhook handlers. Providers retry on non-2xx. If you return 500 on a payload you cannot process, they will redeliver it for days. Return 2xx for "received", handle the problem internally, and only return an error status for something a retry would actually fix.
Bulk operations. Twenty items, three fail. Not 200, not 400 — use 207 Multi-Status, or return 200 with a per-item result array. A single code cannot describe a partial outcome, and picking one loses information either way.
Redirects after POST. 301 and 302 historically let clients change the method to GET. Use 307 or 308 if the method must be preserved.
Monitoring thresholds. Alert on 5xx rate, not total error rate. A spike in 400s means a client deployed a bug; a spike in 500s means you did. Mixing them means neither alert is trustworthy.
The Rule I Apply
Before returning any error, ask: what should the client do with this?
Retry the identical request → 5xx or 429. Fix the request and resend → 4xx. Re-authenticate → 401. Give up and tell a human → 403 or 404.
If you cannot answer that question, you have not decided what the error means yet — and neither will the machine on the other end. Our partner's infinite retry loop was not a bug in their client. It was our API giving them the wrong instruction, very consistently, for three days.



