StackPractices
intermediate By Mathias Paulenko

API Error Response Template

A reusable template for consistent, informative, and developer-friendly API error responses that reduce debugging time.

Topics: api

Overview

A developer integrating with your API hits a 500. What comes back is <html><head><title>Server Error</title></head>... — Nginx’s default page. There’s no request ID to quote in a support ticket, no hint whether they sent something wrong or your service broke, and no documentation to read. They file a ticket, wait a day, and learn the cause was a rate limit you could have communicated in one JSON field.

Error responses are the part of your API contract that developers read under stress, usually at 2 AM, usually while something is on fire. A good one tells them exactly what failed, why, what to do next, and gives them an ID to quote when they escalate. A bad one turns a two-minute fix into a multi-day support thread — or quietly loses you an integration altogether.

This template covers:

  1. RFC 7807 / RFC 9457 Problem Details — the standard format for HTTP API errors
  2. Legacy simple format — for internal APIs or backward compatibility
  3. Error code catalog — mapping HTTP status codes to specific error types
  4. Server-side implementation — middleware that produces problem+json without leaking internals
  5. Best practices — what to include, what to hide, and how to link to docs

Template Structure

Use this template to build consistent, useful error responses for any REST or HTTP API. The format pairs naturally with the API Documentation Template, which covers documenting these error cases per endpoint.


RFC 7807 standardized application/problem+json so HTTP clients — and the libraries they use — can parse errors automatically instead of string-matching your messages. It was superseded by RFC 9457 in 2023, which clarifies the same format. People still say “RFC 7807 errors” out of habit.

{
  "type": "https://api.example.com/errors/invalid-request",
  "title": "Invalid Request",
  "status": 400,
  "detail": "The 'email' field must be a valid email address.",
  "instance": "/orders/123e4567-e89b-12d3-a456-426614174000",
  "errors": [
    {
      "field": "email",
      "message": "must be a valid email address",
      "code": "invalid_format"
    }
  ]
}

Field Reference

FieldRequiredDescription
typeYesURI that identifies the error type and links to human-readable documentation
titleYesShort, human-readable summary of the problem — stable per type, don’t interpolate details here
statusYesHTTP status code (must match the actual response status)
detailNoExplanation specific to this occurrence; this is where specifics belong
instanceNoURI reference identifying this specific occurrence of the problem
errorsNoExtension member: array of field-level validation errors for 400 Bad Request

Two details teams routinely get wrong. First, status must equal the HTTP status on the wire — a 200 response carrying "status": 400 in the body confuses every client and intermediary. Second, title should be identical for every occurrence of a type; clients sometimes branch on it. Put the occurrence-specific information in detail.


Legacy Simple Format

For internal APIs or backward compatibility, a lightweight structure is acceptable:

{
  "error": {
    "code": "INVALID_PARAMETER",
    "message": "The 'page_size' parameter must be between 1 and 100.",
    "request_id": "req_abc123xyz"
  }
}

If you ship this format, keep the field names stable forever — clients write parsers against them, and renaming message to detail in a “minor” release is a breaking change in disguise.


Filled Examples by Status Code

400 Bad Request — Validation Error

{
  "type": "https://api.example.com/errors/validation-failed",
  "title": "Validation Failed",
  "status": 400,
  "detail": "3 fields failed validation.",
  "instance": "/users",
  "errors": [
    {
      "field": "email",
      "message": "must be a valid email address",
      "code": "invalid_format"
    },
    {
      "field": "password",
      "message": "must be at least 12 characters",
      "code": "min_length"
    },
    {
      "field": "role",
      "message": "must be one of: admin, editor, viewer",
      "code": "invalid_enum"
    }
  ]
}

Return all the field errors in one response, not only the first. A client that fixes errors one by one through five round trips will hate your API; a client that gets the full list fixes them in one pass.

401 Unauthorized

{
  "type": "https://api.example.com/errors/unauthorized",
  "title": "Unauthorized",
  "status": 401,
  "detail": "Missing or invalid Authorization header. Use Bearer token authentication.",
  "instance": "/orders/123"
}

401 means “authenticate (or re-authenticate) and retry”. Pair it with a WWW-Authenticate header telling the client which scheme you expect.

403 Forbidden

