Use 200 for a successful request that returns a body, 201 when the request created a new resource, 202 when you accepted work you haven't finished yet, and 204 when there's nothing to return. Reserve 400 for malformed requests, 422 for well-formed but semantically invalid ones, 401 for missing identity, 403 for confirmed identity without permission, and 409 for state conflicts.
Quick answer: 2xx codes describe what happened to the resource (
200/201/202/204); 4xx codes describe whose fault the failure is (400/422= syntax vs. semantics,401/403= identity vs. permission,409= conflicting state). Pick by contract, not by convenience.
Why Status Codes Are a Contract, Not Decoration
A status code is the one part of an HTTP response a client can act on without parsing the body — retry, back off, re-authenticate, or surface an error to a user. Treat it as an interface contract you specify before you build, not a courtesy an engineer bolts on at the end.
Most API disagreements that surface in code review are really disagreements about meaning, not syntax. Two engineers building sibling endpoints each pick a code that "feels right" in isolation, and eighteen months later a client SDK has a special-cased branch for every third endpoint. The IETF's RFC 9110 — the current HTTP Semantics specification — defines what each status class means, but it can't stop a hundred endpoints built by five different squads from drifting apart in practice.
Status-code drift is silent by nature — nothing fails until a client builds automation on top of the wrong assumption, and by then the fix touches every consumer, not just the endpoint that got it wrong.
That drift is expensive precisely because it's invisible until a client hits it. This is why deciding status codes belongs with the person who owns the job of specifying the API contract, not with whichever engineer ships first. A 200-vs-202 argument in a pull request is almost always a spec gap that should have been closed weeks earlier.
Status code discipline is one piece of a larger discipline. If you're building out that muscle from scratch, the complete guide to API product design covers the surrounding decisions — resource shape, versioning, pagination — that a status code strategy has to sit inside of.
The 2xx Decision Map: 200 vs 201 vs 202 vs 204
Pick among the four success codes by asking two questions: did this request create a new resource, and is there a body to return right now? 200 answers "no new resource, yes body," 201 answers "yes, a new resource exists," 202 answers "accepted, not finished," and 204 answers "succeeded, nothing to show."
| Code | Meaning | Typical trigger | Response body |
|---|---|---|---|
200 OK | Request succeeded synchronously | GET, a successful PUT/PATCH, or a POST that doesn't create a resource | Yes — the resource or result |
201 Created | A new resource now exists | POST /orders, POST /users | Yes — the new resource, plus a Location header |
202 Accepted | Request accepted; processing isn't done | Async jobs, batch imports, long-running exports | Optional — usually a job reference, not the result |
204 No Content | Succeeded; nothing to return | DELETE, or a PUT that doesn't echo the resource | No — body must be empty |
The most common bug in this table is a 201 with no Location header, or a 200 doing a 201's job. Getting 201 right depends on getting the resource model right first: if an endpoint is modeled as a noun rather than a verb, "create" maps cleanly onto POST /resource → 201, instead of forcing you to guess whether some bespoke action endpoint counts as creation.
The 202 Pattern for Async Work
Return 202 the moment you've durably queued the work, not when it finishes. Pair it with a Location header pointing at a status resource the client can poll, so the client never has to hold a connection open past your gateway's timeout — a pattern documented in public style guides like Zalando's RESTful API and Event Guidelines for exactly this reason.
A report-export endpoint is the clean example. The client submits the request, gets an acknowledgment, and polls a separate resource until the work is done:
POST /v1/reports/export HTTP/1.1
Content-Type: application/json
{"format": "csv", "range": "last_90_days"}
HTTP/1.1 202 Accepted
Location: /v1/reports/export/8f14e45f
{
"id": "8f14e45f",
"status": "queued",
"poll_url": "/v1/reports/export/8f14e45f"
}
The client then polls GET /v1/reports/export/8f14e45f, which returns 200 with a status field of queued, processing, or complete (and a download link once complete). Three details make this pattern hold up:
- The 202 response body is a status object, never the eventual result — returning the real payload from a
202blurs it back into a same-request success. - The poll endpoint always returns 200, whatever the job's internal status is — the HTTP request itself succeeded even while the job is still running.
- A terminal failure state is still a 200 on the poll endpoint, with
"status": "failed"in the body — the polling request succeeded even though the job it's describing did not.
400 vs 422: The Client-Error Fork Everyone Gets Wrong
Use 400 when the request itself is malformed — invalid JSON, a missing required field, a string where an integer belongs — something a schema validator catches before your business logic ever runs. Use 422 when the request is syntactically valid but violates a rule your domain understands, like an expired discount code or an end date before a start date.
The split matters because it tells the client what kind of fix is needed. A 400 means "the shape of what you sent is wrong"; a 422 means "the shape was fine, but these particular values don't work together." Conflating them forces a client to string-match your error message to figure out which one it's dealing with.
| Dimension | 400 Bad Request | 422 Unprocessable Content |
|---|---|---|
| What fails | Transport or syntax layer | A business or domain rule |
| Caught by | JSON parser, schema validator | Domain/service logic |
| Example | Malformed JSON, missing required field, wrong type | Expired coupon, insufficient inventory, end date before start date |
| Fixed by | Correcting the request's shape | Choosing different values entirely |
422 started life in RFC 4918 (WebDAV) but is now registered for general HTTP use and widely recommended outside WebDAV contexts — Microsoft's public REST API Guidelines and the JSON:API specification both carry it as the standard code for semantic validation failures, distinct from a malformed request. If your team is still arguing about it, that's a sign the argument should be settled once in a shared guideline, not per-endpoint.
This split gets blurrier once you leave pure REST. RPC-style and GraphQL surfaces often collapse both cases into a single 200 with an error payload, which is one of the tradeoffs worth weighing in the REST vs. RPC vs. GraphQL decision framework before committing to a style for a given surface.
401 vs 403: Authentication vs Authorization
Return 401 when the request lacks valid credentials at all — no token, an expired token, a bad signature — and the client's next move is to authenticate. Return 403 when credentials are valid but the authenticated identity isn't allowed to do this particular thing, and no amount of re-authentication will fix that.
The test that resolves most arguments: would logging in again change the outcome? If yes, it's 401. If the caller is already correctly identified and simply lacks permission, it's 403. A shockingly common bug is returning 403 for an expired token — the client dutifully shows a "you don't have permission" message to a user who just needs to log back in.
401 Unauthorized | 403 Forbidden | |
|---|---|---|
| What's wrong | No valid identity presented | Identity confirmed, action not permitted |
| Client's next move | Authenticate or refresh the token | Stop — this identity will never be allowed |
| Retrying helps? | Yes, after re-authenticating | No, not without a permission change |
| Common mistake | Returning 403 for an expired token | Returning 401 for a role/scope failure |
There's one deliberate exception worth naming rather than treating as a bug: some APIs return 404 instead of 403 for a resource the caller isn't allowed to know exists — GitHub's API does this for private repositories, returning 404 rather than confirming a repo exists that the caller can't see. That's a documented, intentional information-disclosure tradeoff, not a slip. If your API does the same, write it down; if it doesn't, don't let it happen by accident.
409 Conflict: When State, Not Input, Is the Problem
Return 409 when the request is individually valid but collides with the resource's current state — someone else already claimed the username, the record changed since you last read it, or the resource is in a state that doesn't allow this transition. It tells the client to re-fetch and reconcile, not just retry the same request.
Three situations account for most legitimate 409 usage:
- Uniqueness collisions — a
POSTthat would violate a unique constraint (a username, an email, a SKU) should return409, not a generic400or, worse, a500bubbled up from the database. - Optimistic concurrency — a
PUT/PATCHcarrying anIf-Matchheader against a staleETagshould return409so the client knows to refetch and reapply its change, rather than silently overwriting someone else's edit. - Invalid state transitions — attempting to cancel an order that already shipped, or reopen a ticket that's archived, is a
409: the request is well-formed, but the resource's current state rules it out.
A related pattern worth adopting alongside 409 is an idempotency key on unsafe requests — Stripe's API documentation describes an Idempotency-Key header specifically so a retried POST (after a timeout or dropped connection) doesn't create a second charge or a second order. Combined with 409 for genuine conflicts, it removes the ambiguity of "did that actually go through?" that drives a disproportionate share of support tickets on write-heavy endpoints.
The Rule Everyone Breaks: Never Return 200 With an Error Body
If the request failed, the status code has to say so. A 200 wrapping {"success": false, "error": "..."} forces every client to parse the body just to learn what any HTTP-aware tool already checks for free — caches, retries, monitoring, and API gateways all key off the status line, and a fake 200 breaks every one of them silently.
The rule: the status code and the body have to agree. If the code says success, the payload must not describe a failure — and if the payload describes a failure, the code has to say so too.
Teams usually back into this pattern for a defensible-sounding reason: an old mobile client that only branched on 2xx vs. everything-else, or a proxy that stripped non-2xx bodies. Those constraints are worth solving directly — a client update, a proxy config change — rather than encoding the workaround permanently into the API's contract for every future consumer.
The cost compounds because it's invisible in the moment, and it shows up in tooling that has no reason to suspect the status line is lying:
- A monitoring dashboard built to alert on
5xxrates won't catch a spike in fake-200errors. - A retry policy that backs off on
429/5xxwill happily hammer an endpoint returning200with an error every time. - An API gateway or CDN caching safe
GETresponses by status code will cache a fake-200failure right alongside real successes.
The failure only surfaces later, and by then it's load-bearing for three different clients who all learned to check body.success instead of the status line.
Getting this right at scale is a governance problem as much as a technical one, and it connects to how developers actually experience your API over time. Debugging a 403 that's secretly an expired-token 401, or a 200 hiding a failure, is exactly the kind of dip that shows up when you map integration work the way the customer journey framework maps any experience — a smooth curve interrupted by an unforced error.
It also helps to reframe the question the way the Jobs-to-be-done framework reframes any product decision. The client didn't hire your endpoint to return JSON — it hired your endpoint to get something done: create the order, start the export, confirm the login. The status code is the fastest signal of how that job is actually progressing, without opening the payload.
A Five-Point Consistency Checklist
- One status code per outcome, documented before implementation — not decided ad hoc by whichever engineer writes the handler.
- Error bodies share one shape across every endpoint — the same
code,message, and field-level detail keys everywhere. - Async endpoints always return
202plus a poll location — never a bare200masquerading as "it's handled." - Auth failures are always
401or403— never silently substituted with404unless that substitution is a documented security decision. - Client errors and server errors stay separated — a
4xxand a5xxnever map to the same generic code just because "it's an error either way."
This is exactly the kind of decision that benefits from being settled at design time rather than argued about in a pull request. Prodinja's API Designing tool lets you attach the intended status code to each endpoint's responses while you're still sketching the contract — success paths and error paths alike — so a 422-vs-400 debate happens once, in the spec, before two engineers ship two different answers.
Key Takeaways
- 2xx codes describe the resource outcome:
200for a synchronous result,201for a new resource (with aLocationheader),202for accepted-but-not-finished work,204for success with nothing to return. 400is a syntax failure;422is a semantic one — a schema validator should catch400s before your domain logic ever runs.401means "authenticate";403means "this identity is correctly known and still not allowed." Test it by asking whether logging in again would change the outcome.409signals a state conflict, not a bad request — uniqueness violations, staleETags, and invalid state transitions all belong here, alongside anIdempotency-Keyfor retried writes.- Never return
200with a failure in the body — it silently breaks caching, retries, monitoring, and every client that reasonably trusts the status line. - Consistency across the whole API surface matters more than any single correct code — one documented decision, applied everywhere, beats five locally-defensible ones.
Frequently Asked Questions
When should I use 422 instead of 400?
Use 422 when the request parses fine but violates a rule your domain understands — an expired coupon, a duplicate SKU, a date range that doesn't make sense. Use 400 for anything a generic schema validator would reject before business logic runs, like malformed JSON or a missing required field.
Is it ever okay to return 200 with an error in the response body?
No — if the request failed, the status code should say so. A 2xx with a failure encoded only in the body defeats caching, retry logic, and monitoring that key off the status line, forcing every client to parse the body to learn what the status code should have told them for free.
Should an API return 404 or 403 for a resource the caller can't access?
403 is the honest default: it confirms the resource exists and says access is denied. 404 is appropriate only as a deliberate information-disclosure decision — GitHub's API does this for private repositories — and should be documented as a security choice, not left as an accidental inconsistency across endpoints.
What status code should a long-running or async API operation return?
Return 202 Accepted the moment the work is durably queued, with a Location header pointing at a resource the client can poll. The poll endpoint itself should return 200 regardless of the job's internal state, with the job's actual status — queued, processing, complete, or failed — inside the body.
Do GraphQL and RPC-style APIs need the same status code discipline?
Less of it at the transport layer, but the same discipline still has to live somewhere. Most GraphQL servers return 200 for nearly everything and encode success or failure inside the response body instead, which means that body's error shape needs the same rigor a REST API puts into its status codes.