API Error Handling: Complete Guide for Production APIs

Production-ready error handling patterns: HTTP status codes, error response formats, retry strategies, logging, and monitoring best practices.

Staff Pick

๐Ÿ“ก Monitor your APIs โ€” know when they go down before your users do

Better Stack checks uptime every 30 seconds with instant Slack, email & SMS alerts. Free tier available.

Start Free โ†’

Affiliate link โ€” we may earn a commission at no extra cost to you

Error handling is where good APIs become great APIs. A well-designed error handling strategy turns debugging nightmares into smooth troubleshooting experiences, reduces support tickets by 60%+, and builds trust with developers integrating your API.

This guide covers production-ready error handling patterns used by Stripe, GitHub, and AWS, with complete TypeScript implementations you can adapt for your API.

Why Error Handling Matters

Poor error handling costs real money:

HTTP Status Codes: The Foundation

Use HTTP status codes correctly. They're the first signal to clients about what went wrong.

Client Errors (4xx)

// 400 Bad Request - Invalid syntax or validation failure
{
  "error": {
    "code": "validation_error",
    "message": "Invalid request parameters",
    "details": [
      {
        "field": "email",
        "message": "Must be a valid email address",
        "value": "not-an-email"
      },
      {
        "field": "age",
        "message": "Must be at least 18",
        "value": 15
      }
    ]
  }
}

// 401 Unauthorized - Missing or invalid authentication
{
  "error": {
    "code": "unauthorized",
    "message": "Invalid API key",
    "hint": "Include your API key in the Authorization header"
  }
}

// 403 Forbidden - Valid authentication but insufficient permissions
{
  "error": {
    "code": "forbidden",
    "message": "Insufficient permissions",
    "required_scope": "users:write",
    "current_scopes": ["users:read"]
  }
}

// 404 Not Found - Resource doesn't exist
{
  "error": {
    "code": "resource_not_found",
    "message": "User not found",
    "resource_type": "user",
    "resource_id": "usr_123"
  }
}

// 409 Conflict - State conflict (duplicate resource, version mismatch)
{
  "error": {
    "code": "duplicate_resource",
    "message": "User with this email already exists",
    "existing_resource_id": "usr_456"
  }
}

// 422 Unprocessable Entity - Valid syntax but semantic errors
{
  "error": {
    "code": "business_logic_error",
    "message": "Cannot refund already refunded payment",
    "payment_id": "pay_789",
    "current_status": "refunded"
  }
}

// 429 Too Many Requests - Rate limit exceeded
{
  "error": {
    "code": "rate_limit_exceeded",
    "message": "API rate limit exceeded",
    "limit": 100,
    "window": "1m",
    "retry_after": 45
  }
}

Server Errors (5xx)

// 500 Internal Server Error - Unexpected server failure
{
  "error": {
    "code": "internal_error",
    "message": "An unexpected error occurred",
    "request_id": "req_abc123",
    "hint": "Contact support with this request_id if the issue persists"
  }
}

// 502 Bad Gateway - Upstream service failure
{
  "error": {
    "code": "upstream_service_error",
    "message": "Payment processor is temporarily unavailable",
    "service": "stripe",
    "request_id": "req_def456"
  }
}

// 503 Service Unavailable - Temporary overload or maintenance
{
  "error": {
    "code": "service_unavailable",
    "message": "API is temporarily unavailable for maintenance",
    "retry_after": 300,
    "status_url": "https://status.example.com"
  }
}

// 504 Gateway Timeout - Upstream service timeout
{
  "error": {
    "code": "upstream_timeout",
    "message": "Request timed out waiting for external service",
    "timeout": 30,
    "service": "database"
  }
}

Error Response Format

Consistent error structure across all endpoints makes integration easier. Here's a battle-tested format:

