Why the status code matters
The status code is the part of your response that machines act on without parsing your body. Load balancers count 5xx to eject an unhealthy instance. Client SDKs retry 502 and 503 with backoff and refuse to retry 400. CDNs cache 200 and 404 and never cache 500. Monitoring pages someone at 3am based on a 5xx rate.
HTTP 200 {"success": false, "error": "not found"} makes every one of those systems blind. Your error rate looks like zero, retries never happen, and every client has to write bespoke parsing. Use the status line.2xx: success is not just 200
| Code | Use when | Notes |
|---|---|---|
| 200 OK | Read or update succeeded and there is a body | Default success |
| 201 Created | A new resource exists | Always include Location |
| 202 Accepted | Work was queued, not done | Return a job URL to poll |
| 204 No Content | Succeeded, nothing to say | DELETE, PUT with no echo. No body at all |
| 206 Partial Content | Range request served | Video seeking, resumable downloads |
202 is underused. Anything that takes more than a second or two — transcoding, report generation, bulk import — should return 202 with a job resource rather than holding a socket open until a gateway kills it at 30 seconds.
3xx: redirects and 304
| Code | Meaning | Method preserved? |
|---|---|---|
| 301 Moved Permanently | New canonical URL, cached forever by browsers | No — may become GET |
| 302 Found | Temporary, historically ambiguous | No — may become GET |
| 303 See Other | POST succeeded, now GET this | Forced to GET |
| 304 Not Modified | Your cached copy is still fresh | n/a — no body |
| 307 Temporary Redirect | Temporary, method and body preserved | Yes |
| 308 Permanent Redirect | Permanent, method and body preserved | Yes |
For APIs prefer 307/308 — 301 and 302 are allowed to rewrite a POST into a GET, which silently drops your body. 303 is the correct answer to the “refresh resubmits the form” problem (Post/Redirect/Get).
4xx: the client's fault
| Code | Use when |
|---|---|
| 400 Bad Request | Malformed syntax — unparseable JSON, missing required param |
| 401 Unauthorized | No or invalid credentials. Include WWW-Authenticate |
| 403 Forbidden | Authenticated but not permitted. Retrying will not help |
| 404 Not Found | No such resource — also use to hide existence from unauthorised users |
| 405 Method Not Allowed | Wrong verb on a real path. Must include Allow header |
| 409 Conflict | State conflict — duplicate unique key, concurrent edit |
| 410 Gone | Deliberately removed and not coming back |
| 412 Precondition Failed | If-Match did not hold — optimistic concurrency rejection |
| 413 Payload Too Large | Body over the limit |
| 415 Unsupported Media Type | Content-Type your endpoint cannot parse |
| 422 Unprocessable Content | Syntactically valid, semantically wrong — validation errors |
| 429 Too Many Requests | Rate limited. Must include Retry-After |
400 vs 422 is the one people argue about. Useful rule: 400 if you could not parse it, 422 if you parsed it fine but the values are unacceptable. Field-level validation errors are 422.
5xx: your fault
- 500 — unhandled exception. Never leak a stack trace; log with a request id and return that id.
- 501 — not implemented. Rare and honest.
- 502 — a bad response from an upstream you proxy to.
- 503 — temporarily unavailable: shedding load, in maintenance, circuit open. Include
Retry-After. - 504 — an upstream timed out. Distinguish from 502; they point at different problems.
Error response shape
Pick one shape and use it everywhere. RFC 9457 (application/problem+json) is a good default because it is already a standard and tooling understands it.
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/validation",
"title": "Validation failed",
"status": 422,
"detail": "quantity must be at least 1",
"instance": "/v1/orders",
"requestId": "3f9a-77cd",
"errors": [
{ "field": "qty", "code": "min", "message": "must be >= 1" }
]
}- A stable machine
codelets clients branch without string-matching messages. - The
requestIdmust also appear in your logs — that is the whole point. - Never put raw SQL, file paths or internal hostnames in an error body.
Headers by category
| Category | Headers | Purpose |
|---|---|---|
| Content | Content-Type, Content-Length, Content-Encoding, Content-Disposition | How to parse, decompress and save the body |
| Negotiation | Accept, Accept-Encoding, Accept-Language, Vary | Client capabilities and the cache key partition |
| Caching | Cache-Control, ETag, Last-Modified, Expires, Age | Freshness and revalidation |
| Conditional | If-None-Match, If-Match, If-Modified-Since | 304s and optimistic concurrency |
| Auth | Authorization, WWW-Authenticate, Set-Cookie, Cookie | Identity and session |
| CORS | Access-Control-Allow-*, Origin | Cross-origin browser permission |
| Tracing | X-Request-Id, traceparent | Distributed tracing correlation |
| Proxy | X-Forwarded-For, X-Forwarded-Proto, Forwarded | Original client details behind a proxy |
Accept-Encoding, Accept-Language or Authorization, say so with Vary. A shared cache that ignores this will happily serve one user's authenticated response to the next visitor.Rate limiting headers
HTTP/1.1 429 Too Many Requests RateLimit-Limit: 100 RateLimit-Remaining: 0 RateLimit-Reset: 37 Retry-After: 37
Retry-After accepts seconds or an HTTP date, and is also valid on 503 during maintenance. Publishing the remaining budget on every response — not just on rejections — is what lets a well-written client throttle itself before it gets blocked.
Security headers
| Header | Value to start from | Protects against |
|---|---|---|
| Strict-Transport-Security | max-age=31536000; includeSubDomains | Protocol downgrade, SSL stripping |
| Content-Security-Policy | default-src 'self' | XSS and injected third-party scripts |
| X-Content-Type-Options | nosniff | MIME sniffing turning uploads into scripts |
| Referrer-Policy | strict-origin-when-cross-origin | Leaking URLs and tokens to third parties |
| X-Frame-Options / frame-ancestors | DENY | Clickjacking |
| Cache-Control | no-store on authenticated responses | Personal data cached by proxies |
Interview framing
“Status codes are the contract: 201 with Location on create, 202 with a job URL for async work, 422 with field-level problem+json for validation, 409 for state conflicts, 412 when an If-Match ETag is stale, and 429 with Retry-After plus RateLimit headers. 5xx is reserved for our own failures so it drives alerting and client backoff honestly. Every error carries a request id that also lands in the logs, and authenticated responses are always no-store with an explicit Vary.”