API Error Handling: Status Codes & Error Objects 2026
How to Handle API Errors: Status Codes and Error Objects
Bad error handling is the number one developer experience complaint about APIs. Generic "Something went wrong" messages waste debugging time. Missing status codes break client error handling. Inconsistent error formats require per-endpoint error parsing. Here's how to handle errors properly on both sides.
The stakes are higher than they appear. A developer integrating your API will encounter an error within the first 30 minutes — it's nearly inevitable during the setup and testing phase. How your API responds to that first error determines whether they can self-serve a fix or have to email support. Clear, actionable error messages with machine-readable codes and links to documentation turn a frustrating experience into a solvable one. The APIs with the best developer reputations (Stripe, Twilio, GitHub) all invest heavily in their error response design.
HTTP Status Codes: The Complete Guide
2xx — Success
| Code | Name | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH, DELETE with response body |
| 201 | Created | Successful POST that creates a resource (include Location header) |
| 202 | Accepted | Request accepted for async processing (not yet completed) |
| 204 | No Content | Successful DELETE or PUT with no response body |
4xx — Client Errors
| Code | Name | When to Use |
|---|---|---|
| 400 | Bad Request | Malformed request (invalid JSON, missing required fields) |
| 401 | Unauthorized | Missing or invalid authentication credentials |
| 403 | Forbidden | Authenticated but not authorized for this resource/action |
| 404 | Not Found | Resource doesn't exist at this URL |
| 405 | Method Not Allowed | HTTP method not supported for this endpoint |
| 409 | Conflict | Resource state conflict (duplicate, version mismatch) |
| 410 | Gone | Resource existed but has been permanently deleted |
| 422 | Unprocessable Entity | Request is well-formed but semantically invalid |
| 429 | Too Many Requests | Rate limit exceeded (include Retry-After header) |
5xx — Server Errors
| Code | Name | When to Use |
|---|---|---|
| 500 | Internal Server Error | Unexpected server failure (bug, unhandled exception) |
| 502 | Bad Gateway | Upstream service returned an invalid response |
| 503 | Service Unavailable | Server is temporarily overloaded or in maintenance |
| 504 | Gateway Timeout | Upstream service didn't respond in time |
The Minimum Set
If you only use a few, use these: 200, 201, 204, 400, 401, 403, 404, 409, 422, 429, 500, 503.
Error Response Format
The Standard Error Object
{
"error": {
"type": "validation_error",
"code": "invalid_parameter",
"message": "The 'email' field must be a valid email address.",
"param": "email",
"request_id": "req_abc123def456",
"doc_url": "https://api.example.com/docs/errors#invalid_parameter"
}
}
With Multiple Validation Errors
{
"error": {
"type": "validation_error",
"message": "Request validation failed.",
"errors": [
{
"field": "email",
"code": "invalid_format",
"message": "Must be a valid email address."
},
{
"field": "password",
"code": "too_short",
"message": "Must be at least 8 characters.",
"metadata": { "min_length": 8, "actual_length": 5 }
}
],
"request_id": "req_abc123"
}
}
Essential Fields
| Field | Purpose | Required |
|---|---|---|
type | Error category (validation_error, authentication_error, rate_limit_error) | Yes |
code | Machine-readable error code (invalid_parameter, not_found, rate_limited) | Yes |
message | Human-readable explanation | Yes |
request_id | Unique ID for debugging and support | Yes |
param / field | Which parameter/field caused the error | For validation errors |
doc_url | Link to documentation about this error | Recommended |
metadata | Additional context (limits, allowed values, etc.) | Optional |
How Top APIs Handle Errors
Stripe
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "Your card has insufficient funds.",
"param": "source",
"charge": "ch_abc123"
}
}
Stripe's errors include the relevant object ID (charge), decline codes, and the specific parameter that caused the error.
GitHub
{
"message": "Validation Failed",
"errors": [
{
"resource": "Issue",
"field": "title",
"code": "missing_field"
}
],
"documentation_url": "https://docs.github.com/rest"
}
GitHub includes the resource type and a documentation URL.
Twilio
{
"code": 20003,
"message": "Permission denied",
"more_info": "https://www.twilio.com/docs/errors/20003",
"status": 403
}
Twilio uses numeric error codes with a direct link to detailed error documentation.
Server-Side Error Handling Implementation
// Centralized error handler middleware (Express)
import { Request, Response, NextFunction } from 'express';
import { ZodError } from 'zod';
interface AppError extends Error {
statusCode?: number;
code?: string;
}
export function errorHandler(
err: AppError,
req: Request,
res: Response,
_next: NextFunction,
) {
const requestId = req.headers['x-request-id'] as string ?? crypto.randomUUID();
// Zod validation errors
if (err instanceof ZodError) {
return res.status(422).json({
error: {
type: 'validation_error',
code: 'invalid_request',
message: 'Request validation failed.',
request_id: requestId,
errors: err.errors.map(e => ({
field: e.path.join('.'),
code: e.code,
message: e.message,
})),
},
});
}
// Known application errors
if (err.statusCode) {
return res.status(err.statusCode).json({
error: {
type: err.name ?? 'api_error',
code: err.code ?? 'error',
message: err.message,
request_id: requestId,
},
});
}
// Unknown errors — log full detail, return generic response
console.error({ requestId, error: err.message, stack: err.stack });
return res.status(500).json({
error: {
type: 'internal_error',
code: 'internal_server_error',
message: 'An unexpected error occurred.',
request_id: requestId,
},
});
}
The key insight: the requestId appears in both the error response body and your server logs. When a user reports a problem and provides their request ID (from your error message or your app's error UI), you can immediately find the full server-side log entry for that specific request.
Client-Side Error Handling
The Error Handling Hierarchy
- Network errors — no response received (timeout, DNS failure, connection refused)
- HTTP errors — response received with error status code
- Application errors — 200 response but with error in the body (some APIs do this)
Pattern: Retry Strategy
| Error Type | Should Retry? | Strategy |
|---|---|---|
| 400 (Bad Request) | No | Fix the request |
| 401 (Unauthorized) | No | Re-authenticate |
| 403 (Forbidden) | No | Check permissions |
| 404 (Not Found) | No | Resource doesn't exist |
| 409 (Conflict) | Maybe | Re-read and retry |
| 429 (Rate Limited) | Yes | Wait Retry-After seconds |
| 500 (Server Error) | Yes | Exponential backoff |
| 502 (Bad Gateway) | Yes | Exponential backoff |
| 503 (Unavailable) | Yes | Wait Retry-After or backoff |
| Network timeout | Yes | Exponential backoff |
Pattern: Error Classification
Classify errors into three categories for your application:
- Retryable — 429, 500, 502, 503, 504, network timeouts → retry with backoff
- Fixable — 400, 401, 422 → user can fix the input
- Terminal — 403, 404, 409 → can't be fixed by retrying or changing input
Client-Side Error UX
The error handling chain extends to your application UI. When an API call fails, the user experience depends on how your application surfaces the error:
- Retryable errors (5xx, 429): Show a "Something went wrong, trying again..." message. Don't show raw error codes to users. Implement automatic retry with user-visible feedback (spinner, progress indicator).
- Fixable errors (4xx): Show specific guidance. "Invalid email address" is better than "Bad Request". Extract the
messagefield from the error response for user-facing display — API providers write them to be human-readable. - Auth errors (401): Redirect to login or refresh the token silently. Users shouldn't see "Unauthorized" — they should see a login prompt.
- Not found (404): Show a friendly "This [thing] doesn't exist" message, not the raw 404. Provide navigation back to a valid state.
Never expose request_id, error codes, or technical details in the user-facing UI — those belong in your application logs and error reporting, not in a toast notification that the user sees.
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
| 200 for everything | Clients can't distinguish success/failure by status code | Use proper HTTP status codes |
| Plain text error messages | No machine parsing | JSON error objects |
| No request ID | Debugging requires reproducing the issue | Include request_id in every error |
| Generic "Server Error" | No debugging information | Specific error codes and messages |
| Stack traces in production | Security vulnerability (leaks internals) | Log server-side, return generic 500 |
| Missing Retry-After on 429 | Clients retry immediately, making it worse | Always include Retry-After |
| Different error formats per endpoint | Client needs per-endpoint error handling | Consistent error schema |
| Undocumented error codes | Developers can't self-diagnose | Publish error reference in API docs |
Building a Typed Error Handler (TypeScript)
For API consumers, centralizing error handling in one place prevents ad-hoc error checking across every API call:
// lib/api-client.ts
export class APIError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly code: string,
public readonly requestId: string,
public readonly retryable: boolean,
public readonly details?: unknown,
) {
super(message);
this.name = 'APIError';
}
}
async function apiRequest<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
...options,
headers: { 'Content-Type': 'application/json', ...options.headers },
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
const error = body.error ?? body;
const isRetryable = [429, 500, 502, 503, 504].includes(response.status);
throw new APIError(
error.message ?? `HTTP ${response.status}`,
response.status,
error.code ?? `http_${response.status}`,
error.request_id ?? response.headers.get('x-request-id') ?? '',
isRetryable,
error.details ?? error.errors,
);
}
return response.json();
}
// Automatic retry for retryable errors
async function apiRequestWithRetry<T>(
url: string,
options: RequestInit = {},
maxRetries = 3,
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await apiRequest<T>(url, options);
} catch (err) {
if (!(err instanceof APIError) || !err.retryable || attempt === maxRetries) throw err;
const delay = err.status === 429
? parseInt(/* Retry-After header */ '1') * 1000
: Math.pow(2, attempt) * 500;
await new Promise(r => setTimeout(r, delay));
}
}
throw new Error('Unreachable');
}
Documenting Errors for API Consumers
Your error codes are part of your API's public contract. Document them in your API reference with the same care you document endpoints.
For each error code in your API, document:
- When it occurs — the specific conditions that trigger it
- How to fix it — what the caller should do differently
- Whether to retry — is this a transient error or a permanent one?
- Example response — copy-paste the JSON so developers know exactly what to expect
Stripe's error documentation is the gold standard: every error code has its own page with trigger conditions, resolution steps, and code examples. For smaller APIs, even a simple error reference table significantly reduces support ticket volume by enabling developers to self-diagnose.
## Error Reference
### invalid_parameter (400)
The request contains an invalid parameter value.
**When it occurs:** A parameter failed validation — wrong type, out of range, or invalid format.
**Resolution:** Check the `param` field to identify which parameter caused the error.
**Retry:** No — fix the request before retrying.
**Example:**
```json
{
"error": {
"type": "validation_error",
"code": "invalid_parameter",
"message": "The 'email' field must be a valid email address.",
"param": "email",
"request_id": "req_abc123"
}
}
rate_limited (429)
Request rate limit exceeded.
When it occurs: You've exceeded the API rate limit for your plan.
Resolution: Wait for the Retry-After header value (in seconds) before retrying.
Retry: Yes — after the specified delay.
## Monitoring API Errors in Production
Errors you don't know about are bugs you can't fix. Two monitoring patterns that catch API error spikes before users report them:
**Error rate alerts**: Alert when the 5xx error rate exceeds 1% of requests in a 5-minute window. Tools like Datadog, Sentry, or Prometheus can track `http_response_status_code` as a metric and alert on sudden increases.
**Request ID in your logs**: Log the `request_id` from every API error response alongside your application error. When a user reports a problem, you can correlate your logs with the API provider's server-side logs using the request ID — dramatically reducing the time to diagnose third-party API issues.
```typescript
// Log API errors with full context for debugging
catch (err) {
if (err instanceof APIError) {
logger.error('API request failed', {
url,
status: err.status,
code: err.code,
requestId: err.requestId, // Reference for support tickets
retryable: err.retryable,
userId: currentUser?.id,
});
}
}
Methodology
Error format examples and patterns sourced from Stripe, GitHub, and Twilio official API documentation. Status code guidance based on RFC 7231 (HTTP/1.1 Semantics) and RFC 9110 (HTTP Semantics, 2022). Retry strategy recommendations based on published SRE practices from Google, Stripe engineering blog, and AWS Well-Architected Framework. Code examples tested against Node.js 20 LTS with Express 4.x and Zod 3.x.
Designing API error handling? Explore API best practices and tools on APIScout — architecture guides, comparisons, and developer resources.
Related: API Error Handling Patterns for Production Applications, How AI Is Transforming API Design and Documentation, API Breaking Changes Without Breaking Clients