Skip to content
APIs

JSON Error Response Format: RFC 9457 Problem Details and Practical Alternatives

·8 min read
JSON Error Response Format: RFC 9457 Problem Details and Practical Alternatives - APIs guide from JSON Prettifier

Quick answer: Use RFC 9457 Problem Details: a JSON object with type, title, status, detail, and instance, served with Content-Type: application/problem+json and the matching HTTP status code. Add an errors array for field-level validation failures and a stable machine-readable code for anything a client needs to branch on. Never include stack traces.

A mobile developer on your team shows you their error handling: a chain of if (message.includes("not found")) checks. It worked until someone fixed the capitalization in one message and the app started showing a generic failure dialog for every missing record. That is what {"error": "something"} costs you. An error response is a contract, and this post is about writing one clients can keep.

Explore a valid sample in the JSON Viewer to check its fields and nesting. The viewer does not perform the protocol-specific validation discussed in this guide.

Why {"error": "message"} Is Not Enough

The single-string error is the default in most tutorials and it fails in four predictable ways:

  • Clients cannot branch on it. "Card declined" and "Card expired" need different UI, and string matching is the only option.
  • It cannot carry more than one problem. A form with three invalid fields produces three round trips.
  • It cannot be localized without the server knowing every language the client supports.
  • It drifts. Every endpoint invents its own shape: error here, message there, errors[] somewhere else. Clients end up with a normalizer that knows all of them.

Fix all four by giving the error body a schema and sticking to it everywhere, including the responses your framework generates for you when a route does not exist. The general shape of request and response bodies is covered in JSON in REST APIs; this post is only about the unhappy path.

RFC 9457 Problem Details: The Standard Error Format

RFC 9457 (which obsoleted RFC 7807 in 2023 without changing the wire format) defines a small JSON object called a problem detail. A minimal example:

HTTP/1.1 402 Payment Required
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/insufficient-funds",
  "title": "Insufficient funds",
  "status": 402,
  "detail": "Your balance is 30.00 but the transfer requires 50.00.",
  "instance": "/transfers/9c4e1a"
}
MemberTypeRoleExample
typestring (URI)Identifies the error category. Defaults to about:blank. Should resolve to docs."https://api.example.com/errors/insufficient-funds"
titlestringShort human-readable summary, the same for every occurrence of this type."Insufficient funds"
statusnumberThe HTTP status code, repeated for clients that lose the transport layer.402
detailstringExplanation specific to this occurrence. For humans, not for parsing."Balance is 30.00, transfer is 50.00."
instancestring (URI)Identifies this specific occurrence, often a request or trace ID path."/requests/7f1c3e"

Every member is optional, but type plus title is the useful minimum. The key design idea is that type is the thing clients switch on. It is a URI, which sounds heavyweight, but it does two jobs: it is globally unique without a registry, and if you make it resolve to a documentation page, a developer who sees it in a log can click it.

The RFC also allows extension members: any additional keys you need. This is where field errors, balances, retry hints, and request IDs go:

{
  "type": "https://api.example.com/errors/insufficient-funds",
  "title": "Insufficient funds",
  "status": 402,
  "detail": "Your balance is 30.00 but the transfer requires 50.00.",
  "instance": "/transfers/9c4e1a",
  "balance": 30.00,
  "required": 50.00,
  "requestId": "req_01J2X8K7P4"
}

Serve it with Content-Type: application/problem+json. The +json suffix means any JSON parser reads it, while clients that recognize the type know to look for these members. The JSON Content-Type guide covers the suffix family and how to make body parsers accept it.

Field-Level Validation Errors

Validation is the one case where a single detail is never enough. Put each failure in an errors array with a pointer to the field, a stable code, and a message:

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/validation",
  "title": "Request validation failed",
  "status": 422,
  "detail": "2 fields are invalid.",
  "instance": "/users",
  "errors": [
    {
      "pointer": "/email",
      "code": "format",
      "message": "Must be a valid email address."
    },
    {
      "pointer": "/address/postalCode",
      "code": "required",
      "message": "Postal code is required for AU addresses."
    }
  ]
}

