RFC 9457 · one error, built layer by layer
Errors need a language. application/problem+json
Follow one rejected order from a bare 422 to a safe, reusable error contract. Each chapter keeps the same example and adds exactly one capability.
Start with the gap
A status code is useful—but broad.
A customer submits an order. The server rejects it. Watch what the client can and cannot infer from the HTTP layer alone.
422 only
Next: make the body recognizable →
422
The request content was understood, but could not be processed.
Predict: should the server invent a new HTTP status?
No. Keep the correct standard status for generic HTTP software, and put domain-specific meaning in the response representation.
The opening: HTTP already owns transport-level meaning. The missing piece is a consistent body for richer API-level meaning.
Add a recognizable body
Start with the smallest useful problem.
The status stays 422. Add the Problem Details media type and
human context first. With no explicit type, the problem defaults to
about:blank: it means no more than the HTTP status.
422 + recognizable problem body
Next: add stable identity →
HTTP/1.1 422 Unprocessable Content Content-Type: application/problem+json { "title": "Unprocessable Content", "status": 422, "detail": "Quantity must be at least 1." }
What the media type says
“This JSON object follows the Problem Details data model.” It is a representation label, not an HTTP status and not an exception class.
What is required?
RFC 9457 defines five standard members, but it does not require every document to contain all five. Include only the members that make this response useful.
Who reads what?
Proxies and generic libraries use the HTTP status. Application clients can additionally understand the problem body.
This body is useful to a person, but still generic to code. It explains the 422 consistently; it does not yet name a reusable application-specific problem.
The identity lesson
A problem type repeats. An instance does not.
Retry the same bad order. Its reusable category stays stable, while each failed request gets its own occurrence identifier.
type + instance
Next: add machine-readable field data →
{
"type": ".../invalid-order",
"title": "Order is invalid",
"status": 422,
"detail": "Quantity must be at least 1.",
"instance": ".../problems/01K42"
}
- same
- type · title · status · detail
- changes
- instance
different event
{
"type": ".../invalid-order",
"title": "Order is invalid",
"status": 422,
"detail": "Quantity must be at least 1.",
"instance": ".../problems/01K43"
}
- same
- type · title · status · detail
- changes
- instance
type
Is this .../invalid-order?
if (problem.type === INVALID_ORDER) {
showOrderEditor();
showMessage(problem.detail);
}
type is a URI, not necessarily a fetch
It is the primary identifier. An HTTPS type URI should resolve to human-readable documentation, but clients should not automatically fetch it while handling every error.
instance may be opaque
It can resolve to occurrence information, or simply act as a unique server-significant identifier. Absolute URIs avoid relative-resolution surprises. Do not assume it exposes public debug data.
Never parse detail to drive logic. It is human text
and may change or be localized. Branch on type; use documented
extension members for structured values.
Let machines help the human
Extensions carry problem-specific data.
One invalid order can contain several validation issues of the
same problem type. Add an errors member so the client can
place each message beside the right input.
errors extension
Next: implement the contract locally →
{
"type": "https://api.example.test/problems/invalid-order",
"title": "Order is invalid",
"status": 422,
"detail": "Correct the highlighted fields.",
"instance": "https://api.example.test/problems/01K44",
"errors": [
{
"pointer": "#/quantity",
"code": "minimum",
"detail": "Must be at least 1."
},
{
"pointer": "#/delivery/postcode",
"code": "format",
"detail": "Use four digits."
}
]
}
The type defines the extension
Documentation for .../invalid-order should define the
errors shape, meaning, and client expectations. Unknown extension
members must be ignored so the model can evolve.
Result in the client
quantity
Must be at least 1.
postcode
Use four digits.
The client reads structure; the customer reads prose.
Several related issues
An errors array is a good fit when each item belongs to the same
validation problem type. RFC 9457 includes this pattern.
Several unrelated problems
Do not casually turn one HTTP failure into a grab bag of unrelated problem types. The RFC recommends representing the most relevant or urgent problem; true batch APIs need deliberately defined batch semantics.
Implementation scale · local
Start on one endpoint.
A local implementation proves the wire contract. This is perfectly reasonable for one route or an incremental rollout.
async function createOrder(request) {
const issues = validate(await request.json());
if (issues.length) {
return json({
type: TYPES.invalidOrder,
title: "Order is invalid",
status: 422,
detail: "Correct the highlighted fields.",
instance: newProblemId(),
errors: issues
}, {
status: 422,
headers: {
"Content-Type": "application/problem+json"
}
});
}
return createTheOrder();
}
What this teaches
The contract is already useful.
- The HTTP status and body agree.
- The media type makes the representation recognizable.
- The type is stable and documented.
- The client receives actionable validation structure.
Local construction is not wrong. Its cost appears when every route repeats policy decisions.
Predict: what drifts when 30 handlers copy this block?
Media types, status/body consistency, instance generation, safe detail text, tracing, localization, and extension conventions can all diverge. The next design centralizes those cross-cutting decisions while each endpoint still chooses its domain problem type.
Failure clinic
The shape helps only if its semantics stay trustworthy.
Most mistakes come from confusing human text with machine identity, duplicating inconsistent status, or exposing implementation details.
HTTP 400 + "status": 422
Mismatch
The server must emit the same actual status and body status. Generic HTTP software follows the status line.
if (detail.includes("quantity"))
Parsing prose
Detail is for humans and can change or be localized. Use type and documented extensions for program logic.
"detail": "NullPointerException at OrderDao:71"
Leaking internals
Problem Details is not a debugging transport. Keep stack traces, queries, and sensitive data in protected server diagnostics.
"type": "invalid-order-42"
Unstable identity
A type is a URI reference identifying a reusable class. Do not create a fresh type for every occurrence; that is the instance’s job.
Content-Type: application/json
Hiding the representation
A body may look similar, but the registered media type tells a generic client that this is a Problem Details document.
one type per wording change
Too many types
Create a new type when clients need distinct semantics or handling—not merely because the sentence changed.
Important topics to learn next
A practical study map
- HTTP status semantics and response headers
- The five standard members and their defaults
- Problem type design and stable URI ownership
- Problem occurrence / instance identifiers
- Extension schemas and forward compatibility
- Validation and batch-error modelling
- Content and language negotiation
- Security, privacy, logging, and trace correlation
- Shared server factories and generic client decoders
Reconstruct the model
status
What broad HTTP outcome occurred?
type
What reusable kind of problem is this?
instance
Which concrete occurrence are we discussing?
detail
What helpful human explanation applies now?
extensions
What structured data does this problem type promise?
In one sentence:
application/problem+json is the JSON representation of the Problem
Details model—a common envelope that preserves HTTP semantics, identifies reusable
problem types and individual occurrences, and allows documented domain extensions.