Choosing the right HTTP status code for your API
HTTP status codes are the first thing every client, proxy, cache, monitoring system and search engine reads in a response. When an API returns the right codes, clients can decide whether to retry, ask the user to log in, fix their input or give up, without parsing error messages. When it returns 200 for everything, or 500 for every problem, every client has to guess. This guide is a practical decision guide for choosing codes in a REST-style API.
The five classes
| Class | Meaning | Who should act |
|---|---|---|
| 1xx | Informational, the request continues | Rarely seen in application code |
| 2xx | Success | Nobody, things worked |
| 3xx | Redirection | The client should look elsewhere |
| 4xx | Client error | The caller must change the request |
| 5xx | Server error | The server operator must fix something; the client may retry |
The split between 4xx and 5xx is the most important decision. A 4xx says "retrying the same request will fail again". A 5xx says "this might work later". Getting it wrong either causes useless retries or makes clients give up on temporary failures. Clients that see an unfamiliar code treat it like the generic code of its class, such as 400 or 500.
Success codes
- 200 OK: the default for successful reads and for updates that return the updated resource.
- 201 Created: a POST that created a resource. Include a
Locationheader with the new resource's URL and usually the resource in the body. - 202 Accepted: the request was accepted for asynchronous processing that has not finished. Return a way to check progress, such as a job URL.
- 204 No Content: success with no body, common for DELETE and for updates that return nothing. A 204 must not include a body.
Redirects
- 301 Moved Permanently and 308 Permanent Redirect: the resource has a new permanent URL. Search engines transfer ranking to the new URL. 308 guarantees that the method and body are kept, while some clients turn a POST into a GET after a 301.
- 302 Found and 307 Temporary Redirect: temporary. 307 also preserves the method.
- 303 See Other: after a POST, tells the client to fetch the result with GET. It is the classic "post, redirect, get" pattern for forms.
- 304 Not Modified: the cached copy is still valid, in response to a conditional request with
If-None-MatchorIf-Modified-Since.
Client errors: a decision path
Work through these questions in order, and return the first code that applies.
- Could the request not be parsed at all? Malformed JSON, a wrong content type or an invalid query parameter format: 400 Bad Request. If the content type itself is unsupported, 415 Unsupported Media Type is more precise.
- Are credentials missing, expired or invalid? 401 Unauthorized, with a
WWW-Authenticateheader. Despite the name, 401 is about authentication: logging in again might help. - Is the caller authenticated but not allowed? 403 Forbidden. Logging in again will not help.
- Does the resource not exist? 404 Not Found. Also use 404 instead of 403 when even revealing that a resource exists would leak information, such as another customer's invoice ID.
- Is the method not supported on this resource? 405 Method Not Allowed, with an
Allowheader listing valid methods. - Does the request conflict with the current state? Creating a user with an email that is already taken, or updating a record that someone else changed: 409 Conflict. For optimistic locking with
If-Match, use 412 Precondition Failed. - Was the request well-formed but semantically invalid? A date in the past for a future booking, a negative quantity, a missing required field: 422 Unprocessable Content. Many APIs use 400 for these too; either is acceptable if you are consistent and the body explains the problem.
- Is the caller sending too many requests? 429 Too Many Requests, with a
Retry-Afterheader.
Other useful client errors include 410 Gone for resources deliberately removed for good, and 413 Content Too Large for oversized uploads.
Server errors
- 500 Internal Server Error: an unexpected failure in your code. Every 500 should be treated as a bug to investigate.
- 502 Bad Gateway: a proxy or gateway got an invalid response from the upstream server, often because the app crashed or closed the connection.
- 503 Service Unavailable: the server is temporarily unable to handle requests, for maintenance or overload. Add
Retry-Afterwhen you can estimate it. - 504 Gateway Timeout: the upstream did not respond in time.
Application code usually returns only 500 and 503; 502 and 504 typically come from load balancers and reverse proxies. When you see them, look at the proxy's logs and the health of the app behind it.
Mistakes most APIs make
200 with an error in the body
Responses like 200 {"success": false, "error": "not found"} break monitoring, caching and retry logic, because everything in the HTTP stack thinks the request worked. Use the status code, and put the details in the body.
500 for validation errors
Unhandled validation exceptions often bubble up as 500, which wakes up on-call engineers for user typos and makes clients retry requests that will never succeed. Catch validation errors and return 400 or 422.
401 and 403 mixed up
Returning 403 for an expired token stops clients from refreshing it automatically, because clients refresh on 401. Returning 401 for a permission problem sends users into a login loop.
404 for an empty list
A collection that exists but has no items should return 200 with an empty array. Reserve 404 for resources or collections that do not exist.
Error bodies
The status code says what kind of problem occurred; the body should say exactly what. RFC 9457 defines a standard "problem details" format with the content type application/problem+json:
{
"type": "https://example.com/errors/validation",
"title": "Validation failed",
"status": 422,
"detail": "quantity must be greater than 0",
"errors": [{ "field": "quantity", "message": "must be greater than 0" }]
}
The cleanest way to get consistent codes is to decide them in one place. In Express, for example, handlers throw typed errors and a single error middleware maps them to status codes and problem details, so a forgotten try can never turn a validation error into a 500:
class HttpError extends Error {
constructor(status, title, detail, extra = {}) { super(detail); Object.assign(this, { status, title, extra }); }
}
app.post('/orders', async (req, res) => {
if (!(req.body.quantity > 0)) throw new HttpError(422, 'Validation failed', 'quantity must be greater than 0');
const order = await orders.create(req.body);
res.status(201).location(`/orders/${order.id}`).json(order);
});
app.use((err, req, res, next) => {
const status = err.status ?? 500;
if (status === 500) console.error(err); // log the real error, never send it
res.status(status).type('application/problem+json').json({
title: status === 500 ? 'Internal Server Error' : err.title,
status,
detail: status === 500 ? undefined : err.message,
...err.extra,
});
});
(Express 5 passes errors thrown in async handlers to the error middleware automatically; Express 4 needs a wrapper or next(err).) Whether you use problem details or your own format, keep it consistent across all endpoints, include a stable machine-readable error code, and never include stack traces or internal details in production responses.
Document and test
List the possible status codes for each operation in your OpenAPI spec, including errors, and test that the API actually returns them. The HTTP Status Codes reference explains every code if you need to check a less common one, and the API Request Builder lets you send requests and see which codes your API really returns.