# HTTP Status Codes for APIs: 4xx vs 5xx and What to Fix First

> A practical debugging guide to HTTP status codes, including 400 vs 422, 401 vs 403, 404 vs 410, 429 retries, and 502 vs 504 gateway failures.

**Quick answer:** The first digit tells you who should act: 4xx means the request is wrong and the client should change it, 5xx means the server failed and the request may succeed later. Use 401 for missing or invalid authentication, 403 for authenticated but forbidden, 409 for conflicts, 422 for semantic validation failures, and 429 with Retry-After for rate limits.

## Key takeaways

- 4xx errors are client-actionable; 5xx errors are server-actionable and often retryable.
- 401 means unauthenticated; 403 means authenticated but not allowed.
- 400 is for malformed requests; 422 is for well-formed requests that fail validation.
- 409 signals a state conflict, such as a duplicate create or a stale version.
- Returning 200 with an error body breaks clients, monitoring, and retries.

---

HTTP status codes are a contract between the client, the server, and every intermediary in between. When the code is wrong, retries, caching, monitoring, and user-facing error messages all make bad decisions.

This guide focuses on the codes that cause the most confusion in APIs and the debugging order that resolves them fastest.

A wrong status code is rarely a cosmetic defect. It changes what caches store, what retry middleware does, whether dashboards page a human, and what the user is told to do next. Most expensive bugs in this area are not obscure codes; they are common codes used for the wrong failure.

## The five classes

| Class | Meaning | Who acts | Typical mistake |
| --- | --- | --- | --- |
| 1xx | Informational | Protocol layer | Treating a 101 upgrade as a normal JSON response |
| 2xx | Success | No action | Returning 200 with an error body |
| 3xx | Redirection | Client follows or caches | A 301 cached long after the move is reversed |
| 4xx | Client error | Client changes the request | Using 403 for a missing token |
| 5xx | Server error | Server fixes or client retries | Using 500 for form validation |

The class is the first thing to read. A 4xx is not a server bug; a 5xx is not a client bug.

The class is a routing signal, not a severity ranking. Proxies, CDNs, and client libraries act on it before your error body is parsed: caches may store a 200 for a year, retry middleware may replay a 503, and analytics may discard a 404 as noise. A 500 on a validation error therefore does two kinds of damage: it tells the client to retry something that will never succeed, and it pages an on-call engineer for a user input problem.

2xx is also broader than 200. Use 201 with a `Location` header when a resource is created, 202 when async work is accepted but not finished, 204 when success has no body, and 206 for range responses. Browsers rarely care, but SDKs and workflow engines do: "done" and "queued" lead to different next steps.

## 400 vs 422

`400 Bad Request` means the server cannot process the request because of malformed syntax, missing required parameters, or an invalid content type.

`422 Unprocessable Content` means the request is syntactically valid but semantically wrong: a negative quantity, an end date before a start date, or a value outside an allowed range.

```json
// 400: malformed JSON
{ "quantity": }

// 422: valid JSON, invalid value
{ "quantity": -2 }
```

Some APIs use 400 for both. That is acceptable if the error body explains the problem, but 422 gives clients a clearer signal that the payload parsed correctly and the values need fixing.

A minimal reproduction belongs in the contract test suite. Send `{ "quantity": }` and assert 400; send `{ "quantity": -2 }` and assert 422. If both return 400, the client cannot tell whether fixing a JSON typo is enough or whether it must change a business value. If both return 422, a truncated body or a wrong `Content-Type` is misreported as a validation problem, and form logic may highlight a field that does not exist.

A useful 422 names the field and the constraint instead of echoing the request: `{"error":{"code":"validation_error","field":"quantity","message":"must be >= 1"}}`. Related codes deserve their own branches: 415 Unsupported Media Type when the body is not the expected format at all, 406 Not Acceptable when content negotiation fails, and 413 Content Too Large when the payload is rejected before parsing. Collapsing all of them into 400 hides what to fix.

422 is the wrong choice for clients that only understand 400, such as some legacy SOAP or XML gateways, and for form-encoded endpoints where "syntactically valid" is hard to define. GraphQL is a separate contract: it conventionally returns 200 with an `errors` array even for validation failures. Pick one convention per API surface and document the exceptions rather than mixing them silently.

## 401 vs 403

`401 Unauthorized` means authentication is missing or invalid. The client should obtain credentials and retry. Include a `WWW-Authenticate` header describing the scheme.

