Development
Key points

Key points

  1. 1Return one JSON error envelope with a stable machine-readable code, a human-readable message, and explicit remediation fields so clients branch on data instead of text.
  2. 2Use the HTTP status code for the error category and the body code for the specific cause, following RFC 9457 Problem Details so generic HTTP tooling and typed clients both work.
  3. 3Make retry behavior explicit with a retryable flag and a retry-after delay, and require idempotency keys before any retried write can be safe.
  4. 4Publish field-level validation details, a docs link, and a support ID in every error, then test that codes stay stable and messages can change freely.

Start from the status code, finish with the payload

HTTP status codes tell generic software, including client libraries, caches and proxies, the broad category of what happened, but they cannot carry the specifics. RFC 9457 was written to close that gap: a problem type URI identifies the specific problem, and the response body carries details the client can act on (RFC 9457). The earlier RFC 7807 was the original version of the same idea and is now obsolete in favor of RFC 9457 (RFC 7807).

The practical rule: pick the most specific status code that applies, then use the body to say exactly which condition fired. Returning 400 for every client mistake or 500 for every server mistake throws away information the client could have used (Zuplo). Postman's checklist states the same goal from the consumer side: clients should understand outcomes without parsing response bodies (Postman).

A status-code map that holds up in practice:

SituationStatusBody code exampleRetryable
Malformed JSON, unreadable body400request.malformed_jsonno
Missing or invalid credentials401auth.invalid_tokenno (refresh first)
Authenticated but not permitted403auth.insufficient_scopeno
Resource does not exist404resource.not_foundno
Duplicate create or lost update409order.already_existsno
Syntactically valid, business rule failed422validation.field_invalidno
Rate limit exceeded429rate_limit.exceededyes, with Retry-After
Unhandled server failure500internal.unexpectedmaybe
Upstream dependency down503upstream.unavailableyes, with Retry-After

These categories match the commonly used conventions: 400 for invalid format, 401 for missing authentication, 403 for lacking permission, 409 for a conflict with current state, 422 for semantic errors, and 429 for rate limits with a Retry-After header (Postman). The split between 400 and 422 is the one teams argue about most. Decide once and write it down: 400 means "I could not read this request," 422 means "I read it and a business rule rejected it." Clients can then treat a 400 as a bug in their serializer and a 422 as something to show the user.

One envelope, two audiences

Every error response serves two readers: a program that decides what to do and a person who has to understand it. Mixing those concerns in one string is where most error designs fail. Speakeasy puts it plainly: error codes are specific to an API or organization, while messages should be specific, human-readable, actionable and consistent (Speakeasy).

A complete envelope on top of RFC 9457's five standard fields:

{
  "type": "https://api.example.com/errors/validation-field-invalid",
  "title": "Request validation failed",
  "status": 422,
  "detail": "The email field is not a valid address.",
  "instance": "/v1/users/registration/req_8f31c2",
  "code": "validation.field_invalid",
  "retryable": false,
  "errors": [
    {
      "field": "email",
      "pointer": "#/email",
      "code": "validation.email_format",
      "detail": "must match [email protected]"
    },
    {
      "field": "age",
      "pointer": "#/age",
      "code": "validation.out_of_range",
      "detail": "must be between 18 and 120"
    }
  ],
  "docs": "https://docs.example.com/errors/validation.field_invalid",
  "support_id": "req_8f31c2"
}

Five decisions are embedded in that payload:

  • type is a URI that identifies the problem class, which is the mechanism RFC 9457 defines for precise problem identification (RFC 9457).
  • code is a short, stable, dot-namespaced string. Clients switch on it; it must never change meaning.
  • detail is the sentence a human reads. It can be reworded or translated; nothing breaks if it changes.
  • errors is the RFC 9457 extension array for multiple validation problems in one response (Zuplo).
  • support_id is the occurrence identifier. RFC 9457 notes that an identifier for a specific occurrence is useful for support and forensics (RFC 9457).

The Spring Framework ships ProblemDetail and ResponseEntityExceptionHandler as abstractions for exactly this shape, and it renders the application/problem+json media type through content negotiation (Spring Framework). Spring also shows the right pattern for non-standard fields: put them in the properties map or subclass the problem detail, so the extension members stay top-level JSON (Spring Framework). If your stack has no such helper, write a single error serializer and route every thrown exception through it.

Choosing codes clients can branch on

An error code is an API contract. Treat it like a field name: it goes in your documentation, in your OpenAPI schema, and in your compatibility tests. Speakeasy's example shows why the string code matters: if (error.code === 'trip_too_short') is the kind of branch a client can write safely (Speakeasy).

