1. Home
  2. Guides
  3. Designing API Error Responses

What an API error should look like when it fails

An error response is part of the API, not an afterthought. Clients branch on the status code, then on a stable machine-readable code, and only then show a human message.

By Enrique M. · 8 min read · Last reviewed 2026-10-18

Three layers, in the order a client uses them

The status code tells the client what kind of failure it is: a problem with the request, a problem with authentication, a problem with the resource, or a problem on your side. It is the layer every HTTP library already understands.

The error code is a stable, machine-readable identifier for the specific failure — `insufficient_funds`, `email_already_verified`, `rate_limit_exceeded`. It is the layer your client code should branch on, because a human-readable message can be reworded, localised or translated without breaking anybody.

The message is for the person reading the response. It should be specific enough to act on and should never contain a stack trace, a SQL fragment or an internal hostname.

HTTP/1.1 409 Conflict
Content-Type: application/json

{
  "error": {
    "code": "email_already_verified",
    "message": "That address was already verified on 3 March 2026.",
    "request_id": "req_8f2a1c4b7d3e91",
    "docs": "https://api.example.com/errors/email_already_verified"
  }
}

Which status code to use

The distinction that causes the most confusion is between a malformed request and an invalid one, and between not being allowed and not being authenticated. Getting these right means clients do not have to guess whether to retry, refresh a token or give up.

  • `400` — the request could not be parsed. Malformed JSON, a bad header, a broken query string.
  • `401` — no valid credentials were presented. Send a `WWW-Authenticate` header.
  • `403` — authenticated, but not allowed to do this. The server will not say why.
  • `404` — the resource does not exist, or you are hiding that it does.
  • `409` — the request conflicts with the current state. A duplicate, a version mismatch.
  • `410` — it existed and is deliberately gone, and will not come back.
  • `422` — it parsed, and the content failed validation. The precise counterpart to `400`.
  • `429` — the rate limit was hit. Always send `Retry-After`.
  • `500` — something broke on your side. Never the client’s fault.
  • `503` — temporarily unavailable, with a `Retry-After`. A retry may work.

Make it safe to retry

A client that cannot tell a retryable failure from a permanent one either retries forever or gives up too early. The status code is the main signal, and it is worth being deliberate about it.

A network timeout, a `429` and a `503` are retryable. A `400`, a `401`, a `403` and a `422` are not, and retrying them just wastes the user’s time and your capacity. When a request genuinely is idempotent, say so, and consider accepting an idempotency key so a retry cannot create a duplicate.

Keep it debuggable without leaking

The tension is that the person who needs the most detail is the one who did not write the error: a developer debugging an integration at 2am. The answer is a correlation identifier rather than more detail in the body.

Return an identifier in the error body, put the same identifier in your logs, and let the support conversation start there. The client gets a safe message, your logs keep the stack trace, and nobody has to paste internal details into a ticket.

  • A `request_id` in the body and in every log line for that request.
  • Stack traces and internal hostnames stay in the logs.
  • Database errors are logged with their code and summarised for the client.
  • A link to the documentation for the error code, when there is one.

What to take away

  • Status code for the class of failure, a stable error code for the specific one, a message for the human.
  • `400` is malformed, `422` is invalid, `401` is unauthenticated, `403` is forbidden.
  • Say explicitly which failures are retryable, and send `Retry-After` when they are.
  • Use a request identifier to be debuggable without leaking internals.

Related tools