`403 Forbidden` means the client is authenticated but does not have permission. Retrying with the same credentials will not help.

A common mistake is returning 403 for missing tokens to hide whether a resource exists. That can be a deliberate security choice, but it makes client behavior ambiguous. Document the convention.

Exact shapes matter. A 401 should carry `WWW-Authenticate: Bearer realm="api", error="invalid_token"`; SDKs use that header to decide whether to refresh, redirect to login, or fail. A 403 should not carry `WWW-Authenticate`, because the credentials were accepted and the problem is authorization. Returning 401 without the header, or 403 after a token expires, produces refresh loops: the client either never refreshes or refreshes forever.

A real failure case: an API returns 403 for an expired access token. The web client sees 403, assumes the user lacks permission, shows "You do not have access", and never calls the refresh endpoint. Users are locked out roughly every token lifetime. The server-side fix is to check `exp` and return 401 with a code such as `token_expired`, so the client can distinguish expiry from bad credentials.

Deliberately returning 404 instead of 403 to hide another tenant's resource is a legitimate security decision. It moves the ambiguity into monitoring: dashboards show 404 spikes that are really authorization denials. Log the real reason server-side, keep the external body and timing identical to a genuine 404, and alert on the internal reason rather than the public status.

## 404 vs 410

`404 Not Found` means the server cannot find the resource. It may be temporary or intentionally hidden.

`410 Gone` means the resource existed and has been permanently removed. It tells clients and crawlers to stop requesting it.

Use 410 sparingly and only when you are sure the resource will not return. For most APIs, 404 is sufficient.

Edge cases decide the choice. A soft-deleted record should usually return 404, not 410, because the deletion may be reversible and clients should not permanently forget the URL. A 410 fits a retired endpoint or a resource with a documented lifecycle end. Both codes are cacheable by default, so a 410 for something that might come back needs an explicit `Cache-Control: no-store`; otherwise clients can pin the absence long after the resource returns.

Be careful with 410 during migrations. Crawlers drop URLs quickly, and restoring the content later means rebuilding authority from scratch. If the resource may return, use 404 or a 301 to its successor, and keep the old URL in the sitemap until the move is settled.

## 409 Conflict

`409 Conflict` means the request conflicts with the current state of the resource. Examples:

- Creating a user with an email that already exists.
- Updating a record with a stale version number.
- Deleting a resource that still has dependent records.

Include enough detail in the response for the client to resolve the conflict:

```json
{
  "error": "conflict",
  "field": "email",
  "message": "An account with this email already exists."
}
```

409 is about state, not syntax. The response should let the client resolve the conflict without guessing: include the conflicting field, the current version, or a link to the existing resource. For optimistic concurrency, pair 409 with an `ETag` and `If-Match` flow, and return 412 Precondition Failed when the version check fails at the HTTP level. Many teams use 409 for both; be explicit about which one clients should branch on.

A minimal reproduction: two clients read a record at version 7 and both write. The first succeeds; the second must not silently overwrite. Return `{"error":{"code":"version_conflict","currentVersion":8}}`, and make the client re-read, merge, and retry. If the second write returns 200, you have lost an update, and no error log will show it.

Idempotent creation needs a related rule. A retried POST carrying an `Idempotency-Key` should return the original result, not a fresh 409. If the same key arrives with a different payload, return 409 or 422 with a clear code; the client needs to know the retry was not a replay.

## 429 Too Many Requests

