The Practical Developer

Stop Returning `{error: "Something went wrong"}`: RFC 9457 Problem Details for HTTP APIs

Your API returns ad-hoc error objects with no structure, no standard fields, and no documentation. Every client has to guess what the response shape means. RFC 9457 (Problem Details) gives you a machine-readable, standardized error format that works with any HTTP API, and this post shows you the Express and Fastify middleware to adopt it in under 50 lines.

Rows of server racks with blinking indicator lights, the infrastructure layer where inconsistent API error responses silently cause integration failures

Every API returns errors. Almost no two APIs return them the same way.

Your payment service returns {"error": "card_declined"}. The inventory service returns {"code": 4003, "message": "Insufficient stock"}. The user service returns {"errors": [{"field": "email", "message": "already taken"}]}. The auth gateway returns a bare string body with no JSON at all. Every client that talks to more than one of these has to write a custom parser for each. Every new hire on the team has to learn the undocumented error dialect of every service. Every integration test has to assert against a bespoke shape that nobody wrote down.

This is not a cosmetic problem. It is a coupling problem. When error responses have no standard structure, clients hard-code parsing logic for each service. When a service changes its error field from message to detail, every dependent breaks. When there is no standard place to put debugging metadata like a trace ID or validation errors, teams invent their own conventions and the sprawl compounds.

RFC 9457 (which supersedes the older RFC 7807) defines a standard HTTP error response format called Problem Details. It is the most widely available tool you are not using. It is supported out of the box by ASP.NET Core, Spring Boot, and Python’s http.client. It takes about 50 lines of middleware in Express or Fastify. And it eliminates an entire class of integration friction that nobody budgets for.

This post covers the RFC, the middleware to implement it in any Node.js framework, and the conventions that make Problem Details actually useful in production.

The shape of a problem

A Problem Details response is a JSON object with a tiny set of standard keys. Here is the minimum viable payload:

{
  "type": "https://api.example.com/problems/insufficient-funds",
  "title": "Insufficient Funds",
  "status": 402,
  "detail": "Your account balance is $5.00 but the transfer requires $15.00.",
  "instance": "/api/transfers/abc-123"
}

Every client that understands Problem Details can read this response without prior knowledge of your API’s error format. Here is what each field means:

  • type (string, required): A URI that identifies the problem type. When dereferenced, it should provide human-readable documentation for this specific error. It is the closest thing Problem Details has to an error code, and it is the field clients should switch on.
  • title (string, required): A short, human-readable summary of the problem type. It should not change between occurrences of the same problem. Think of it as the stable label for this error category.
  • status (number, required): The HTTP status code. Including it in the body means the body is self-describing, even if the response passes through a proxy or load balancer that rewrites the HTTP status.
  • detail (string, optional): A human-readable explanation specific to this occurrence. This is where you put the per-request context like the actual balance vs. the required amount.
  • instance (string, optional): A URI that identifies the specific occurrence of the problem. This is where you put the request path or the request ID.

The genius of this design is that it is both minimal and extensible. The five standard fields give every client a common vocabulary. The type URI gives you a way to document and categorize errors without coupling the client to stringly typed codes. And you can add any extra fields you need without breaking clients that only read the standard ones.

What Problem Details replaces

Before RFC 9457, most APIs ship one of three error patterns, each with a different flaw:

Pattern 1: The bare string or numeric code

{
  "error": "invalid_input"
}

The problem: every possible error goes in the same flat field. There is no room for context. The client cannot distinguish “the email field is missing” from “the email field has the wrong format” unless you start appending suffixes, and there is no standard place to put the offending value or the field name. The format collapses under any realistic error surface.

Pattern 2: The kitchen-sink error object

{
  "success": false,
  "errorCode": "ERR-4023",
  "errorMessage": "Insufficient funds",
  "data": null,
  "timestamp": "2026-06-09T10:00:00Z",
  "path": "/api/transfers"
}

The problem: everything is ad-hoc. Every service picks different field names. errorMessage in one service is message in another. errorCode vs code vs errCode. Clients must learn each service’s dialect separately. The success boolean is cargo-culted from RPC responses and is always false in an error response anyway, making it redundant at best and misleading at worst.

Pattern 3: The standardized-but-proprietary format

Some teams define their own internal error standard. This is better than the alternatives, but it creates a maintenance burden. You have to write docs, enforce the convention in code reviews, and update every service. And the resulting format will not be understood by external clients, third-party tools, or future team members who learned RFC 9457.

Problem Details solves all three cases with a format that is defined by an IETF standard, extensible by design, and documented enough that clients can handle it generically.

The middleware you need

Here is an Express middleware that converts thrown errors into Problem Details responses. It handles three cases: explicit ProblemDetail errors from your own code, validation errors (from Zod or similar), and unexpected server errors that should never leak internals.

import { Request, Response, NextFunction } from 'express';

// A typed error class that carries Problem Details fields
export class ProblemDetail extends Error {
  constructor(
    public readonly type: string,
    public readonly title: string,
    public readonly status: number,
    detail?: string,
    public readonly instance?: string,
    public readonly extra?: Record<string, unknown>
  ) {
    super(detail ?? title);
    this.name = 'ProblemDetail';
  }
}

// Helper to create common problem types
ProblemDetail.notFound = (detail?: string) =>
  new ProblemDetail(
    'https://api.example.com/problems/not-found',
    'Resource Not Found',
    404,
    detail
  );

ProblemDetail.validation = (detail?: string) =>
  new ProblemDetail(
    'https://api.example.com/problems/validation-error',
    'Validation Error',
    422,
    detail
  );

ProblemDetail.conflict = (detail?: string) =>
  new ProblemDetail(
    'https://api.example.com/problems/conflict',
    'Conflict',
    409,
    detail
  );

ProblemDetail.rateLimited = (detail?: string) =>
  new ProblemDetail(
    'https://api.example.com/problems/rate-limited',
    'Too Many Requests',
    429,
    detail
  );

// Express error-handling middleware
export function problemDetailsMiddleware(
  err: Error,
  req: Request,
  res: Response,
  _next: NextFunction
): void {
  if (err instanceof ProblemDetail) {
    const body: Record<string, unknown> = {
      type: err.type,
      title: err.title,
      status: err.status,
      detail: err.message,
      instance: err.instance ?? req.originalUrl,
    };

    // Merge any extra fields into the response body
    if (err.extra) {
      Object.assign(body, err.extra);
    }

    res.setHeader('Content-Type', 'application/problem+json');
    res.status(err.status).json(body);
    return;
  }

  // For unexpected errors, return a generic 500 without leaking details
  // Log the real error server-side, then return a safe problem
  console.error('Unhandled error:', err);

  const body = {
    type: 'https://api.example.com/problems/internal-error',
    title: 'Internal Server Error',
    status: 500,
    detail: 'An unexpected error occurred. Contact support if this persists.',
    instance: req.originalUrl,
  };

  res.setHeader('Content-Type', 'application/problem+json');
  res.status(500).json(body);
}

The critical detail here is the Content-Type header. RFC 9457 specifies application/problem+json as the media type. Setting it correctly means any generic Problem Details client library or middleware can detect and parse the response without knowing your API. This is the same principle behind application/json vs application/ld+json: the media type tells the consumer what schema to expect.

Here is how you register the middleware and use it:

import express from 'express';
import { ProblemDetail, problemDetailsMiddleware } from './problem-detail';

const app = express();

app.post('/api/transfers', (req, res) => {
  const { amount, fromAccount, toAccount } = req.body;

  if (amount <= 0) {
    throw ProblemDetail.validation('Transfer amount must be positive.');
  }

  const balance = getBalance(fromAccount);
  if (balance < amount) {
    throw ProblemDetail.notFound('Account not found.'); // wrong! use conflict
  }

  // Actually, let me fix that
  if (balance < amount) {
    throw new ProblemDetail(
      'https://api.example.com/problems/insufficient-funds',
      'Insufficient Funds',
      402,
      `Your balance is $${balance}. The transfer requires $${amount}.`,
      req.originalUrl,
      { balance, required: amount }
    );
  }

  // proceed with transfer...
  res.json({ status: 'ok' });
});

// Error middleware must be registered last
app.use(problemDetailsMiddleware);

app.listen(3000);

When a client sends a transfer for more than the balance, they get back:

{
  "type": "https://api.example.com/problems/insufficient-funds",
  "title": "Insufficient Funds",
  "status": 402,
  "detail": "Your balance is $5.00. The transfer requires $15.00.",
  "instance": "/api/transfers",
  "balance": 5,
  "required": 15
}

The client can check type to decide how to handle the error (show a “low balance” UI vs. a generic error), use detail for the user-facing message, and inspect balance and required for more granular handling. All of this works without prior documentation of the API’s error format.

Validation errors with RFC 9457

The most common error in any CRUD API is invalid input. RFC 9457 does not define a standard way to represent per-field validation errors, but its extension mechanism makes it straightforward. The convention that has emerged in practice uses a validation_errors array on the response:

import { z } from 'zod';

const transferSchema = z.object({
  amount: z.number().positive('Amount must be positive.'),
  fromAccount: z.string().min(1, 'Source account is required.'),
  toAccount: z.string().min(1, 'Destination account is required.'),
});

app.post('/api/transfers', (req, res, next) => {
  const result = transferSchema.safeParse(req.body);

  if (!result.success) {
    const validationErrors = result.error.issues.map((issue) => ({
      field: issue.path.join('.'),
      message: issue.message,
      code: issue.code,
    }));

    throw new ProblemDetail(
      'https://api.example.com/problems/validation-error',
      'Validation Error',
      422,
      'One or more fields failed validation.',
      req.originalUrl,
      { validation_errors: validationErrors }
    );
  }

  // result.data is now typed and valid
  // proceed with transfer...
});

The response looks like this:

{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Validation Error",
  "status": 422,
  "detail": "One or more fields failed validation.",
  "instance": "/api/transfers",
  "validation_errors": [
    {
      "field": "amount",
      "message": "Amount must be positive.",
      "code": "too_small"
    },
    {
      "field": "toAccount",
      "message": "Destination account is required.",
      "code": "too_small"
    }
  ]
}

The validation_errors extension is not part of the RFC, but it follows the RFC’s guidance: standard fields stay standard, and extensions use descriptive names that do not collide with future RFC fields. A client that only reads standard fields gets type, title, status, detail, and instance. A client that knows about the extension reads validation_errors for field-level rendering.

Fastify has it built in

If you use Fastify instead of Express, you get most of this for free. Fastify has a setErrorHandler API and a built-in schema validation system that can produce Problem Details out of the box:

import Fastify from 'fastify';