pointer is a JSON Pointer (RFC 6901) into the request body: /address/postalCode means the postalCode key inside the address object, and /items/2/sku means the third item's SKU. Clients can map it to a form field mechanically. A dotted path like address.postalCode is a common alternative; pick one and never mix them. The syntax is explained in JSONPath vs JSON Pointer.

Return all validation failures in one response rather than stopping at the first one. It is the difference between a form that lights up every bad field at once and one that nags the user five times.

Error Codes vs Messages

The single most useful rule: every error carries two strings with different contracts.

CodeMessage
PurposeMachine branchingHuman display
StabilityNever changes once publishedCan be reworded any time
LocalizationNever translatedTranslate via Accept-Language
Examplevalidation_failedEmail address is not valid.
Who reads itClient code, dashboards, alertsEnd users, developers in logs

In a Problem Details body the type URI plays the code role at the top level, and a code extension plays it inside each field error. Use snake_case or kebab-case codes consistently; the key naming conventions post explains how to choose and stick with one. Document every code, because the list is part of your public API.

For localization, read the Accept-Language request header and translate title, detail, and message on the server, or ship only codes and let the client translate. Either works; what does not work is a client that parses English prose.

What Stripe and GitHub Do

Two widely imitated APIs predate RFC 9457 and use their own envelopes. They are worth knowing because the ideas transfer.

Stripe-style: a single error object with a type (broad category), a code (specific condition), a human message, and a param naming the offending field. Their public docs show bodies in this shape:

{
  "error": {
    "type": "card_error",
    "code": "card_declined",
    "decline_code": "insufficient_funds",
    "message": "Your card has insufficient funds.",
    "param": "payment_method"
  }
}

GitHub-style: a top-level message, an optional errors array with resource, field, and code, and a documentation_url:

{
  "message": "Validation Failed",
  "errors": [
    { "resource": "Issue", "field": "title", "code": "missing_field" }
  ],
  "documentation_url": "https://docs.github.com/rest/issues/issues#create-an-issue"
}

Notice what both share with the RFC: a stable code separate from the message, field-level detail, and a link to documentation. If you are starting fresh, use the RFC shape so clients can reuse generic libraries; if you are maintaining one of these styles, keep it consistent rather than migrating halfway.

Implementing a JSON Error Handler in Express

One error middleware at the end of the chain turns every thrown error into a problem detail. Define a small error class so route code can throw with a type and status:

class ProblemError extends Error {
  constructor({ status, type, title, detail, errors }) {
    super(detail || title);
    Object.assign(this, { status, type, title, detail, errors });
  }
}

// In a route
app.post("/transfers", async (req, res, next) => {
  try {
    const result = await transfer(req.body);
    res.status(201).json(result);
  } catch (err) {
    next(err);
  }
});

// Last middleware: four arguments make it an error handler
app.use((err, req, res, next) => {
  const status = err.status || 500;
  const body = {
    type: err.type || "about:blank",
    title: err.title || (status === 500 ? "Internal Server Error" : err.message),
    status,
    detail: status === 500 ? undefined : err.detail,
    instance: req.originalUrl,
    requestId: req.id,
  };
  if (err.errors) body.errors = err.errors;

  if (status === 500) console.error(req.id, err);   // stack goes to logs only

  res.status(status).type("application/problem+json").json(body);
});

// Unknown routes get the same shape
app.use((req, res) => {
  res.status(404).type("application/problem+json").json({
    type: "https://api.example.com/errors/not-found",
    title: "Not Found",
    status: 404,
    instance: req.originalUrl,
  });
});

Two details matter. res.type() is called before res.json() so the helper does not overwrite the media type. And the 500 branch replaces detail with undefined, which JSON.stringify drops entirely; the null vs undefined post explains why that is the behaviour you want here.

Implementing Problem Details in FastAPI

FastAPI already returns a detail array for validation errors. Override its handler to reshape that into the RFC format and set the media type:

from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException

app = FastAPI()
PROBLEM = "application/problem+json"

