API Error Responses: How to Use Problem Details (RFC 9457)

Every API I’ve worked on has had its own idea of what an error looks like. One endpoint returns {"error": "Not found"}, another returns {"message": "...", "code": 1042}, and a third sends a 200 with "success": false buried inside. API error responses are the part of an API people design last, and it shows. The frontend ends up with a pile of special cases just to show a sensible message.
There’s a standard for this, and it’s been around since 2016. It’s called Problem Details for HTTP APIs, and the current version is RFC 9457. It’s small, it’s boring in the best way, and once you adopt it your error handling gets a lot simpler on both sides.
Key takeaways
- Problem Details is a standard JSON shape for API errors, served with the
application/problem+jsoncontent type.- The core members are
type,title,status,detailandinstance. All of them are optional.typeis the machine-readable identifier. Clients should branch on it, never on thedetailtext.- The
statusmember must match the real HTTP status code. It’s advisory, not a replacement.- You can add your own fields, like an
errorsarray for validation problems, and clients must ignore fields they don’t know.- Never put stack traces, SQL errors or internal hostnames in an error response.
Short answer: return errors with the correct HTTP status code and a JSON body in the Problem Details format from RFC 9457. Set Content-Type: application/problem+json, give each kind of error a stable type URI, keep title short and fixed, and use detail for a message that helps the caller fix this specific request.
What is Problem Details for HTTP APIs?
Problem Details is an IETF standard, RFC 9457, that defines one JSON object for describing an error in an HTTP API. It gives every error the same predictable fields, so clients can handle failures from any endpoint with the same code. It was published in July 2023 and replaces the older RFC 7807.
Here’s what one looks like. This is close to the example in the RFC itself:
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
Content-Language: en
{
"type": "https://example.com/probs/out-of-credit",
"title": "You do not have enough credit.",
"detail": "Your current balance is 30, but that costs 50.",
"instance": "/account/12345/msgs/abc",
"balance": 30,
"accounts": ["/account/12345", "/account/67890"]
}
That’s the whole idea. A status code that tells generic HTTP tools what happened, and a body that tells your client exactly which problem it hit and what to do about it.
The balance and accounts fields aren’t part of the standard. They’re extensions, and the spec explicitly allows them. More on that below.
Why do API error responses need a standard format?
API error responses need a standard format because every client has to parse them. When each endpoint invents its own shape, frontend and integration code fills up with special cases, and errors get shown to users as raw text or swallowed entirely. One shared format means one error handler, written once and reused everywhere.
I’ve seen the cost of not having one many times. A React form shows “Something went wrong” because the code only checked error.message, and this endpoint used error.msg. A mobile app crashes because it expected JSON and got an HTML error page from a proxy. A partner integration retries a request forever because nothing told it the problem was permanent.
None of those are hard bugs. They’re just the result of guessing.
A shared format fixes the guessing. Here’s how the common approaches compare:
| Approach | What the client gets | Problem |
|---|---|---|
| Status code only | 404, no body | No way to say which thing was missing or why |
200 with "success": false | A “successful” response that isn’t | Caches, monitoring and HTTP tools all think it worked |
Custom { "error": "..." } | A string, shape varies per team | Clients end up parsing message text |
| Problem Details (RFC 9457) | Correct status plus a typed, documented body | Needs a little discipline to define types |
The last row is the only one where the client can make a decision without reading English.
What fields does a Problem Details response have?
A Problem Details object has five standard members: type, title, status, detail and instance. All are optional. type identifies the kind of problem, title summarizes it, status repeats the HTTP status code, detail explains this occurrence, and instance identifies the specific occurrence.

