Loading content...
HTTP status codes are the standardized vocabulary for communicating outcomes. When a client sends a request, the server's three-digit response code instantly conveys whether the operation succeeded, failed, or requires further action—before the client even parses the response body.
This standardization is powerful. A client library can implement retry logic for 503 (Service Unavailable) without knowing anything about your specific API. CDNs can cache 200 responses and invalidate on 201. Monitoring systems can alert on 5xx spikes without understanding your business logic.
But this power requires precision. Using 200 for everything (including errors) breaks these assumptions. Returning 404 for authorization failures confuses developers. Sending 500 for client input errors makes your API appear unstable.
Mastering status codes means choosing the most precise, semantically correct code for every situation—enabling clients to handle responses correctly without reading documentation for every endpoint.
By the end of this page, you will understand every major HTTP status code, when to use each one, how to design error responses, and common anti-patterns to avoid. You'll be able to communicate outcomes precisely and build APIs that are self-documenting through their status codes.
HTTP status codes are grouped into five categories, each indicating a different class of response:
| Range | Category | Meaning |
|---|---|---|
| 1xx | Informational | Request received, processing continues |
| 2xx | Success | Request successfully received, understood, and accepted |
| 3xx | Redirection | Further action needed to complete the request |
| 4xx | Client Error | Request contains bad syntax or cannot be fulfilled |
| 5xx | Server Error | Server failed to fulfill a valid request |
The first digit tells you the general outcome; the specific code provides nuance. A well-designed API uses the most specific applicable code, not just the generic 200/400/500.
The 4xx/5xx distinction is crucial: 4xx means the CLIENT made a mistake (fix your request); 5xx means the SERVER had a problem (retry later). Misclassifying a client error as a server error triggers unnecessary retries and makes your service appear unreliable. Misclassifying server errors as client errors causes clients to give up when retrying might succeed.
2xx codes indicate the request was successfully processed. The specific code communicates nuances about what happened and what the response contains.
| Code | Name | When to Use | Response Body |
|---|---|---|---|
| 200 | OK | General success with content | Required |
| 201 | Created | New resource created | Usually the created resource + Location header |
| 202 | Accepted | Request accepted for async processing | Status/tracking info |
| 204 | No Content | Success with nothing to return | Must be empty |
| 206 | Partial Content | Range request fulfilled | Requested range only |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
// 200 OK - Standard success with contentapp.get('/users/:id', async (req, res) => { const user = await userRepository.findById(req.params.id); if (!user) return res.status(404).json({ error: 'Not Found' }); res.status(200).json(user); // 200 is default, but explicit is clearer}); // 201 Created - Resource created, include Location headerapp.post('/users', async (req, res) => { const user = await userRepository.create(req.body); res.status(201) .header('Location', `/users/${user.id}`) .json({ id: user.id, name: user.name, createdAt: user.createdAt, _links: { self: { href: `/users/${user.id}` } } });}); // 202 Accepted - Async operation started (polling pattern)app.post('/reports/generate', async (req, res) => { const job = await jobQueue.enqueue('generate-report', req.body); res.status(202).json({ message: 'Report generation started', jobId: job.id, status: 'pending', estimatedTime: '30 seconds', checkStatus: `/jobs/${job.id}`, _links: { status: { href: `/jobs/${job.id}` }, cancel: { href: `/jobs/${job.id}`, method: 'DELETE' } } });}); // 204 No Content - Success, but nothing to returnapp.delete('/users/:id', async (req, res) => { await userRepository.delete(req.params.id); res.status(204).send(); // No body, no .json()}); // Also good for PUT/PATCH when client doesn't need echoapp.put('/settings', async (req, res) => { await settingsRepository.replace(req.user.id, req.body); res.status(204).send(); // Client sent the data, knows what it is}); // 206 Partial Content - Range requests (video streaming, large files)app.get('/videos/:id', async (req, res) => { const video = await videoRepository.findById(req.params.id); const range = req.headers.range; if (range) { const [start, end] = parseRange(range, video.size); const stream = createReadStream(video.path, { start, end }); res.status(206) .header('Content-Range', `bytes ${start}-${end}/${video.size}`) .header('Accept-Ranges', 'bytes') .header('Content-Length', end - start + 1) .header('Content-Type', 'video/mp4'); stream.pipe(res); } else { // Full file request res.sendFile(video.path); }});Always use 201 for resource creation, not 200. It signals that something new exists, enables clients to handle creation differently (e.g., redirect to the new resource), and the Location header tells clients exactly where to find it. 200 is for retrieving or modifying existing things.
3xx codes indicate the client must take additional action to complete the request—typically following a redirect to a different URI. These are essential for resource lifecycle management and maintaining stable API contracts.
Key distinction: Some redirects preserve the HTTP method; others change it to GET. This matters enormously for non-GET requests.
| Code | Name | Permanent? | Method After Redirect | Use Case |
|---|---|---|---|---|
| 301 | Moved Permanently | Yes | GET (changes) | URL permanently moved; update bookmarks |
| 302 | Found | No | GET (changes) | Temporary redirect (legacy, avoid) |
| 303 | See Other | No | GET (always) | Redirect after POST (PRG pattern) |
| 304 | Not Modified | N/A | N/A | Cache validation; use cached version |
| 307 | Temporary Redirect | No | Same as original | Temporary; preserve method |
| 308 | Permanent Redirect | Yes | Same as original | Permanent; preserve method |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
// 301 Moved Permanently - Resource has a new permanent home// Browsers/clients should update bookmarks and cachesapp.get('/api/v1/users/:id', (req, res) => { // Old API version, permanently redirect to v2 res.redirect(301, `/api/v2/users/${req.params.id}`);}); // 308 Permanent Redirect - Preserves HTTP method// Use this when moving PUT/POST/DELETE endpointsapp.all('/old-endpoint', (req, res) => { res.redirect(308, '/new-endpoint'); // If client sent POST, they'll POST to /new-endpoint}); // 303 See Other - After form submission (POST-Redirect-GET pattern)app.post('/orders', async (req, res) => { const order = await orderService.create(req.body); // Redirect to the created order (prevent refresh = resubmit) res.redirect(303, `/orders/${order.id}`); // Client will GET /orders/{id}, which is safe to refresh}); // 304 Not Modified - Conditional request, use cacheapp.get('/products/:id', async (req, res) => { const product = await productRepository.findById(req.params.id); const etag = generateETag(product); // Check if client's cached version is still valid if (req.headers['if-none-match'] === etag) { return res.status(304).send(); // Use your cached version } res.header('ETag', etag) .json(product);}); // 307 Temporary Redirect - Temporary, but preserve method// Use for maintenance, A/B testing, or temporary alternativesapp.all('/payments', (req, res) => { if (maintenanceMode) { // Temporarily redirect to backup payment processor res.redirect(307, 'https://backup.payments.example.com/process'); // Original POST stays POST } else { // Normal processing... }}); // Practical: Canonical URL redirectapp.get('/users/:id', async (req, res) => { const user = await userRepository.findById(req.params.id); if (!user) return res.status(404).json({ error: 'Not Found' }); // If accessed by numeric ID but we prefer slug const canonicalPath = `/users/${user.slug}`; if (req.path !== canonicalPath) { return res.redirect(301, canonicalPath); } res.json(user);});Use 308 (not 301) when permanently redirecting POST/PUT/DELETE endpoints. 301 historically changed the method to GET after redirect, breaking non-GET requests. 308 was introduced specifically to maintain the original method. For APIs, 308 is usually what you want for permanent redirects.
4xx codes indicate the client's request was invalid, unauthorized, or cannot be fulfilled. The client should NOT automatically retry—they need to fix the request first.
The critical 4xx codes every API uses:
| Code | Name | Meaning | Example Cause |
|---|---|---|---|
| 400 | Bad Request | Malformed request syntax or invalid data | Invalid JSON, wrong data types |
| 401 | Unauthorized | Authentication required or failed | Missing or invalid token |
| 403 | Forbidden | Authenticated but not permitted | Non-admin accessing admin route |
| 404 | Not Found | Resource doesn't exist at this URI | User ID doesn't exist |
| 405 | Method Not Allowed | HTTP method not supported for this resource | DELETE on read-only resource |
| 409 | Conflict | Request conflicts with current state | Duplicate email, version mismatch |
| 422 | Unprocessable Entity | Semantically invalid (well-formed but wrong) | Business rule violations |
| 429 | Too Many Requests | Rate limit exceeded | API quota exhausted |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
// 400 Bad Request - Malformed inputapp.post('/users', (req, res) => { // Check for syntactically invalid JSON (usually caught by middleware) // or missing required fields if (!req.body.email || !req.body.name) { return res.status(400).json({ error: 'BAD_REQUEST', message: 'Missing required fields', details: { email: !req.body.email ? 'Required' : undefined, name: !req.body.name ? 'Required' : undefined } }); }}); // 401 Unauthorized - Not authenticated// Always include WWW-Authenticate header per RFCapp.use('/protected/*', (req, res, next) => { if (!req.headers.authorization) { return res.status(401) .header('WWW-Authenticate', 'Bearer realm="api"') .json({ error: 'UNAUTHORIZED', message: 'Authentication required', authType: 'Bearer token' }); } next();}); // 403 Forbidden - Authenticated but not allowedapp.delete('/users/:id', requireAuth, async (req, res) => { const targetUser = await userRepository.findById(req.params.id); // User exists but requester can't delete them if (req.user.role !== 'admin' && req.user.id !== req.params.id) { return res.status(403).json({ error: 'FORBIDDEN', message: 'You do not have permission to delete this user', required: 'admin role or own account' }); } await userRepository.delete(req.params.id); res.status(204).send();}); // 404 Not Found - Resource doesn't existapp.get('/users/:id', async (req, res) => { const user = await userRepository.findById(req.params.id); if (!user) { return res.status(404).json({ error: 'NOT_FOUND', message: `User with ID '${req.params.id}' not found`, resource: 'User', identifier: req.params.id }); } res.json(user);}); // 409 Conflict - State conflictapp.post('/users', async (req, res) => { const existing = await userRepository.findByEmail(req.body.email); if (existing) { return res.status(409).json({ error: 'CONFLICT', message: 'A user with this email already exists', field: 'email', conflictingResource: `/users/${existing.id}` }); } // ... create user}); // 422 Unprocessable Entity - Valid syntax but semantic errorsapp.post('/orders', async (req, res) => { // JSON is valid, but business rules violated const errors = []; if (req.body.quantity < 1) { errors.push({ field: 'quantity', message: 'Must be at least 1' }); } if (req.body.discount > req.body.subtotal) { errors.push({ field: 'discount', message: 'Cannot exceed subtotal' }); } if (errors.length > 0) { return res.status(422).json({ error: 'VALIDATION_FAILED', message: 'Request failed validation', validationErrors: errors }); }}); // 429 Too Many Requests - Rate limitingapp.use(rateLimit({ windowMs: 60 * 1000, // 1 minute max: 100, handler: (req, res) => { res.status(429) .header('Retry-After', '60') .header('X-RateLimit-Limit', '100') .header('X-RateLimit-Remaining', '0') .header('X-RateLimit-Reset', Math.ceil(Date.now() / 1000) + 60) .json({ error: 'RATE_LIMITED', message: 'Too many requests, please slow down', retryAfter: 60 }); }}));401 means 'I don't know who you are' (authenticate). 403 means 'I know who you are, but you can't do this' (authorize). If re-authenticating could help, use 401. If the user simply doesn't have permission regardless of authentication, use 403.
5xx codes indicate the server failed to fulfill a valid request. The client did everything right, but something on the server side broke. Clients SHOULD retry (with backoff), and operators should investigate.
5xx codes trigger alerts. Use them only for genuine server failures—never for client errors the server couldn't prevent.
| Code | Name | Meaning | Retry? |
|---|---|---|---|
| 500 | Internal Server Error | Unexpected condition; catch-all for bugs | Yes (with backoff) |
| 501 | Not Implemented | Server doesn't support this functionality | No |
| 502 | Bad Gateway | Upstream server returned invalid response | Yes |
| 503 | Service Unavailable | Server temporarily overloaded/down | Yes (use Retry-After) |
| 504 | Gateway Timeout | Upstream server timed out | Yes |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
// 500 Internal Server Error - Unexpected failures// This should be your global error handlerapp.use((err: Error, req: Request, res: Response, next: NextFunction) => { // Log full error for debugging (never expose to client) console.error('Unhandled error:', { message: err.message, stack: err.stack, requestId: req.id, path: req.path, method: req.method }); // Return safe error to client res.status(500).json({ error: 'INTERNAL_ERROR', message: 'An unexpected error occurred', requestId: req.id, // For support reference // NEVER include: err.message, err.stack, internal details });}); // 502 Bad Gateway - Upstream service returned garbageasync function callPaymentService(data: PaymentRequest) { try { const response = await fetch('https://payments.internal/process', { method: 'POST', body: JSON.stringify(data) }); if (!response.ok) { // Upstream returned error throw new UpstreamError(`Payment service returned ${response.status}`); } const result = await response.json(); return result; } catch (error) { if (error instanceof UpstreamError) { throw { status: 502, body: { error: 'BAD_GATEWAY', message: 'Payment service returned an invalid response', service: 'payments' } }; } throw error; }} // 503 Service Unavailable - Intentional downtime or overloadapp.use('/api', (req, res, next) => { if (maintenanceMode) { const maintenanceEnd = new Date('2025-01-08T02:00:00Z'); const retryAfter = Math.ceil((maintenanceEnd.getTime() - Date.now()) / 1000); return res.status(503) .header('Retry-After', String(retryAfter)) .json({ error: 'SERVICE_UNAVAILABLE', message: 'Service is undergoing scheduled maintenance', estimatedEnd: maintenanceEnd.toISOString(), retryAfter: retryAfter }); } // Circuit breaker: shed load when overwhelmed if (healthCheck.isOverloaded()) { return res.status(503) .header('Retry-After', '30') .json({ error: 'SERVICE_UNAVAILABLE', message: 'Service is temporarily overloaded', retryAfter: 30 }); } next();}); // 504 Gateway Timeout - Upstream didn't respond in timeasync function fetchWithTimeout(url: string, timeoutMs: number) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { signal: controller.signal }); return response; } catch (error) { if (error.name === 'AbortError') { throw { status: 504, body: { error: 'GATEWAY_TIMEOUT', message: 'Upstream service did not respond in time', timeout: timeoutMs } }; } throw error; } finally { clearTimeout(timeout); }} // 501 Not Implemented - We don't support thisapp.all('/legacy-xml-api', (req, res) => { res.status(501).json({ error: 'NOT_IMPLEMENTED', message: 'XML API is no longer supported', alternatives: ['/api/v2 (JSON)'], documentation: 'https://docs.example.com/migration' });});When returning 500 errors, NEVER include stack traces, database queries, internal paths, or error messages from dependencies. These are security risks and confuse end users. Log everything internally, but return only safe, generic messages to clients with a request ID for support reference.
Status codes alone aren't enough. Clients need structured error responses to handle failures gracefully, show meaningful messages to users, and enable debugging. A well-designed error response includes:
VALIDATION_FAILED)Consistency is paramount. Every error response in your API should follow the same structure.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
// Standardized error response schemainterface APIError { // Always present error: string; // Machine-readable error code message: string; // Human-readable explanation // Optional but recommended requestId?: string; // For support/debugging timestamp?: string; // When the error occurred path?: string; // What endpoint was called // Context-specific details details?: Record<string, any>; // Additional information // For validation errors validationErrors?: Array<{ field: string; message: string; code?: string; value?: any; }>; // For rate limiting retryAfter?: number; // For help documentation?: string; // Link to relevant docs hint?: string; // Suggestion for fixing} // Examples of well-structured error responses: // 400 Bad Request - Malformed JSON{ "error": "INVALID_JSON", "message": "Request body contains invalid JSON", "requestId": "req_abc123def456", "details": { "position": 47, "issue": "Unexpected token at position 47" }} // 401 Unauthorized{ "error": "TOKEN_EXPIRED", "message": "Your authentication token has expired", "requestId": "req_abc123def456", "hint": "Obtain a new token via POST /auth/token", "documentation": "https://docs.example.com/auth"} // 403 Forbidden{ "error": "PERMISSION_DENIED", "message": "You do not have permission to delete this resource", "requestId": "req_abc123def456", "details": { "requiredRole": "admin", "yourRole": "user", "resource": "User", "action": "delete" }} // 404 Not Found{ "error": "RESOURCE_NOT_FOUND", "message": "User with ID 'usr_12345' not found", "requestId": "req_abc123def456", "details": { "resourceType": "User", "identifier": "usr_12345" }} // 422 Validation Error - Multiple field errors{ "error": "VALIDATION_FAILED", "message": "Request validation failed", "requestId": "req_abc123def456", "validationErrors": [ { "field": "email", "message": "Invalid email format", "code": "INVALID_FORMAT", "value": "not-an-email" }, { "field": "age", "message": "Must be at least 18", "code": "MIN_VALUE", "value": 16 }, { "field": "password", "message": "Must contain at least one uppercase letter", "code": "WEAK_PASSWORD" } ]} // 429 Rate Limited{ "error": "RATE_LIMIT_EXCEEDED", "message": "You have exceeded your API rate limit", "requestId": "req_abc123def456", "retryAfter": 45, "details": { "limit": 100, "window": "1 minute", "remaining": 0, "resetAt": "2025-01-08T12:01:00Z" }} // 500 Internal Error (safe for client){ "error": "INTERNAL_ERROR", "message": "An unexpected error occurred. Please try again or contact support.", "requestId": "req_abc123def456", "timestamp": "2025-01-08T12:00:00Z", "support": "support@example.com"}Use SCREAMING_SNAKE_CASE for error codes (easier to grep in logs). Make codes specific: 'EMAIL_ALREADY_EXISTS' is better than 'CONFLICT'. Document all error codes in your API reference. Let clients programmatically handle errors based on codes, not message text (which may change).
These mistakes are common but undermine API usability::
{"success": false} breaks HTTP semantics. Clients can't use status for control flow.{"error": "An error occurred"} is useless for debugging. Be specific.12345678910111213
// ❌ WRONG: 200 with error in body HTTP/1.1 200 OK { "success": false, "error": "User not found"} // Problems:// - Caches may store this// - Clients can't trust 200// - Monitoring blind to errors1234567891011121314
// ✅ CORRECT: Proper status code HTTP/1.1 404 Not Found { "error": "RESOURCE_NOT_FOUND", "message": "User not found", "identifier": "usr_12345"} // Benefits:// - Standard HTTP handling// - Cacheable correctly// - Monitoring worksUse this decision tree to select the appropriate status code:
123456789101112131415161718192021222324252627282930313233343536373839
Was the request successful?│├── YES: Did it create something new?│ ├── YES → 201 Created (+ Location header)│ └── NO: Is there content to return?│ ├── YES → 200 OK│ └── NO → 204 No Content│└── NO: Where is the problem? │ ├── CLIENT PROBLEM (4xx): │ │ │ ├── Request malformed/invalid? → 400 Bad Request │ │ │ ├── Authentication issue? │ │ ├── Missing/invalid credentials → 401 Unauthorized │ │ └── Valid credentials, no permission → 403 Forbidden │ │ │ ├── Resource not found? → 404 Not Found │ │ │ ├── Method not allowed? → 405 Method Not Allowed │ │ │ ├── State conflict? (duplicate, version mismatch) → 409 Conflict │ │ │ ├── Business rule violation? → 422 Unprocessable Entity │ │ │ └── Rate limited? → 429 Too Many Requests │ └── SERVER PROBLEM (5xx): │ ├── Unexpected bug/crash? → 500 Internal Server Error │ ├── Feature not implemented? → 501 Not Implemented │ ├── Upstream service error? → 502 Bad Gateway │ ├── Service overloaded/maintenance? → 503 Service Unavailable │ └── Upstream timeout? → 504 Gateway Timeout| Scenario | Status Code |
|---|---|
| GET succeeded | 200 |
| POST created resource | 201 |
| DELETE succeeded | 204 |
| JSON syntax error | 400 |
| No auth header | 401 |
| Invalid token | 401 |
| User lacks role | 403 |
| Resource doesn't exist | 404 |
| Duplicate email | 409 |
| Validation failed | 422 |
| Too many requests | 429 |
| Unhandled exception | 500 |
| Database down | 503 |
Status codes are the universal language of API outcomes. Using them correctly enables automatic error handling, caching, and monitoring. Let's consolidate the key principles:
What's Next:
With the foundation of REST principles, resource design, HTTP methods, and status codes complete, we'll explore HATEOAS (Hypermedia as the Engine of Application State)—the most advanced (and often misunderstood) aspect of REST that enables truly self-descriptive, discoverable APIs.
You now understand HTTP status codes deeply—when to use each code, how to structure error responses, and common anti-patterns to avoid. Your APIs will communicate outcomes clearly and enable proper client behavior. Next, we'll explore the final piece of REST: HATEOAS.