interface APIError {
  error: {
    // Machine-readable error code (use for conditional logic)
    code: string;
    
    // Human-readable error message (show to users)
    message: string;
    
    // Optional: Additional context for debugging
    details?: Array<{
      field?: string;
      message: string;
      value?: any;
    }>;
    
    // Optional: Hints for resolution
    hint?: string;
    
    // Optional: Link to documentation
    doc_url?: string;
    
    // Optional: Unique request identifier for support
    request_id?: string;
    
    // Optional: For nested errors from upstream services
    cause?: APIError;
  };
}

Real-World Example: Stripe's Error Format

Stripe's error handling is considered the gold standard:

{
  "error": {
    "type": "card_error",
    "code": "card_declined",
    "message": "Your card was declined.",
    "decline_code": "insufficient_funds",
    "charge": "ch_3NmJw92eZvKYlo2C0Dq7tqBi",
    "doc_url": "https://stripe.com/docs/error-codes/card-declined"
  }
}

Server-Side Error Handler Implementation

Centralized error handling with Express middleware:

// Custom error class
class APIError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public details?: any[],
    public hint?: string
  ) {
    super(message);
    this.name = 'APIError';
  }
}

// Error factory functions
export const errors = {
  badRequest: (message: string, details?: any[]) =>
    new APIError(400, 'bad_request', message, details),
    
  unauthorized: (message = 'Invalid authentication credentials') =>
    new APIError(401, 'unauthorized', message),
    
  forbidden: (message = 'Insufficient permissions') =>
    new APIError(403, 'forbidden', message),
    
  notFound: (resourceType: string, resourceId: string) =>
    new APIError(
      404,
      'resource_not_found',
      `${resourceType} not found`,
      undefined,
      `No ${resourceType} exists with id ${resourceId}`
    ),
    
  conflict: (message: string) =>
    new APIError(409, 'conflict', message),
    
  validationError: (fields: Array<{ field: string; message: string }>) =>
    new APIError(
      422,
      'validation_error',
      'Validation failed',
      fields
    ),
    
  rateLimit: (limit: number, window: string, retryAfter: number) =>
    new APIError(
      429,
      'rate_limit_exceeded',
      `Rate limit of ${limit} requests per ${window} exceeded`,
      [{ limit, window, retry_after: retryAfter }]
    ),
    
  internal: (message = 'Internal server error') =>
    new APIError(500, 'internal_error', message),
    
  serviceUnavailable: (service: string) =>
    new APIError(
      503,
      'service_unavailable',
      `${service} is temporarily unavailable`
    ),
};

