HTTP Status Codes Guide: Practical Reference for API Developers
Quick answer
💡HTTP status codes tell clients whether a request succeeded (2xx), needs a different location (3xx), has a client error (4xx), or hit a server problem (5xx). The most important distinction for API design is 401 versus 403, 400 versus 422, and 200 versus 201 versus 204. fetch() in JavaScript does not throw for 4xx or 5xx responses — you must check response.ok or response.status manually after every call.
Error symptoms
- ✕
API returns 200 OK for every request including errors, hiding failures from clients - ✕
Client receives 403 Forbidden for expired tokens instead of 401 Unauthorized, preventing automatic refresh - ✕
Server returns 500 Internal Server Error for invalid client input instead of 400 Bad Request - ✕
fetch() does not throw and the app silently processes an error response as if it were success data - ✕
DELETE endpoint returns 200 with an empty body instead of 204 No Content, confusing API consumers - ✕
Clients do not implement Retry-After from 429 Too Many Requests and immediately flood the API again
Common causes
- •Framework default error handlers returning 500 for validation errors that should be 400 or 422
- •Authentication middleware returning 403 for all auth failures instead of distinguishing 401 from 403
- •POST handlers returning 200 instead of 201 for newly created resources without a Location header
- •Teams using 200 with an error field in the body instead of HTTP status codes for error signaling
- •Clients not checking response.ok after fetch() calls, silently treating error responses as success
- •Rate limit responses lacking Retry-After headers, leaving clients unable to implement backoff correctly
When it happens
- •When building the first version of an API without an explicit status code convention
- •After adding a new error condition to an existing endpoint without updating the documented status codes
- •When refactoring authentication to distinguish between unauthenticated and unauthorized access
- •After adding input validation to endpoints that previously accepted any input
Examples and fixes
An API that returns 200 OK when creating a resource instead of 201 Created with a Location header.
POST endpoint returning wrong status for creation
❌ Wrong
// Express — wrong status for resource creation
app.post('/api/orders', async (req, res) => {
const { customerId, items } = req.body;
if (!customerId || !items?.length) {
return res.status(500).json({ error: 'Invalid input' });
}
const order = await db.orders.create({ customerId, items });
res.status(200).json(order);
});✅ Fixed
// Express — correct status codes for creation and validation
app.post('/api/orders', async (req, res) => {
const { customerId, items } = req.body;
if (!customerId) {
return res.status(400).json({
error: 'customerId is required'
});
}
if (!items?.length) {
return res.status(422).json({
error: 'items must be a non-empty array'
});
}
const order = await db.orders.create({ customerId, items });
res.status(201)
.set('Location', `/api/orders/${order.id}`)
.json(order);
});The broken version returns 500 for client input errors. HTTP 500 means the server crashed — returning it for missing fields tells clients the server is broken when the actual problem is the request. Clients that monitor 5xx error rates will incorrectly alert on user input mistakes. The fix uses 400 for a missing required field (syntactically wrong request) and 422 for an empty items array (structurally valid but semantically wrong). The created resource gets 201 Created with a Location header so clients know where to retrieve the new order. This allows clients to check response.status === 201 to confirm creation versus response.status === 200 for other successful operations.
A middleware that returns 403 for all authentication problems, preventing clients from distinguishing expired tokens from permission failures.
Authentication middleware conflating 401 and 403
❌ Wrong
function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(403).json({ error: 'Access denied' });
}
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
return res.status(403).json({ error: 'Access denied' });
}
}
function requireAdmin(req, res, next) {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied' });
}
next();
}✅ Fixed
function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401)
.set('WWW-Authenticate', 'Bearer realm="api"')
.json({ error: 'Authentication required' });
}
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch (err) {
const status = err.name === 'TokenExpiredError' ? 401 : 401;
return res.status(status).json({ error: 'Token invalid or expired' });
}
}
function requireAdmin(req, res, next) {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
}The CORS specification and RFC 7235 define 401 Unauthorized as the status for requests that lack valid authentication credentials, and 403 Forbidden for requests that are authenticated but lack permission for the resource. Using 403 for a missing or expired token means clients cannot implement automatic token refresh — they have no way to know whether the failure is an authentication problem (fixable by refreshing the token) or a permission problem (not fixable without changing the user's role). The fix returns 401 with a WWW-Authenticate header for all authentication failures. The header tells clients the expected authentication scheme. 403 is reserved for authenticated users who lack the specific permission for the requested operation.
A 429 Too Many Requests response that does not include the Retry-After header, leaving clients unable to back off correctly.
Rate limiting without Retry-After
❌ Wrong
// Rate limit middleware — no Retry-After
app.use(rateLimit({
windowMs: 60 * 1000,
max: 100,
message: { error: 'Too many requests' }
}));
// Client — retries immediately on 429
async function callApi(url) {
const res = await fetch(url);
if (res.status === 429) {
return callApi(url); // infinite retry loop
}
return res.json();
}✅ Fixed
// Rate limit middleware — includes Retry-After
app.use(rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: (req, res) => {
res.setHeader('Retry-After', '60');
return { error: 'Rate limit exceeded', retryAfter: 60 };
}
}));
// Client — respects Retry-After before retrying
async function callApi(url, retries = 3) {
const res = await fetch(url);
if (res.status === 429 && retries > 0) {
const wait = parseInt(res.headers.get('Retry-After') || '10', 10);
await new Promise(r => setTimeout(r, wait * 1000));
return callApi(url, retries - 1);
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}A 429 response without Retry-After leaves clients with no information about how long to wait before retrying. The broken client retries immediately on 429, which causes it to hit the rate limit again and again in a tight loop, consuming its retry budget instantly and making the overload worse for the server. The fix adds a Retry-After header with the number of seconds to wait, and the standardHeaders: true option adds RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers following the standard draft specification. The client reads the Retry-After value, waits the specified duration, and then retries with a decremented retry counter to prevent infinite retry loops.
What each status code class signals
HTTP status codes are organized into five classes by their leading digit. Understanding what each class communicates is more important than memorizing individual codes, because the class tells clients how to handle the response before they parse the body.
The 1xx Informational class is rarely encountered in API development. The most relevant is 100 Continue, which a server sends when it has received request headers and the client should proceed with sending the body. The 101 Switching Protocols response is used for WebSocket upgrades — the client sends an Upgrade header requesting WebSocket, and the server responds with 101 to confirm the protocol switch.
The 2xx Success class means the request was received, understood, and accepted. The specific code carries important semantic meaning that API clients use to drive their behavior. 200 OK is the general success response for GET, PUT, and PATCH operations that return a body. 201 Created should be returned for POST requests that create a new resource — it signals creation specifically and should include a Location header pointing to the new resource. 204 No Content signals success with no response body, which is appropriate for DELETE operations and some PUT or PATCH operations where the client does not need the updated resource echoed back. 206 Partial Content is used for range requests, where a client requested only a byte range of a large file.
The 3xx Redirection class means the client must take additional action to complete the request. 301 Moved Permanently tells the client and its caches that the resource has permanently moved. 302 Found is a temporary redirect that is not cached. 304 Not Modified is the response to a conditional GET using ETag or Last-Modified — it tells the client the resource has not changed and it can use its cached version. 307 Temporary Redirect and 308 Permanent Redirect preserve the original HTTP method on the redirected request, unlike 302 and 301 which historically caused browsers to convert POST to GET on redirect.
The 4xx Client Error class means the request contains an error that the client is responsible for fixing. The server understood the request but cannot or will not process it as sent. The 5xx Server Error class means the server encountered an unexpected condition while processing a valid request. Clients typically retry 5xx responses because the server failure may be transient, while 4xx responses indicate a problem with the request itself that retrying will not fix.
Reading status codes in API responses
The first debugging step for any API failure is to confirm what status code is being returned. This sounds obvious, but many API clients wrap responses in try-catch blocks around JSON parsing and throw generic errors that hide the HTTP status. JavaScript's fetch() is particularly misleading — it does not throw for 4xx or 5xx responses. A fetch call to an endpoint returning 500 resolves successfully and response.ok is false, but no exception is thrown. You must check response.ok or response.status after every fetch call to detect HTTP-level errors.
When debugging API responses, open the browser DevTools Network tab and click on the failing request. The Status column shows the code. The Response Headers panel shows additional context: Location on 3xx responses, WWW-Authenticate on 401 responses, Retry-After on 429 and 503 responses, and X-Request-ID or trace identifiers that help correlate the request with server-side logs. The Response tab shows the error body, which well-designed APIs use to provide machine-readable error codes alongside the HTTP status.
For server-side debugging, check whether the status code being returned matches the intent of the handler. Add temporary logging that prints the status code and the response body for every request in development. This often reveals that error handlers are returning 500 for input validation failures, or that framework defaults are overriding carefully crafted status codes. In Express, if an async handler throws an unhandled exception, the default error handler returns 500 regardless of what the exception object says.
Use /tools/http-request-builder to send requests to your API and inspect the raw status code, headers, and body. This tool bypasses any client-side error handling and shows exactly what the server returns. Test the endpoint with missing required fields to confirm it returns 400 or 422, with no authentication to confirm it returns 401, and with an authenticated but unauthorized user to confirm it returns 403. Testing these cases explicitly before shipping an API prevents consumers from writing error handling based on incorrect status codes.
For third-party APIs, the status code is often the fastest way to diagnose a problem. 401 means the API key is missing, invalid, or expired. 403 means the API key is valid but the account lacks access to that endpoint, often due to pricing tier. 429 means the rate limit has been exceeded and the Retry-After header tells you when to resume. 503 with a Retry-After means the service is temporarily unavailable and will return. Each of these requires a different response from the client and a different resolution strategy.
Choosing the right status code for each case
The most impactful status code decisions in API design are the ones that distinguish between closely related codes. Getting these distinctions right prevents consumer confusion and enables automatic error handling.
200 versus 201 versus 204: Use 200 for GET, PUT, and PATCH that return the resource. Use 201 for POST that creates a new resource, and include a Location header with the URL of the new resource. Use 204 for DELETE and for PUT or PATCH operations where you choose not to return the updated resource in the body. Never return 200 with an empty body where 204 is more accurate — some clients treat an unexpected empty body as a parse error.
400 versus 422: Use 400 Bad Request for syntactically malformed requests — a JSON body that fails to parse, a required URL parameter that is missing entirely, or an integer field that contains a string. Use 422 Unprocessable Entity for requests that are syntactically valid but fail semantic validation — a JSON body that parses correctly but contains a value outside the allowed range, a date that is in the past when a future date is required, or a combination of fields that are individually valid but mutually exclusive. The distinction matters because clients may treat 400 as a programming error and 422 as a user input error.
401 versus 403: Use 401 Unauthorized (counterintuitively named) when the request lacks valid authentication credentials. Include a WWW-Authenticate header that tells the client which authentication scheme to use. Use 403 Forbidden when the request is properly authenticated but the authenticated identity lacks permission for the requested resource. If a user is authenticated as a regular user but tries to access an admin endpoint, 403 is correct. If no token is sent at all or the token is expired, 401 is correct. The distinction drives automatic token refresh behavior in API clients.
503 with Retry-After: When an API is temporarily unavailable due to maintenance, overload, or a dependency outage, return 503 Service Unavailable with a Retry-After header in seconds. This tells load balancers to route around the instance, tells clients how long to wait before retrying, and distinguishes planned downtime from unexpected failures. 502 Bad Gateway indicates the load balancer or proxy received an invalid response from the upstream server — it is returned by the infrastructure, not the application.
For APIs with clients that may use the /tools/http-request-builder tool for manual testing, return structured error bodies alongside the status code. Include a machine-readable error code string, a human-readable message, and when applicable, a field name indicating which part of the request caused the error. This allows both automated error handling and human debugging without requiring the client to decode the status code alone.
Status code edge cases in real APIs
GraphQL always returns 200 OK regardless of whether the query succeeded or failed. Errors are communicated through an errors array in the response body. A GraphQL request that hits a missing field, a permission error, or a resolver exception all return 200 with the errors array populated. This is intentional in the GraphQL specification — HTTP status codes represent transport-level success (the request was received and processed), not application-level success (the query returned the expected data). When building clients that consume GraphQL APIs, always check for the errors array even when the HTTP status is 200.
Conditional GET requests with ETags demonstrate why 304 Not Modified is important for performance. The client sends an If-None-Match header containing the ETag value from a previous response. If the resource has not changed, the server returns 304 with no body — saving the bandwidth cost of transmitting the unchanged resource. If the resource has changed, the server returns 200 with the new body and a new ETag. Omitting ETag support means every GET request transfers the full response body even when the client already has the current version cached.
The 410 Gone status communicates permanent removal more precisely than 404. Both indicate the resource is not available, but 410 tells search engine crawlers and smart clients that the resource will never exist at this URL again. Using 410 for deleted resources that were previously indexed by search engines allows crawlers to remove them from their index faster than if they return 404. This is useful during content migrations or when deliberately retiring API endpoints. Return 410 with a response body explaining where the replacement resource can be found.
HTTP/2 and HTTP/3 use the same status codes as HTTP/1.1. The version of the protocol does not change the semantic meaning of any status code. If you see a 200 over HTTP/2 or HTTP/3, it means the same thing as 200 over HTTP/1.1. The protocol version affects framing and multiplexing but not application-layer semantics.
Custom status codes in the 4xx and 5xx range are occasionally used by APIs but are not part of the HTTP specification. Some systems use 499 for client-closed requests or 522 for connection timeouts at the CDN layer. These are vendor-specific and should not be used in application code. Stick to registered IANA status codes to maintain compatibility with standard HTTP infrastructure like load balancers, proxies, and monitoring systems.
Status code mistakes that confuse API consumers
Returning 200 with an error body is the most harmful status code mistake because it defeats every layer of standard HTTP error handling. Load balancers that retry 5xx responses do not retry 200 responses. Monitoring systems that alert on error status rates miss 200 responses containing error objects. Client-side fetch wrappers that check response.ok see true for 200 and skip error handling. The pattern appears in some older API designs where the body contains a success: false field, but it forces every consumer to implement custom error detection logic instead of relying on HTTP semantics. Always return a 4xx or 5xx status for error conditions.
Not checking fetch()'s response.ok is the corresponding client-side mistake. JavaScript's fetch() only throws for network errors — DNS failures, connection refused, or the request being aborted. An HTTP 500 response resolves the fetch promise successfully. Code like fetch(url).then(r => r.json()).then(doSomething) will call doSomething with whatever the server returned, including error bodies. Add if (!response.ok) throw new Error(response.status) or similar error handling after every fetch call.
Returning 500 for all unhandled exceptions exposes internal error details and inflates server error metrics. When a handler throws because a required query parameter is missing, that is a 400. When it throws because a database record was not found, that is 404. When it throws because the user lacks permission, that is 403. Only genuine server-side failures — unhandled exceptions, database connection errors, third-party service timeouts — should return 500. Add a global exception handler that distinguishes business logic errors from infrastructure errors and maps them to appropriate status codes.
Omitting the Location header on 201 Created responses forces API consumers to make a second request to discover the URL of the new resource. The Location header is a first-class HTTP mechanism for communicating where a newly created resource lives. Including it allows clients to immediately follow up with GET /api/orders/{id} without parsing the response body to find the ID. Some hypermedia API designs include the full resource URL in the body as a self link — providing both the Location header and the body link is redundant but harmless and maximally compatible.
Ignoring Retry-After on 429 and 503 responses causes clients to implement exponential backoff with arbitrary starting intervals when the server has told them exactly how long to wait. The Retry-After header is either a number of seconds or an HTTP date. Reading this value and waiting before retrying respects the server's capacity signals and recovers from rate limiting as quickly as possible. Ignoring it and using fixed backoff intervals either waits too long (slowing recovery) or retries too soon (making the rate limit situation worse).
Building a consistent status code convention
Define a status code convention for your API before writing the first handler, not after. Agree on which codes you use for each situation and document them in your API specification. Consistency across endpoints matters more than any individual choice being theoretically perfect. A team that always uses 400 for validation errors and 422 for business rule violations is easier to work with than a team that uses them interchangeably depending on who wrote the handler.
Return structured error bodies alongside every error status code. The body should include at minimum a machine-readable error code string and a human-readable message. For 422 errors, include a errors array with field-level details so clients can show inline form validation messages. For 429 errors, include the reset time and current limits alongside the Retry-After header. Consistent error body structure across all endpoints is as important as consistent status codes — it lets clients write a single error handler that works for every endpoint.
Use the WWW-Authenticate header on every 401 response. RFC 7235 requires it, and many HTTP client libraries use its presence to trigger automatic authentication flows. The minimal value is WWW-Authenticate: Bearer realm="api" for JWT-based APIs. Including it costs nothing and enables standard HTTP authentication negotiation for clients that support it.
Test every API endpoint's error behavior explicitly in your test suite. For each endpoint, test the success case, the missing required field case, the invalid value case, the unauthenticated case, and the unauthorized case. Assert the status code, not just the body content. A test that only checks the success response body will not catch a regression where an error handler starts returning 500 instead of 400. Make status code correctness a first-class testing concern.
Monitor the distribution of status codes in production as a health metric. A sudden increase in 4xx rates may indicate a client bug or a breaking API change. A sudden increase in 5xx rates indicates a server problem. An increase in 429 rates indicates traffic spikes that may require capacity planning. Use /tools/http-request-builder to reproduce specific requests that are generating unexpected status codes in production — this is faster than adding debug logging and redeploying. Pair this with /tools/cors-tester when CORS-related errors accompany the status code issues.
Quick fix checklist
- ✓Return 201 Created with a Location header for POST requests that create new resources, not 200
- ✓Return 204 No Content for DELETE operations and updates that do not need to echo the resource body
- ✓Return 401 Unauthorized with a WWW-Authenticate header for missing or expired tokens, not 403
- ✓Return 403 Forbidden only for authenticated requests that lack the required permission
- ✓Return 400 for syntactically malformed requests and 422 for semantically invalid but well-formed requests
- ✓Return 429 Too Many Requests with a Retry-After header for rate limit responses
- ✓Check response.ok after every JavaScript fetch() call to detect 4xx and 5xx responses
- ✓Return 503 Service Unavailable with Retry-After during planned maintenance instead of 200 with an error body
Related guides
Frequently asked questions
What is the difference between 400 and 422?
HTTP 400 Bad Request means the request syntax is wrong — the JSON body fails to parse, a required header is missing, or a URL parameter has the wrong type. HTTP 422 Unprocessable Entity means the request is syntactically valid but fails semantic validation — valid JSON with a value outside the allowed range, conflicting fields, or a date in the wrong direction. Use 400 for structural problems and 422 for business rule violations.
Why should I use 201 instead of 200 for resource creation?
HTTP 201 Created signals specifically that a new resource was created as a result of the request. It should include a Location header with the URL of the new resource. Using 200 for creation loses this semantic signal — clients cannot distinguish a creation from an update or a read, and automatic link traversal tools cannot follow the Location header to immediately retrieve the created resource without parsing the response body.
When should an API return 204 instead of 200?
Return 204 No Content when the operation succeeded but there is no meaningful response body to return. DELETE operations typically return 204. Some PUT and PATCH operations return 204 when the server has processed the update but chooses not to echo the updated resource back. Never return 200 with an intentionally empty body — 204 communicates intentional emptiness, while an empty 200 body looks like a bug to many clients.
Does fetch() throw an error for 404 or 500 responses?
No. JavaScript's fetch() only throws for network-level errors — DNS failure, connection refused, or request abort. HTTP error responses like 404, 500, or 429 resolve the fetch promise successfully. You must check response.ok (true for 200-299) or response.status after every fetch call to detect HTTP-level errors and throw or handle them explicitly.
What is the difference between 401 Unauthorized and 403 Forbidden?
HTTP 401 means the request lacks valid authentication credentials — the token is missing, expired, or invalid. The client should re-authenticate. HTTP 403 means the request is authenticated but the identity lacks permission for the specific resource or operation. A logged-in regular user accessing an admin endpoint gets 403. An unauthenticated request or one with an expired token gets 401.
Why does GraphQL return 200 for errors?
GraphQL treats HTTP as a transport layer. The HTTP 200 status means the HTTP request was successfully received and processed. Whether the GraphQL query itself succeeded or failed is communicated in the response body through the errors array. This separates transport-level success from application-level success. When consuming GraphQL APIs, always check for the errors field in the response body even when the HTTP status is 200.
What should I return for a rate limit error?
Return 429 Too Many Requests with a Retry-After header set to the number of seconds the client should wait before retrying. Also include RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers following the standard headers draft. Include a structured error body with the reset time in a machine-readable format. Clients that read Retry-After can recover from rate limits as quickly as possible without overloading the server further.
What is the difference between 502 and 503?
HTTP 502 Bad Gateway means a proxy or load balancer received an invalid response from the upstream server. It is typically generated by the infrastructure layer, not the application. HTTP 503 Service Unavailable means the server is temporarily unable to handle the request, often due to overload or scheduled maintenance. Return 503 from your application when intentionally refusing traffic, and include a Retry-After header to signal when the service will resume.
Should I use 410 Gone or 404 Not Found for deleted resources?
Use 410 Gone when a resource was permanently deleted and will never be recreated at that URL. Use 404 Not Found for resources that simply do not exist or have never existed. The distinction matters for search engine crawlers: 410 instructs crawlers to immediately remove the URL from their index, while 404 may cause the crawler to retry the URL later expecting it to be a temporary absence.
All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.