API Error Response Template
A reusable template for consistent, informative, and developer-friendly API error responses that reduce debugging time.
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:
- RFC 7807 / RFC 9457 Problem Details — the standard format for HTTP API errors
- Legacy simple format — for internal APIs or backward compatibility
- Error code catalog — mapping HTTP status codes to specific error types
- Server-side implementation — middleware that produces problem+json without leaking internals
- 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 Problem Details (Recommended)
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
| Field | Required | Description |
|---|---|---|
type | Yes | URI that identifies the error type and links to human-readable documentation |
title | Yes | Short, human-readable summary of the problem — stable per type, don’t interpolate details here |
status | Yes | HTTP status code (must match the actual response status) |
detail | No | Explanation specific to this occurrence; this is where specifics belong |
instance | No | URI reference identifying this specific occurrence of the problem |
errors | No | Extension 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 Status | Error Code | When to Use |
|---|---|---|
| 400 | INVALID_REQUEST | Generic malformed request |
| 400 | VALIDATION_ERROR | Schema or business rule validation failed |
| 401 | UNAUTHORIZED | Missing or invalid authentication token |
| 403 | FORBIDDEN | Authenticated user lacks permission |
| 404 | RESOURCE_NOT_FOUND | Requested resource doesn’t exist |
| 409 | CONFLICT | Resource already exists or state conflict |
| 422 | UNPROCESSABLE_ENTITY | Semantic validation failed |
| 429 | RATE_LIMIT_EXCEEDED | Too many requests |
| 500 | INTERNAL_ERROR | Unexpected server-side failure |
| 503 | SERVICE_UNAVAILABLE | Temporary 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
4xxor5xxresponses - 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
detailif needed: useAccept-Languageto return localized messages, but keeptitlestable - Link to documentation: the
typeURI 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-Afterheader tells clients when to back off - Use consistent error codes: clients build retry logic around specific codes, not HTTP statuses
Common Mistakes
- Returning
500with 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 OKwith 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:titlemust stay stable pertype; occurrence details belong indetail - Treating 401 and 403 as interchangeable: 401 invites re-authentication, 403 doesn’t
Variant Comparison
| Variant | Context | Approach | Notes |
|---|---|---|---|
| RFC 7807/9457 Problem Details | Public API, external clients | Structured JSON with type, title, status | IETF standard, libraries available |
| Simple legacy format | Internal API, backward compat | code + message + request_id | Minimal, sufficient for small APIs |
| GraphQL errors | GraphQL API | errors array with extensions | No HTTP status codes |
| gRPC errors | Internal microservices | Status codes 0-16 + protobuf details | Binary, 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.
- 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 UnavailablewithContent-Type: application/problem+json,Retry-After: 60, and a body carryingrequest_idandcorrelation_idso the client can quote it in a ticket. - Internal logging. Full request, timeout, and breaker state go to logs tagged
downstream_timeout, service=inventory, plusinventory_timeout_count++and a trace span (inventory.call, status=error, duration=3001ms). - Client communication. 503 with
Retry-Aftermeans 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_verificationand 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_pagein nginx, exception middleware ordering) — your handler only runs for errors it can see. statusin body doesn’t match the HTTP status. Something rewrote the response after your handler (a catch-all, a gateway transform, orres.json()called twice). Assert in tests that wire status equals bodystatus.- Clients can’t parse the error. Verify
Content-Type: application/problem+json— many clients branch on it, andapplication/jsonteaches them to sniff instead. - Same problem returns different codes on different endpoints. The catalog isn’t enforced centrally — one handler returns
VALIDATION_ERROR, another returnsINVALID_REQUESTfor 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
- API Documentation Template — document each endpoint’s error cases alongside this format
- API Deprecation Notice Template — communicate breaking changes before they break clients
- REST API Design Guide — the broader design context this error format plugs into
- Circuit Breaker Pattern — the resilience pattern behind the cascade scenario
- RFC 9457 — Problem Details for HTTP APIs — the current standard (obsoletes RFC 7807)
- MDN — HTTP response status codes — status code semantics reference
- OWASP API Security Top 10 — why verbose errors are an information-leak vector
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.
Related Resources
API Documentation Template
A reusable template for documenting REST and GraphQL APIs with endpoints, schemas, errors, and examples.
DocAPI Deprecation Notice Template
A template for communicating API deprecations, breaking changes, and sunset timelines to consumers.
GuideREST API Design Guide
A thorough guide to designing clean, scalable, and maintainable REST APIs.
RecipeREST API Design: What Works
Design reliable, scalable REST APIs with proper HTTP methods, status codes, versioning, and pagination strategies.