API PlaybookAPI Design
API DesignBeginner5 min

Error Handling & Status Codes

Stop returning 200 for errors

In a nutshell

When something goes wrong with an API request, the response needs to clearly say what happened and why. HTTP status codes (like 404 for "not found" or 422 for "bad data") tell tools and code how to react automatically, while the response body gives the developer enough detail to fix the problem. Getting this wrong -- like returning "200 OK" when there's actually an error -- breaks every tool in the chain.

The situation

You're integrating with a third-party API. You send a request. You get back:

HTTP/1.1 200 OK

{
  "status": 200,
  "error": true,
  "message": "User not found"
}

Status code says 200 — success. Body says error. Your HTTP client thinks everything is fine. Your error handling logic never triggers. You discover the bug three days later in production.

This is depressingly common, and it's completely avoidable.

Here's the difference between the wrong way and the right way to return errors:

Loading diagram...

The anti-pattern: errors wrapped in 200

HTTP/1.1 200 OK

{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "The requested user does not exist"
  }
}

This breaks every tool in the HTTP ecosystem. Caches cache it as a successful response. Monitoring dashboards report 100% success rate. Load balancers don't detect backend failures. API gateways don't trigger retry logic.

HTTP status codes exist for a reason. Use them.

HTTP/1.1 404 Not Found

{
  "type": "https://api.example.com/errors/not-found",
  "title": "Resource not found",
  "status": 404,
  "detail": "No user found with ID user_999."
}

Status codes are machine-readable semantics

Status codes aren't just for humans reading logs. They drive behavior in HTTP clients, proxies, CDNs, and monitoring tools. A 429 triggers backoff logic. A 503 triggers circuit breakers. A 401 triggers token refresh. Wrapping everything in 200 disables all of this for free.

Status code quick reference

You don't need to memorize all 70+ HTTP status codes. These are the ones that matter for APIs:

Success (2xx)

CodeMeaningWhen to use
200OKSuccessful GET, PUT, PATCH
201CreatedSuccessful POST that creates a resource
204No ContentSuccessful DELETE (no body to return)

Client errors (4xx)

CodeMeaningWhen to use
400Bad RequestMalformed request body, invalid JSON, type mismatches
401UnauthorizedMissing or invalid authentication credentials
403ForbiddenAuthenticated but not authorized for this action
404Not FoundResource doesn't exist
409ConflictResource state conflict (duplicate email, version mismatch)
422Unprocessable EntityRequest is well-formed but semantically invalid (validation errors)
429Too Many RequestsRate limit exceeded

Server errors (5xx)

CodeMeaningWhen to use
500Internal Server ErrorUnhandled exception — something broke
502Bad GatewayUpstream service returned an invalid response
503Service UnavailableServer is temporarily down (maintenance, overload)
504Gateway TimeoutUpstream service didn't respond in time

400 vs 422

Use 400 when the request is syntactically broken (malformed JSON, wrong content type). Use 422 when the syntax is fine but the data doesn't pass validation (email already taken, end date before start date). This distinction helps consumers separate "fix your request format" from "fix your data."

RFC 7807: Problem Details

RFC 7807 (updated by RFC 9457) defines a standard JSON format for error responses. Instead of every API inventing its own error shape, use this:

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/insufficient-permissions",
  "title": "Insufficient permissions",
  "status": 403,
  "detail": "You need the 'admin' role to delete projects.",
  "instance": "/api/projects/proj_123"
}
FieldRequiredDescription
typeYesA URI identifying the error type (can be a docs link)
titleYesA short, human-readable summary
statusYesThe HTTP status code (matches the response status)
detailNoA human-readable explanation specific to this occurrence
instanceNoA URI identifying the specific request that caused the error

You can add extension fields for machine-readable data:

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 30

{
  "type": "https://api.example.com/errors/rate-limit-exceeded",
  "title": "Rate limit exceeded",
  "status": 429,
  "detail": "You've exceeded 100 requests per minute. Try again in 30 seconds.",
  "retryAfter": 30,
  "limit": 100,
  "remaining": 0,
  "resetAt": "2026-04-13T14:05:00Z"
}

Validation errors with field-level detail

Generic error messages like "Validation failed" are useless. Tell the consumer exactly which fields failed and why:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/validation-error",
  "title": "Validation failed",
  "status": 422,
  "detail": "The request body contains 3 validation errors.",
  "errors": [
    {
      "field": "email",
      "message": "Must be a valid email address.",
      "value": "not-an-email"
    },
    {
      "field": "password",
      "message": "Must be at least 8 characters.",
      "value": null
    },
    {
      "field": "dateOfBirth",
      "message": "Must be a date in the past.",
      "value": "2030-01-01"
    }
  ]
}

This format lets consumers:

  • Highlight specific form fields with their errors
  • Display all validation issues at once (not one at a time)
  • Programmatically map errors to UI elements using the field key

Never leak internals in error messages

Error details should help consumers fix their request. They should never expose stack traces, SQL queries, internal service names, or file paths. Log those server-side. Return a clean, actionable message to the consumer.

Common mistakes

Using 200 for everything

// Don't
HTTP/1.1 200 OK
{ "error": "Unauthorized" }

// Do
HTTP/1.1 401 Unauthorized
{ "type": "...", "title": "Authentication required", "status": 401 }

Using 500 for client errors

// Don't — this isn't a server error, the client sent bad data
HTTP/1.1 500 Internal Server Error
{ "message": "Email is required" }

// Do
HTTP/1.1 422 Unprocessable Entity
{ "type": "...", "title": "Validation failed", "status": 422, "errors": [...] }

Inconsistent error shapes

// Endpoint A returns this
{ "error": "Not found" }

// Endpoint B returns this
{ "message": "Resource does not exist", "code": 404 }

// Endpoint C returns this
{ "errors": [{ "msg": "missing" }] }

Pick one error format (RFC 7807) and use it everywhere. Consumers should be able to write a single error handler for your entire API.

Centralize error handling in your framework

Create a shared error middleware that catches exceptions and formats them as Problem Details. This ensures consistency even when individual handlers forget to format their errors properly.

Error response checklist

  • Status codes match the error category (4xx for client, 5xx for server)
  • Error body follows RFC 7807 Problem Details format
  • Validation errors include field-level details
  • Error messages are actionable ("email must be valid" not "validation error")
  • Internal details (stack traces, SQL) are never exposed to consumers
  • Error format is identical across all endpoints
  • Retry-After header is included with 429 and 503 responses

Next up: schema evolution and versioning — because the best API version is the one you never have to create.