Status codes are one of the most underrated parts of API design. I have seen APIs that return 200 OK for everything, even errors, which forces every client to parse the response body just to figure out what actually happened. In this article, I want to walk through exactly how I choose HTTP status codes, category by category, so my APIs communicate clearly using the protocol itself, not just the response body.
Why Status Codes Matter
A status code tells the client, at a glance, what kind of outcome just happened, before it even looks at the response body. Good status code usage means:
- Clients can build generic error handling based on status code ranges
- Monitoring tools and load balancers can detect problems automatically
- Caching layers know whether a response is cacheable
- Developers debugging an issue immediately know where to look
The Five Categories of Status Codes
I always remember status codes by their first digit:
- 1xx — Informational (rarely used directly in API design)
- 2xx — Success
- 3xx — Redirection
- 4xx — Client error (the caller did something wrong)
- 5xx — Server error (something broke on my side)
Let me go through the ones I actually use in real APIs.
2xx: Success Codes
200 OK
My default success code for GET, PUT, and PATCH requests that return data.
GET /users/45
200 OK
201 Created
I use this specifically after a successful POST that creates a new resource. I always include a Location header pointing to the new resource.
POST /orders
201 Created
Location: /orders/902
202 Accepted
I use this when a request has been accepted for processing but is not finished yet — common for asynchronous operations like report generation or batch jobs.
POST /reports/generate
202 Accepted
204 No Content
I use this when the request succeeded but there is nothing meaningful to return in the body — typically for DELETE requests, or for PUT/PATCH requests where I don’t need to send the updated resource back.
DELETE /orders/902
204 No Content
3xx: Redirection Codes
These come up less often in typical REST APIs, but I still use them in specific cases:
301 Moved Permanently / 308 Permanent Redirect
I use these when a resource has permanently moved to a new URL, for example after a major API restructuring.
304 Not Modified
I use this alongside caching headers like ETag or If-None-Match, so clients can avoid re-downloading data that has not changed.
4xx: Client Error Codes
This is the category I spend the most time thinking about, because getting these right dramatically improves the developer experience of an API.
400 Bad Request
My general-purpose code for malformed input — invalid JSON, missing required fields, or values that fail basic validation.
{
"error": "invalid_request",
"message": "The 'email' field is required."
}
401 Unauthorized
I use this when the request has no valid authentication credentials at all, or the credentials provided are invalid or expired. Despite the name, this is really about authentication, not authorization.
403 Forbidden
I use this when the client is properly authenticated, but they are not allowed to perform this specific action. This is about authorization, not authentication.
I always keep this distinction clear in my head:
401= “I don’t know who you are, or your credentials are invalid.”403= “I know who you are, but you’re not allowed to do this.”
404 Not Found
I use this when the requested resource simply does not exist.
GET /users/99999
404 Not Found
405 Method Not Allowed
I use this when the resource exists, but the HTTP method used is not supported on it — for example, sending DELETE to a read-only resource.
409 Conflict
I use this when the request conflicts with the current state of the resource — for example, trying to create a user with an email that already exists, or updating a resource that has been modified since the client last fetched it.
410 Gone
I use this instead of 404 when a resource used to exist but has been intentionally and permanently removed. This is especially useful for deprecated endpoints during a migration.
422 Unprocessable Entity
I use this when the request is syntactically correct (valid JSON, right structure) but fails business-level validation — for example, an end date that comes before a start date.
429 Too Many Requests
I use this when a client has hit a rate limit. I always pair it with a Retry-After header so the client knows exactly when to try again.
429 Too Many Requests
Retry-After: 30
5xx: Server Error Codes
500 Internal Server Error
My generic fallback when something unexpected breaks on my side and I have no more specific code to return. I never expose internal stack traces or sensitive debugging information in this response.
502 Bad Gateway
I use this when my server, acting as a gateway, receives an invalid response from an upstream service it depends on.
503 Service Unavailable
I use this when the server is temporarily unable to handle requests — during maintenance, overload, or a dependency outage. I often pair this with a Retry-After header too.
504 Gateway Timeout
I use this when an upstream service my API depends on takes too long to respond.
My Personal Decision Process
When I am not sure which status code to use, I ask myself these questions in order:
- Did the request succeed? → Choose from the 2xx range based on what happened (created, updated, no content, etc.)
- Is the client asking for something that requires a redirect? → 3xx
- Is the problem caused by the client? → Look through the 4xx codes and pick the most specific one that fits (auth, validation, conflict, rate limit, not found)
- Is the problem caused by my server or a dependency? → Look through the 5xx codes
I always try to pick the most specific code available, rather than defaulting to generic ones like 400 or 500 for everything.
A Real Example: Designing Status Codes for a Signup Endpoint
POST /users
201 Created -> user created successfully
400 Bad Request -> missing required fields, malformed JSON
409 Conflict -> email address already registered
422 Unprocessable Entity -> valid JSON, but password does not meet complexity rules
429 Too Many Requests -> too many signup attempts from this IP
500 Internal Server Error -> unexpected failure on my end
Notice how each failure mode gets its own distinct, meaningful code, instead of lumping everything into a single 400 or 500.
Common Mistakes I See
- Returning
200 OKeven when an error occurred, with the actual error hidden inside the response body - Using
500for validation errors that are really the client’s fault - Using
404for authorization failures (this can sometimes be intentional for security reasons, but it should be a deliberate choice, not an accident) - Forgetting to add a
Retry-Afterheader on429and503responses - Not being consistent — using
400for missing fields in one endpoint and422in another
Final Thoughts
Status codes are a language of their own, built directly into HTTP. When I choose them carefully and consistently, my API becomes self-explanatory at the protocol level, which makes life easier for every developer who has to build against it, debug it, or monitor it in production.