Home / Blog / API error responses: Problem Details (RFC 7807) in practice

API error responses: Problem Details (RFC 7807) in practice

Every API invents its own error format and client devs keep rewriting parsers. RFC 7807 Problem Details is the standard fix. What three projects taught me.

Is the API error response format a solved problem? In theory yes, RFC 7807 Problem Details has been the standard for years. In practice everyone invents their own shape, and client teams write parsing logic from scratch for every integration.

I’ve used Problem Details on my last three API projects. Hesitantly on the first, happily on the second, as default on the third. Here’s why and how.

The problem: format anarchy

Typical API error shapes in the wild:

Stripe:

{"error": {"type": "invalid_request", "code": "missing", "message": "..."}}

GitHub:

{"message": "...", "errors": [...], "documentation_url": "..."}

Google:

{"error": {"code": 400, "message": "...", "status": "INVALID_ARGUMENT"}}

Twilio:

{"code": 20001, "message": "...", "more_info": "..."}

The client team writes a different handler per API. Retry logic, user-facing messages, debugging, all separate.

RFC 7807 Problem Details

IETF standard, published in 2016. Core idea: a standard schema for error responses.

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
    "type": "https://example.com/errors/insufficient-funds",
    "title": "Insufficient Funds",
    "status": 400,
    "detail": "Account balance is 100 USD, requested 150 USD.",
    "instance": "/accounts/12345/transfer/xyz"
}

Five standard fields:

  • type: URI for the error type (doc link or unique ID)
  • title: short human-readable title
  • status: HTTP status code
  • detail: description of this specific instance
  • instance: the URI that produced the error

The win: the client writes a generic parser, and new API integrations only need new type URIs.

Extension fields

When the standard five fields aren’t enough, you add extensions:

{
    "type": "https://example.com/errors/validation",
    "title": "Validation Failed",
    "status": 400,
    "detail": "Request body has errors.",
    "instance": "/orders",
    "errors": [
        {"field": "email", "code": "invalid_format", "message": "Not a valid email"},
        {"field": "age", "code": "too_small", "message": "Must be 18+"}
    ]
}

The errors array is an extension. Clients that know about it use it, clients that don’t ignore it.

Type URI: the doc link

type is a URI. Ideally a resolvable URL, pointing at documentation.

{
    "type": "https://api.example.com/errors/insufficient-funds"
}

The URL leads to a detailed explanation:
– What the error means
– When it’s raised
– What the client should do (retry? what to tell the user?)
– Example request and response

A resolvable URL is nice but not required. A unique identifier works too.

Implementation: PHP example

class ProblemResponse {
    public static function fromException(Throwable $e): array {
        if ($e instanceof ValidationException) {
            return [
                'type' => '/errors/validation',
                'title' => 'Validation Failed',
                'status' => 400,
                'detail' => $e->getMessage(),
                'errors' => $e->getErrors()
            ];
        }
        if ($e instanceof InsufficientFundsException) {
            return [
                'type' => '/errors/insufficient-funds',
                'title' => 'Insufficient Funds',
                'status' => 400,
                'detail' => $e->getMessage(),
                'balance' => $e->getBalance(),
                'requested' => $e->getRequested()
            ];
        }
        // Generic
        return [
            'type' => '/errors/internal',
            'title' => 'Internal Server Error',
            'status' => 500,
            'detail' => 'Unexpected error occurred.'
        ];
    }
}

// Usage
try {
    $order = $service->createOrder($input);
} catch (Throwable $e) {
    http_response_code($e->code ?? 500);
    header('Content-Type: application/problem+json');
    echo json_encode(ProblemResponse::fromException($e));
    exit;
}

Centralise in middleware and controllers only have to throw.

Client parsing

Client side:

async function apiCall(url, options) {
    const response = await fetch(url, options);
    
    if (!response.ok) {
        const contentType = response.headers.get('content-type') || '';
        if (contentType.includes('application/problem+json')) {
            const problem = await response.json();
            throw new APIProblem(problem);
        }
        throw new Error(`HTTP ${response.status}`);
    }
    
    return response.json();
}

