Most API bugs I have chased that involved status codes came from three habits: returning 200 with an error in the body, using 400 for everything that is not a 500, and returning 404 when the caller lacked permission. Each has a correct answer and it takes about ten minutes to fix.
Location header pointing at it. Clients and caches both use this.301 and 308 are permanent; browsers cache them aggressively and will not re-request the old URL, sometimes for months. Never use 301 to test something. 302 and 307 are temporary. The difference within each pair is method preservation: 301 and 302 historically allowed clients to rewrite a POST into a GET, while 307 and 308 guarantee the method and body are preserved. For an API, use 307/308. 304 Not Modified is the one worth wiring up — combined with ETag and If-None-Match it turns repeat fetches into a header exchange with no body.
WWW-Authenticate header per the spec.Retry-After; without it a client's only sane strategy is guessing.500 means your code broke. 502 means an upstream returned garbage. 503 means you are deliberately unavailable — maintenance, or shedding load — and should carry Retry-After. 504 means an upstream timed out. Keeping these distinct matters because alerting on "5xx rate" without splitting them mixes your bugs with your dependency's outages, and those need different pagers.
RFC 9457 defines application/problem+json and it is worth adopting simply so every client parses one shape:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://example.com/errors/invalid-date-range",
"title": "Invalid date range",
"status": 422,
"detail": "end_date must be after start_date",
"instance": "/bookings/8814",
"errors": [
{"field": "end_date", "code": "before_start"}
]
}
Two rules make this useful. First, type is a stable identifier clients can branch on — never change it, and never make clients string-match on detail. Second, detail is for a human and must not contain stack traces, SQL, or internal hostnames.
Match the status code to the class of problem, not to how bad it feels. Never return 200 for a failure; every retry, cache, circuit breaker and dashboard in the chain relies on the status line and none of them read your body. And when you are unsure between two adjacent codes, pick one, write it in the API docs, and stay consistent — consistency is worth more to integrators than being right about 400 versus 422.