When your API returns an error, the consumer needs two things: to know what happened, and to know what to do about it. Most APIs fail at delivering both.

The most common pattern I see in codebases: a generic catch that returns 500 with a vague message. The consumer doesn't know if it's their problem, the network, or the server. There's no safe way to retry. No way to report the bug with context.

The error format that works

Standardize your error response. Always the same shape, regardless of failure type:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid email",
    "details": [
      {
        "field": "email",
        "message": "Invalid email format",
        "value": "marc@"
      }
    ],
    "request_id": "req_abc123",
    "timestamp": "2026-03-22T14:30:00Z"
  }
}

code is machine-readable. message is human-readable. details contains specific context. request_id allows correlating with logs. timestamp helps with temporal debugging.

Status code map

Each status code has a meaning. Use them correctly:

  • 400 Bad Request: the client sent invalid data. Include what's wrong.
  • 401 Unauthorized: the client didn't authenticate. Return authentication headers.
  • 403 Forbidden: the client authenticated but lacks permission.
  • 404 Not Found: the resource doesn't exist. Differentiate from 403 for sensitive resources.
  • 409 Conflict: state conflict, duplicate email, stale version.
  • 422 Unprocessable: data validated but business rules violated.
  • 429 Too Many Requests: rate limit. Return Retry-After headers.
  • 500 Internal Error: server failure. Never expose stack traces.
  • 503 Service Unavailable: temporarily unavailable.

Implementation in Express/Nest

A centralized error middleware prevents repetition and ensures consistency:

class AppError extends Error {
  constructor(
    public readonly code: string,
    public readonly statusCode: number,
    message: string,
    public readonly details?: unknown[]
  ) {
    super(message)
  }
}

// Error middleware
function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      error: {
        code: err.code,
        message: err.message,
        details: err.details,
        request_id: req.id,
        timestamp: new Date().toISOString()
      }
    })
  }

  console.error('Unhandled error:', err)
  return res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: 'Internal server error',
      request_id: req.id,
      timestamp: new Date().toISOString()
    }
  })
}

What not to expose in errors

Never return stack traces, file paths, or implementation details. In production, these data are attack vectors. Log everything internally, return only what the consumer needs to fix the problem.

For validation errors, show which fields have issues. For authentication errors, don't reveal whether the user exists. For database errors, return a generic message and log the details.

Error logging

Every error should generate a log with context: request_id, user_id, endpoint, sanitized payload, and full stack trace. Tools like Sentry or Datadog aggregate these logs and allow searching by error code, frequency, and impact.

The request_id pattern in the X-Request-Id header lets consumers report exactly which request failed, and the team find the corresponding log.