// Express error handler middleware
export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) {
  // Generate unique request ID for tracking
  const requestId = req.headers['x-request-id'] || 
    `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
  
  // Log error with context
  console.error({
    requestId,
    method: req.method,
    path: req.path,
    error: {
      name: err.name,
      message: err.message,
      stack: err.stack,
    },
  });
  
  // Handle known API errors
  if (err instanceof APIError) {
    return res.status(err.statusCode).json({
      error: {
        code: err.code,
        message: err.message,
        details: err.details,
        hint: err.hint,
        request_id: requestId,
      },
    });
  }
  
  // Handle Zod validation errors
  if (err.name === 'ZodError') {
    const zodError = err as any;
    return res.status(422).json({
      error: {
        code: 'validation_error',
        message: 'Validation failed',
        details: zodError.errors.map((e: any) => ({
          field: e.path.join('.'),
          message: e.message,
          value: e.input,
        })),
        request_id: requestId,
      },
    });
  }
  
  // Handle JWT errors
  if (err.name === 'JsonWebTokenError') {
    return res.status(401).json({
      error: {
        code: 'invalid_token',
        message: 'Invalid or expired token',
        request_id: requestId,
      },
    });
  }
  
  // Handle Prisma errors
  if (err.name === 'PrismaClientKnownRequestError') {
    const prismaError = err as any;
    
    // Unique constraint violation
    if (prismaError.code === 'P2002') {
      return res.status(409).json({
        error: {
          code: 'duplicate_resource',
          message: `Resource with this ${prismaError.meta?.target?.[0]} already exists`,
          request_id: requestId,
        },
      });
    }
    
    // Foreign key constraint violation
    if (prismaError.code === 'P2003') {
      return res.status(400).json({
        error: {
          code: 'invalid_reference',
          message: 'Referenced resource does not exist',
          request_id: requestId,
        },
      });
    }
  }
  
  // Unknown errors - don't leak internal details in production
  const isDevelopment = process.env.NODE_ENV === 'development';
  
  res.status(500).json({
    error: {
      code: 'internal_error',
      message: 'An unexpected error occurred',
      request_id: requestId,
      ...(isDevelopment && {
        debug: {
          name: err.name,
          message: err.message,
          stack: err.stack,
        },
      }),
    },
  });
}

// Usage in routes
app.post('/api/users', async (req, res, next) => {
  try {
    const { email, name } = req.body;
    
    // Validation
    if (!email || !name) {
      throw errors.validationError([
        !email && { field: 'email', message: 'Email is required' },
        !name && { field: 'name', message: 'Name is required' },
      ].filter(Boolean));
    }
    
    // Check for duplicate
    const existing = await db.user.findUnique({ where: { email } });
    if (existing) {
      throw errors.conflict('User with this email already exists');
    }
    
    // Create user
    const user = await db.user.create({ data: { email, name } });
    res.status(201).json({ user });
    
  } catch (error) {
    next(error); // Pass to error handler middleware
  }
});

// Register error handler (must be last)
app.use(errorHandler);

Client-Side Error Handling

Client libraries should handle errors gracefully and provide clear feedback:

class APIClient {
  private baseURL: string;
  private apiKey: string;
  
  constructor(apiKey: string, baseURL = 'https://api.example.com') {
    this.apiKey = apiKey;
    this.baseURL = baseURL;
  }
  
  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    const url = `${this.baseURL}${endpoint}`;
    
    try {
      const response = await fetch(url, {
        ...options,
        headers: {
          'Authorization': `Bearer ${this.apiKey}`,
          'Content-Type': 'application/json',
          ...options.headers,
        },
      });
      
      // Success
      if (response.ok) {
        return await response.json();
      }
      
      // Parse error response
      const errorData = await response.json();
      
      // Throw specific error types based on status code
      switch (response.status) {
        case 400:
          throw new ValidationError(errorData.error);
        case 401:
          throw new AuthenticationError(errorData.error);
        case 403:
          throw new PermissionError(errorData.error);
        case 404:
          throw new NotFoundError(errorData.error);
        case 409:
          throw new ConflictError(errorData.error);
        case 429:
          throw new RateLimitError(errorData.error);
        case 500:
        case 502:
        case 503:
        case 504:
          throw new ServerError(errorData.error);
        default:
          throw new APIError(errorData.error);
      }
      
    } catch (error) {
      // Network errors, timeout, etc.
      if (error instanceof TypeError) {
        throw new NetworkError('Network request failed');
      }
      
      // Re-throw API errors
      throw error;
    }
  }
  
  async getUser(userId: string) {
    return this.request<User>(`/users/${userId}`);
  }
}

// Custom error classes
class APIError extends Error {
  constructor(public error: any) {
    super(error.message);
    this.name = 'APIError';
  }
}

class ValidationError extends APIError {
  get fields() {
    return this.error.details || [];
  }
}

class AuthenticationError extends APIError {}
class PermissionError extends APIError {}
class NotFoundError extends APIError {}
class ConflictError extends APIError {}

class RateLimitError extends APIError {
  get retryAfter() {
    return this.error.details?.[0]?.retry_after || 60;
  }
}

class ServerError extends APIError {}
class NetworkError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'NetworkError';
  }
}

// Usage with proper error handling
async function displayUser(userId: string) {
  try {
    const user = await apiClient.getUser(userId);
    console.log('User:', user);
    
  } catch (error) {
    if (error instanceof ValidationError) {
      console.error('Validation errors:');
      error.fields.forEach(field => {
        console.error(`  - ${field.field}: ${field.message}`);
      });
    } else if (error instanceof AuthenticationError) {
      console.error('Authentication failed. Please check your API key.');
      // Redirect to login
    } else if (error instanceof NotFoundError) {
      console.error(`User ${userId} not found`);
      // Show 404 UI
    } else if (error instanceof RateLimitError) {
      console.error(`Rate limited. Retry after ${error.retryAfter}s`);
      // Schedule retry
    } else if (error instanceof ServerError) {
      console.error('Server error. Please try again later.');
      // Show error toast
    } else if (error instanceof NetworkError) {
      console.error('Network error. Please check your connection.');
      // Show offline UI
    } else {
      console.error('Unexpected error:', error);
      // Log to error tracking service
    }
  }
}

Retry Strategies

Not all errors are permanent. Implement smart retry logic for transient failures:

// Exponential backoff with jitter
async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  options: {
    maxRetries?: number;
    baseDelayMs?: number;
    maxDelayMs?: number;
    retryableErrors?: Array<new (...args: any[]) => Error>;
  } = {}
): Promise<T> {
  const {
    maxRetries = 3,
    baseDelayMs = 1000,
    maxDelayMs = 30000,
    retryableErrors = [ServerError, NetworkError, RateLimitError],
  } = options;
  
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      // Don't retry client errors (4xx except 429)
      const isRetryable = retryableErrors.some(
        ErrorClass => error instanceof ErrorClass
      );
      
      if (!isRetryable || attempt === maxRetries) {
        throw error;
      }
      
      // Calculate delay with exponential backoff + jitter
      let delay = Math.min(
        baseDelayMs * Math.pow(2, attempt),
        maxDelayMs
      );
      
      // Add jitter (ยฑ25% randomness)
      delay = delay * (0.75 + Math.random() * 0.5);
      
      // Honor rate limit retry-after if present
      if (error instanceof RateLimitError) {
        delay = error.retryAfter * 1000;
      }
      
      console.log(`Retry attempt ${attempt + 1}/${maxRetries} after ${Math.round(delay)}ms`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
  
  throw new Error('Max retries exceeded');
}

// Usage
const user = await retryWithBackoff(() => apiClient.getUser('usr_123'));

When to Retry

Always retry:

Sometimes retry:

Never retry:

Logging and Monitoring

Production error handling requires visibility. Implement structured logging:

import winston from 'winston';

// Structured logger
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  defaultMeta: { service: 'api' },
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' }),
  ],
});

// Enhanced error handler with logging
export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) {
  const requestId = req.headers['x-request-id'] || generateRequestId();
  
  // Build error context
  const errorContext = {
    requestId,
    timestamp: new Date().toISOString(),
    method: req.method,
    path: req.path,
    query: req.query,
    user: req.user?.id,
    ip: req.ip,
    userAgent: req.headers['user-agent'],
    error: {
      name: err.name,
      message: err.message,
      code: (err as any).code,
      statusCode: (err as any).statusCode,
      stack: err.stack,
    },
  };
  
  // Log based on severity
  if (err instanceof APIError) {
    if (err.statusCode >= 500) {
      // Server errors are critical
      logger.error('Server error', errorContext);
    } else if (err.statusCode === 429) {
      // Rate limits are warnings
      logger.warn('Rate limit exceeded', errorContext);
    } else {
      // Client errors are info
      logger.info('Client error', errorContext);
    }
  } else {
    // Unknown errors are critical
    logger.error('Unexpected error', errorContext);
  }
  
  // Send to error tracking service (Sentry, Datadog, etc.)
  if (process.env.NODE_ENV === 'production') {
    captureException(err, {
      tags: {
        request_id: requestId,
        endpoint: req.path,
      },
      user: req.user ? { id: req.user.id } : undefined,
    });
  }
  
  // Return error response...
}

Monitor Error Rates

Track error rates to detect issues early:

Set up alerts:

Use services like Datadog, New Relic, or Sentry for error tracking and alerting.

๐Ÿ”
Recommended

Secure your API keys and credentials

Leaked API keys in error logs are a top security risk. Use 1Password to manage secrets across your team โ€” never hardcode credentials again.

Try 1Password Free โ†’

Error Documentation

Document every possible error in your API docs. Developers need to know:

### Error: validation_error

**Status Code:** 422 Unprocessable Entity

**Description:** The request body contains invalid data that failed validation.

**Causes:**
- Missing required fields
- Invalid field types
- Field values outside allowed ranges
- Invalid format (e.g., malformed email)

**Resolution:**
1. Check the `details` array in the error response
2. Each detail contains `field`, `message`, and rejected `value`
3. Fix the invalid fields and retry

**Example:**

```json
{
  "error": {
    "code": "validation_error",
    "message": "Validation failed",
    "details": [
      {
        "field": "email",
        "message": "Must be a valid email address",
        "value": "not-an-email"
      }
    ],
    "request_id": "req_abc123"
  }
}
```

**Related:**
- [Input Validation Guide](#)
- [Field Reference](#)

Real-World Error Handling Examples

GitHub API

GitHub's API provides excellent error context:

// Rate limit exceeded
{
  "message": "API rate limit exceeded for user ID 12345.",
  "documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting"
}

// Validation failed
{
  "message": "Validation Failed",
  "errors": [
    {
      "resource": "Issue",
      "field": "title",
      "code": "missing_field"
    }
  ],
  "documentation_url": "https://docs.github.com/rest/reference/issues#create-an-issue"
}

AWS API

AWS APIs use detailed error codes:

// S3 NoSuchKey error
{
  "Error": {
    "Code": "NoSuchKey",
    "Message": "The specified key does not exist.",
    "Key": "example-object",
    "RequestId": "4442587FB7D0A2F9",
    "HostId": "..."
  }
}

// DynamoDB ConditionalCheckFailedException
{
  "__type": "com.amazon.coral.validate#ValidationException",
  "message": "The conditional request failed"
}

Testing Error Handling

Test error paths just as thoroughly as success paths:

import request from 'supertest';
import { app } from '../app';

describe('Error Handling', () => {
  describe('POST /api/users', () => {
    it('returns 422 for missing required fields', async () => {
      const response = await request(app)
        .post('/api/users')
        .send({})
        .expect(422);
      
      expect(response.body.error.code).toBe('validation_error');
      expect(response.body.error.details).toHaveLength(2);
      expect(response.body.error.details[0].field).toBe('email');
    });
    
    it('returns 409 for duplicate email', async () => {
      // Create user
      await request(app)
        .post('/api/users')
        .send({ email: 'test@example.com', name: 'Test' })
        .expect(201);
      
      // Try to create duplicate
      const response = await request(app)
        .post('/api/users')
        .send({ email: 'test@example.com', name: 'Test 2' })
        .expect(409);
      
      expect(response.body.error.code).toBe('duplicate_resource');
    });
    
    it('returns 401 for missing authentication', async () => {
      const response = await request(app)
        .post('/api/users')
        .set('Authorization', '') // Remove auth header
        .send({ email: 'test@example.com', name: 'Test' })
        .expect(401);
      
      expect(response.body.error.code).toBe('unauthorized');
    });
    
    it('includes request_id in all error responses', async () => {
      const response = await request(app)
        .post('/api/users')
        .send({})
        .expect(422);
      
      expect(response.body.error.request_id).toMatch(/^req_/);
    });
  });
  
  describe('Error Handler Middleware', () => {
    it('converts Zod errors to validation_error', async () => {
      // Test with Zod validation
    });
    
    it('converts Prisma unique constraint to duplicate_resource', async () => {
      // Test with database constraint violation
    });
    
    it('sanitizes error messages in production', async () => {
      process.env.NODE_ENV = 'production';
      
      // Trigger an unexpected error
      const response = await request(app)
        .get('/api/test-error')
        .expect(500);
      
      expect(response.body.error.message).toBe('An unexpected error occurred');
      expect(response.body.error.debug).toBeUndefined();
    });
  });
});

Common Mistakes to Avoid

1. Leaking Sensitive Information

โŒ Don't do this:

// Exposes internal database details
{
  "error": {
    "message": "Error: column 'password_hash' does not exist",
    "stack": "Error: column 'password_hash' does not exist\n    at Connection.query (/app/node_modules/pg/lib/connection.js:159:17)\n    at /app/src/db.ts:42:19"
  }
}

โœ… Do this instead:

{
  "error": {
    "code": "internal_error",
    "message": "An unexpected error occurred",
    "request_id": "req_abc123"
  }
}

2. Generic Error Messages

โŒ Don't do this:

{
  "error": "Something went wrong"
}

โœ… Do this instead:

{
  "error": {
    "code": "invalid_email",
    "message": "Email address is invalid",
    "field": "email",
    "value": "not-an-email",
    "hint": "Email must contain @ symbol and valid domain"
  }
}

3. Inconsistent Error Format

Use the same error structure across all endpoints. Don't mix formats:

// โŒ Endpoint A
{ "error": "Invalid email" }

// โŒ Endpoint B
{ "message": "User not found", "code": 404 }

// โŒ Endpoint C
{ "success": false, "error": { "type": "validation" } }

// โœ… All endpoints
{
  "error": {
    "code": "...",
    "message": "...",
    "details": [...],
    "request_id": "..."
  }
}

4. Not Logging Error Context

Log enough context to debug issues:

// โŒ Insufficient logging
console.error(error.message);

// โœ… Comprehensive logging
logger.error('Request failed', {
  requestId: req.id,
  method: req.method,
  path: req.path,
  userId: req.user?.id,
  error: {
    name: error.name,
    message: error.message,
    stack: error.stack,
  },
});

5. Retrying Non-Idempotent Operations

Don't automatically retry operations that modify state:

// โŒ Dangerous - could charge user multiple times
await retryWithBackoff(() => 
  stripe.charges.create({ amount: 1000, currency: 'usd', source: token })
);

// โœ… Safe - use idempotency key
await retryWithBackoff(() => 
  stripe.charges.create({
    amount: 1000,
    currency: 'usd',
    source: token,
    idempotency_key: `charge_${orderId}` // Same key = same charge
  })
);
๐Ÿ›ก๏ธ
Recommended

Protect your developer identity online

Developers are high-value targets for social engineering. Optery scans 400+ data brokers and removes your personal information โ€” reduce your attack surface.

Scan Free with Optery โ†’

Error Handling Checklist

Use this checklist to audit your API's error handling:

Next Steps

Now that you have production-ready error handling, consider:

Track the status of key API dependencies at API Status Check.

๐Ÿ“ก
Recommended

Monitor your APIs and infrastructure in real-time

Better Stack combines uptime monitoring, incident management, and log aggregation. Free tier includes 10 monitors with 3-minute checks.

Try Better Stack Free โ†’

๐Ÿ›  Tools We Use & Recommend

Tested across our own infrastructure monitoring 200+ APIs daily

Better StackBest for API Teams

Uptime Monitoring & Incident Management

Used by 100,000+ websites

Monitors your APIs every 30 seconds. Instant alerts via Slack, email, SMS, and phone calls when something goes down.

โ€œWe use Better Stack to monitor every API on this site. It caught 23 outages last month before users reported them.โ€

Free tier ยท Paid from $24/moStart Free Monitoring
1PasswordBest for Credential Security

Secrets Management & Developer Security

Trusted by 150,000+ businesses

Manage API keys, database passwords, and service tokens with CLI integration and automatic rotation.

โ€œAfter covering dozens of outages caused by leaked credentials, we recommend every team use a secrets manager.โ€

SEMrushBest for SEO

SEO & Site Performance Monitoring

Used by 10M+ marketers

Track your site health, uptime, search rankings, and competitor movements from one dashboard.

โ€œWe use SEMrush to track how our API status pages rank and catch site health issues early.โ€

From $129.95/moTry SEMrush Free
View full comparison & more tools โ†’Affiliate links โ€” we earn a commission at no extra cost to you