Mehdi Akiki
Published on

How to Use API Error Codes the Right Way

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

Reference

Your client calls an API and gets this back:

{ "error": "Bad Request" }

What is the program supposed to do with that? Retry? Fix the input? Ask the user to log in again? Give up? The message does not say. So the client guesses, and usually guesses wrong.

This is the most common mistake with API errors: they are written for a human reading logs, not for the program that has to react to them.

What an error is actually for

When a client receives an error, it has to make one decision: what do I do next?

There are only a few possible answers:

  • fix the request and try again (the input was wrong),
  • retry the same request later (a temporary problem),
  • send the user to log in (auth),
  • stop and show a real message (nothing will help).

A good error helps the client choose the right answer quickly and safely. If it does not help that choice, it is just decoration.

What most APIs do today (and why it hurts)

Look at how errors are usually returned, and the problems appear fast.

Only an HTTP status. 400, 409, 500. This is too coarse. Ten different problems all become 400, and the client cannot tell them apart. The status is a good first hint, not an answer.

The real information is in a free-text message. So the client starts to read the message: if (error.message.includes("not found")). Now your message text is an API. The day someone improves the wording, every client that parsed it breaks. You changed a sentence and caused an outage.

A different shape on every endpoint. One route returns { error }, another { message }, another { errors: [...] }. The client needs special code for each one.

200 OK with an error inside. The worst case. The transport says success, the body says failure, and every layer in between (caches, retries, monitoring) believes the success.

The standard that already exists

There is a standard for this: RFC 9457, "Problem Details for HTTP APIs" (it replaced RFC 7807). It defines a JSON shape with type, title, status, detail, and instance, served as application/problem+json.

It is a good baseline and you should know it. But in practice teams still get it wrong, because they treat detail (a human sentence) as the real signal and skip the one field that matters most for a program: a stable, machine-readable code. The standard puts this in the type field as a URI; many APIs prefer a short string like customer_not_found.

What actually works

After enough of these, the rules that matter are short.

Give every error a stable, machine-readable code. A string like customer_not_found, separate from the HTTP status and separate from the message. The code is the contract. The message is for humans and you can change it any time.

{
  "code": "customer_not_found",
  "message": "We could not find that customer.",
  "retryable": false,
  "request_id": "req_8f2a"
}

Tell the client if it can retry. This one field saves a lot of pain. A timeout or a 503 is retryable; a validation error is not. If the client must guess, it will either retry things that will never work, or give up on things that would have worked. (This is the other half of safe retries, together with an idempotency key.)

Put context in fields, not in prose. If a field is invalid, say which one: "field": "email". Do not make the client extract it from a sentence.

Use one envelope everywhere. The same shape for every endpoint, so the client writes the error handling one time.

Treat error codes like a versioned contract. A code is a public promise, exactly like a field in your schema. Never reuse a code for a new meaning, never quietly delete one, and add new ones in an additive way. This is the same discipline as a protobuf field number (more on contracts in centralize your API IDLs).

Add a request id, and do not leak internals. A correlation id lets support find the exact failure. The stack trace and the SQL error do not belong in the response.

The point

An error code is not an afterthought you add when something breaks. It is part of your API contract, and clients depend on it as much as on your success responses.

Design it like a contract: stable codes for the machine, free text for the human, and a clear signal about what to do next. Then your errors stop being decoration and start doing their job.