Rules that keep a code list maintainable:

  1. Name the condition, not the sentence. payment.card_declined describes a state; "Your card was declined by the issuer" is a message. The first can be in a union type, the second cannot.
  2. Namespace by domain. Two or three dot-separated segments (auth., payment., validation., upstream.) let clients group and route errors, and let you list documented codes per namespace.
  3. Never delete or repurpose a code. Versioning exists because renaming a property or changing response structures is a breaking change for consumers (Postman API versioning). Adding a new code is additive; changing what an existing one means is not.
  4. Keep the list short enough to document. If you have hundreds of codes, clients will not branch on them; they will fall back to the status code. Fewer, well-chosen codes with rich detail beat a taxonomy.

Speakeasy also connects this to stability: HTTP status code semantics should continue to work across API versions so that clients that need no human intervention keep working (Stack Overflow, versioning). An error code is part of that contract surface.

One structural question is whether validation failures should stop at the first error or return all of them. The consensus in long-running discussions is to validate the whole payload and return multiple field errors, because it reduces round trips and server load, while a single fatal error still short-circuits (Stack Overflow, error structure). Returning every field error in one 422 with an errors array is the choice that saves your users the most time.

Remediation fields: retryable, retry-after, field details, docs, support ID

This is the part most APIs skip, and it is where clients do the most guessing. A remediation hint turns a message into a decision.

retryable. A boolean, always present, always explicit. Do not make clients infer retry policy from the status class. Some 5xx conditions are hopeless (a database constraint violation surfacing as 500), and some 4xx conditions are worth one more attempt after a refresh (an expired token). The flag is your chance to say so. Zuplo's guidance is that 5xx errors are typically transient and worth retrying, while 4xx errors mean the client must change something first (Zuplo). The flag encodes that default; your specific codes can refine it.

retry_after_seconds. When retryable is true, send a number (and mirror it in the Retry-After header). RFC 9457 defines the body, but the header is what generic HTTP tooling reads, so send both. For a 429 or 503, include the delay: rate-limit responses should always carry Retry-After (Zuplo). A worked retry example: if the server says retry after 30 seconds and the client uses full jitter, the actual wait is random(0, 30s), which spreads a fleet of clients over the window instead of firing in unison. That is the strategy PostGrid documents, with a retry schedule of 30s, 1m, 2m, 5m and 15m and jitter applied on top (PostGrid).

field details. For 400 and 422, tell the client which field failed and why in a machine-checkable form. RFC 9457's worked example uses an errors array where each entry has a detail and a JSON pointer such as #/email (Zuplo). A client can then highlight the right input without matching strings, and a form library can map pointer to widget.

docs. A URL that resolves to a page explaining the code, its causes and its fixes. Swagger's write-up on Problem Details lists links to documentation among the fields a complete error object should include (Speakeasy). Make the URL predictable (/errors/<code>) so the client can build it too, and keep each page short: when it fires, what it means, what to change.

support_id. An opaque request identifier, echoed in a response header such as X-Request-Id and in your logs. When a user files a ticket, the support engineer searches one string and gets the whole trace, the exact code and the request body. RFC 9457 calls this the occurrence identifier and explicitly ties it to support use (RFC 9457). Generate it at the edge so it exists even when the error happens before your application code runs.

Retried writes need idempotency before they need hints

A retryable flag is only safe if replaying the request cannot duplicate a side effect. PUT and DELETE are defined as idempotent, meaning repeated identical requests produce the same result, but POST is not (Postman). That distinction matters the moment you tell a client "this one is retryable."

The mechanism that makes it safe: an idempotency key. PostGrid requires an Idempotency-Key header on order creation and guarantees that repeated requests with the same key within 24 hours return the identical response without creating a second order, including error responses (PostGrid). The client generates a V4 UUID per logical operation, reuses it across retries, and gets exactly-once semantics from an at-least-once transport.

The decision rule this implies: for any code where retryable: true on a non-idempotent operation, the client must have sent an idempotency key. If it did not, the correct client behavior is to surface the error rather than retry blindly. Document this in the error page for every retryable write code. When you generate a new attempt server-side, expire the key generously and return the stored response on replay, not a fresh one.

A minimal client that follows these hints, without any string matching:

async function callWithHints(send, attempt = 0) {
  const res = await send();
  if (res.ok) return res.body;

  const err = res.body;
  if (!err.retryable || attempt >= 5) throw err;

  const base = (err.retry_after_seconds ?? 1) * 1000;
  await new Promise((r) => setTimeout(r, Math.random() * base * 2 ** attempt));
  return callWithHints(send, attempt + 1);
}

That loop logs err.support_id and shows the user a generic message for internal.* codes, and shows err.detail for validation.* codes. No branch reads the message text, so rewording never breaks anyone.

What not to put in an error body

The same completeness that makes errors useful makes them dangerous. Treblle's guidance is to categorize errors by who is responsible and to avoid leaking internal information in responses (Treblle). Swagger lists hiding errors inside 200 OK responses among the common anti-patterns, because it misleads clients into treating a failure as success (Swagger).

