201 With a Location, 204 With No Body: Marking Success Precisely
A client POSTs a new article and your API answers 200 with a JSON body. It works — until the client team writes code that treats every 200 as "the thing I sent now exists" and cannot find the created record's URL anywhere. 200 OK says the request succeeded; it says nothing about what changed. The 2xx family exists to make that machine-readable, and two codes do most of the work: 201 Created and 204 No Content.
201 is a promise that a new resource now exists somewhere, which is why the spec pairs it with a Location header pointing at it:
$id = $repo->insert($article);
$uri = 'https://api.example.com/v1/articles/' . $id;
header('Location: ' . $uri, true, 201); // third argument sets the status
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'id' => $id,
'slug' => $article['slug'],
'created_at' => gmdate('c'),
]);
The body is optional — some APIs return only the header — but the pairing is the contract: 200 confirms the response, 201 tells the client a resource exists and where to fetch it. 201 without Location is the detail interviewers probe here, and "the client can construct the URL itself" is a weak answer: ids are not the only thing that locates a resource (slugs, tenant prefixes, and hashed keys all appear in real URIs), and the header costs you nothing.
204 is the mirror image: the request succeeded and there is deliberately nothing to return. DELETE /articles/42 answering 204 is the standard shape, and PUT follows it whenever the client already holds the representation it just sent.
$repo->delete((int) $id);
http_response_code(204);
exit; // NO body, NO Content-Type — not even a stray newline
That exit is load-bearing. Emitting {"deleted":true} after 204 is a protocol violation: the status says "no content" while the bytes say otherwise, and strict clients — fetch() in some paths, gRPC-gateway proxies, mobile HTTP stacks — throw on the mismatch. The same rule kills the habit of echoing null "so the client has JSON to parse": a client that cannot handle an empty body should branch on the status, not the payload.
The rest of the success family, for context:
| Status | Use it when | Body |
|---|---|---|
200 OK |
Read or update succeeded | The representation |
201 Created |
A resource was stored; send Location |
New representation (optional) |
202 Accepted |
Work queued, not finished | Job reference / status URL |
204 No Content |
Success with nothing to return | None, ever |
202 matters as soon as work is slow: a report-generation job that finishes later answers 202 with a status URL instead of holding a connection open for thirty seconds, and the client polls that URL with GET — a pattern your router has to support explicitly.
Ordering still applies — header() and http_response_code() must run before any output, the rule you already own from Reading Requests with Superglobals. What changes here is the choice of code, because clients switch on it: SDKs read 201's Location, retry queues ignore all of 2xx, and dashboards count 204 as success while counting a 200-wrapped failure as… success. Getting the body right and the status wrong is still a wrong answer, and the sharp failure modes live on the error side — where 400 and 422 split the difference, and where the payloads are formalized in Building JSON Endpoints.
400 or 422? Choosing the Client Error That Matches the Failure
Your validator rejects an email field and returns... what? Two answers are defensible — 400 and 422 — and teams that use them interchangeably train clients to ignore both. The split is not pedantry: 400 Bad Request says the HTTP message itself is malformed, while 422 Unprocessable Entity says the message parsed fine and its contents fail your rules.
Use 400 when you cannot even parse the body:
$raw = file_get_contents('php://input');
try {
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => [
'code' => 'malformed_json',
'message' => 'Request body is not valid JSON',
]]);
exit;
}
Use 422 when parsing succeeded and validation failed. The distinguishing feature is that a 422 body can carry field-level detail, because nothing is wrong with the transport:
$errors = [];
if (!filter_var($data['email'] ?? '', FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'Must be a valid email address';
}
if ((int) ($data['birth_year'] ?? 0) < 1900) {
$errors['birth_year'] = 'Must be 1900 or later';
}
if ($errors !== []) {
http_response_code(422);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => [
'code' => 'validation_failed',
'message' => 'Fix the fields below',
'fields' => $errors,
]]);
exit;
}
The client rule of thumb falls straight out of that shape: 400 means "your request was broken — check your encoding, don't change a value"; 422 means "your request was well-formed — fix these specific fields." Automated clients may retry a 400 after inspecting the body; they treat a 422 as terminal for that payload, which is what you want when the same input would fail forever.
Method and resource failures need their own codes, and the pairings are where interviews focus:
| Scenario | Status | Extra header |
|---|---|---|
| Route does not exist | 404 Not Found |
— |
| Route exists, wrong verb | 405 Method Not Allowed |
Allow |
| Not authenticated | 401 Unauthorized |
WWW-Authenticate |
| Authenticated, insufficient rights | 403 Forbidden |
— |
| Body is XML, endpoint wants JSON | 415 Unsupported Media Type |
— |
| Same record edited twice | 409 Conflict |
— |
| Rate limit exceeded | 429 Too Many Requests |
Retry-After |
// /articles/42 exists; this server simply does not handle PATCH on it.
header('Allow: GET, PUT, DELETE');
http_response_code(405);
The Allow header is not garnish — it is how a well-behaved client discovers the correct verb without probing, and RFC 9110 requires it on a 405. Omitting it turns a helpful error into a dead end: the client now knows only that PATCH failed, not what to try instead. Emitting 404 for this case is worse still, because it says the resource is missing when the resource is sitting right there.
401 versus 403 is the pair everyone conflates: 401 says "identify yourself" and the WWW-Authenticate header tells the client how; 403 says "I know who you are, and no." Answering an anonymous request with 403 sends mobile clients down the authorization branch instead of the login branch, and the user sees "Access denied" where they should see a login screen.
429 with Retry-After: 30 deserves the same precision: the header is in seconds, and clients that parse it back off automatically instead of hammering you. Every one of these statuses travels inside a body that still has to be valid JSON with the right header — the encoding rules are Building JSON Endpoints, and the sequencing rule that all of this runs before any output comes from Reading Requests with Superglobals.
Why '200 OK' with an Error Body Breaks Clients, Dashboards, and Retries
Every mature codebase contains an endpoint shaped like this:
try {
$user = $service->find($id);
} catch (UserNotFoundException $e) {
http_response_code(200); // "the API itself worked fine"
echo json_encode(['status' => 'error', 'message' => 'User not found']);
exit;
}
The HTTP layer reports success while the body reports failure. A browser renders it without complaint, and it quietly breaks four things at once.
Client libraries. Guzzle, fetch(), Retrofit, and axios all offer "throw on non-2xx" modes, and their interceptors, retry policies, and success callbacks key off the status line. Wrapped in a 200, the failure lands in the success branch: the client stores {"status":"error"} as data, the retry middleware sees a success and stops, and a mobile screen renders an empty profile instead of an error state.
Monitoring. APM tools, load balancers, and uptime checks bucket requests by status class. 5xx rate, "errors by status", and error-budget alerts all read the status line — never your JSON. A spike of 200 OK failures is invisible on every dashboard until a customer reports it.
Caching. CDNs and HTTP caches store by status; a 200 is cacheable under default rules, so one unlucky error body can be served to every subsequent visitor. Error statuses are not stored the same way, which is one reason they exist as a class.
Logs and alerts. Log-based alerting greps status codes first. Finding errors by parsing bodies means the alert arrives late, or never.
The fix for the endpoint at the top is two lines — same body, same message, honest status:
} catch (UserNotFoundException $e) {
http_response_code(404);
echo json_encode(['error' => ['code' => 'user_not_found',
'message' => 'No user with that id']]);
exit;
}
Now retries, dashboards, caches, and SDKs all agree with what actually happened.
The fix is a mapping from outcome to status, applied in exactly one place:
| Outcome | Status | Notes |
|---|---|---|
| Uncaught exception | 500 |
Generic body; the real trace goes to the log |
| Known, expected failure | 4xx |
Match the cause: 404, 422, 409... |
| Dependency down, DB unreachable | 503 |
Send Retry-After so clients back off |
| Proxy/upstream failure | 502 / 504 |
Usually set by the proxy, not your code |
set_exception_handler(function (Throwable $e): never {
error_log((string) $e); // full trace stays server-side
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
}
echo json_encode(['error' => [
'code' => 'internal_error',
'message' => 'Something went wrong',
]]);
exit;
});
Two details make that production-grade. First, headers_sent() — the one case where output has already begun is exactly when you must not attempt to change the status, so the handler degrades to "send the body anyway" instead of emitting a warning on top of the failure. Second, the body says nothing about the internals: stack traces, SQL fragments, and file paths in a 500 body hand attackers a map, while error_log keeps them for you. If you need correlation, ship a request id the support team can look up.
Note the division of labor that runs through every code in this topic: the status is decided by the server's state of knowledge, and the body adds detail for the client. 503 says "later", 409 says "conflict", 422 says "these fields" — and once status and body agree, dashboards, retries, and SDKs all work without special-casing your API. The body half of that agreement — headers, encoding flags, and stable error shapes — is what Building JSON Endpoints formalizes, and the "set everything before the first byte" sequencing you need to make any of it work comes from Reading Requests with Superglobals.
Quiz
1. A DELETE request succeeds and there is deliberately nothing to return. Which response is correct?
2. The request body parses as valid JSON, but the email value has no @ sign. Which status fits, and what should the body carry?
3. PATCH /articles/42 returns 405 Method Not Allowed. Which response header is required, and what does it tell the client?
4. Customer-visible failures spike, but the 5xx rate on your dashboard stays flat. What is the most likely cause?
Flashcards
Question
What does 201 Created promise beyond 200 OK, and which header should accompany it?
Click to reveal answer
Answer
201 says a new resource now exists; 200 only says the request succeeded. It should carry Location with the resource's URI (e.g. https://api.example.com/v1/articles/42), optionally plus a body. 201 without Location forces clients to construct the URL themselves — ids are not the only thing that locates a resource.
Question
When is 204 No Content correct, and what must you not send with it?
Click to reveal answer
Answer
Use it when a request succeeded and there is deliberately nothing to return — DELETE, and PUT when the client already holds the representation it sent. Send no body at all (not null, not a newline) and no Content-Type; any payload after 204 is a protocol violation that strict clients such as fetch() or gRPC-gateway proxies reject.
Question
How do 400 and 422 differ, and what does each tell the client to do?
Click to reveal answer
Answer
400 means the HTTP message could not be parsed (malformed JSON, broken syntax) — fix your encoding, don't change a value. 422 means the message parsed but its content fails your rules — fix these specific fields, which the body should enumerate per field. Same error family, different remedy.
Question
What header is mandatory on a 405 response, and why does 405 exist separately from 404?
Click to reveal answer
Answer
Allow, listing the methods the matched endpoint supports (RFC 9110). 404 says the route/resource does not exist — your URL is wrong; 405 says it exists but not for this verb — your verb is wrong. Keeping them apart lets clients self-correct instead of hunting phantom data problems.
Question
Name four things that break when an API returns 200 with an error body.
Click to reveal answer
Answer
1) Client libraries run success paths: retries stop, error screens never show, wrong data is stored. 2) Monitoring keyed on status never sees the failures, so dashboards and error budgets lie. 3) CDNs may cache the 200 error and serve it to everyone. 4) Log alerts grepping 4xx/5xx miss the spike entirely.
Revision Notes
Key Takeaways
- 1. 201 pairs with Location and 204 forbids any body — success codes describe what changed, not merely that the request worked
- 2. 400 = unparseable request; 422 = parsed but invalid content, answered with a stable code and a per-field errors map
- 3. 405 requires Allow, 401 requires WWW-Authenticate, 429 uses Retry-After in seconds — each error status has a header obligation
- 4. 200 with an error body breaks SDK success branches, status-based monitoring, CDN caching, and grep-based alerting — status must match outcome
- 5. Uncaught exceptions become a generic 500 JSON body with the trace in error_log; 503 pairs with Retry-After when a dependency is down
Interview Tips
- • "200 vs 201 vs 204?" — 200 returns a representation, 201 declares a resource exists plus Location, 204 declares deliberate silence; add that appending any body to 204 trips strict clients
- • "400 or 422?" — draw the line at the parser: 400 means you could not read the message, 422 means you read it and the values fail validation; show the 422 body carrying field-level errors
- • "When do you return 405, and what must it include?" — route matched, verb didn't; always send Allow, and distinguish it from 404 (unknown route) and 403 (known route, insufficient rights)
- • "A legacy endpoint wraps errors in 200. What breaks?" — walk the four: SDK success paths and stopped retries, APM/error budgets, CDN caching, grep-based alerting; close with the fix, one status-to-outcome mapping in a single respond() helper
Cheat Sheet
Choosing HTTP Status Codes
Success family:
| Status | Meaning | Extra |
|---|---|---|
200 |
Read/update succeeded | body = representation |
201 |
Resource created | Location: <uri> required |
202 |
Accepted for async work | status/queue URL in body |
204 |
Success, deliberately no body | no bytes at all, no Content-Type |
Client errors:
| Status | Meaning | Extra |
|---|---|---|
400 |
Message unparseable (bad JSON) | — |
401 |
Identify yourself | WWW-Authenticate |
403 |
Known caller, denied | — |
404 |
Route/resource unknown | — |
405 |
Route known, verb unsupported | Allow: GET, PUT, DELETE |
409 |
State conflict (duplicate/merge) | — |
413 |
Body too large | check CONTENT_LENGTH first |
415 |
Wrong Content-Type |
— |
422 |
Parsed fine, values invalid | per-field errors map |
429 |
Rate limited | Retry-After: <seconds> |
400 vs 422: 400 = cannot read the message, fix encoding. 422 = read it, fix these fields.
http_response_code(422);
echo json_encode(['error' => ['code' => 'validation_failed', 'fields' => $errors]]);
5xx: uncaught exception → 500 (generic body, trace in error_log, guard with headers_sent()); dependency down → 503 + Retry-After.
Why 200-with-error-body fails four checks: SDK success branches run (retries stop, bad data stored) · APM/error budgets never see it · CDNs may cache it · grep-based alerts miss it. Fix: one status-to-outcome mapping in a single respond() helper.