`429` means the client exceeded a rate limit. Include `Retry-After` with the number of seconds to wait:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 30
```

Clients should back off exponentially with jitter and honor `Retry-After`. Returning 403 or 503 for rate limits makes retry behavior ambiguous.

Two `Retry-After` forms are legal: a delay in seconds (`Retry-After: 30`) or an HTTP date (`Retry-After: Wed, 11 Nov 2026 10:00:00 GMT`). Clients that parse only one form either wait forever or ignore the hint. If the server cannot estimate a wait, omit the header rather than sending `0`, which triggers an immediate retry storm.

Add rate-limit metadata where possible: `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset`, or the older `X-RateLimit-*` names. Well-behaved clients then slow down before they are rejected. A common failure: a mobile app catches 429, retries three times immediately, and earns a longer ban. Exponential backoff with full jitter — sleep for a random duration between zero and `min(cap, base * 2^attempt)` — prevents thousands of clients from retrying in lockstep after an outage.

429 is wrong when a single connection or endpoint is throttled for capacity reasons; 503 with `Retry-After` describes service-level unavailability better. It is also wrong when a WAF rejected the request for policy reasons; that is 403. The distinction tells the client whether to wait or change the request.

## 500 vs 502 vs 503 vs 504

These are all server-side, but they mean different things:

| Code | Meaning | Typical cause |
| --- | --- | --- |
| 500 | Internal Server Error | Unhandled exception in the application |
| 502 | Bad Gateway | An upstream service returned an invalid response |
| 503 | Service Unavailable | The server is overloaded or in maintenance |
| 504 | Gateway Timeout | An upstream service did not respond in time |

The debugging path differs:

- 500: check application logs and stack traces.
- 502: check the proxy, load balancer, and upstream health.
- 503: check capacity, deployment state, and maintenance mode.
- 504: check upstream latency, timeouts, and connection pools.

A 502 or 504 often means the application never saw the request. Do not start by changing application code.

Timing and request IDs separate these quickly. If application logs have no entry for the request ID returned to the client, the request died in the proxy or never arrived: start at 502 or 504. If the log shows the handler started but never finished, look for a downstream timeout or a blocked worker. If the handler logged a stack trace, it is a genuine 500.

A concrete failure: a CDN returns 504 after 30 seconds while the origin keeps working and completes a payment 45 seconds later. The client retries and charges the customer twice. A gateway timeout does not cancel origin work, so any non-idempotent operation behind a timeout needs an idempotency key or a status endpoint. Blindly retrying every 504 is dangerous for the same reason.

Proxy-generated 502 and 503 responses often contain an HTML error page rather than your JSON error shape. Clients that assume JSON throw a parse error and lose the status context. Test the proxy path, not only the application, and configure the gateway to return JSON for API routes.

## 3xx and redirects

`301` and `308` are permanent; `302`, `303`, and `307` are temporary. For APIs, redirects are usually a configuration smell. If a client must follow a redirect for every request, fix the base URL instead.

Be careful with `307` and `308`: they preserve the method and body, while `303` changes the method to GET. A redirect that turns a POST into a GET can silently drop data.

`301` is cached aggressively by browsers, sometimes beyond the header's expiry, so use it only for moves you cannot reverse. `308` is the safer permanent redirect for non-GET APIs because it preserves the method. `303` is the correct response after a POST when the client should GET a result page.

Cross-origin redirects drop the `Authorization` header in most HTTP clients. If an API base URL redirects to another host, authenticated calls fail with 401 even though the token is valid; the fix is to update the base URL, not the auth code. Redirects also add a round trip per request when clients do not cache them, which is why API redirects are usually a configuration smell rather than a feature.

## Retry semantics

Retry only when the operation is safe to repeat:

- `GET`, `HEAD`, `PUT`, and `DELETE` are idempotent by definition.
- `POST` is not idempotent unless you add an idempotency key.

Retryable status codes:

```text
408 Request Timeout
425 Too Early
429 Too Many Requests
500 Internal Server Error
502 Bad Gateway
503 Service Unavailable
504 Gateway Timeout
```

Use exponential backoff with jitter, cap the number of attempts, and honor `Retry-After`. For non-idempotent requests, use an idempotency key so a retry cannot create duplicate resources.

Retry budgets matter more than status lists. A service that retries every 503 three times multiplies load by four during an incident, exactly when the backend can least afford it. Cap retries per request and per client, and add jitter so retries do not synchronize. For idempotent writes, prefer `PUT` with the full representation or a conditional request; for `POST`, send `Idempotency-Key: <uuid>` and have the server store the key and response for at least the retry window.

`425 Too Early` is for replay protection, not generic retries: it tells the client the request may have been replayed and it should retry after the TLS handshake or early-data window settles. Do not add it to a generic retry list without understanding the cause.

## Error body design

A useful error body contains:

- A stable machine-readable code.
- A human-readable message.
- The field or parameter that caused the error.
- A request identifier for support.
- Optional documentation link.

```json
{
  "error": {
    "code": "validation_error",
    "message": "The end date must be after the start date.",
    "field": "endDate",
    "requestId": "req_01JCH6X8Y4Q2V7M3N9K5R1T6B8"
  }
}
```

Do not put stack traces, secrets, or internal hostnames in the response.

Use a consistent media type. RFC 9457 defines `application/problem+json` with `type`, `title`, `status`, `detail`, and `instance`; adopting it gives clients and tooling a stable shape without inventing one. If you keep a custom envelope, keep the machine-readable code stable across versions because clients will branch on it, and treat messages as human-facing text that may change or be localized.

Include the request ID in both the body and the `X-Request-ID` header. When a user reports a failure, support can search logs by that ID instead of asking for a screenshot. Error bodies are frequently logged by third parties and shown in browser dev tools, so they are a poor place for anything sensitive.

## Debugging order

1. Read the status code class.
2. Read the response body and headers.
3. Reproduce with curl or the [HTTP request workbench](/tools/http-request).
4. For 4xx, inspect the request: method, URL, headers, body, auth.
5. For 5xx, inspect the server logs and upstream health.
6. Check whether a proxy, CDN, or gateway changed the response.
7. Check `Retry-After`, `WWW-Authenticate`, and CORS headers when relevant.

The [HTTP status lookup](/tools/http-status) provides the standard meaning and common causes for each code. The [header parser](/tools/http-headers) shows the response headers that often explain a failure.

Before trusting a status code, confirm you are looking at the right response. Browser dev tools show `(failed)` with status 0 for DNS, TLS, and CORS failures; no HTTP response exists, so no status code does either. `curl -v` shows whether a proxy answered, whether TLS succeeded, and whether a redirect was followed. Compare timing too: a fast 502 usually means connection refused upstream, a slow 504 means a timeout, and a 500 after two milliseconds is likely a routing or middleware bug.

Keep a verification checklist for any status-handling change: the client refreshes on 401 but not 403; retry middleware handles only the listed codes and honors `Retry-After`; monitoring separates 4xx from 5xx; caches do not store error responses unless intended; the error body still parses as JSON through the proxy; and the request ID appears in server logs.

## Consistency beats perfection

Different APIs draw the 400/422 and 401/403 lines differently. What matters most is consistency and documentation. A client should be able to predict the status class from the failure type, and an operator should be able to tell whether a retry will help.

Choose a convention, document it, and make the error body useful. The status code is the first signal; the body is where the fix begins.

A decision matrix keeps new endpoints consistent: malformed syntax leads to 400; valid syntax with invalid values to 422; missing or invalid credentials to 401; authenticated but unauthorized to 403; a state conflict to 409; a rate limit to 429; an unexpected server failure to 500; an invalid upstream response to 502; overload or maintenance to 503; and an upstream timeout to 504. Put the matrix in the API style guide and add contract tests that assert each branch, because conventions drift one handler at a time.

When REST purity conflicts with client behavior, favor the client. An internal API with one client can survive a slightly unusual code choice, but the ambiguity becomes expensive as soon as a second client, a proxy, or a retry layer appears. GraphQL and some RPC frameworks intentionally return 200 for application errors; that is a different contract, not a license to return 200 with errors in a REST API. Choose one model per surface, document it, and test the failure paths rather than only the happy path.

## Frequently asked questions

### What is the difference between 400 and 422?

400 Bad Request means the server cannot understand the request syntax or required parameters. 422 Unprocessable Content means the syntax is valid but the content fails semantic validation, such as a negative quantity or an end date before a start date.

### When should an API return 401 versus 403?

Return 401 when authentication is missing or invalid and the client should obtain credentials. Return 403 when the client is authenticated but lacks permission for the resource. Do not use 403 to hide missing authentication.

### Is 404 always the right response for a missing resource?

Usually, yes. Use 410 Gone when the resource existed and has been permanently removed, and 404 when the server does not want to reveal whether it exists. Be consistent within an API.

### Which status codes should be retried?

Retry 408, 425, 429, 500, 502, 503, and 504 with exponential backoff and jitter. Do not blindly retry non-idempotent requests. Honor Retry-After when the server provides it.

### Why is returning 200 with an error body a problem?

Clients, proxies, monitoring, and retry logic use the status code to decide what happened. A 200 with an error body looks successful to every layer except your application code, which hides failures and breaks observability.

## References

- [RFC 9110 - HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110)
- [MDN - HTTP response status codes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status)
- [RFC 6585 - Additional HTTP Status Codes](https://www.rfc-editor.org/rfc/rfc6585)
- [RFC 9111 - HTTP Caching](https://www.rfc-editor.org/rfc/rfc9111)

---

- Canonical: https://www.jsonmind.com/guides/http-status-codes-for-apis
- Locale: en
- Published: 2026-11-09
- Updated: 2026-11-09
- Tools: http-status, http-request, http-headers, cors-checker