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."

CodeMeaningTypical triggerResponse body
200 OKRequest succeeded synchronouslyGET, a successful PUT/PATCH, or a POST that doesn't create a resourceYes — the resource or result
201 CreatedA new resource now existsPOST /orders, POST /usersYes — the new resource, plus a Location header
202 AcceptedRequest accepted; processing isn't doneAsync jobs, batch imports, long-running exportsOptional — usually a job reference, not the result
204 No ContentSucceeded; nothing to returnDELETE, or a PUT that doesn't echo the resourceNo — 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 /resource201, 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:

  1. The 202 response body is a status object, never the eventual result — returning the real payload from a 202 blurs it back into a same-request success.
  2. 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.
  3. 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.

Dimension400 Bad Request422 Unprocessable Content
What failsTransport or syntax layerA business or domain rule
Caught byJSON parser, schema validatorDomain/service logic
ExampleMalformed JSON, missing required field, wrong typeExpired coupon, insufficient inventory, end date before start date
Fixed byCorrecting the request's shapeChoosing 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 Unauthorized403 Forbidden
What's wrongNo valid identity presentedIdentity confirmed, action not permitted
Client's next moveAuthenticate or refresh the tokenStop — this identity will never be allowed
Retrying helps?Yes, after re-authenticatingNo, not without a permission change
Common mistakeReturning 403 for an expired tokenReturning 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 POST that would violate a unique constraint (a username, an email, a SKU) should return 409, not a generic 400 or, worse, a 500 bubbled up from the database.
  • Optimistic concurrency — a PUT/PATCH carrying an If-Match header against a stale ETag should return 409 so 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 5xx rates won't catch a spike in fake-200 errors.
  • A retry policy that backs off on 429/5xx will happily hammer an endpoint returning 200 with an error every time.
  • An API gateway or CDN caching safe GET responses by status code will cache a fake-200 failure 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

  1. One status code per outcome, documented before implementation — not decided ad hoc by whichever engineer writes the handler.
  2. Error bodies share one shape across every endpoint — the same code, message, and field-level detail keys everywhere.
  3. Async endpoints always return 202 plus a poll location — never a bare 200 masquerading as "it's handled."
  4. Auth failures are always 401 or 403 — never silently substituted with 404 unless that substitution is a documented security decision.
  5. Client errors and server errors stay separated — a 4xx and a 5xx never 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: 200 for a synchronous result, 201 for a new resource (with a Location header), 202 for accepted-but-not-finished work, 204 for success with nothing to return.
  • 400 is a syntax failure; 422 is a semantic one — a schema validator should catch 400s before your domain logic ever runs.
  • 401 means "authenticate"; 403 means "this identity is correctly known and still not allowed." Test it by asking whether logging in again would change the outcome.
  • 409 signals a state conflict, not a bad request — uniqueness violations, stale ETags, and invalid state transitions all belong here, alongside an Idempotency-Key for retried writes.
  • Never return 200 with 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.