APIs & developer platforms / Tutorial
API Errors Worth Reading: Adopting RFC 9457 Problem Details
Replace ad-hoc error JSON with RFC 9457 problem details: a standard envelope, extension members for validation, and a migration path that keeps old clients working.

By the end of this tutorial you will have replaced your API’s hand-rolled error responses with RFC 9457 problem details: one standard JSON envelope for every failure, a media type clients can branch on, extension members that carry field-level validation errors, and a rollout strategy that does not break existing consumers. You will build it in Express with TypeScript, but the pattern transfers to any framework that lets you control the response body and Content-Type header.
The problem with invented error formats
Most APIs grow their error format by accident. One endpoint returns { "error": "not found" }, another returns { "message": "...", "code": 1042 }, a third leaks whatever the framework produced. Every client then writes per-endpoint parsing code, and every support conversation starts with “what did the response body look like?”
RFC 9457 (which obsoletes RFC 7807) standardises the shape. A problem detail is a JSON object with five reserved members:
type— a URI that identifies the kind of problem, stable across occurrences.about:blankmeans “the HTTP status code fully describes it.”title— a short, human-readable summary of that problem type. It should not change between occurrences of the same type.status— the HTTP status code, duplicated in the body so the problem survives proxies and logs that strip status lines.detail— a human-readable explanation of this specific occurrence.instance— a URI reference identifying this occurrence, often a request or trace ID.
Consumers are expected to key their logic off type and status, never off detail. That one rule is what makes the format worth adopting: your prose can improve without breaking anyone’s error handling.
Defining your problem types
Treat type URIs as part of your public API contract. Dereferenceable HTTPS URIs are best, because a developer who encounters one can open it in a browser and land on documentation for that exact failure.
https://api.example.com/problems/validation-failed
https://api.example.com/problems/insufficient-balance
https://api.example.com/problems/resource-version-conflict
https://api.example.com/problems/rate-limit-exceededNotice what is not in the catalogue: there is no user-not-found type. A missing resource is adequately described by about:blank plus a 404 status, unless clients genuinely need to distinguish it from other 404s. Add a type only when a consumer must react differently to it. Type proliferation is the most common way problem-details rollouts go wrong: hundreds of bespoke types that no client ever branches on, all of which you now have to maintain forever.
Building the envelope in Express
Start with a small factory that produces a problem object and sets the right headers. The media type matters: problem details are served as application/problem+json, which is the signal clients use to parse the body as a problem rather than as a successful payload.
export interface ProblemDetails {
type: string;
title: string;
status: number;
detail?: string;
instance?: string;
[extension: string]: unknown;
}
export class ProblemError extends Error {
readonly problem: ProblemDetails;
constructor(problem: ProblemDetails) {
super(problem.detail ?? problem.title);
this.problem = problem;
}
}
export function validationFailed(detail: string, errors: unknown): ProblemError {
return new ProblemError({
type: "https://api.example.com/problems/validation-failed",
title: "Validation failed",
status: 422,
detail,
errors, // extension member, defined below
});
}
export function versionConflict(currentVersion: number): ProblemError {
return new ProblemError({
type: "https://api.example.com/problems/resource-version-conflict",
title: "Resource version conflict",
status: 409,
detail: "The resource was modified after you read it. Re-fetch and retry.",
currentVersion, // extension member
});
}Then a single error-handling middleware converts everything — thrown ProblemErrors, body-parser failures, and unexpected exceptions — into the envelope:
import type { ErrorRequestHandler, Response } from "express";
import { ProblemError, type ProblemDetails } from "./problem";
function sendProblem(res: Response, problem: ProblemDetails): void {
res
.status(problem.status)
.type("application/problem+json")
.json(problem);
}
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
if (res.headersSent) return next(err);
const instance = `urn:request:${req.id}`;
if (err instanceof ProblemError) {
return sendProblem(res, { instance, ...err.problem });
}
if (err?.type === "entity.parse.failed") {
return sendProblem(res, {
type: "about:blank",
title: "Malformed JSON",
status: 400,
detail: "The request body could not be parsed as JSON.",
instance,
});
}
// Unknown failure: log the real error, return a generic problem.
req.log.error({ err }, "unhandled error");
return sendProblem(res, {
type: "about:blank",
title: "Internal server error",
status: 500,
detail: "An unexpected error occurred.",
instance,
});
};Two details in that handler do heavy lifting. First, unknown errors collapse into a generic 500 problem: stack traces, SQL fragments, and file paths never reach the body. Second, every problem carries an instance tied to the request ID, so a user reporting “I got a version conflict” can give support one string that finds the exact log lines.
Extension members: where the format earns its keep
RFC 9457 explicitly allows additional members, and this is where problem details beat a plain { message } body. Validation failures are the canonical case. Rather than flattening field errors into a string, expose them as a structured extension member consumers can render next to form fields:
{
"type": "https://api.example.com/problems/validation-failed",
"title": "Validation failed",
"status": 422,
"detail": "2 fields did not pass validation.",
"instance": "urn:request:9f1c2a",
"errors": [
{ "field": "email", "code": "format", "message": "Must be a valid email address." },
{ "field": "items[0].quantity", "code": "min", "message": "Must be at least 1." }
]
}Rules for extensions that will not come back to haunt you:
- Never reuse a reserved name. An extension member called
statusortypecollides with the standard members and will confuse generic parsers. - Keep them additive. Clients must be able to ignore extensions they do not understand; the RFC requires consumers to ignore unknown members, and your extensions should be designed so that ignoring them loses decoration, not meaning.
- Document them per type. Extensions belong to the problem type, not to the envelope. The
errorsarray is defined forvalidation-failed,currentVersionforresource-version-conflict. Documenting “the envelope sometimes has extra stuff” teaches clients nothing.
Content negotiation and the legacy format
You probably cannot flip every endpoint overnight. The safe rollout uses the Accept header: clients that ask for application/problem+json get problems; everyone else keeps the legacy format until the deprecation window closes.
import type { Request } from "express";
export function wantsProblemDetails(req: Request): boolean {
const accept = req.headers.accept ?? "";
return (
accept.includes("application/problem+json") ||
req.headers["x-api-version"] !== undefined // new API version opts in
);
}In the handler, branch on that check: render a problem, or translate the same ProblemError into your legacy shape. Run both shapes in parallel for a full deprecation cycle, log which clients still receive the legacy format, and contact them before removing it. Content negotiation keeps the migration boring, which is the goal.
What clients should do with it
Adoption only pays off if consumers handle problems correctly, so document the client contract alongside the format:
| Client behaviour | Correct approach |
|---|---|
| Deciding whether to retry | Branch on status (429, 503) and your type URIs, never on detail text |
| Showing an error to a user | Prefer detail, fall back to title |
| Reporting a bug | Log type and instance; instance is the support lookup key |
| Validating a form | Read the documented extension members for that type |
Handling an unknown type | Fall back to status-based handling; unknown types must not crash the client |
That last row is a requirement of the RFC, not a nicety: new problem types must be safe for old clients. If you ever ship a type where status-based fallback gives the wrong behaviour, you have designed the type wrong.
Choosing type URIs you can live with
The type URI is the one part of this design you can never take back, so choose its form deliberately. Dereferenceable URIs under a domain you control are the strongest option, because the identifier doubles as its own documentation link, and a consumer who pastes it into a browser lands on the page that explains the problem and its extension members. Keep the path stable and versionless: the URI identifies a problem type, not a release, and embedding an API version in it means every version bump mints a new type that clients must learn to recognise for the same failure. If your organisation will not commit to keeping documentation at those URLs, an opaque URN under your own namespace is an acceptable second choice — but then the catalogue must be published somewhere else and kept in sync by process, which is exactly the kind of obligation that quietly lapses.
Decide, too, who owns the catalogue. Problem types are API surface: adding one is a minor change, renaming or repurposing one is breaking, and retiring one requires the same deprecation care as retiring an endpoint. Put the catalogue in the API repository, review changes to it like schema changes, and generate the public documentation from the same source the handlers import. The moment the documented types and the emitted types can differ, they will.
Testing the contract
Problem details are a contract, so test them like one. Three layers of tests pay for themselves. First, unit tests per error path asserting the exact envelope: the type URI, the status matching the HTTP status line, and the extension members for that type. These catch the classic regression where a refactor makes the body say 422 while the status line says 400 — a mismatch the RFC’s duplicated status member exists to surface, and one real clients have learned to distrust.
Second, a contract test that walks your route table and asserts every error response leaves with Content-Type: application/problem+json. Frameworks love to bypass your handler: a body-parser failure in a different middleware, a 404 generated before your router runs, an auth layer returning its own default HTML. The failures your handler never sees are exactly the ones that keep the old inconsistency alive. Probe each category deliberately: malformed JSON, an unknown route, a rejected token, an oversized body, and a handler that throws unexpectedly. Each should come back in the envelope.
Third, test the negotiation path. A legacy client sending no Accept header must receive the legacy shape; a client asking for application/problem+json must receive a problem; and the two shapes must carry the same information. When the deprecation window closes, the negotiation tests are deleted along with the branch — and what remains is the guarantee that one format, and only one, can leave your API.
Verdict
Adopt RFC 9457 if your API has more than one consumer, more than one error shape today, or any plans to version. The envelope itself is an afternoon of work; the real value is the discipline it imposes — a small catalogue of stable problem types, occurrence detail kept out of client logic, and extension members where structured data genuinely helps. Skip the temptation to model every error as its own type, keep internals out of detail, and negotiate the migration instead of breaking clients. Do those three things and your error responses stop being an archaeological layer of your API’s history and start being part of its interface.


