Skip to main content

Implementing RFC 7807 Problem Details Responses

Every endpoint in the service returns a different error shape. The /users endpoint returns {"error": "not found"}, the /orders endpoint returns {"message": "validation failed", "fields": [...]}, and the payment service returns a raw string. Client teams have written four separate error-parsing branches, all of which break silently when an upstream service changes its payload. This guide extends Designing Robust Error Response Contracts by replacing that chaos with a single, machine-readable format: application/problem+json as defined in RFC 9457 (which supersedes RFC 7807).

RFC 9457 Problem Details Response Flow Diagram showing how an API error handler maps thrown exceptions through a ProblemDetail mapper, sets the correct Content-Type header, and returns a structured application/problem+json body to the client. Client HTTP Request Error Handler catch(err) toProblemDetail(err) res.status(status) ProblemDetail type (URI) title · status detail · instance extensions… Response HTTP 4xx/5xx Content-Type: application/ problem+json JSON body ↓ client OpenAPI ProblemDetail component constrains both boxes above

Symptom

API clients receive a different error payload shape from every service boundary:

GET /users/99
← 404 {"error": "not found"}

POST /orders  (invalid body)
← 422 {"message": "validation failed", "fields": [{"name": "qty", "issue": "required"}]}

POST /payments  (card declined)
← 402 "Payment method declined"

Client-side error handling becomes a brittle tangle of if (body.error)… else if (body.message)… else if (typeof body === 'string')…. When any upstream team changes its error format, clients break silently. Generated TypeScript types from Compile-Time Type Generation from OpenAPI collapse to any for error responses because the spec cannot express a single coherent schema.

Root Cause

No shared error contract exists. Each service author invents a payload shape in the moment, guided only by the framework’s default exception serializer. Express returns {"error": "..."}, FastAPI returns {"detail": [...]}, Spring Boot returns {"timestamp":..., "status":..., "error":..., "path":...}. None of these shapes is machine-readable in a cross-service sense — there is no stable field name for “what went wrong” or “which resource was affected.”

RFC 7807 (2016) and its successor RFC 9457 (2023) solve this by defining exactly five standard fields and a dedicated MIME type. Clients can parse any RFC 9457 response with a single code path regardless of which service produced it.

The five standard fields are:

Field Type Required Purpose
type URI string Yes (defaults to about:blank) Identifies the problem type; used as a stable error class identifier
title string Yes Short human-readable summary of the problem type
status integer Yes HTTP status code (mirrors the actual response status)
detail string Yes Human-readable, instance-specific explanation
instance URI-reference string No URI identifying the specific occurrence (e.g. a trace or request ID path)

Any additional members are extension fields and are explicitly permitted by the spec. You define and document them; the standard does not restrict them.

Step-by-Step Fix

Step 1 — Define the TypeScript interface and error-to-problem mapper

Create a shared module that owns the ProblemDetail type and the logic that converts application errors into it. Centralizing this prevents individual route handlers from inventing shapes.

// src/errors/problem-detail.ts  (TypeScript 5.4)

export interface ProblemDetail {
  type: string;           // URI — identifies the problem class
  title: string;          // Short summary, stable per problem type
  status: number;         // HTTP status code
  detail: string;         // Instance-specific human explanation
  instance?: string;      // URI-reference for this occurrence
  // Extension fields (define explicitly; do not use a free-form index signature)
  errors?: FieldError[];  // validation extension
  error_code?: string;    // internal machine-readable code
}

export interface FieldError {
  field: string;
  message: string;
}

// Typed application error base class
export class AppError extends Error {
  constructor(
    public readonly problemType: string,
    public readonly title: string,
    public readonly status: number,
    public readonly detail: string,
    public readonly instance?: string,
    public readonly extensions?: Partial<Pick<ProblemDetail, 'errors' | 'error_code'>>,
  ) {
    super(detail);
    this.name = 'AppError';
  }
}

// Well-known problem types — keep these stable across releases
export const ProblemTypes = {
  VALIDATION_ERROR:   'https://api.example.com/problems/validation-error',
  NOT_FOUND:          'https://api.example.com/problems/not-found',
  CONFLICT:           'https://api.example.com/problems/conflict',
  INTERNAL_ERROR:     'https://api.example.com/problems/internal-error',
  PAYMENT_REQUIRED:   'https://api.example.com/problems/payment-required',
} as const;

