API Error Handling Conventions

Last reviewed on 4 May 2026.

An error response is an interface, not an afterthought. Clients code against it, write retry logic against it, and surface it to humans. Get it right and integrations are pleasant; get it wrong and every integrator reinvents their own error mapping. This page covers the conventions that work — status codes, the problem-details envelope, partial-success shapes, and what separates an actionable error from a useless one.

Status codes: the small set you actually need

Most APIs use far more status codes than they need to. The useful working set is small:

  • 200 OK — request succeeded, body contains the result.
  • 201 Created — for POSTs that create a resource. Include a Location header pointing at it.
  • 204 No Content — for successful operations with no useful response body (typically DELETE).
  • 400 Bad Request — the request was malformed or violated a precondition the client could have checked.
  • 401 Unauthorized — credentials missing or invalid. Better called "Unauthenticated"; the name is a historical artefact.
  • 403 Forbidden — credentials valid, but the caller is not allowed to perform this action.
  • 404 Not Found — the resource does not exist (or the caller is not allowed to know that it exists).
  • 409 Conflict — the request conflicts with current state (a unique-constraint collision, an out-of-date version).
  • 422 Unprocessable Entity — the request was syntactically valid but semantically wrong. Use this for validation failures rather than overloading 400.
  • 429 Too Many Requests — the caller has hit a rate limit. See API Rate Limiting Strategies for the headers that go with this.
  • 500 Internal Server Error — something failed on the server in a way the client cannot fix.
  • 502 / 503 / 504 — upstream is unreachable, service is overloaded, gateway timed out. All retryable.

That's it. If you find yourself reaching for 418, 451, or one of the WebDAV codes, you are almost certainly designing yourself into a corner.

The 401 / 403 distinction

Worth its own paragraph because almost everybody gets it wrong. 401 means "I don't know who you are, send credentials." 403 means "I know who you are, and you can't do this." A request without an API key gets 401. A request with a valid API key for an account that doesn't have access to the resource gets 403. A common bug is returning 401 for both, which makes clients retry-with-fresh-credentials when the actual problem is a permissions one — wasted work and confusing logs.

The error envelope: RFC 7807 Problem Details

The status code says what category of thing went wrong. The body says what specifically went wrong, and what the client should do about it. The most widely adopted convention is RFC 7807, which defines a media type (application/problem+json) and a small set of standard fields:

{
  "type": "https://myappapi.com/errors/validation",
  "title": "One or more fields are invalid",
  "status": 422,
  "detail": "The amount field must be a positive integer.",
  "instance": "/payments/req_01HX9...",
  "errors": [
    { "field": "amount", "code": "must_be_positive", "message": "Must be greater than 0." }
  ]
}

The standard fields:

  • type — a URI that identifies the error class. Doesn't have to resolve, but if it does, it should point at human-readable documentation for that class.
  • title — a short, human-readable summary that doesn't change between occurrences of the same error.
  • status — the HTTP status, repeated in the body so clients that lose the status (some logging pipelines do) can still see it.
  • detail — a human-readable explanation specific to this occurrence.
  • instance — a URI that identifies this specific occurrence, useful for support tickets.

You can extend the envelope with anything you need — the example above adds an errors array for field-level validation. The standard explicitly allows extensions, as long as you don't conflict with the reserved field names.

Stable error codes

The type URI works well for documentation. For programmatic handling, clients want a stable string code that doesn't change between releases. Add a code field — a short identifier like insufficient_balance or token_expired — that clients can branch on without parsing the message text.

Keep the code namespace flat and lowercase-with-underscores. Avoid hierarchical codes (billing.payment.declined.insufficient_funds) — they look organised but they require clients to parse them, and they ossify your taxonomy at the moment of first use.

Validation errors and field-level shapes

When the request body has multiple problems, return them all at once, not one per round trip. The pattern that has settled out:

{
  "type": "https://myappapi.com/errors/validation",
  "title": "Validation failed",
  "status": 422,
  "errors": [
    { "field": "email",   "code": "invalid_format", "message": "Must be a valid email." },
    { "field": "amount",  "code": "must_be_positive", "message": "Must be greater than 0." },
    { "field": "items[2].quantity", "code": "out_of_stock", "message": "Only 3 left in stock." }
  ]
}

The field path uses dot-and-bracket notation to point at the offending location in the request body, including array indices. Clients can map these straight onto a form. The code drives any client-side logic (which message to show, whether to disable the submit button); the message is the fallback for display when the client doesn't have a localised string for the code.

Partial success

Bulk endpoints don't fit cleanly into single-status responses. If a caller sends 100 items and 5 fail, the choice is:

  • All-or-nothing. Reject the whole request with 422. Simple semantics, but the caller has to retry 100 items to make the 95 succeed.
  • Per-item statuses with overall 200. Return a 200 with a body that includes per-item results. The caller has to iterate the body to find what failed. Beware: clients that only check the status code will miss the failures entirely.
  • 207 Multi-Status. The HTTP status code designed for exactly this case (originally from WebDAV, but applicable). Each item gets its own embedded status. Less common in the wild but the most semantically honest option.

The choice depends on the caller's tolerance. Payment batches are usually all-or-nothing because partial settlement is a nightmare to reconcile. Notification batches are usually per-item because losing 5% of pushes is better than retrying all of them.

What separates an actionable error from a useless one

An actionable error answers three questions for the caller: What went wrong? Whose fault is it? What should I do next?

  • What went wrong comes from code and message. Be specific. "Validation failed" is useless; "amount must be a positive integer" is actionable.
  • Whose fault is it comes from the status class. 4xx is "yours, fix the request"; 5xx is "ours, retry or contact us." Don't return 500 for a malformed request, and don't return 400 for a database that's down — both turn correct caller behaviour into wrong-shaped retries.
  • What should I do next sometimes lives in extra fields: Retry-After for 429s and 503s, a fix_url for errors that need an action in a dashboard, links to docs for unfamiliar error types.

Common mistakes

  • Returning 200 with an error in the body. Catastrophic for any client that uses HTTP status to drive control flow. The status code and the response body must agree.
  • Different error envelopes from different endpoints. A single API surface should have a single error shape. Wrappers, gateways, and middleware sometimes substitute their own; catch and normalise.
  • Leaking implementation details. Stack traces, SQL fragments, and internal hostnames in error responses leak attack surface. Log them server-side; don't return them.
  • Using the same code for different errors. If invalid_input covers everything from "missing field" to "value out of range" to "card declined", clients have no way to branch on it. Granular codes age better than coarse ones.
  • Localised messages without codes. A message string that varies by language is fine for humans but useless for clients that need to react. Always pair a localised message with a stable code.
  • No correlation ID. When a client opens a support ticket, the first question is "what's the request ID?" If the error response doesn't carry one, the answer is "we don't know." Include a request or trace ID on every response, success or failure.

Where to go next

For where errors fit in the broader design picture, see API Design Best Practices. For the rate-limit-specific error shape, see API Rate Limiting Strategies. For idempotency-related error responses (mismatched bodies, in-progress requests), see Idempotency Keys for APIs. For the conventions used in this platform's API, see the API introduction.