Stop Inventing Your Own API Error Format: Use RFC 9457 Problem Details
Every API eventually grows its own snowflake error format. One endpoint returns {"error": "not found"}, another returns {"message": "Not Found", "code": 404}, and a third buries the real reason in a 200 OK with {"success": false}. Clients end up writing brittle, per-endpoint parsing logic just to figure out what went wrong.
There is a standard that fixes this: RFC 9457 — Problem Details for HTTP APIs (the update to the older RFC 7807). It defines a single, predictable JSON shape for errors, with a registered media type. If you adopt it, any client — including generic tooling — can understand your errors without custom code.
The shape
A Problem Details response uses the application/problem+json content type and a small set of standard fields:
{
"type": "https://example.com/probs/insufficient-funds",
"title": "Insufficient funds",
"status": 403,
"detail": "Your balance is 30 but the transfer requires 50.",
"instance": "/accounts/12345/transfers/abc",
"balance": 30,
"required": 50
}
The fields mean:
-
type— a URI identifying the kind of problem. It doubles as documentation. -
title— a short, human-readable summary that stays constant for a giventype. -
status— the HTTP status code, repeated in the body for convenience. -
detail— a human-readable explanation specific to this occurrence. -
instance— a URI identifying the specific occurrence.
You can add extension members (balance, required above) so machines can act on the error programmatically, not just display it.
Producing it in Express
Here is a tiny helper plus an example route. No framework magic required:
const express = require("express");
const app = express();
app.use(express.json());
function problem(res, { type = "about:blank", title, status, detail, instance, ...extra }) {
res
.status(status)
.type("application/problem+json")
.json({ type, title, status, detail, instance, ...extra });
}
app.post("/accounts/:id/transfers", (req, res) => {
const balance = 30;
const amount = req.body.amount ?? 0;
if (amount > balance) {
return problem(res, {
type: "https://api.example.com/probs/insufficient-funds",
title: "Insufficient funds",
status: 403,
detail: `Your balance is ${balance} but the transfer requires ${amount}.`,
instance: req.originalUrl,
balance,
required: amount,
});
}
res.status(201).json({ ok: true });
});
app.listen(3000);
The about:blank default for type is exactly what the spec recommends when you have nothing more specific to say — it means "the status code is the whole story."
Validation errors are where it shines
Most APIs return their ugliest errors on validation. Problem Details handles this cleanly with an extension array:
{
"type": "https://api.example.com/probs/validation-error",
"title": "Your request body is invalid",
"status": 422,
"errors": [
{ "field": "email", "detail": "must be a valid email address" },
{ "field": "age", "detail": "must be >= 18" }
]
}
Because errors is a documented extension, every client can render field-level messages the same way across every endpoint.
Consuming it on the client
The payoff is a single, reusable error handler:
async function callApi(url, options) {
const res = await fetch(url, options);
if (res.ok) return res.json();
const ct = res.headers.get("content-type") || "";
if (ct.includes("application/problem+json")) {
const p = await res.json();
throw new Error(`${p.title} (${p.status}): ${p.detail ?? ""}`);
}
throw new Error(`Request failed: ${res.status}`);
}
One handler, every endpoint, no per-route special cases.
A few practical rules
Keep type URIs stable — clients may branch on them, so treat them as part of your public contract. Never put sensitive data in detail, since it's meant to be shown to users. And always set the real HTTP status code; the body mirrors it, but proxies, caches, and monitoring still rely on the status line.
Closing
Adopting a standard error format is one of the cheapest reliability upgrades you can make, but it only helps if it's applied consistently across every endpoint — which is hard to enforce by hand. This is where a workspace like APIKumo helps: you can define, send, and document requests in one place, capture real problem+json responses as examples, and keep your error contract consistent as your API grows. Standardize the shape once, and every future client — and every future you — gets to stop guessing.
Top comments (0)