API Design · Lesson 12

Problem Details for HTTP APIs (RFC 9457)

Design RFC 9457 problem details for HTTP APIs with stable type URIs, safe extensions, validation errors, Spring Boot, and Node.js examples.

An HTTP status code tells a client the broad class of failure. It rarely gives enough information to fix the request, display a useful message, or connect the failure to support logs.

RFC 9457 defines a common problem-details document for that gap. It replaces RFC 7807 and standardizes a small JSON object that APIs can extend without inventing a different error envelope for every endpoint.

Quick answer

Return RFC 9457 problem details when an HTTP API needs a machine-readable error document:

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

{
  "type": "https://api.example.com/problems/validation-failed",
  "title": "Request validation failed",
  "status": 422,
  "detail": "Two request fields are invalid.",
  "instance": "https://api.example.com/problem-occurrences/req_01JZ8Y",
  "errors": [
    {
      "pointer": "#/email",
      "code": "invalid_format"
    }
  ]
}

Use a stable type URI to identify the category of problem. Keep title stable for that type, use detail for this occurrence, and use instance to identify this particular failure. Add typed extension members for information clients must process.

Do not expose stack traces, SQL, internal hostnames, secrets, or raw exception messages. Clients must branch on type or defined extension fields, not parse the human-readable detail string.

What RFC 9457 standardizes

The authoritative source is RFC 9457, published in July 2023. It obsoletes RFC 7807 and defines the application/problem+json and application/problem+xml media types.

The JSON object has five standard members:

MemberPurpose
typeURI reference identifying the problem type
statusAdvisory copy of the HTTP response status
titleShort human-readable summary of the type
detailHuman-readable explanation of this occurrence
instanceURI reference identifying this occurrence

The object can include extension members. An extension is not a free-form dumping ground; it is part of the problem type’s contract.

A minimal response can be:

{
  "title": "Not Found",
  "status": 404
}

When type is absent, it defaults to about:blank. In that case, the problem has no semantics beyond the HTTP status code and title should match the status phrase. Use a custom type only when it communicates a reusable, documented condition.

Read REST API Status Codes Explained before choosing the response status.

Type is the machine-readable identity

Type is the most important field for client logic.

A useful type URI is stable and controlled by the API owner:

{
  "type": "https://api.example.com/problems/insufficient-credit"
}

The RFC recommends that an HTTP problem type URI resolve to human-readable documentation when possible. That page can explain:

  • What the problem means.
  • The usual HTTP status.
  • Which extension fields may appear.
  • Whether a retry can succeed.
  • What the client should change.
  • Security or privacy considerations.
  • Example requests and responses.

Do not create a new type for every exception class. Internal names such as NullPointerException or DatabaseTimeoutException are implementation details, not public API concepts.

Also avoid unstable identifiers:

{
  "type": "https://api.example.com/problems/build-20260718-error-42"
}

A client needs a durable category such as quota-exceeded, validation-failed, or account-suspended. The occurrence belongs in instance, logs, and tracing.

Keep title and detail separate

Title summarizes the type and should remain stable across occurrences except for localization.

{
  "title": "Request validation failed"
}

Detail explains the current occurrence:

{
  "detail": "The start date must be before the end date."
}

Clients should display detail only when the API guarantees it is safe for the end user. They should not parse it. If a client needs a field name, retry time, balance, or error code, expose a typed extension.

Bad client contract:

{
  "detail": "Field email has error INVALID_FORMAT"
}

The frontend now has to parse English prose.

Better:

{
  "detail": "One request field is invalid.",
  "errors": [
    {
      "pointer": "#/email",
      "code": "invalid_format"
    }
  ]
}

The message can change or be translated while pointer and code remain stable.

Status is advisory

The actual HTTP status line remains authoritative for generic HTTP software:

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

If the body includes status, it must match what the origin server generated:

{
  "status": 409
}

Why duplicate it? Problem objects may be logged, stored, or forwarded without the original HTTP metadata. The field is convenient in those contexts.

A proxy could change the HTTP status while a stored body still says something else, so clients must not use the JSON member to override transport behavior.

Never return HTTP 200 with a body whose status says 400. That breaks caching, monitoring, SDKs, and every generic HTTP component between the caller and server.

Instance is an occurrence identifier

Instance identifies one particular occurrence, not the problem category.

A useful absolute URI is:

{
  "instance": "https://api.example.com/problem-occurrences/req_01JZ8Y"
}