class APIProblem extends Error {
    constructor(problem) {
        super(problem.detail || problem.title);
        this.type = problem.type;
        this.title = problem.title;
        this.status = problem.status;
        this.errors = problem.errors;
    }
}

Same handler across every API. UI behaviour keyed on error type:

try {
    await api.createOrder(data);
} catch (e) {
    if (e instanceof APIProblem) {
        if (e.type.endsWith('/validation')) {
            showFieldErrors(e.errors);
        } else if (e.type.endsWith('/insufficient-funds')) {
            showInsufficientFundsDialog(e);
        } else {
            showGenericError();
        }
    }
}

Localization

What does the Problem Details spec say about i18n? Not much. Practical approach:

Option 1: server-side localization. Server translates title and detail based on Accept-Language.

Option 2: client-side localization. Server sends type plus context, client handles translation.

I prefer option 2. The client has better user context, and server-side translation tends to miss nuance.

{
    "type": "/errors/insufficient-funds",
    "status": 400,
    "balance": 100,
    "requested": 150,
    "currency": "USD"
}

The client assembles the message from its own i18n files: “Your balance is 100 USD, you requested 150 USD”.

Security: don’t over-disclose

Don’t leak sensitive data in errors. Stack traces, internal paths, raw DB errors shouldn’t go to the client.

// Bad
{
    "detail": "SQL error: duplicate key constraint users_email_key"
}

// Good
{
    "detail": "Email already registered."
}

For internal errors (5xx):

{
    "type": "/errors/internal",
    "title": "Internal Server Error",
    "status": 500,
    "detail": "Something went wrong.",
    "requestId": "req_abc123"
}

requestId is there for client-side debugging and can be correlated with server logs. The actual stack trace stays in logs only.

Validation error pattern

Form validation is the most common error case. Pattern:

{
    "type": "/errors/validation",
    "title": "Validation Failed",
    "status": 400,
    "detail": "Request body has errors.",
    "errors": [
        {
            "field": "email",
            "code": "invalid_format",
            "message": "Email is not valid"
        },
        {
            "field": "items[0].quantity",
            "code": "too_small",
            "message": "Quantity must be at least 1",
            "minimum": 1
        }
    ]
}

Field paths in JSON Pointer syntax (items[0].quantity). The client can attach the error message to the right form field.

Rate limit error

Rate limiting gets a specialised pattern:

{
    "type": "/errors/rate-limited",
    "title": "Rate Limit Exceeded",
    "status": 429,
    "detail": "Too many requests. Retry after 60 seconds.",
    "retry_after": 60,
    "limit": 100,
    "remaining": 0,
    "reset": 1704067200
}

Combined with the HTTP Retry-After header. The client has everything it needs for automatic retry.

OpenAPI integration

Define Problem Details as a schema in your OpenAPI spec and reference it from every endpoint’s error responses:

components:
  schemas:
    Problem:
      type: object
      properties:
        type:
          type: string
        title:
          type: string
        status:
          type: integer
        detail:
          type: string
      required: [type, title, status]

paths:
  /orders:
    post:
      responses:
        '400':
          description: Bad Request
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'

Client code generation (OpenAPI Generator) produces the Problem Details type automatically.

A real migration

On one API project we migrated from a custom error format to Problem Details in three stages:

  1. Parallel format: return both the old format and Problem Details, switching on the Accept header.
  2. Deprecation: add an X-Deprecated header to the old format with a 3-month runway.
  3. Cutover: old format gone.

During the migration window, client teams wrote the new parser. Four months later every team was on Problem Details.

Result: integration effort dropped by about 30%, error handling code was centralised.

Closing thought

On a new API, Problem Details is the default. For rewrites, migrate via parallel format plus deprecation.

Standard formats give you ecosystem benefits: OpenAPI tooling, client library support, auto-generated documentation.

Bespoke error formats have had their day.

Have a project on this topic?

Leave a brief summary — I’ll get back to you within 24 hours.

Get in touch