Status Codes Clients Can Branch On
The first digit answers "who acts next?" — that is the real contract. You need the dozen codes clients actually branch on, used honestly, far more than you need the other forty memorized.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
The first digit is a routing decision
A status code's job is to answer one question for a program that cannot read prose: *who acts next?* 2xx: nobody — it worked. 3xx: the client's HTTP layer follows a redirect, usually invisibly. 4xx: the calling *code* is wrong — same request will fail again; fix the request, do not blindly retry. 5xx: the server is wrong — the request may be fine; retry with backoff is reasonable. That partition, applied honestly, is the entire foundation of generic error handling: SDK retry policies, circuit breakers and alerting rules are all written against the first digit.
This is why the categories matter more than the catalog. A client that handles "4xx → surface to caller, 5xx → retry with backoff, plus special cases for 401/409/429" is well-built; a team that debates 421 vs 424 while returning validation failures as 500 has optimized the wrong layer. The honest-partition failures are the expensive ones: validation errors as 5xx train clients to retry requests that can never succeed (and page your on-call for caller typos); upstream failures as 4xx teach clients to give up on requests that would have worked in two seconds.
The audience is bigger than your clients, too: intermediaries act on codes. Caches store some 2xx and 404s but must not cache a 500; load balancers eject backends on 5xx rates; the monitoring split between "user error" and "our error" *is* the 4xx/5xx line. Lying to that audience is how you get the anti-pattern with its own chapter — 200-for-everything — whose full bill (blind monitoring, poisoned caches) is itemized in The Error Model: Structure Over Apology and API Anti-Patterns Field Guide.
| Class | Who acts | Generic client behavior | Intermediary behavior |
|---|---|---|---|
| 2xx | No one — done | Proceed | Cacheable where headers allow |
| 3xx | Client HTTP layer | Follow redirect / use cached copy (304) | Caches serve revalidated content |
| 4xx | Calling code | Do not retry unchanged (except 408/429); fix the request | Some cache 404/410; LBs stay calm |
| 5xx | The server | Retry with backoff + jitter; then surface | LBs eject backends; monitors page (see HTTP Debugging: 502, 503 and 504 Are Different Failures for the gateway trio) |
The dozen that carry the traffic
Within the categories, a short list of codes carries almost all real branching. Learn these as *contract clauses* — each with a client action attached — and let the long tail stay in the reference.
Two distinctions deserve their asterisks. 401 vs 403: 401 means "you have not (validly) authenticated — get a token and try again"; 403 means "you are authenticated and the answer is no — do not retry". Clients wire token-refresh to 401; misusing it for authorization failures sends them into refresh loops. 404 vs 403 as policy: returning 404 for resources the caller may not see prevents confirming existence to enumeration probes (Authorization Design in the Contract covers the choice); either is fine — inconsistency is what leaks, because the difference between "not found" and "forbidden" becomes an oracle.
- 200 / 201 / 202 / 204 — success flavors: plain, created (with
Location— see POST: More Than Create), accepted-for-async (see Long-Running Operations: 202 and the Job Resource), and success-with-no-body. Branching on 201 vs 200 is how clients learn creation happened. - 304 Not Modified — "your cached copy is current"; the payoff of Conditional Requests: ETags, 304 and 412.
- 400 vs 422 — malformed request vs well-formed-but-invalid; many APIs collapse these into 400, which is fine *if consistent* and the body carries field detail (see Validation Errors: Feedback, Not Verdicts).
- 401 / 403 — authenticate vs authorized-no; refresh-and-retry vs give-up.
- 404 / 410 — not here / gone deliberately; 410 is a courtesy for deleted-forever (see DELETE: What Does Gone Mean?).
- 409 / 412 — conflict with current state (see Resources Have State Machines) / your precondition failed (see Optimistic Concurrency: Versions and If-Match); both mean "refetch, reconcile, maybe retry".
- 429 + Retry-After — slow down, and by how much (see The Rate-Limit Contract).
- 500 / 502 / 503 / 504 — broke / bad upstream / overloaded-try-later / upstream timeout; all retryable-with-backoff, all page someone.
Codes are coarse routing; the body is the detail
The status code cannot carry everything, and stretching it produces exotic-code archaeology (451? 418?) that no client branches on. The working division of labor: the *code* routes generic machinery (retry? cache? refresh? page?); the *body* carries the machine-readable specifics — an error code like insufficient_funds, the offending field, the request id (the full shape belongs to The Error Model: Structure Over Apology and An Error Taxonomy Clients Can Branch On). One status code maps to many error codes: 409 might be invalid_transition, duplicate_key or version_conflict, and clients branch on the body string for those.
Two consistency rules make the whole scheme trustworthy. First, same situation, same code, everywhere: if one endpoint returns 404 for a missing resource and its neighbor returns 200-with-null, every client pays for both dialects (see Design Principles Without Commandments on convention debt). Second, never invent meanings: a 404 that means "deactivated", a 503 that means "rate limited" — each private meaning breaks the generic machinery that is the entire point of standard codes. If no standard code fits precisely, use the honest category and put the precision in the body.
1POST /transfers (insufficient funds)2→ 500 Internal Server Error3{ "message": "insufficient funds" }4# client retries with backoff… forever; on-call paged for a5# caller-side condition; monitors show a fake outage6 7GET /users/42 (deactivated account)8→ 404 Not Found9# …but the user exists; sync clients now delete it locally1POST /transfers (insufficient funds)2→ 422 Unprocessable Entity3{ "error": { "code": "insufficient_funds",4 "message": "Balance 12.40 < transfer 50.00",5 "request_id": "req_01J…" } }6# client: do not retry; show the user; nobody paged7 8GET /users/42 (deactivated account)9→ 200 OK10{ "id": "usr_42", "status": "deactivated", … }11# existence and state are different factsThe bad column is not missing information — the prose says "insufficient funds" — it is missing *routing*. Machines branch on codes, not messages: the 500 recruits every retry loop and pager against you, and the fake 404 corrupts every cache and sync client that trusted its standard meaning.
Key points
- A status code answers "who acts next?" — client code, server, HTTP layer, or no one — and generic machinery everywhere is wired to that answer.
- The 4xx/5xx boundary is the retry and alerting boundary: misplacing errors across it creates retry storms, false pages and abandoned recoverable requests.
- About a dozen codes carry real branching (200/201/202/204, 304, 400/422, 401/403, 404/410, 409/412, 429, 500/502/503/504); learn them as clauses with client actions.
- Codes route, bodies detail: one status maps to many machine-readable error codes in the payload.
- Same situation → same code on every endpoint, and never assign private meanings to standard codes.
Progressive depth
Overview
A status code is the one part of the response every intermediary understands. 2xx means the request did what it said; 3xx means look elsewhere; 4xx means the caller must change something before trying again; 5xx means the server or a dependency failed and a retry may help. Learn the *categories* first — a client that branches correctly on the first digit is already ahead of most.
Practical
The dozen that carry contract weight: 200/201/202/204 for "done / created / accepted for later / done with nothing to say"; 304 for "your cached copy is still good"; 400 vs 422 for "unparseable" vs "parseable but invalid"; 401 vs 403 for "who are you?" vs "you may not"; 404 for absence, 409 for state conflicts, 412 for a failed precondition, 429 for throttling with Retry-After; 500 for a bug, 502/503/504 for a dependency or gateway problem. Pick one code per situation in An Error Taxonomy Clients Can Branch On and never let the same failure map to two.
Advanced
Codes are consumed by more than your client: load balancers eject backends on 5xx bursts, CDNs cache 200s and 404s but not 5xx by default, SDK retry middleware retries 5xx and 429 but not 4xx, and alerting counts 5xx as *your* error budget. Returning 500 for a caller-side condition therefore pages you, misleads the balancer and triggers blind retries at once. Retry-After on 429 and 503 is the contract's only standard way to say *when*.
Internals
Status lives in the HTTP status line (HTTP/1.1) or the :status pseudo-header (HTTP/2, HTTP/3) — it is parsed before a single body byte is read, which is why proxies and caches can act on it without understanding your JSON. Gateways synthesize 502/503/504 themselves when the upstream misbehaves, so a client can receive a status your handler never wrote; see HTTP Debugging: 502, 503 and 504 Are Different Failures for telling the three apart from the outside.
Status Code Picker
Change the contract and observe which guarantee moves.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → API: returns 500 for all failures because the framework's default exception handler does — validation, auth and outages all look identical.
- 2Client SDKs → API: apply standard policy, retrying all 5xx with backoff; invalid requests hammer the API in exponential waves.
- 3On-call → dashboards: error-rate alerts fire on caller typos; real outages hide inside the noise floor.
- 4Client teams → workarounds: parse message strings to classify failures; a copyedit to an error message breaks three integrations (see An Error Taxonomy Clients Can Branch On).
- 5Team → cleanup: correcting the codes is now itself a breaking change — clients branched on the wrong ones.
- Retry storms on permanent failures and premature give-ups on transient ones — both from a misplaced 4xx/5xx boundary.
- Monitoring loses the user-error/our-error split, so paging is either noisy or blind.
- Clients that learned private code meanings (404-means-deactivated) corrupt their local state when the standard meaning eventually applies.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Publish a code map as part of the contract: which codes this API uses, what each means here, and the expected client action per code.
- • Enforce the 4xx/5xx boundary in the exception-to-response layer — caller faults must never surface as 5xx, upstream faults never as 4xx.
- • Pair every retry-relevant code with its signals: Retry-After on 429/503, error codes in bodies, request ids everywhere (see [[retryability]]).
- • Lint for consistency across endpoints: same situation, same code, one 400-vs-422 policy, one 404-vs-403 policy.
- • Dashboard 4xx and 5xx as separate signals per endpoint; a 4xx surge means a client shipped a bug, a 5xx surge means you did.
- • A near-zero 4xx rate on a busy public API means errors are hiding inside 200s or 500s, not that callers are perfect.
- • Track retries-after-4xx from your own SDKs — nonzero means either the code map or the SDK policy is wrong.
- • Adding a *new* code to existing endpoints is semi-breaking: clients with exhaustive status handling may treat it as unknown-fatal, so document category-level defaults ("treat unknown 4xx as 400") from day one.
- • Correcting a wrong code (500→422) changes client-visible behavior; announce it like any contract change and stage it with telemetry on who still mis-branches (see [[backward-compatibility]]).
- • New error *codes in the body* under an existing status are the additive, safe evolution path — which is exactly why the body carries the detail.
- • Honest codes leak some information (403 vs 404 confirms existence); pick a policy per resource sensitivity and accept the inconsistency it costs.
- • A strict code map adds review friction to every endpoint — the payback is invisible until the first incident postmortem reads cleanly.
- • Collapsing 400/422 simplifies the map but discards a distinction some clients (form UIs) genuinely branch on.