It does not have to be publicly dereferenceable. It can be an opaque identifier that support staff map to a request ID or trace.

If it is dereferenceable, protect it like any other sensitive diagnostic resource. Require authorization and prevent one tenant from reading another tenant’s incident data.

Relative instance values can be confusing because they resolve against the document base URI. If using a relative reference, include a complete path:

{
  "instance": "/problem-occurrences/req_01JZ8Y"
}

Do not put stack traces, database keys, email addresses, or access tokens in the URI.

Design validation errors deliberately

Validation often produces several errors that share one problem type. RFC 9457 includes an example of an errors extension with JSON Pointer locations.

A practical contract is:

{
  "type": "https://api.example.com/problems/validation-failed",
  "title": "Request validation failed",
  "status": 422,
  "detail": "Two request fields are invalid.",
  "errors": [
    {
      "pointer": "#/email",
      "code": "invalid_format"
    },
    {
      "pointer": "#/profile/displayName",
      "code": "too_short",
      "minimum": 3
    }
  ]
}

Use a documented pointer format. JSON Pointer works well for JSON request bodies and aligns with existing JSON tooling. Use stable codes, not localized strings, for client decisions.

When problems do not share one type, RFC 9457 recommends representing the most relevant or urgent problem rather than combining unrelated failures into a generic batch.

Read JSON Pointer Resolver for testing pointer paths and JSON Schema vs OpenAPI Schema for documenting validation contracts.

Spring Boot implementation

Spring Framework supports ProblemDetail as an RFC 9457 representation. The Spring error response documentation explains its integration with ErrorResponse and ResponseEntityExceptionHandler.

A focused exception handler can translate a domain exception:

@RestControllerAdvice
public final class ApiExceptionHandler {

    @ExceptionHandler(OrderStateConflictException.class)
    ResponseEntity<ProblemDetail> handleOrderConflict(
            OrderStateConflictException exception,
            HttpServletRequest request
    ) {
        ProblemDetail problem =
                ProblemDetail.forStatus(HttpStatus.CONFLICT);

        problem.setType(URI.create(
                "https://api.example.com/problems/order-state-conflict"
        ));
        problem.setTitle("Order state conflict");
        problem.setDetail(
                "The order cannot transition from " +
                exception.currentState() +
                " to " +
                exception.requestedState() +
                "."
        );
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("errorCode", "order_state_conflict");
        problem.setProperty("requestId", request.getHeader("X-Request-Id"));

        return ResponseEntity
                .status(HttpStatus.CONFLICT)
                .contentType(MediaType.APPLICATION_PROBLEM_JSON)
                .body(problem);
    }
}

The handler maps a public domain condition, not every raw exception. A final fallback handler should log the internal exception and return a generic 500 problem without exception text.

For field validation, map binding errors to a typed list:

record FieldProblem(String pointer, String code) {}

List<FieldProblem> errors = bindingResult.getFieldErrors().stream()
        .map(error -> new FieldProblem(
                "#/" + error.getField().replace(".", "/"),
                error.getCode() == null ? "invalid" : error.getCode()
        ))
        .toList();

problem.setProperty("errors", errors);

Do not expose rejected password, token, or secret values from a binding error.

Node.js and Express implementation

Express recognizes error-handling middleware by its four arguments. The Express error handling guide recommends passing asynchronous failures to the error pipeline.

Define public errors separately from unexpected exceptions:

class ApiProblem extends Error {
  constructor({ type, title, status, detail, extensions = {} }) {
    super(detail);
    this.type = type;
    this.title = title;
    this.status = status;
    this.detail = detail;
    this.extensions = extensions;
  }
}