A short do-not list:

  • Stack traces, SQL fragments, internal hostnames, library versions. Put them in the log under support_id and nowhere else.
  • Free text that encodes identity or pricing for a different customer. A 403 should not reveal another tenant's resource names.
  • A 200 OK with success: false. Use the status code.
  • Cascading detail from internal services. Map upstream errors to your own code and keep the raw provider text in logs.

On the output side, prove the shape is stable. Validate a sampled set of error responses against your JSON schema in CI, so a status/body mismatch fails a build rather than a customer. That is a testing concern your error section can reference rather than duplicate.

For teams generating clients from OpenAPI, define the error schema once and reference it from every operation's non-2xx responses. Tooling then generates a typed error union, and the compiler enforces that each branch handles a known code. If you are wiring error envelopes across services that mix model providers (LLM calls, embedding endpoints and image models each with their own failure shapes), one compatible API key for text, image, video, audio and embedding models reduces the number of distinct failure modes you have to normalize: Quanta Inference is the product that does that.

Document and test the codes like a public contract

Documentation is not a wiki page; it is part of the API. xMatters recommends documenting every API version and publishing a changelog, and adding unit tests that verify response schemas stay the same across versions (xMatters). Error envelopes belong in both.

A checklist you can put in a pull request template:

CheckPass criteria
Code is in the registryNew code added to the docs and to the schema
Code is stableNo rename or change of meaning for an existing code
Status matches the map400/401/403/404/409/422/429/5xx per the table above
retryable presentEvery error body has the boolean, not just some
retry_after on 429/503Number in body and header
Validation errorserrors array with field or JSON pointer per entry
docs URL resolvesPage exists and lists cause and fix
support_id emittedSame value in body, header and logs
No internalsNo stack trace or upstream raw text
Schema testContract test validates the error body

Two process notes. First, when you move a code to docs, link the page from your OpenAPI description as the externalDocs of the error schema, so generated docs and generated clients both find it. Second, version the error contract deliberately: additive changes (new codes, new optional remediation fields) do not need a version bump; renaming a code, removing a field or changing a status code does, because those are exactly the kind of breaking changes versioning is meant to absorb (Postman API versioning, Speakeasy versioning). Postman's rule of thumb is to version whenever a change requires consumers to modify code to keep working (Postman API versioning).

If your team is small and the API surface is large, start with the envelope and the remediation fields, then grow the code registry. The earlier the codes appear in a schema, the earlier client generators can produce typed branches, and the fewer string comparisons end up in production. When the client side is a site or app rather than another service, the same error contract is what a builder wires into the UI states; Quanta is where a specialist builds the site or app with you, using the contract you have defined.

FAQ

FAQ

What is the single most important field in an error response?

The stable machine-readable code. It is the only field a client can branch on without parsing prose, and it survives rewording, translation and localization. Status codes narrow the category; the code identifies the condition.

Should clients retry on 500 or on the retryable flag?

On the flag. Some 500s are permanent and some 4xx cases are worth one more attempt after refreshing a credential. Turning retry policy into an explicit boolean removes the guesswork and lets the API change its stance without a client release.

How do I handle validation errors across many fields?

Return 422 with an errors array, one entry per failing field, using a JSON pointer or a field path plus a code and a detail sentence. RFC 9457 supports this as an extension member, so you keep standard fields and add the array (Zuplo).

Do I need both the Retry-After header and a body field?

Yes. The header is what generic HTTP clients and proxies read; the body field is what your typed client uses. Sending both keeps the value consistent and serves both audiences, and 429 and 503 responses should always carry it (Zuplo).

How do I keep error messages changeable without breaking clients?

Make it a rule that no client reads detail or title for control flow, and enforce it in review and in client code style. Speakeasy's guidance that messages should be specific and actionable sits alongside the requirement that code drives behavior (Speakeasy).

Sources

Sources

  1. RFC 9457: Problem Details for HTTP APIs
  2. RFC 7807: Problem Details for HTTP APIs
  3. Best Practices for API Error Handling - Zuplo
  4. REST API Best Practices: A Developer's Guide to Building ...
  5. Errors Best Practices in REST API Design - Speakeasy
  6. Error Responses :: Spring Framework
  7. What is API versioning? Benefits, types & best practices | Postman
  8. Best practices for API versioning? closed - Stack Overflow
  9. REST API error codes return structure - Stack Overflow
  10. Response Codes and Retry Logic - Getting Started - ReadMe
  11. How to Handle and Return Errors in a REST API - Treblle
  12. Problem Details (RFC 9457): Doing API Errors Well - Swagger
  13. API Versioning: Strategies & Best Practices - xMatters
  14. Versioning Best Practices in REST API Design - Speakeasy
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles