API Status Code Best Practices
A practical guide to choosing the right HTTP status codes for REST APIs. Well-chosen status codes make APIs predictable, debuggable, and pleasant to work with.
Why Status Codes Matter for APIs
HTTP status codes are the primary signaling mechanism between an API server and its clients. When a client makes a request, the status code is the first piece of information it receives about the outcome. A well-designed API uses status codes to communicate precisely what happened: did the resource get created? Was the input invalid? Does the client need to authenticate? Is the server having a temporary issue?
Many API developers fall into the trap of returning 200 OK for everything and embedding the actual status in the response body. This anti-pattern breaks HTTP semantics, confuses monitoring tools, makes caching impossible, and forces every client to parse the response body just to determine if the request succeeded. HTTP already has a rich vocabulary of status codes designed for exactly this purpose. Use it.
This guide covers the status codes you will use most frequently when designing REST APIs, organized by the situations where you need them. For a complete reference of all codes, see our HTTP Status Code Cheat Sheet or use the interactive HTTP Status Code Reference tool.
CRUD Operations
The foundation of any REST API is CRUD (Create, Read, Update, Delete). Each operation has a natural mapping to HTTP methods and status codes. Following these conventions makes your API predictable to any developer who has worked with RESTful services before.
Create (POST)
When a client creates a new resource, the appropriate response depends on whether the creation is synchronous or asynchronous:
// Synchronous creation - resource is immediately available
// Status: 201 Created
// Include Location header pointing to the new resource
POST /api/users
Content-Type: application/json
{ "name": "Ada Lovelace", "email": "ada@example.com" }
HTTP/1.1 201 Created
Location: /api/users/42
Content-Type: application/json
{
"id": 42,
"name": "Ada Lovelace",
"email": "ada@example.com",
"createdAt": "2026-02-10T12:00:00Z"
}
// Asynchronous creation - resource will be created later
// Status: 202 Accepted
// Include a reference to check the status of the operation
POST /api/reports
Content-Type: application/json
{ "type": "annual", "year": 2025 }
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"jobId": "rpt-abc-123",
"status": "queued",
"statusUrl": "/api/reports/jobs/rpt-abc-123"
}Read (GET)
Successful reads return 200 OK with the resource in the body. There are a few important variations:
- Single resource found:
200 OKwith the resource object - Resource not found:
404 Not Found(not 200 with an empty body) - List endpoint with no results:
200 OKwith an empty array (the list itself exists, it just has zero items) - Conditional GET with no changes:
304 Not Modified(when the client sendsIf-None-MatchorIf-Modified-Since)
Update (PUT/PATCH)
For updates, the status code communicates what the server did with the change:
- Update with response body:
200 OKwith the updated resource - Update without response body:
204 No Content - Update created a new resource (upsert):
201 Created
Delete (DELETE)
- Successful deletion:
204 No Content(most common, nothing left to return) - Successful deletion with confirmation:
200 OKwith a confirmation message or the deleted resource - Resource does not exist: Some APIs return
404 Not Found, others return204(idempotent approach). Both are valid; choose one and be consistent.
Validation Errors
Validation is where many APIs make their biggest status code mistakes. There are two levels of validation, and they map to different status codes:
400 Bad Request: Structural Errors
Use 400 when the request is structurally invalid: malformed JSON, missing required headers, or request body that cannot be parsed. The client sent something that does not even make sense as a request.
// Malformed JSON in request body
POST /api/users
Content-Type: application/json
{ "name": "Ada", "email": } // Invalid JSON
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": "Bad Request",
"message": "Request body contains invalid JSON at position 32."
}422 Unprocessable Content: Semantic Errors
Use 422 when the request is structurally valid (well-formed JSON, correct content type) but the data fails business validation rules. The server understood the request but cannot process it because the contents are semantically incorrect.
// Valid JSON, but validation rules fail
POST /api/users
Content-Type: application/json
{ "name": "", "email": "not-an-email" }
HTTP/1.1 422 Unprocessable Content
Content-Type: application/json
{
"error": "Validation Failed",
"details": [
{ "field": "name", "message": "Name is required and cannot be empty." },
{ "field": "email", "message": "Must be a valid email address." }
]
}Some APIs use 400 for both structural and semantic errors. While this is not technically wrong, distinguishing between 400 and 422 gives clients more precise information. A 400 means "fix how you are sending the request" while a 422 means "fix the data in the request."
Authentication and Authorization
The distinction between 401 Unauthorized and 403 Forbidden is one of the most commonly confused aspects of HTTP status codes, especially relevant when working with tokens like JWTs. Use our JWT Decoder to inspect token claims when debugging auth issues.
401 Unauthorized: Identity Unknown
Return 401 when the request does not include valid authentication credentials. The name is misleading (it should have been called "Unauthenticated"), but the meaning is clear: the server does not know who the client is. The response should include a WWW-Authenticate header indicating the authentication scheme.
// No token provided
GET /api/profile
// (no Authorization header)
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api"
Content-Type: application/json
{
"error": "Unauthorized",
"message": "Authentication required. Provide a valid Bearer token."
}
// Expired or invalid token
GET /api/profile
Authorization: Bearer eyJ...expired...
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token"
Content-Type: application/json
{
"error": "Unauthorized",
"message": "Token has expired. Please re-authenticate."
}403 Forbidden: Insufficient Permissions
Return 403 when the client is authenticated (you know who they are) but they do not have permission to access the requested resource. Re-authenticating will not help; the user simply does not have the required role or permission.
// User is authenticated but not an admin
DELETE /api/admin/users/42
Authorization: Bearer eyJ...valid-user-token...
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"error": "Forbidden",
"message": "Admin role required to delete users."
}Security Consideration: 404 for Hidden Resources
In some cases, returning 403 reveals that a resource exists even though the user cannot access it. If this is a security concern (e.g., checking whether a private repository exists), return 404 instead. This is a deliberate choice that trades precision for security.
Rate Limiting
Use 429 Too Many Requests when a client exceeds your rate limit. Always include headers that help the client understand the limits and when they can retry:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1707580800
Content-Type: application/json
{
"error": "Too Many Requests",
"message": "Rate limit exceeded. Please retry after 60 seconds.",
"retryAfter": 60
}The Retry-After header is the most important: it tells the client exactly when to try again. The X-RateLimit-* headers (not standardized but widely adopted) provide additional context about the rate limit window.
Pagination
List endpoints that return paginated results should always return 200 OK, even when there are no results. The key is in the response body and headers:
GET /api/users?page=1&limit=20
HTTP/1.1 200 OK
Link: </api/users?page=2&limit=20>; rel="next",
</api/users?page=5&limit=20>; rel="last"
X-Total-Count: 97
Content-Type: application/json
{
"data": [...],
"pagination": {
"page": 1,
"limit": 20,
"totalPages": 5,
"totalCount": 97,
"hasNext": true,
"hasPrev": false
}
}
// Empty results - still 200, just with an empty data array
GET /api/users?page=1&limit=20&filter=role:superadmin
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": [],
"pagination": {
"page": 1,
"limit": 20,
"totalPages": 0,
"totalCount": 0,
"hasNext": false,
"hasPrev": false
}
}Conflict and Concurrency
Use 409 Conflict when the request cannot be completed because it conflicts with the current state of the resource. This is particularly useful for:
- Duplicate creation: A user tries to create a resource that already exists (e.g., registering with an email that is already taken)
- Optimistic locking failures: The resource was modified by another client between the read and the update attempt
- State machine violations: An order cannot be cancelled because it has already been shipped
// Duplicate email conflict
POST /api/users
Content-Type: application/json
{ "name": "Ada", "email": "ada@example.com" }
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"error": "Conflict",
"message": "A user with email ada@example.com already exists.",
"field": "email"
}
// Optimistic locking conflict (version mismatch)
PUT /api/documents/42
If-Match: "v3"
Content-Type: application/json
{ "title": "Updated Title" }
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"error": "Conflict",
"message": "Resource was modified. Current version is v5.",
"currentVersion": "v5"
}Common Anti-Patterns
Avoid these status code mistakes that make APIs harder to use and debug:
- 200 for everything: Returning 200 with
{"success": false, "error": "Not found"}instead of 404 breaks HTTP semantics and makes monitoring useless. HTTP clients, proxies, and monitoring tools all rely on status codes to understand response outcomes. - 500 for validation errors: If the client sent bad data, that is a 400 or 422, not a 500. A 500 tells the client "it is our fault" when actually the client needs to fix their request.
- 403 without authentication: If the client has not identified themselves, return 401 first. Only return 403 when you know who the user is and they lack permission.
- 200 for deletes with no body: If there is no response body after a deletion, return 204 No Content. A 200 implies there is a meaningful response body to read.
- Inconsistent status codes: The same outcome should produce the same status code across all endpoints. If one endpoint returns 404 for missing resources, they all should.
Error Response Format
Regardless of which status code you use, adopt a consistent error response format across your entire API. Here is a recommended structure:
{
"error": "Unprocessable Content", // HTTP status text
"message": "Human-readable summary", // For developers
"code": "VALIDATION_FAILED", // Machine-readable error code
"details": [ // Optional: field-level errors
{
"field": "email",
"message": "Must be a valid email address.",
"code": "INVALID_FORMAT"
}
],
"requestId": "req_abc123", // For support reference
"docs": "https://docs.api.com/errors/VALIDATION_FAILED" // Optional
}Validate and format your API's JSON responses using the JSON Formatter to ensure they are consistently structured and easy to read.
Further Reading
- RFC 9110 — HTTP Semantics
The current IETF specification for HTTP semantics and status codes.
- IANA HTTP Status Code Registry
Official registry of all assigned HTTP status codes.
- Microsoft REST API Guidelines
Microsoft's best practices for REST API design including status code usage.