6 min read
On this page

Status Codes & Headers

HTTP status codes and headers are not decoration. They are the API's signaling mechanism. The status code tells the client what happened. The headers tell the client what to do next. Using them correctly means clients can handle responses programmatically without parsing error message strings. Using them incorrectly means every integration is a special case.

Status Codes That Matter

There are dozens of HTTP status codes. In practice, you need about a dozen. The rest are either obscure or misused.

2xx: Success

200 OK — the request succeeded. Use for successful GET, PATCH, and DELETE responses.

GET /users/123
200 OK

{
  "id": "usr_123",
  "name": "Jane Doe",
  "email": "jane@example.com"
}

201 Created — a new resource was created. Use for successful POST responses. Include a Location header pointing to the new resource.

POST /users
201 Created
Location: /users/usr_456

{
  "id": "usr_456",
  "name": "John Smith",
  "email": "john@example.com"
}

204 No Content — the request succeeded but there is no body to return. Use for DELETE when you do not return the deleted object, or for PUT/PATCH when the client does not need the updated representation.

DELETE /users/usr_456
204 No Content

4xx: Client Errors

400 Bad Request — the request is malformed. The JSON is invalid, a required field is the wrong type, or the request body cannot be parsed at all.

{
  "error": {
    "type": "invalid_request_error",
    "message": "Request body is not valid JSON. Unexpected token at position 42."
  }
}

401 Unauthorized — the client did not provide valid authentication credentials. The name is misleading — this is about authentication, not authorization.

{
  "error": {
    "type": "authentication_error",
    "message": "No valid API key provided."
  }
}

403 Forbidden — the client is authenticated but does not have permission to perform this action. The difference from 401: with 401, re-authenticating might help. With 403, it will not.

{
  "error": {
    "type": "authorization_error",
    "message": "Your API key does not have permission to delete customers."
  }
}

404 Not Found — the resource does not exist. Also use when the resource exists but the authenticated user does not have access (to avoid leaking information about resource existence).

409 Conflict — the request conflicts with the current state of the resource. Common for concurrent modifications or business rule violations.

{
  "error": {
    "type": "conflict_error",
    "message": "This order has already been shipped and cannot be canceled.",
    "code": "order_already_shipped"
  }
}

422 Unprocessable Entity — the request is well-formed JSON, but the content fails validation. The difference from 400: a 400 means the request could not be parsed; a 422 means it was parsed but the values are invalid.

Stripe uses 422 for validation errors:

{
  "error": {
    "type": "invalid_request_error",
    "code": "parameter_invalid_integer",
    "param": "amount",
    "message": "Invalid integer: abc. The amount must be an integer representing cents."
  }
}

429 Too Many Requests — the client has exceeded the rate limit. Include a Retry-After header telling the client when to retry.

429 Too Many Requests
Retry-After: 30

{
  "error": {
    "type": "rate_limit_error",
    "message": "Rate limit exceeded. Please wait 30 seconds before retrying."
  }
}

5xx: Server Errors

500 Internal Server Error — something broke on the server. The client did nothing wrong. Do not expose stack traces or internal details — log them server-side and return a generic message with a request ID for debugging.

{
  "error": {
    "type": "api_error",
    "message": "An internal error occurred. Please contact support with request ID req_abc123.",
    "request_id": "req_abc123"
  }
}

502 Bad Gateway — the server, acting as a gateway, received an invalid response from an upstream service. Common behind load balancers and API gateways.

503 Service Unavailable — the server is temporarily down (maintenance, overloaded). Include Retry-After if you know when the service will be back.

The Status Code Decision Tree

Did the request succeed?
  Yes -> Is a resource being created?
    Yes -> 201 Created
    No  -> Is there a response body?
      Yes -> 200 OK
      No  -> 204 No Content
  No  -> Is it the client's fault?
    Yes -> Is it an authentication problem?
      Yes -> 401 Unauthorized
      No  -> Is it a permissions problem?
        Yes -> 403 Forbidden
        No  -> Does the resource not exist?
          Yes -> 404 Not Found
          No  -> Is it a rate limit?
            Yes -> 429 Too Many Requests
            No  -> Is the request unparseable?
              Yes -> 400 Bad Request
              No  -> Is the content invalid?
                Yes -> 422 Unprocessable Entity
                No  -> Is there a state conflict?
                  Yes -> 409 Conflict
    No  -> 500 Internal Server Error (or 502/503 if appropriate)

Headers That Matter

Request Headers

Content-Type — tells the server the format of the request body. Almost always application/json for modern APIs.

Content-Type: application/json

Accept — tells the server what format the client wants in the response. If you only support JSON, return JSON regardless, but respect this header if you support multiple formats.