Here’s what each one is for, in plain terms:
typeis a URI that identifies the problem type. It’s the primary identifier, so it’s what your client code should check. If you leave it out, it defaults toabout:blank.titleis a short, human-readable summary of the problem type. The spec says it shouldn’t change from one occurrence to the next, except for translation.statusis the HTTP status code as a number. It’s advisory. The real status code on the response still has to be the same value.detailis a human-readable explanation of this particular occurrence. It should help the caller fix the problem, not help you debug your server.instanceis a URI reference for this specific occurrence. I usually make it a unique ID I can search for in the logs.
Two rules from the spec catch people out.
First, consumers shouldn’t parse detail for information. If the client needs the current balance, put it in a balance field. Don’t make it pull a number out of a sentence.
Second, if a member has the wrong type, say status is a string, the client must ignore that member and carry on as if it wasn’t there. That’s a nice safety net when you’re consuming someone else’s API.
How should the type URI work?
The type URI is a stable identifier for one kind of error, like https://api.example.com/problems/out-of-credit. Clients compare it as a string and branch on it. The RFC recommends absolute URIs, and if the URI is a web address, opening it should show human-readable documentation about the problem.
I treat type URIs like an enum that happens to be a URL. Once you publish one, don’t rename it. A client somewhere has a switch statement that depends on it.
A few habits that work well:
- Use absolute URIs on a domain you control. The RFC recommends absolute URIs because relative ones resolve differently depending on the endpoint that returned them.
- Make them resolve to a docs page that explains the error and how to fix it. The spec encourages this.
- Don’t make clients fetch the URI at runtime. The RFC says consumers shouldn’t automatically dereference it.
- Use
about:blankwhen the status code says everything. In that casetitleshould match the standard HTTP status phrase, like “Not Found”.
That last point matters more than it sounds. You don’t need a custom type for every 404. The RFC itself says truly generic problems are usually better expressed as plain status codes. Save custom types for errors the client can actually act on, like an expired card or a duplicate email address.
Which HTTP status codes should API error responses use?
API error responses should use the most specific HTTP status code that fits. Use 4xx when the client can fix the request and 5xx when the server failed. The status code drives retries, caching and monitoring, so a Problem Details body adds detail to it but never replaces it.
This is the table I keep coming back to. The MDN HTTP status code reference is the place to check the full list.
| Status | Use it when | Safe to retry? |
|---|---|---|
| 400 Bad Request | The request is malformed, like broken JSON | No, fix the request |
| 401 Unauthorized | No valid credentials were sent | After signing in again |
| 403 Forbidden | Credentials are fine but not allowed to do this | No |
| 404 Not Found | The resource doesn’t exist | No |
| 409 Conflict | The request clashes with current state | Sometimes, after refetching |
| 422 Unprocessable Content | Well-formed JSON that fails validation | No, fix the input |
| 429 Too Many Requests | The client hit a rate limit | Yes, after Retry-After |
| 500 Internal Server Error | Something broke on the server | Maybe, with backoff |
| 503 Service Unavailable | Overloaded or down for maintenance | Yes, after Retry-After |
Two small notes. A 401 response is required by the HTTP spec to include a WWW-Authenticate header, so don’t skip it. And the RFC lets a problem type say when to use Retry-After, which fits nicely with rate limiting that doesn’t punish real users.
Retries are where the status code really earns its keep. If a client retries a 500 on a payment endpoint, you want that endpoint protected with idempotency keys so the retry can’t charge twice.
How do you return validation errors with Problem Details?
Return validation errors with a 422 or 400 status and one problem type for validation, then add an errors extension array. Each item gets a detail message and a pointer, a JSON Pointer to the field that failed. The RFC shows exactly this pattern, so it’s a safe default.
The example in the RFC looks like this:
{
"type": "https://example.net/validation-error",
"title": "Your request is not valid.",
"errors": [
{ "detail": "must be a positive integer", "pointer": "#/age" },
{ "detail": "must be 'green', 'red' or 'blue'", "pointer": "#/profile/color" }
]
}
A JSON Pointer, defined in RFC 6901, is a string path into a JSON document. #/profile/color means the color field inside profile. The frontend can map that straight onto a form field and show the message right next to it.