/** Map any thrown value to a ProblemDetail object. */
export function toProblemDetail(err: unknown, requestId?: string): ProblemDetail {
  const instance = requestId ? `/requests/${requestId}` : undefined;

  if (err instanceof AppError) {
    return {
      type:     err.problemType,
      title:    err.title,
      status:   err.status,
      detail:   err.detail,
      instance: err.instance ?? instance,
      ...err.extensions,
    };
  }

  // Unknown errors — do not leak internal details
  return {
    type:     ProblemTypes.INTERNAL_ERROR,
    title:    'Internal Server Error',
    status:   500,
    detail:   'An unexpected error occurred. Please try again later.',
    instance,
  };
}

Why this works: The AppError base class carries all RFC 9457 fields as first-class typed properties. The toProblemDetail function is a pure transformation — it has no I/O side effects, making it independently testable. Unknown errors (bugs, unhandled third-party throws) always collapse to a safe 500 shape that never leaks stack traces.

Step 2 — Write the Express error-handling middleware

Register a single four-argument middleware at the bottom of your Express application stack. This is the only place in the codebase that should write error responses.

// src/middleware/error-handler.ts  (Express 4.x / 5.x)

import { Request, Response, NextFunction } from 'express';
import { toProblemDetail } from '../errors/problem-detail.js';

export function problemDetailErrorHandler(
  err: unknown,
  req: Request,
  res: Response,
  _next: NextFunction,
): void {
  // Extract request ID from header set by your load balancer / tracing layer
  const requestId = req.headers['x-request-id'] as string | undefined;
  const problem = toProblemDetail(err, requestId);

  res
    .status(problem.status)
    // RFC 9457 requires this exact content type for problem responses
    .set('Content-Type', 'application/problem+json; charset=utf-8')
    .json(problem);
}

Register it after all routes:

// src/app.ts
import express from 'express';
import { problemDetailErrorHandler } from './middleware/error-handler.js';
import { userRouter } from './routes/users.js';

const app = express();
app.use(express.json());
app.use('/users', userRouter);

// Must be last — Express identifies error handlers by arity (4 parameters)
app.use(problemDetailErrorHandler);

Throw typed errors from route handlers or service layers:

// src/routes/users.ts
import { Router } from 'express';
import { AppError, ProblemTypes, FieldError } from '../errors/problem-detail.js';

export const userRouter = Router();

userRouter.get('/:id', async (req, res, next) => {
  try {
    const user = await db.users.findById(req.params.id);
    if (!user) {
      throw new AppError(
        ProblemTypes.NOT_FOUND,
        'User Not Found',
        404,
        `No user exists with id ${req.params.id}.`,
      );
    }
    res.json(user);
  } catch (err) {
    next(err);  // always delegate to the central error handler
  }
});

Why this works: Express error-handling middleware only fires when next(err) is called with a non-null argument. A single registration point means every error — routing errors, thrown exceptions, async rejections (Express 5 catches these automatically; Express 4 needs express-async-errors or manual wrapping) — flows through the same formatter.

Step 3 — Declare the OpenAPI ProblemDetail response component

Add a reusable ProblemDetail schema and response objects to your OpenAPI 3.1 specification. Reference them everywhere with $ref to enforce the contract without duplication. This directly complements standardizing HTTP error codes in OpenAPI definitions.

# openapi.yaml (OpenAPI 3.1)
components:
  schemas:
    ProblemDetail:
      type: object
      required: [type, title, status, detail]
      # Explicitly set false — prevents undocumented extension fields from silently appearing
      additionalProperties: false
      properties:
        type:
          type: string
          format: uri
          description: >
            URI that identifies the problem type. Stable across occurrences.
            Defaults to "about:blank" when no specific type applies.
          example: https://api.example.com/problems/not-found
        title:
          type: string
          description: Short, human-readable summary of the problem type.
          example: User Not Found
        status:
          type: integer
          minimum: 400
          maximum: 599
          description: HTTP status code. Must mirror the actual response status.
          example: 404
        detail:
          type: string
          description: Human-readable explanation specific to this occurrence.
          example: No user exists with id f47ac10b.
        instance:
          type: string
          format: uri-reference
          description: URI-reference identifying this specific occurrence.
          example: /requests/a1b2c3d4
        # Extension: validation errors
        errors:
          type: array
          items:
            $ref: '#/components/schemas/FieldError'
          description: Field-level validation failures (validation-error type only).
        error_code:
          type: string
          pattern: '^[A-Z_]+$'
          description: Internal machine-readable error identifier.
          example: USER_NOT_FOUND

    FieldError:
      type: object
      required: [field, message]
      additionalProperties: false
      properties:
        field:
          type: string
          example: email
        message:
          type: string
          example: Must be a valid email address.

  responses:
    NotFound:
      description: The requested resource does not exist.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: https://api.example.com/problems/not-found
            title: User Not Found
            status: 404
            detail: No user exists with id f47ac10b.
            instance: /requests/a1b2c3d4

    ValidationError:
      description: The request body failed schema validation.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: https://api.example.com/problems/validation-error
            title: Validation Error
            status: 422
            detail: The request body contains invalid fields.
            errors:
              - field: email
                message: Must be a valid email address.
              - field: age
                message: Must be a positive integer.

    InternalServerError:
      description: An unexpected server-side error occurred.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'

