Skip to content
Knowledge Hub

API errors with RFC 9457: status, type and detail must not swap roles Basic

Estimated reading time: 7 min read

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 OK
Content-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 Conflict
Content-Type: application/problem+json
Content-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

RFC 9457 (2023) obsoletes RFC 7807. The JSON format is unchanged; RFC 9457 adds:

  • An IANA HTTP Problem Types registry for shared type values (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 type URIs that cannot be dereferenced (section 3.1.1).

Two rules that are easy to miss:

  • A missing type defaults to about:blank. In that case title SHOULD equal the HTTP status phrase (for example Not Found for 404) and may be localized (section 4.2.1). It means there is only a status, with no specific business meaning.
  • title does not change per occurrence (except for localization); anything that varies per occurrence belongs in detail.

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.

A business error travels from HTTP status to problem type and log
A business error travels from HTTP status to problem type and logStep 1/4: The client sends a payment request. The API receives the order ID and authenticates the caller.ClientAPILogsPOST
Client → APIPOST
Step 1/4

The client sends a payment request. The API receives the order ID and authenticates the caller.

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.

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.

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

Run the demo (it only builds the response object, no HTTP server):

Terminal window
npx --prefix examples ts-node examples/src/problem-details/problem-demo.ts

Change 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.

What a shared format gives you

  • Clients distinguish errors by a stable type instead 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 detail leaks data; too little makes user support hard.
  1. The status on the wire is in the right error class; never 200 with success: false.
  2. Content-Type: application/problem+json; body.status is generated from the same variable as the real status.
  3. type is stable and contains no occurrence ID; every type has docs, a retryable flag and a user action.
  4. Validation errors have a stable pointer + code.
  5. detail contains no stack traces, SQL, tokens or other users’ data; details live in logs, looked up by request ID (correlation ID).
  6. 429/503 include Retry-After.
  7. Test the contract over real HTTP (headers, status, body).

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?