{
  "type": "https://api.example.com/errors/forbidden",
  "title": "Forbidden",
  "status": 403,
  "detail": "Your plan allows read access only. Contact your workspace admin to request write access.",
  "instance": "/reports"
}

Authenticated but not allowed — and unlike 401, retrying won’t help. The detail should say who can grant access or what action unlocks it.

404 Not Found

{
  "type": "https://api.example.com/errors/resource-not-found",
  "title": "Resource Not Found",
  "status": 404,
  "detail": "Order with ID '123e4567-e89b-12d3-a456-426614174000' does not exist.",
  "instance": "/orders/123e4567-e89b-12d3-a456-426614174000"
}

A subtle decision: if the caller lacks permission to know the resource exists, returning 404 (not 403) avoids leaking existence. Apply it consistently, or clients learn to distinguish your two behaviors anyway.

409 Conflict

{
  "type": "https://api.example.com/errors/conflict",
  "title": "Conflict",
  "status": 409,
  "detail": "A user with email 'a@b.com' already exists.",
  "instance": "/users"
}

Use for state conflicts: duplicate resources, optimistic-lock failures, already-processed operations. For idempotent creates, consider returning the existing resource with a 200/303 instead.

422 Unprocessable Content

{
  "type": "https://api.example.com/errors/unprocessable",
  "title": "Unprocessable Content",
  "status": 422,
  "detail": "Cannot schedule delivery for a date in the past: '2020-01-01'.",
  "instance": "/shipments"
}

The request was syntactically valid JSON but semantically wrong. Where 400 ends and 422 begins is genuinely blurry. Pick one convention, document it in your catalog, and never use both for the same condition.

429 Rate Limit Exceeded

{
  "type": "https://api.example.com/errors/rate-limit-exceeded",
  "title": "Rate Limit Exceeded",
  "status": 429,
  "detail": "You have exceeded the rate limit of 200 requests per minute. Retry after 30 seconds.",
  "instance": "/reports"
}

Response headers for 429 should include:

Retry-After: 30
X-RateLimit-Limit: 200
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1721003460

500 Internal Server Error

{
  "type": "https://api.example.com/errors/internal",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "An unexpected error occurred. Reference request ID req_abc123 when contacting support.",
  "instance": "/orders"
}

This is the response where sanitization matters most: the client gets a request ID and a generic message, while the stack trace, SQL query, and internal hostnames go to your logs, never the body. The OWASP API Security Top 10 lists verbose error messages as an information-leak vector for exactly this reason.


Error Code Catalog (Example)

Keep the catalog machine-readable so documentation, code, and tests generate from one source — not three copies that drift apart.

HTTP StatusError CodeWhen to Use
400INVALID_REQUESTGeneric malformed request
400VALIDATION_ERRORSchema or business rule validation failed
401UNAUTHORIZEDMissing or invalid authentication token
403FORBIDDENAuthenticated user lacks permission
404RESOURCE_NOT_FOUNDRequested resource doesn’t exist
409CONFLICTResource already exists or state conflict
422UNPROCESSABLE_ENTITYSemantic validation failed
429RATE_LIMIT_EXCEEDEDToo many requests
500INTERNAL_ERRORUnexpected server-side failure
503SERVICE_UNAVAILABLETemporary outage or maintenance

Naming guidance: use UPPER_SNAKE_CASE, be specific (TOKEN_EXPIRED, not BAD_TOKEN), and never recycle a retired code for a different meaning. Old clients still interpret it the old way.

Server-Side Implementation

The format only survives as the path of least resistance — if handlers have to opt in, they’ll forget. Centralize it in middleware so every handler, including the ones nobody remembered, emits the same shape.

Express (Node.js)

// error-handler.js — mount LAST, after all routes
class ApiError extends Error {
  constructor(status, type, title, detail, errors) {
    super(detail || title);
    this.status = status;
    this.type = type;
    this.title = title;
    this.errors = errors;
  }
}