Accept: application/json

Authorization — carries authentication credentials. The format depends on the auth scheme:

Authorization: Bearer sk_live_abc123           # OAuth/API key
Authorization: Basic dXNlcjpwYXNz             # Basic auth (base64)

Idempotency-Key — for safe retries of POST requests. Stripe pioneered this pattern:

Idempotency-Key: unique-request-id-456

Response Headers

Location — the URL of a newly created resource. Include with 201 responses:

HTTP/1.1 201 Created
Location: https://api.example.com/orders/ord_789

ETag — a version identifier for caching. The client sends it back in If-None-Match to check if the resource has changed:

# First request
GET /users/123
ETag: "v1-abc123"

# Subsequent request
GET /users/123
If-None-Match: "v1-abc123"

# If unchanged:
304 Not Modified

# If changed:
200 OK
ETag: "v2-def456"

GitHub uses ETags extensively. Their API enforces rate limits, and conditional requests (using If-None-Match) do not count against the limit.

Retry-After — tells the client when to retry after a 429 or 503. Value is seconds:

Retry-After: 60

X-Request-Id — a unique identifier for the request, useful for debugging. Include in every response:

X-Request-Id: req_abc123def456

When a client reports an issue, the request ID lets you find the exact log entry. Stripe, Twilio, and GitHub all include request IDs in every response.

RateLimit headers — communicate rate limit status. There is no universal standard, but this pattern is common:

X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 742
X-RateLimit-Reset: 1677000000

GitHub uses these headers on every response so clients can proactively throttle before hitting the limit.

Content Negotiation

The Content-Type and Accept headers enable content negotiation — the client and server agree on the data format. For most APIs, this is always JSON and content negotiation is irrelevant. But if you support multiple formats:

# Client requests JSON
Accept: application/json

# Client requests XML (if supported)
Accept: application/xml

# Server cannot produce the requested format
406 Not Acceptable

In practice, return JSON and document it. Do not add XML support unless you have a specific customer requirement.

Consistent Error Format

Every error response should follow the same structure. Stripe's error format is a good model:

{
  "error": {
    "type": "invalid_request_error",
    "code": "parameter_missing",
    "param": "currency",
    "message": "Missing required param: currency.",
    "doc_url": "https://stripe.com/docs/error-codes/parameter-missing"
  }
}

The fields:

  • type — a machine-readable category (authentication_error, invalid_request_error, rate_limit_error, api_error)
  • code — a machine-readable specific error (parameter_missing, parameter_invalid, resource_not_found)
  • param — the specific field that caused the error (when applicable)
  • message — a human-readable explanation
  • doc_url — a link to documentation about this error

This format serves both machines (type and code for programmatic handling) and humans (message and doc_url for debugging).

Common Pitfalls

  • 200 for everything — returning 200 OK with {"success": false, "error": "Not found"}. This forces clients to parse the body to determine if the request succeeded. Status codes exist for this purpose.
  • 500 for client errors — returning 500 when the client sends invalid input. This triggers server error alerts and makes it impossible to distinguish client bugs from server bugs in monitoring.
  • 401 vs 403 confusion — using 401 when the user is authenticated but lacks permissions. 401 means "who are you?" 403 means "I know who you are, and the answer is no."
  • Missing request IDs — when a client reports "I got a 500 error," the first question is "what was the request ID?" Without it, debugging requires timestamp correlation across logs.
  • Inconsistent error format — some endpoints return {"error": "message"}, others return {"message": "error"}, and others return {"errors": ["message"]}. Pick one format and enforce it everywhere.
  • Ignoring Retry-After — returning 429 without telling the client when to retry. The client has to guess, usually with exponential backoff. A Retry-After header saves both sides time.
  • Exposing internal errors — returning stack traces, SQL errors, or internal service names in 500 responses. These leak implementation details and are useless to the client. Log them server-side, return a generic message.

Key Takeaways

  • Use the right status code for the situation. 200 for success, 201 for creation, 400 for bad requests, 401 for authentication failures, 403 for permission failures, 404 for missing resources, 422 for validation errors, 429 for rate limits, 500 for server errors.
  • Include a Location header with 201 responses and a Retry-After header with 429 responses.
  • Return a X-Request-Id (or Request-Id) header with every response. It is the single most useful header for debugging production issues.
  • Use ETags for caching. Conditional requests save bandwidth and reduce server load.
  • Standardize your error response format across every endpoint. Include a machine-readable type/code, a human-readable message, and the specific field that caused the error.
  • Never return 200 with an error in the body. Never return 500 for client mistakes. Status codes have meaning — use them.