function problemDetails(error, req, res, next) {
  if (res.headersSent) {
    return next(error);
  }

  const known = error instanceof ApiProblem;
  const status = known ? error.status : 500;

  if (!known) {
    req.log.error({ error, requestId: req.id }, "Unhandled request error");
  }

  const body = {
    type: known
      ? error.type
      : "https://api.example.com/problems/internal-error",
    title: known ? error.title : "Internal server error",
    status,
    detail: known
      ? error.detail
      : "The request could not be completed.",
    instance: "/problem-occurrences/" + req.id,
    ...(known ? error.extensions : {}),
    requestId: req.id
  };

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

A route can raise a documented problem:

throw new ApiProblem({
  type: "https://api.example.com/problems/order-state-conflict",
  title: "Order state conflict",
  status: 409,
  detail: "A shipped order cannot be cancelled.",
  extensions: {
    errorCode: "order_state_conflict"
  }
});

Register the middleware after routes. Keep logging and response serialization centralized so one route cannot accidentally leak a stack trace.

Content negotiation and compatibility

A server can return application/problem+json even when a client listed application/json, depending on normal HTTP negotiation. In practice, document the behavior and test the clients you support.

If an existing API already returns a stable error envelope, migration needs care. Replacing this:

{
  "error": "validation_failed",
  "message": "The request is invalid."
}

with a problem document can break generated clients and frontend code.

Safer migration options include:

  • Introduce problem details in a new API version.
  • Negotiate with Accept during a transition.
  • Preserve a stable errorCode extension.
  • Update OpenAPI schemas and SDKs before switching.
  • Measure old-client traffic before removing the legacy shape.

Problem details avoid inventing a new base format; they do not remove the need for compatibility planning.

Read API Versioning Explained and API Deprecation Headers Explained before changing a public error contract.

Security and privacy review

RFC 9457 warns that problem documents can expose attack and privacy information.

Never include:

  • Stack traces or source paths.
  • Raw SQL or database names.
  • Internal service URLs.
  • Secrets, tokens, cookies, or Authorization values.
  • Password rules that reveal whether a specific account exists.
  • Another tenant’s identifiers.
  • Detailed dependency failures useful for reconnaissance.

Be careful with validation. Returning that an email is already registered can enable account enumeration. A password-reset endpoint may need the same outward response whether an account exists or not.

Instance URLs and request IDs also require review. They are useful for support but should not reveal sequential internal IDs or grant unauthenticated access to logs.

Log more detail internally, with redaction and access control. Keep the public problem focused on what the client can safely do next.

OpenAPI contract

Define reusable schemas rather than copying fields across responses.

Problem:
  type: object
  required: [type, title, status]
  properties:
    type:
      type: string
      format: uri-reference
    title:
      type: string
    status:
      type: integer
      minimum: 100
      maximum: 599
    detail:
      type: string
    instance:
      type: string
      format: uri-reference
    errorCode:
      type: string

Document specific problem types as examples or specialized schemas. List the responses an operation can return and keep the HTTP status aligned with the problem object.

Use the JSON Schema Generator to prototype a schema and OpenAPI Explained for Backend Developers for reusable response components.

Contract and integration tests

Test problem documents as public API behavior:

  • Content-Type is application/problem+json.
  • HTTP status and body status match.
  • Type is the expected stable URI.
  • Title is stable for the problem type.
  • Detail contains no raw exception text.
  • Instance or requestId maps to the internal log entry.
  • Validation pointers use the documented format.
  • Unknown extension members do not break clients.
  • Authentication failures do not leak account existence.
  • A fallback 500 response never includes stack traces.
  • OpenAPI examples match actual responses.

Also test localization if title or detail changes with Accept-Language. Machine-readable fields must remain stable across languages.

Common mistakes

The first mistake is changing only Content-Type while keeping an undocumented custom body.

The second mistake is using detail as a machine-readable error code.

The third mistake is generating a new type URI for each request or exception class.

The fourth mistake is exposing internal debugging data in extensions.

The fifth mistake is returning 200 while the problem body says 4xx or 5xx.

The sixth mistake is calling an RFC 7807 implementation current without checking RFC 9457 differences and documentation.

The seventh mistake is returning validation values that contain passwords, tokens, or personal data.

Practical recommendation

Start with a small registry of documented public problem types. For each type, define:

  • Stable type URI and title.
  • Expected HTTP status.
  • Safe detail guidance.
  • Typed extension fields.
  • Retry behavior.
  • Security considerations.
  • OpenAPI example.
  • Contract tests.

Centralize serialization in Spring advice or Express error middleware, preserve request correlation, and keep a generic safe fallback for unexpected exceptions. Problem details work best as a deliberate API vocabulary, not an automatic dump of the exception hierarchy.

Read REST API Error Handling Best Practices, REST API Status Codes Explained, and API Authentication Best Practices for the surrounding error contract.

For adjacent operational standards, read API Rate Limit Headers Explained and API Deprecation Headers Explained. Review RFC 9457, the Spring ProblemDetail documentation, and the Express error handling guide before implementation.

Knowledge check

Check your understanding

Answer both questions correctly to mark this lesson as mastered. You can retry without penalty.

1. Which description best matches Problem Details for HTTP APIs (RFC 9457)?

2. Which statement matches the lesson's quick answer?