function problemDetails(err, req, res, next) {
  const isApiError = err instanceof ApiError;
  const status = isApiError ? err.status : 500;

  // Full context goes to logs, not the client
  if (status >= 500) {
    console.error({ requestId: req.id, err });
  }

  res.status(status).type('application/problem+json').json({
    type: isApiError ? err.type : 'https://api.example.com/errors/internal',
    title: isApiError ? err.title : 'Internal Server Error',
    status,
    detail: status >= 500
      ? `An unexpected error occurred. Reference request ID ${req.id} when contacting support.`
      : err.message,
    instance: req.originalUrl,
    ...(err.errors && { errors: err.errors }),
    request_id: req.id,
  });
}

// Usage: throw new ApiError(422, '.../errors/unprocessable', 'Unprocessable Content',
//   "Cannot schedule delivery for a past date.");

Flask (Python)

from flask import jsonify, request, g

@app.errorhandler(Exception)
def problem_details(err):
    status = getattr(err, "status", 500)
    if status >= 500:
        app.logger.error("unhandled", extra={"request_id": g.request_id}, exc_info=err)
        detail = f"An unexpected error occurred. Reference request ID {g.request_id} when contacting support."
    else:
        detail = getattr(err, "detail", str(err))

    body = {
        "type": getattr(err, "type", "https://api.example.com/errors/internal"),
        "title": getattr(err, "title", "Internal Server Error" if status >= 500 else "Error"),
        "status": status,
        "detail": detail,
        "instance": request.path,
        "request_id": g.request_id,
    }
    return jsonify(body), status, {"Content-Type": "application/problem+json"}

The two principles in both examples: the request_id links the client’s screenshot to your logs, and the 500 branch never trusts err.message. Only expected errors (your ApiError class) earn the right to reach the response body. A helper that generates these handlers from your catalog is in the companion resources.

What works

  • Always return a body: never send an empty body for 4xx or 5xx responses
  • Use Problem Details for public APIs: consumers expect standard formats; libraries can parse them automatically
  • Include a request ID: essential for correlating client reports with server logs
  • Never leak stack traces: internal details belong in logs, not in responses
  • Localize detail if needed: use Accept-Language to return localized messages, but keep title stable
  • Link to documentation: the type URI should lead to a docs page explaining the error and how to fix it
  • Be specific in validation errors: say "phone must match E.164 format" instead of "invalid input"
  • Include retry guidance for 429: the Retry-After header tells clients when to back off
  • Use consistent error codes: clients build retry logic around specific codes, not HTTP statuses

Common Mistakes

  • Returning 500 with a plain HTML page — breaks API clients expecting JSON (usually a default server or gateway page bypassing your handler)
  • Including stack traces or SQL queries in error bodies: security risk and information leak
  • Using generic "error": "something went wrong" for every failure: impossible to debug
  • Changing error field names between versions: breaks client parsing logic
  • Not logging the full error context server-side: you lose the ability to investigate what the client only saw as request_id
  • Returning 200 OK with an error body: violates HTTP semantics and breaks middleware, retries, and caches
  • Different error formats per endpoint: clients need one consistent format
  • Interpolating into title: title must stay stable per type; occurrence details belong in detail
  • Treating 401 and 403 as interchangeable: 401 invites re-authentication, 403 doesn’t

Variant Comparison

VariantContextApproachNotes
RFC 7807/9457 Problem DetailsPublic API, external clientsStructured JSON with type, title, statusIETF standard, libraries available
Simple legacy formatInternal API, backward compatcode + message + request_idMinimal, sufficient for small APIs
GraphQL errorsGraphQL APIerrors array with extensionsNo HTTP status codes
gRPC errorsInternal microservicesStatus codes 0-16 + protobuf detailsBinary, high performance

Detailed Scenario: Handling a Downstream Error Cascade

System: an Orders API that calls an inventory service. Flow: POST /orders → check stock → create order. The inventory service times out, the circuit breaker opens, and the client needs to know what just happened.

Mermaid flowchart LR diagram
  • Error detection. The inventory request times out after 3s and the circuit breaker opens after 3 consecutive failures. The error is downstream, not client-side: a 503, not a 400.
  • Response to client. 503 Service Unavailable with Content-Type: application/problem+json, Retry-After: 60, and a body carrying request_id and correlation_id so the client can quote it in a ticket.
  • Internal logging. Full request, timeout, and breaker state go to logs tagged downstream_timeout, service=inventory, plus inventory_timeout_count++ and a trace span (inventory.call, status=error, duration=3001ms).
  • Client communication. 503 with Retry-After means temporary failure: the client retries with exponential backoff, and only shows a maintenance message if 503s persist.
  • Degradation (optional). With the breaker open, accept orders in degraded mode marked pending_stock_verification and reconcile when inventory recovers.