@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
    errors = [
        {
            "pointer": "/" + "/".join(str(p) for p in e["loc"] if p != "body"),
            "code": e["type"],
            "message": e["msg"],
        }
        for e in exc.errors()
    ]
    return JSONResponse(
        status_code=422,
        media_type=PROBLEM,
        content={
            "type": "https://api.example.com/errors/validation",
            "title": "Request validation failed",
            "status": 422,
            "detail": f"{len(errors)} field(s) are invalid.",
            "instance": str(request.url.path),
            "errors": errors,
        },
    )

@app.exception_handler(StarletteHTTPException)
async def http_handler(request: Request, exc: StarletteHTTPException):
    return JSONResponse(
        status_code=exc.status_code,
        media_type=PROBLEM,
        content={
            "type": "about:blank",
            "title": exc.detail,
            "status": exc.status_code,
            "instance": str(request.url.path),
        },
    )

The loc tuple from Pydantic already looks like a path (("body", "address", "postalCode")), so joining it with slashes produces a JSON Pointer with almost no work.

Common Mistakes in JSON Error Responses

Retry-related errors need an operation contract as well as a body format. The guide to JSON API idempotency mistakes covers payload mismatches, concurrent requests, stored outcomes, expiration, and duplicate downstream effects.

  • 200 OK with an error body. Some APIs return 200 and {"success": false}. Every HTTP client, cache, and monitoring tool now believes the request worked. Use the real status code; the body elaborates, it does not replace it.
  • HTML error pages from a JSON API. A proxy or framework default returns a 502 page as text/html. The client calls response.json() and gets an unexpected token < error instead of the real failure. Configure the gateway to return JSON for errors too.
  • Leaking internals. Stack traces, SQL fragments, file paths, and library versions belong in server logs keyed by a request ID. Return the ID, not the trace.
  • Different shapes for 4xx and 5xx. Clients write one parser. Make the shape identical and vary the members.
  • Changing a message without versioning. Not a problem if clients branch on codes. A guaranteed outage if they do not, which is one more reason to ship codes from day one.
  • Invalid JSON in error paths. Error handlers are tested less than success paths. A hand-built template with a trailing comma will pass code review and fail in production. Capture a real error response and run it through JSON Prettifier once; it takes seconds and the Validate button finds the comma.

A Checklist for Your Error Contract

  1. One schema for every error, including framework-generated 404s and 405s.
  2. Correct HTTP status code, repeated in the status member.
  3. A stable type URI or code that clients branch on.
  4. A human title and detail that you are free to change.
  5. An errors array with pointers for validation failures.
  6. A request or trace ID for support, and nothing from the stack trace.
  7. Content-Type: application/problem+json.
  8. Every code documented at the URL the type points to.

Frequently Asked Questions

What is the standard format for JSON API error responses?
RFC 9457 Problem Details is the IETF standard. It defines a JSON object with type, title, status, detail, and instance members, served with the Content-Type application/problem+json. It replaced RFC 7807 in 2023 and is backwards compatible with it.
What Content-Type should an error response use?
application/problem+json if you follow RFC 9457, otherwise application/json. Never return an HTML error page from a JSON API; clients that call response.json() on it fail with a parse error that hides the real problem.
Should error responses include a stack trace?
Never in production. Stack traces reveal file paths, library versions, and internal structure to attackers. Log the full trace server-side with a request ID, and return that ID in the response so support can find it.
How should validation errors with multiple fields be returned?
Return an errors array where each entry names the field (as a JSON Pointer or dotted path), a stable code, and a message. Use 400 or 422 for the overall status and keep the top-level title generic, such as Validation failed, so clients can branch on one condition and then iterate the array.
Should I use error codes or error messages?
Both, with different jobs. Codes are stable strings like rate_limited that client code branches on; messages are human-readable text that can change or be translated. Clients that match on message text break the first time you fix a typo.

Check Your Error Bodies Too

Paste a captured error response to validate and format it. Error paths deserve the same scrutiny as the happy path, and this runs entirely in your browser.

Validate My JSON