const app = Fastify({
  schemaErrorFormatter: (errors, dataVar) => {
    const validationErrors = errors.map((err) => ({
      field: err.instancePath.replace(/^\//, ''),
      message: err.message,
    }));

    return new Error(
      JSON.stringify({
        type: 'https://api.example.com/problems/validation-error',
        title: 'Validation Error',
        status: 422,
        detail: 'One or more fields failed validation.',
        instance: dataVar,
        validation_errors: validationErrors,
      })
    );
  },
});

app.setErrorHandler((error, request, reply) => {
  let problem: Record<string, unknown>;

  try {
    problem = JSON.parse(error.message);
  } catch {
    problem = {
      type: 'https://api.example.com/problems/internal-error',
      title: 'Internal Server Error',
      status: 500,
      detail: 'An unexpected error occurred.',
      instance: request.url,
    };
  }

  reply
    .header('Content-Type', 'application/problem+json')
    .status((problem.status as number) || 500)
    .send(problem);
});

Fastify’s schema validation runs on every request automatically. When validation fails, the schemaErrorFormatter is called, and the custom error handler serializes the Problem Details response. The entire integration is about two dozen lines.

What to put in type

The type URI is the most important design decision in your Problem Details implementation. It is the stable identifier that clients use to distinguish error categories. Getting it right means clients never have to parse your detail field programmatically.

Here are the rules:

Use absolute URIs, not short codes. A type of https://api.example.com/problems/insufficient-funds is better than INSUFFICIENT_FUNDS or ERR-1002. URIs are globally unique, dereferenceable (you can put docs at that URL), and they do not pollute a shared namespace. Short codes collide easily when you merge services or acquire another company’s API.

Group by cause, not by HTTP status. Two different 422 errors should have different type URIs. A validation error where the email is missing is a different problem from a validation error where the credit card is expired. Give them separate types so clients can handle them differently.

Version the URI if the semantics change. If your insufficient-funds problem changes its detail format or adds new extension fields, increment the URL path: v1/insufficient-funds becomes v2/insufficient-funds. Clients that still reference the old URI get the old documentation. This is especially useful for public APIs where you cannot force clients to upgrade.

Document every type at its URI. The URL in type should resolve to a page (HTML or Markdown) that explains the problem, lists all extension fields, and shows example responses. You can host these statically alongside your API docs. A client developer who sees a problem type they have not encountered can open the URL and learn everything they need.

Handling validation vs. processing errors

One of the trickiest design decisions in an API error system is distinguishing errors the client should fix (validation) from errors the server owns (processing failures, unavailable resources, unexpected states). Problem Details gives you the vocabulary to make this distinction clear without coupling clients to status code ranges.

Use these conventions:

  • Status 422 for validation errors where the client sent structurally invalid data (wrong types, missing required fields, constraint violations). The type should point to the specific validation that failed.
  • Status 400 for request-level problems that are not about individual fields (malformed JSON, unsupported media type, missing content-type header). These are often caught by framework middleware before your handler runs.
  • Status 409 for conflicts where the request was valid but the current state of the resource prevents it (optimistic locking failures, duplicate creation, version conflicts).
  • Status 402 for payment-required scenarios. This status code is rarely used in practice, but RFC 9457 gives you a clean home for it. If your API deals with payments, use it.
  • Status 429 for rate limiting. The response should include a Retry-After header and, optionally, an extension field with the reset timestamp.

Here is a rate-limit response that follows all the conventions:

{
  "type": "https://api.example.com/problems/rate-limited",
  "title": "Too Many Requests",
  "status": 429,
  "detail": "You exceeded 100 requests per minute. Try again after 30 seconds.",
  "instance": "/api/transfers",
  "retry_after_seconds": 30,
  "retry_at": "2026-06-09T10:01:00Z"
}

The Retry-After header goes in the HTTP headers where tools like CDNs and proxies can read it. The retry_after_seconds and retry_at fields go in the body where the client SDK can read them after parsing the JSON.

Using Problem Details outside Express

The pattern works for any HTTP framework in any language, but if you use something other than Express or Fastify in Node.js, the approach is the same.

For plain Node.js http.createServer:

import http from 'node:http';

function sendProblem(res: http.ServerResponse, problem: Record<string, unknown>): void {
  res.writeHead((problem.status as number) || 500, {
    'Content-Type': 'application/problem+json',
  });
  res.end(JSON.stringify(problem));
}

const server = http.createServer((req, res) => {
  if (req.url === '/api/transfers' && req.method === 'POST') {
    // parse body, validate, handle business logic...
    sendProblem(res, {
      type: 'https://api.example.com/problems/insufficient-funds',
      title: 'Insufficient Funds',
      status: 402,
      detail: 'Your balance is too low.',
      instance: req.url,
    });
    return;
  }
  // ...
});

For Hono or other modern frameworks, the pattern is identical: catch errors, build a Problem Details object, set the content type, and send it. The framework does not matter. The protocol does.

Testing Problem Details responses

Once you have a standard error format, your integration tests become simpler and more explicit. Instead of asserting on ad-hoc response shapes, you assert against the standard fields:

import { describe, it, expect } from 'vitest';
import request from 'supertest';
import app from './app';

describe('POST /api/transfers', () => {
  it('returns a Problem Details response for insufficient funds', async () => {
    const res = await request(app)
      .post('/api/transfers')
      .send({ amount: 100, fromAccount: 'abc', toAccount: 'xyz' });

    expect(res.status).toBe(402);
    expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
    expect(res.body).toMatchObject({
      type: 'https://api.example.com/problems/insufficient-funds',
      title: 'Insufficient Funds',
      status: 402,
    });
  });

  it('returns validation errors with field-level detail', async () => {
    const res = await request(app)
      .post('/api/transfers')
      .send({ amount: -5, fromAccount: '', toAccount: '' });

    expect(res.status).toBe(422);
    expect(res.body.validation_errors).toBeDefined();
    expect(res.body.validation_errors.length).toBeGreaterThan(0);
    expect(res.body.validation_errors[0]).toHaveProperty('field');
    expect(res.body.validation_errors[0]).toHaveProperty('message');
  });
});

The test is readable. It checks the content type, the standard fields, and any extension fields. A developer joining the team can look at any error test and immediately understand what shape the response has, because every error follows the same structure.

Migrating an existing API

If you have an API that already returns ad-hoc errors, do not rewrite all handlers in one commit. The migration path is incremental:

  1. Add the ProblemDetail class and error middleware to your framework. This does not change any existing responses.
  2. Pick one handler or one route group and convert its error paths to throw ProblemDetail instances instead of returning ad-hoc objects.
  3. Add a response transformation layer that catches any non-ProblemDetail error response from old handlers and wraps it into a Problem Details shape. This means old handlers keep working the same way internally, but the wire format normalizes.
  4. Remove the transformation layer once all handlers are converted.
  5. Update your API documentation to reference Problem Details types.

The response transformation layer in step 3 looks like this:

app.use((req, res, next) => {
  // Intercept the original res.json to wrap non-standard errors
  const originalJson = res.json.bind(res);
  res.json = function (body: unknown) {
    // If it looks like a legacy error response, wrap it
    if (body && typeof body === 'object' && 'error' in (body as object)) {
      const legacy = body as { error?: string; message?: string };
      const wrapped = {
        type: `https://api.example.com/problems/legacy/${legacy.error ?? 'unknown'}`,
        title: legacy.message ?? 'Error',
        status: res.statusCode,
        detail: legacy.message ?? 'An error occurred.',
        instance: req.originalUrl,
      };
      res.setHeader('Content-Type', 'application/problem+json');
      return originalJson(wrapped);
    }
    return originalJson(body);
  };
  next();
});

This is a bridge, not a permanent solution. It lets you ship Problem Details responses from day one while old handlers are gradually converted. Remove it when the last old handler is updated.

The takeaway

RFC 9457 Problem Details is the most widely applicable API design improvement you can make in an afternoon. It costs about 50 lines of middleware. It replaces ad-hoc error formats with a structure that every client can parse generically. It gives you a clean place for trace IDs, validation errors, and business-specific context without inventing a new schema for every service.

The reason teams skip it is that error response formats feel like a low-priority detail compared to shipping features. But error handling is the part of your API that clients interact with when things go wrong, which is exactly when clarity matters most. A standardized error format means your client SDKs, dashboards, monitoring, and on-call engineers all speak the same vocabulary.

Adopt Problem Details now. Document every type URI. Use application/problem+json as the content type. Your clients will thank you, your tests will be cleaner, and your next integration with an external partner will not require a three-day slog through undocumented error codes.


A note from Yojji

Designing a consistent, well-documented API error surface is the kind of infrastructure discipline that pays compounding returns. Every client integration gets easier. Every on-call rotation has a shared vocabulary for describing what broke. Every new service on the team already knows how to format its errors. Yojji builds APIs and microservices on the JavaScript stack for clients in finance, healthcare, and logistics, where error clarity is not a nice-to-have but a contractual requirement.

Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their senior engineering teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, GCP), and the full cycle of product delivery from discovery through DevOps and production support.