Variants

GraphQL errors

GraphQL returns errors in the errors array of the response rather than as HTTP error status codes. Each error includes message, locations, path, and optional extensions:

{
  "errors": [
    {
      "message": "Field 'email' must be a valid email address.",
      "locations": [{ "line": 3, "column": 10 }],
      "path": ["createUser", "input", "email"],
      "extensions": { "code": "INVALID_FORMAT" }
    }
  ]
}

Put your catalog codes in extensions.code so REST and GraphQL clients share the same vocabulary.

gRPC errors

gRPC replaces HTTP status codes with its own range (0-16). Map gRPC status codes to your error catalog and include details with structured error information using protobuf messages — INVALID_ARGUMENT maps to your VALIDATION_ERROR, UNAVAILABLE to SERVICE_UNAVAILABLE, and so on.

Async/webhook errors

For webhooks, return errors as event payloads: the original event ID, an error code, and a timestamp. Provide a retry webhook endpoint for clients to replay failed deliveries.

Troubleshooting

  • Clients see HTML instead of problem+json. A reverse proxy or framework default error page is intercepting the error. Check gateway error-page configuration (error_page in nginx, exception middleware ordering) — your handler only runs for errors it can see.
  • status in body doesn’t match the HTTP status. Something rewrote the response after your handler (a catch-all, a gateway transform, or res.json() called twice). Assert in tests that wire status equals body status.
  • Clients can’t parse the error. Verify Content-Type: application/problem+json — many clients branch on it, and application/json teaches them to sniff instead.
  • Same problem returns different codes on different endpoints. The catalog isn’t enforced centrally — one handler returns VALIDATION_ERROR, another returns INVALID_REQUEST for the same condition. Generate handlers from the catalog, or gate merges on a contract test.
  • Error bodies are empty under load. Timeouts and circuit-breaker shortcuts bypass your normal handler path — verify the breaker middleware produces problem+json too, not just a bare 503.

See Also

Companion code: API error response resources — problem-details.js and problem_details.py middleware plus the error catalog as error-responses.json.

Frequently Asked Questions

Should I use RFC 7807 for internal APIs?

Yes, even for internal APIs. The small overhead of adding type and title pays off when another team needs to integrate, and it forces you to document error cases. RFC 9457 superseded 7807 in 2023; the format is the same, so use whichever number your team cites.

What if an error has multiple causes?

Use the errors array with one object per cause. Each object should include field, message, and optionally code. That's the usual shape of validation failures, where more than one field can be wrong. Return them all at once rather than making the client fix errors one per round trip.

How do I handle errors from downstream services?

Wrap downstream errors in your own format. Consider Circuit Breaker and Retry patterns for resilient downstream communication. Don't proxy raw third-party error bodies directly. Map the downstream failure to one of your documented error codes, log the original upstream response, and return a sanitized message to the client.

Should I include a correlation ID in addition to request ID?

Yes. A request ID identifies the client request; a correlation ID traces the request across services. Include both — request_id for the client and correlation_id for internal tracing. That's how you debug a distributed system where one client request fans out into a chain of service calls.

How do I version error response formats?

Add new fields, never remove or rename existing ones. If you need breaking changes, create a new error format and use content negotiation (Accept: application/problem+json; version=2) to let clients opt in. Document the versioning strategy in your API Documentation.

How do I document errors that vary by customer plan?

Use the same error code but vary the detail field by context. A free plan that exceeds the export limit returns 403 with detail: "Your plan allows 100 exports/month. Upgrade for more.". Don't create separate error codes per plan — the code identifies the problem, not the user context.

Should I include timestamps in error responses?

Yes — add a timestamp field in ISO 8601 format. Clients can correlate errors with their own logs and file accurate reports. Some teams prefer the HTTP Date header, but a body field is more useful for clients that only persist the error body.

Should I return different errors for the same problem based on context?

No. The same error condition should produce the same error code and status regardless of which endpoint triggered it. Context-specific information goes in detail and instance, not in the error code.