What is RFC 9457 Problem Details and what does it define?
RFC 9457 defines a standard JSON (and XML) envelope for HTTP API errors, served with the media type application/problem+json. Without it, every API invents its own error format, and every client writes custom parsing for each one. RFC 9457 obsoletes RFC 7807. The document format is unchanged, so problem documents and parsers written for 7807 remain valid. The newer RFC mainly clarifies wording, gives guidance on reporting multiple problems, and creates an IANA registry for common problem types.
The spec defines five standard members, all optional:
| Member | JSON type | Purpose | Notes |
|---|---|---|---|
type |
URI reference | Identifies the problem category | Defaults to about:blank when absent |
title |
string | Short human-readable summary of the type | Should stay the same across occurrences, except for localization |
status |
number | HTTP status code | Advisory only; must match the actual response status |
detail |
string | Explanation of this specific occurrence | Written for humans, not for parsing |
instance |
URI reference | Identifies this specific occurrence | Often points to a log or trace entry |
A minimal response for a failed payment has three parts. The status line is 403 Forbidden. The Content-Type header is application/problem+json. The body sets type to a URI such as titleto "Insufficient credit",statusto 403, anddetail` to a sentence naming the balance and the attempted charge.
The scope is narrow by design. RFC 9457 standardizes the envelope, not the meaning of errors. It does not say whether an error is retryable, how to localize title, or which problem types your API should have. Those decisions belong to your API contract, and the rest of this guide covers them.
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
Content-Language: en
{
"type": "",
"title": "Insufficient credit",
"status": 403,
"detail": "Your current balance is 30, but the charge costs 50.",
"instance": "/account/12345/payments/abc-789"
}
A minimal RFC 9457 error response: a 403 status line, the application/problem+json content type, and a body with the five standard members, where status repeats the actual HTTP status.
Why does the type URI matter more than the JSON shape?
Clients cannot reliably branch on prose or on a status code alone. A 409 might mean a version conflict, a duplicate resource, or a state-machine violation, and each needs different handling. The type URI is the stable identifier that tells them which one it is. Once published, a problem type is part of your contract, just like an endpoint path.
Each member has a separate job:
| Member | Job | Who relies on it |
|---|---|---|
type |
Stable identifier clients branch on | Client code |
status |
Advisory copy of the HTTP status code; must match the actual response | Logs, proxies that lose headers |
title |
Short summary, fixed per type (changes only for localization) | Dashboards, developers |
detail |
Explanation of this occurrence | Humans only; clients must not parse it |
If you omit type, it defaults to about:blank. That means the problem has no meaning beyond the HTTP status, and title should then match the status phrase. This is fine for generic errors but gives clients nothing to branch on.
Pick a namespace you control and won't rename, and use absolute URIs. Dereferenceable HTTPS URLs let a developer open the type and read what went wrong, but then you have documentation to keep current. Clients should not fetch it automatically. URNs or tag URIs avoid hosting anything but are harder to discover.
Stable types benefit three groups. Consumers write handlers once. Support teams can search tickets by type instead of paraphrased messages. Platform teams can enforce a shared registry across services.
How do extension members and instance carry machine-readable data?
Extension members are where problem details carry data a client can act on. A validation failure that says only "invalid request" forces the caller to guess which field to fix. A common convention, also used in an example in the RFC itself, is an errors array. Each entry has a detail and a pointer, which is a JSON Pointer such as /address/postcode identifying the offending input. Clients can map pointers straight to form fields without parsing prose.
RFC 9457 requires consumers to ignore extension members they don't recognize. You can add fields without breaking existing clients. Clients must not fail on unknown keys, and strict deserializers that reject unexpected properties violate this rule.
When several things are wrong at once, the RFC suggests returning the most relevant problem. For repeated problems of the same type, it allows a single type with a list extension like errors. If you adopt that convention, document it on the type's page: the array's name, its entry schema, and whether it is always present. An undocumented list is one clients cannot rely on.
Use instance to identify this specific occurrence. A value like urn:uuid: followed by the request ID, or the trace ID your tracing system already propagates, lets a support engineer jump from a customer's pasted error to the matching log lines.
Sanitize everything you return. Never put stack traces, SQL errors, internal hostnames, or file paths in detail or extensions. Keep messages identical for "unknown user" and "wrong password" so the response does not reveal which accounts exist.
// Body for a 422 response, sent with Content-Type: application/problem+json.
interface FieldError {
detail: string; // user-safe message, never a raw DB or library error
pointer: string; // JSON Pointer into the request body, e.g. "/address/postcode"
}
interface ValidationProblem {
type: string;
title: string;
status: number;
detail: string;
instance: string;
errors: FieldError[]; // extension member, documented on the type's page
}
function validationProblem(requestId: string, errors: FieldError[]): ValidationProblem {
return {
type: "",
title: "Your request is not valid.",
status: 422,
detail: `${errors.length} field(s) failed validation.`, // fixed text, no exception messages
instance: `urn:uuid:${requestId}`, // same ID appears in server logs for this request
errors,
};
}
// Client side: read only the members you know and ignore everything else.
// Don't use a strict schema that rejects unknown properties.
function fieldErrors(body: Record<string, unknown>): Map<string, string> {
const result = new Map<string, string>();
if (!Array.isArray(body.errors)) return result;
for (const e of body.errors) {
if (typeof e?.pointer === "string" && typeof e?.detail === "string") {
result.set(e.pointer, e.detail); // pointer maps directly to a form field
}
}
return result;
}
const problem = validationProblem("3f2b8c1e-7d4a-4e9b-9a61-0c5d2e8f4a17", [
{ detail: "Postcode is required.", pointer: "/address/postcode" },
{ detail: "Must be a positive integer.", pointer: "/quantity" },
]);
This example builds a validation problem with a documented errors list extension, where each entry has a JSON Pointer to the bad input, and an instance URN taken from the request ID. It also shows a client that reads only the members it recognizes and ignores the rest.
How do you implement problem details with a registry and centralized error mapping?
One registry, one mapper
Start with a registry: a single module that declares every problem type your API can emit. Each entry holds the type URI, a fixed title, the HTTP status, and the schema of its extension members. Generate the public error documentation from the same module, so the docs and the code cannot drift apart. Any code that throws an error not in the registry should fail review.
Domain code raises typed exceptions that carry only data. It never builds responses. A single error-handling layer at the edge of the application turns them into problem documents. That layer must also catch what the framework produces before your handlers run: unmatched routes (404), wrong methods (405), malformed bodies (400), and authentication rejections (401/403). If you skip these, the framework's default error pages leak through.
client ─▶ CDN / LB ─▶ API gateway ─▶ framework router ─▶ auth ─▶ handler
│ │ │ │ │
own bodies own bodies 404/405/parse 401/403 domain exc.
└──────────────┴────────┘
▼
error mapper + registry
▼
application/problem+json
Set Content-Type: application/problem+json. If Accept lists only application/json, send the problem document anyway. HTTP lets a server ignore Accept, and a 406 tells the client less than the real error does. The mapper must keep the headers that carry protocol semantics: WWW-Authenticate on 401, Retry-After on 429 and 503, and Allow on 405.
Infrastructure in front of the app is a known gap. Load balancers, gateways, and CDNs return their own HTML or plain-text bodies for timeouts, size limits, and rate limits. Where these components let you replace their error bodies, configure them to return problem documents. Where they don't, document which statuses can arrive without one.
import json
from dataclasses import dataclass
BASE = ""
@dataclass(frozen=True)
class ProblemType:
type: str
title: str # fixed per type; per-occurrence text goes in detail
status: int
extensions: tuple = () # allowed extension member names
# The registry: the only place problem types are defined. Docs are generated from it.
REGISTRY = {p.type: p for p in [
ProblemType(BASE + "not-found", "Resource not found", 404),
ProblemType(BASE + "method-not-allowed", "Method not allowed", 405),
ProblemType(BASE + "malformed-body", "Request body could not be parsed", 400),
ProblemType(BASE + "unauthenticated", "Authentication required", 401),
ProblemType(BASE + "rate-limited", "Too many requests", 429, ("limit",)),
]}
class ProblemError(Exception):
"""Raised by domain code and framework adapters. Carries data only, never a response."""
def __init__(self, slug, detail=None, headers=None, **ext):
self.problem = REGISTRY[BASE + slug] # KeyError for unregistered types
if set(ext) - set(self.problem.extensions):
raise ValueError(f"undeclared extension members: {set(ext)}")
self.detail, self.ext, self.headers = detail, ext, headers or {}
def to_response(err: ProblemError, instance: str):
"""The single mapper. Same output whatever Accept says."""
p = err.problem
body = {"type": p.type, "title": p.title, "status": p.status, "instance": instance}
if err.detail:
body["detail"] = err.detail
body.update(err.ext)
# Keep protocol headers (WWW-Authenticate, Retry-After, Allow) set by the raiser.
headers = {**err.headers, "Content-Type": "application/problem+json"}
return p.status, headers, json.dumps(body)
# A framework 405 adapter would raise: ProblemError("method-not-allowed", headers={"Allow": "GET, POST"})
print(to_response(ProblemError("rate-limited", "Limit is 100 requests per minute",
headers={"Retry-After": "30"}, limit=100), "/orders"))
A framework-agnostic problem-type registry with one mapper. It rejects unregistered types and undeclared extension members, always sets application/problem+json, and keeps headers like Retry-After and Allow.
How should clients parse and test problem detail responses?
A client should treat a problem document as something it may or may not receive. Check the Content-Type first. Only parse the body as a problem when the media type is application/problem+json. Plain application/json is a weaker signal, so validate before trusting it. If the response is HTML or text from a proxy, don't try to extract meaning from it. Build a local fallback from the status code alone, and keep the raw body for logs.
Once you have a valid document, branch on type first and status second. The type URI tells you which recovery path applies, such as re-authenticating, retrying with backoff, or showing a validation error on a specific field. The status code is the fallback when the type is unrecognized or set to about:blank. Never branch on title or detail. Those fields are for humans, and the server may reword or localize them at any time.
Testing
Apply the same rule to tests on both sides:
- Server tests assert the status, the media type, the
typeURI and any extension members the contract promises. - Client tests feed in canned problem documents, an HTML 502 page and an empty body, then check which branch the client takes.
Neither side asserts on message text. A copy edit should not break CI.
Where the time savings come from
Contracts built on type and status survive wording changes. That removes the brittle string matching that otherwise breaks after every copy update.
Debugging also gets faster. When clients log the instance value, a support ticket can carry an identifier that points to one specific occurrence. Engineers can look up that exact failure instead of trying to reproduce it from a vague description.
import { test } from "node:test";
import assert from "node:assert/strict";
type Problem = { type: string; status: number; instance?: string; raw?: string };
// Never throws. Returns a parsed problem, or a local fallback built from the status alone.
async function readProblem(res: Response): Promise<Problem> {
const mediaType = (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
const raw = await res.text(); // keep the raw body for logs, whatever it is
if (mediaType === "application/problem+json" || mediaType === "application/json") {
try {
const body = JSON.parse(raw);
// Plain application/json is a weaker signal, so require a string `type` before trusting it
if (mediaType === "application/problem+json" || typeof body?.type === "string") {
const type = typeof body.type === "string" ? body.type : "about:blank";
return { type, status: res.status, instance: body.instance }; // log `instance` for support tickets
}
} catch { /* malformed JSON: fall through to the fallback */ }
}
return { type: "about:blank", status: res.status, raw: raw.slice(0, 500) };
}
// Branch on `type` first, `status` second. Never on title or detail.
function nextAction(p: Problem): "reauth" | "show-fields" | "retry" | "generic" {
if (p.type === "") return "reauth";
if (p.type === "") return "show-fields";
if ([429, 502, 503, 504].includes(p.status)) return "retry";
return "generic";
}
const problem = (status: number, body: string, ct: string) =>
readProblem(new Response(body || null, { status, headers: { "content-type": ct } }));
// Tests assert on the branch taken; message text can be reworded without breaking CI.
test("problem+json routes by type", async () => {
const body = JSON.stringify({ type: "", title: "Any wording" });
assert.equal(nextAction(await problem(401, body, "application/problem+json")), "reauth");
});
test("HTML 502 from a proxy falls back to status", async () => {
assert.equal(nextAction(await problem(502, "<html>Bad Gateway</html>", "text/html")), "retry");
});
test("empty body falls back to status", async () => {
assert.equal(nextAction(await problem(500, "", "application/problem+json")), "generic");
});
A TypeScript client that checks Content-Type before parsing, branches on type then status, falls back cleanly on HTML or empty bodies, and is tested with node:test assertions on the chosen branch rather than on message strings.
When should you avoid RFC 9457, and what are the alternatives?
That traceability is worth having, but problem details are not always the right choice. There are three common cases where adopting them costs more than it returns.
The first is an internal endpoint with a single, tightly coupled client. If the same team ships both sides in lockstep, a published type catalog adds process without adding clarity. The client already knows every failure mode.
The second is a protocol that has its own error model. gRPC returns a status code, a message, and optional typed details. GraphQL reports failures in an errors array, often alongside partial data. Wrapping either one in application/problem+json creates two competing sources of truth.
The third is an established public API. If the existing envelope works and clients already parse it, switching formats is a breaking change. That only pays off if the new format fixes problems clients actually have.
| Option | Strength | Weakness |
|---|---|---|
| RFC 9457 problem details | Standard media type, stable type identifiers |
Needs a governed catalog |
| In-house JSON envelope | Fits existing clients exactly | Every consumer learns a bespoke format |
| gRPC status / GraphQL errors | Native to the tooling and client libraries | Not portable to plain HTTP clients |
| Bare status codes | Zero overhead | No way to distinguish failures that share a code |
Granularity matters whichever format you pick. If types are too fine, such as one per validation rule, the catalog fills with entries no client branches on. If types are too coarse, such as a single validation-error, clients start pattern-matching detail strings, and those strings were never meant to be a contract. A useful test is to create a distinct type only where a client would take a different action.
How do you migrate to problem details, and what does it cost?
| Strategy | How it works | Main risk |
|---|---|---|
| Parallel formats | Legacy fields and problem members in one body | Bloated payloads, two contracts to maintain |
| Accept negotiation | Return application/problem+json only when the client asks for it |
Clients sending */* stay on legacy indefinitely |
| Versioned change | New API version uses problem details exclusively | Forces consumers to schedule the upgrade |
Publish every type URI before any endpoint emits it. Each URI should resolve to a page that lists the meaning, the HTTP status, and the extension members. Announce new types in your changelog so consumers can add handlers ahead of time. Unknown types should fall back to status-based handling, so you never need to coordinate a release with every client.
The costs are real. Each new type needs a registry review. The dual-format period often outlasts the plan, because the slowest consumer sets the deprecation date. Gateways and proxies also generate their own 401, 429, and 502 responses, and you have to configure each of them to emit problem details, or clients will see two error shapes.
Illustrative estimate (assumed numbers, not a benchmark)
- Cost = build hours + (registry review hours/month + dual-format hours/month) × months
- Savings = (client teams × parser hours avoided) + (tickets/month × triage hours saved × months)
- Assumed cost: 120 + (2 + 4) × 12 = 192 h
- Assumed savings: (8 × 10) + (20 × 0.5 × 12) = 200 h
With these inputs, the migration roughly breaks even in the first year.
Adoption checklist
- More than one client team writes its own error parser.
- You control the gateway's error output.
- Someone owns the type registry.
- You can name a date to retire the legacy format.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to rfc 9457 problem details, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Consumers learn about a breaking change at review time rather than from production errors.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.