API errors with RFC 9457: status, type and detail must not swap roles Basic
Estimated reading time: 7 min read
The problem
Section titled “The problem”A client calls POST /orders/42/payments, but the order has already been paid. Two ways to answer:
Wrong: HTTP 200 with an error flag in the body
HTTP/1.1 200 OKContent-Type: application/json
{ "success": false, "message": "already paid" }SDKs, metrics and caches see 200 and treat it as success. Another client parses message to decide whether to retry, so rewording the message breaks its logic.
Right: a status in the correct error class + application/problem+json
HTTP/1.1 409 ConflictContent-Type: application/problem+jsonContent-Language: en
{ "type": "https://api.example.org/problems/order-already-paid", "title": "Order already paid", "status": 409, "detail": "Order 42 was paid on 2026-10-17; a second payment was rejected.", "instance": "/orders/42/payments/attempts/9f3c", "orderId": 42}| Field | Role | Client uses it for |
|---|---|---|
409 (status line) |
Class of outcome per HTTP | SDK, retry, metrics, cache |
type |
Stable identifier of the error type | Branching logic |
title |
Short name of the error type | Display, not branching |
detail |
Description of this occurrence | Human readers |
instance |
Identifier of the occurrence | Lookup with support |
orderId |
Extension member | Structured, documented data |
Core concepts
Section titled “Core concepts”RFC 9457 (2023) obsoletes RFC 7807. The JSON format is unchanged; RFC 9457 adds:
- An IANA HTTP Problem Types registry for shared
typevalues (section 4.2). - Clarification on handling multiple errors in one response: return the most relevant or urgent one rather than inventing a generic “batch” type (section 3).
- Guidance for
typeURIs that cannot be dereferenced (section 3.1.1).
Two rules that are easy to miss:
- A missing
typedefaults toabout:blank. In that casetitleSHOULD equal the HTTP status phrase (for exampleNot Foundfor 404) and may be localized (section 4.2.1). It means there is only a status, with no specific business meaning. titledoes not change per occurrence (except for localization); anything that varies per occurrence belongs indetail.
The status in the body is only an advisory copy; the status on the wire is what the protocol actually uses. Generate both from a single variable so they never diverge.
POSTThe client sends a payment request. The API receives the order ID and authenticates the caller.
Choosing a status and retry guidance
Section titled “Choosing a status and retry guidance”| Status | Use when | Client retries? |
|---|---|---|
400 |
Malformed request, missing parameter | No, fix the request |
401 |
Not authenticated / token expired | After refreshing credentials |
403 |
Authenticated but lacks permission | No |
404 |
Resource does not exist (or hiding its existence, see BOLA) | No |
409 |
Conflict with the current state | No, re-read the state and then decide |
422 |
Syntax is valid but content fails validation | No, fix the data |
429 |
Rate limit exceeded | Yes, after Retry-After if the server sends it |
503 |
Overloaded or temporarily under maintenance | Yes, respect Retry-After, back off per retry & jitter |
500 |
Unexpected error | Only for idempotent operations or with an idempotency key |
504 |
Gateway timed out waiting for upstream | State is unknown: look it up before resending (timeout budget) |
Retrying also depends on the method and idempotency key; the table is only a sensible client default. Choose the status by semantics; don’t return 200 for every error just to make frontend parsing easier.
Validation errors across multiple fields
Section titled “Validation errors across multiple fields”Use an errors extension member with JSON Pointer (the same shape as the example in the RFC):
{ "type": "https://api.example.org/problems/validation-error", "title": "Your request is not valid.", "status": 422, "errors": [ { "pointer": "#/amount", "code": "must_be_positive", "detail": "must be a positive integer" }, { "pointer": "#/currency", "code": "unsupported", "detail": "must be VND or USD" } ]}Clients render by pointer and code; detail is only for human readers. Document the errors schema as part of the contract. Spring has ProblemDetail and ASP.NET Core has ProblemDetails to provide the scaffolding; in Node you usually write your own exception filter.
When to use it
Section titled “When to use it”| Use | Not needed |
|---|---|
| Public APIs or many clients (web, mobile, partners) | Internal script with one client, no need to branch on errors |
| Clients must distinguish business errors | Standard status codes are enough, about:blank suffices |
| You want consistent errors across many services | Non-HTTP channels (GraphQL, messages) have their own contract |
Real-world example
Section titled “Real-world example”Run the demo (it only builds the response object, no HTTP server):
npx --prefix examples ts-node examples/src/problem-details/problem-demo.tsChange the input status to 400: the outer status and body.status change together because the helper uses one source. Try setting type to /orders/42: clients would see different types for the same error, so don’t put an occurrence ID in type.
At the API boundary, write a layer that converts domain errors (for example OrderAlreadyPaid) into problems. The domain service does not need to know about 409. Test the Content-Type, status, body, and that no data leaks.
Trade-offs
Section titled “Trade-offs”What a shared format gives you
- Clients distinguish errors by a stable
typeinstead of parsing free-form messages. - HTTP status and body have clear roles, which helps SDKs and operational observability.
- Extension members let you add structured data.
What you still have to design
- You must maintain a problem type registry and contract compatibility.
- It does not pick the right status for you, nor solve retry or authorization.
- Too much
detailleaks data; too little makes user support hard.
Production checklist
Section titled “Production checklist”- The status on the wire is in the right error class; never
200withsuccess: false. Content-Type: application/problem+json;body.statusis generated from the same variable as the real status.typeis stable and contains no occurrence ID; everytypehas docs, a retryable flag and a user action.- Validation
errorshave a stablepointer+code. detailcontains no stack traces, SQL, tokens or other users’ data; details live in logs, looked up by request ID (correlation ID).429/503includeRetry-After.- Test the contract over real HTTP (headers, status, body).
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”Self-check: (1) If the wording of detail changes, should clients change their logic? (2) Why does type not contain orderId? (3) With about:blank, what should title be?