If you already validate request bodies with Zod, the mapping is short. I covered the validation side in Zod validation in TypeScript. Here’s how I turn Zod issues into that errors array:
import { z } from 'zod';
const Signup = z.object({
email: z.string().email(),
age: z.number().int().positive(),
});
// JSON Pointer needs ~ and / escaped inside each segment
const toPointer = (path) =>
'#/' + path.map((p) => String(p).replace(/~/g, '~0').replace(/\//g, '~1')).join('/');
export function validateSignup(body) {
const result = Signup.safeParse(body);
if (result.success) return result.data;
throw new HttpProblem({
type: 'https://api.example.com/problems/validation-error',
title: 'Your request is not valid.',
status: 422,
errors: result.error.issues.map((issue) => ({
detail: issue.message,
pointer: toPointer(issue.path),
})),
});
}
What about several different problems at once, like a validation error and an auth error? The RFC recommends returning the most relevant or urgent one. Generic “batch” problem types don’t map well onto HTTP, so I don’t build them.
How do you send Problem Details from an Express API?
In Express, create one error class that carries the problem object, throw it from routes, and handle everything in a single error middleware. That middleware sets the status, sets Content-Type: application/problem+json, adds a unique instance ID, and turns unknown errors into a plain 500 without internal details.
Here’s the class:
// problem.js
export class HttpProblem extends Error {
constructor({ type = 'about:blank', title, status = 500, detail, ...extensions }) {
super(detail || title);
this.problem = { type, title, status, detail, ...extensions };
}
}
And the error middleware, registered after all your routes:
import { randomUUID } from 'node:crypto';
import { HttpProblem } from './problem.js';
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
const instance = `urn:uuid:${randomUUID()}`;
let problem;
if (err instanceof HttpProblem) {
problem = err.problem;
} else {
// Unknown error: log everything, send nothing internal
console.error(instance, err);
problem = { type: 'about:blank', title: 'Internal Server Error', status: 500 };
}
res
.status(problem.status)
.type('application/problem+json')
.json({ ...problem, instance });
});
A route then just throws:
app.get('/orders/:id', async (req, res) => {
const order = await db.orders.find(req.params.id);
if (!order) {
throw new HttpProblem({ title: 'Not Found', status: 404, detail: `No order with id ${req.params.id}.` });
}
res.json(order);
});
One version note. Express 5 passes rejected promises from async handlers to the error middleware for you. On Express 4 you need to wrap async handlers or call next(err) yourself, otherwise the throw never reaches the middleware.
Other stacks have this built in. Spring Framework 6 has a ProblemDetail class, and ASP.NET Core has ProblemDetails support. Check your framework before writing your own.
How should the frontend handle API error responses?
The frontend should check response.ok, then look at the Content-Type header. If it contains application/problem+json, parse it and branch on type. If it doesn’t, build a fallback problem from the status code, because proxies, CDNs and load balancers often return HTML error pages.
That fallback is the bit most people forget. Your API might be perfect, but the gateway in front of it doesn’t know about RFC 9457. A 502 from a proxy will usually be HTML.
export class ApiProblem extends Error {
constructor(problem) {
super(problem.detail || problem.title || 'Request failed');
this.problem = problem;
}
}
export async function api(url, options = {}) {
const res = await fetch(url, {
...options,
headers: { Accept: 'application/json, application/problem+json', ...options.headers },
});
if (res.ok) return res.status === 204 ? null : res.json();
const contentType = res.headers.get('content-type') || '';
const problem = contentType.includes('application/problem+json')
? await res.json()
: { type: 'about:blank', title: res.statusText, status: res.status };
throw new ApiProblem(problem);
}
Then in the UI you branch on the type, not the text:
try {
await api('/api/signup', { method: 'POST', body: JSON.stringify(form) });
} catch (err) {
const p = err.problem;
if (p?.type === 'https://api.example.com/problems/validation-error') {
showFieldErrors(p.errors);
} else if (p?.status === 429) {
showToast('Too many attempts. Try again in a minute.');
} else {
showToast(p?.title || 'Something went wrong.');
}
}
If your frontend and API live on different origins, remember the error responses need CORS headers too. A missing header on a 422 makes the browser hide the body, and you’ll see a CORS error instead of your nice validation message.
What should never go in an API error response?
Never put stack traces, SQL queries, file paths, internal hostnames, library versions or raw exception messages in an API error response. The RFC warns that problem details aren’t a debugging tool. Log the full error on the server under the instance ID, and send the client only what helps it fix the request.

This is where the instance field pays off. The client gets something like urn:uuid:3f1c..., support can ask for it, and you search your logs for the full story. Nobody outside your team ever sees the stack trace.
A few more things I watch for:
- Don’t confirm whether an account exists on login or password reset errors. Use the same response either way.
- Don’t echo raw user input back into
detailwithout thinking. If something renders it as HTML, that’s an injection risk. - Don’t return a 404 for some IDs and a 403 for others if that leaks which records exist. Pick one.
How do you add Problem Details to an existing API?
Add Problem Details to an existing API gradually. Start with the error middleware so new errors use the format, keep old fields during a transition if clients depend on them, and document each type URI. The RFC also allows content negotiation, so clients can request the new format through the Accept header.
Here’s the order I’d do it in:
- List the error shapes your API returns today. It’s usually more than you think.
- Add the central error middleware, and make unknown errors return a safe 500 problem.
- Define a small set of custom types for errors clients act on, like validation, conflicts and payment problems.
- Write a short docs page for each type URI.
- Update the frontend to use one
api()helper like the one above. - Remove the old error fields once every client has moved over.
Before inventing a type, look at the IANA HTTP Problem Types registry. RFC 9457 created it so common problem types can be shared and reused.
What’s the difference between RFC 7807 and RFC 9457?
RFC 9457 replaced RFC 7807 in July 2023 without changing the format. Responses that followed RFC 7807 are still valid. The new version adds an IANA registry of shared problem types, clearer guidance on reporting multiple problems, and advice on type URIs that can’t be opened in a browser.
So if a library or blog post says “RFC 7807”, it’s talking about the same thing. The media type is still application/problem+json, and there’s an XML version too, application/problem+xml, if you need it.
The full text of RFC 9457 is short and readable. It’s worth half an hour, mostly for the security section and the examples.
Is Problem Details worth it for a small API?
Yes, Problem Details is worth it even for a small API. It takes one error class, one middleware and one fetch helper to set up. After that, every error has the same shape and a stable type, so the frontend needs less special-case code and failures are much easier to trace.
I’d use it on a weekend project too, honestly.
In return for that small setup, every error your API sends has the same shape, the right status code and a stable identifier your frontend can trust. That’s less code on the client, fewer “Something went wrong” messages, and a much easier time when you debug a failure from a log line.
If you also deal with incoming events from third parties, the same mindset helps there. Clear status codes and predictable bodies are half of verifying webhook signatures properly, because the sender decides whether to retry based on what you return.
Start with the middleware today. You can define the custom types as you need them.