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).
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.