# Reference the components from every operation
paths:
  /users/{id}:
    get:
      operationId: getUserById
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: User resource retrieved successfully.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /users:
    post:
      operationId: createUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          description: User created.
        '422':
          $ref: '#/components/responses/ValidationError'
        '500':
          $ref: '#/components/responses/InternalServerError'

Why this works: Declaring additionalProperties: false on ProblemDetail in OpenAPI means any code generator, mock server, or Spectral lint rule can catch extension fields that appear at runtime but were never declared in the spec. The $ref pattern ensures there is exactly one place to update the schema when you add a new extension field — the change propagates to every endpoint automatically.

Step 4 — Verify with a contract test and Spectral lint rule

Send a real invalid request to the running server and assert the response. This test belongs in your integration test suite, not a unit test, because it exercises the full middleware stack.

// tests/integration/error-contract.test.ts  (Vitest 1.x + supertest 7.x)
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import supertest from 'supertest';
import { app } from '../../src/app.js';

const api = supertest(app);

describe('RFC 9457 error contract', () => {
  it('returns application/problem+json for a missing resource', async () => {
    const res = await api.get('/users/nonexistent-id');

    expect(res.status).toBe(404);
    expect(res.headers['content-type']).toMatch(/application\/problem\+json/);

    // Core RFC 9457 fields must all be present
    expect(res.body).toMatchObject({
      type:   expect.stringMatching(/^https?:\/\//),
      title:  expect.any(String),
      status: 404,
      detail: expect.any(String),
    });

    // status in body must mirror the HTTP status line
    expect(res.body.status).toBe(res.status);
  });

  it('returns validation errors extension on 422', async () => {
    const res = await api.post('/users').send({ name: '' });

    expect(res.status).toBe(422);
    expect(res.body.type).toBe('https://api.example.com/problems/validation-error');
    expect(Array.isArray(res.body.errors)).toBe(true);
    expect(res.body.errors.length).toBeGreaterThan(0);
    expect(res.body.errors[0]).toHaveProperty('field');
    expect(res.body.errors[0]).toHaveProperty('message');
  });

  it('never exposes stack traces in error responses', async () => {
    // Force a 500 by calling a route that throws an unexpected error
    const res = await api.get('/users/__force_500__');

    expect(res.status).toBe(500);
    expect(JSON.stringify(res.body)).not.toMatch(/Error:|at Object\.|\.ts:\d+/);
  });
});

Add a Spectral 6.11 custom rule to fail CI when any operation omits a 4xx response with application/problem+json:

# .spectral.yaml
extends: ['spectral:oas']
rules:
  problem-json-on-errors:
    description: All 4xx and 5xx responses must use application/problem+json.
    message: "Response {{property}} uses {{value}} instead of application/problem+json."
    severity: error
    given: "$.paths[*][*].responses[?(@property >= '400')]..content"
    then:
      field: "application/problem+json"
      function: truthy

Run the lint check in CI:

npx @stoplight/spectral-cli@6.11 lint openapi.yaml --ruleset .spectral.yaml

Before / After

Before — three different shapes from three services, requiring branching client code:

// GET /users/99 — service A
{"error": "not found"}

// POST /orders (invalid body) — service B
{"message": "validation failed", "fields": [{"name": "qty", "issue": "required"}]}

// POST /payments — service C
"Payment method declined"

After — all services return a single parseable structure:

// GET /users/99 — service A
{
  "type": "https://api.example.com/problems/not-found",
  "title": "User Not Found",
  "status": 404,
  "detail": "No user exists with id 99.",
  "instance": "/requests/b3d8e1f2"
}

// POST /orders (invalid body) — service B
{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Validation Error",
  "status": 422,
  "detail": "The request body contains invalid fields.",
  "errors": [
    {"field": "qty", "message": "Required."}
  ]
}

// POST /payments — service C
{
  "type": "https://api.example.com/problems/payment-required",
  "title": "Payment Declined",
  "status": 402,
  "detail": "The provided card was declined by the issuer.",
  "error_code": "CARD_DECLINED"
}

A single client function handles all three:

async function parseApiError(res: Response): Promise<ProblemDetail> {
  const contentType = res.headers.get('content-type') ?? '';
  if (!contentType.includes('application/problem+json')) {
    throw new Error(`Unexpected error content-type: ${contentType}`);
  }
  return res.json() as Promise<ProblemDetail>;
}

Verification

Run the integration tests and Spectral lint in sequence:

# Integration tests (Vitest 1.x)
npx vitest run tests/integration/error-contract.test.ts

# OpenAPI lint — fails build if any operation is missing problem+json on 4xx/5xx
npx @stoplight/spectral-cli@6.11 lint openapi.yaml --ruleset .spectral.yaml

# Confirm the running server returns the correct Content-Type
curl -si http://localhost:3000/users/nonexistent | grep -E "HTTP|content-type|type"

Expected output:

HTTP/1.1 404 Not Found
content-type: application/problem+json; charset=utf-8
  "type": "https://api.example.com/problems/not-found",

If the integration tests pass and Spectral reports zero error-severity violations, the contract is in place. Pair this with runtime validation with Zod to validate request bodies before they reach route logic, producing structured errors arrays that map cleanly to the ValidationError problem type.

Edge Cases & Caveats

Validation error extensions and the additionalProperties: false constraint. Setting additionalProperties: false on the OpenAPI ProblemDetail schema blocks all undeclared fields, which is correct — but you must explicitly declare every extension field (errors, error_code, etc.) you plan to emit. If you add a new extension in the middleware without updating the OpenAPI schema, Spectral’s oas-schema rule will flag it and your mock server (Prism 5) will reject responses during contract testing. Treat the OpenAPI schema as the source of truth and update it before shipping new extension fields.

Content negotiation in mixed-client environments. Some HTTP clients (particularly older mobile SDKs and some GraphQL clients) send Accept: application/json exclusively and will receive your application/problem+json responses without issue — the +json suffix is a structured-syntax suffix and browsers accept it transparently. However, if you operate an API gateway that enforces strict Accept header matching, configure it to treat application/problem+json as a specialization of application/json rather than a distinct type requiring explicit opt-in. Never negotiate error responses down to a plain string body; the cost of losing machine-readable structure outweighs any compatibility gain.

RFC 9457 vs RFC 7807 in OpenAPI examples. RFC 9457 clarifies that type must not be null — if no problem type applies, use the literal string "about:blank" and set title to the standard phrase for the HTTP status code (e.g., "Not Found" for 404). Update any OpenAPI examples that use null for type; both AJV and OpenAPI validators will reject them when the schema declares format: uri.

Frequently Asked Questions

What is the difference between RFC 7807 and RFC 9457?

RFC 9457 (published August 2023) supersedes RFC 7807. It clarifies that the type URI must not be dereferenced to retrieve machine-readable information, tightens the definition of the instance field, and formally introduces the concept of problem type extensions. Implementations targeting either RFC are compatible — RFC 9457 is a refinement, not a breaking change.

Should I use application/problem+json or application/json for error responses?

Always use application/problem+json for RFC 9457 error bodies. This content type signals to clients, proxies, and observability tooling that the payload follows the problem details structure. Returning application/json for errors forces clients to inspect the body to determine shape, which defeats contract-driven parsing.

Can I add custom fields to a problem+json response?

Yes. RFC 9457 explicitly allows extension members alongside the five standard fields. Define all extensions as named properties in your OpenAPI ProblemDetail schema with additionalProperties: false so you do not drift back into uncontrolled shapes. Common extensions include errors (an array of field-level validation failures) and error_code (a machine-readable internal identifier).

Does RFC 9457 require the type URI to resolve?

No. RFC 9457 explicitly states that the type URI is used as an identifier, not a locator. You do not need a publicly resolvable documentation page at that URI — though linking to human-readable docs is recommended. A URI under your API domain such as https://api.example.com/problems/validation-error is perfectly valid even if it returns 404.

How do I handle content negotiation when a client does not send Accept: application/problem+json?

For REST APIs, returning application/problem+json unconditionally for error responses is safe — clients that do not send an Accept header or send Accept: */* will accept it. For strict conneg environments, inspect the Accept header in your error middleware and fall back to application/json with the same body if the client cannot handle the problem media type.