API Errors: Consistent Schemas, Status Codes, Field Errors, and Correlation IDs
Design a consistent JSON error response schema, map exceptions to correct HTTP status codes and application-level error codes, return field-level validation errors, attach correlation IDs for tracing, and ensure stack traces never leak in production.
When something goes wrong in an API — bad input, a missing resource, an unauthorized request, a database outage — the *shape* of the error response matters just as much as the shape of a successful one. A client-side error handler that has to special-case ten different inconsistent error formats across ten different endpoints is fragile and painful to maintain; a client that can rely on one predictable error schema everywhere can build a single generic error handler for the whole app. This lesson covers what a production-grade API error response needs: a consistent JSON envelope, the right HTTP status code for the failure category, a stable machine-readable error code a client can branch on, field-level detail for validation failures, a correlation ID that ties the response back to server-side logs, and — critically — never exposing a raw stack trace or internal exception message to the outside world in production.
A consistent error schema
Every error response, regardless of what went wrong, should have the same top-level shape so a client's error-handling code never has to guess the structure. A common, effective envelope is a top-level error object containing a machine-readable code, a human-readable message, and optional extra detail like fields for validation errors or correlation_id for tracing. Success responses never use this envelope, so the presence of an error key alone tells a client 'this request failed', without even needing to inspect the status code first (though the status code should always agree).
Consistency also means picking one casing convention (snake_case is common for JSON APIs) and sticking to it across every error and every success response — mixing errorCode in one endpoint and error_code in another is exactly the kind of inconsistency that turns a generic client error handler into a pile of special cases.
Status codes and application error codes together
HTTP status codes communicate the *category* of failure at a level every HTTP-aware tool (browsers, proxies, monitoring) understands: 400 malformed request, 401 unauthenticated, 403 forbidden, 404 not found, 409 conflict (e.g. a duplicate unique field), 422 semantically invalid (fails business validation despite being well-formed), 429 rate limited, 500 unexpected server error. These are coarse-grained on purpose — there are only so many HTTP status codes, and they're meant to be understood by infrastructure, not just your specific app.
An application-level error.code string like "insufficient_stock" or "email_already_registered" complements the status code with something a client's *code* can safely switch on to show a specific message or trigger specific UI behavior, without parsing the human-readable message string (which might change wording, or be localized, without warning). The pairing matters: the status code tells infrastructure and generic handlers 'what category', the app code tells your specific client 'what exactly, and what to do about it'.
• 400 Bad Request — malformed request (e.g. invalid JSON syntax). • 401 Unauthorized — missing/invalid authentication credentials. • 403 Forbidden — authenticated, but not allowed to do this. • 404 Not Found — the resource doesn't exist (or, deliberately, to avoid leaking existence to unauthorized users). • 409 Conflict — e.g. a unique constraint violation, a version conflict. • 422 Unprocessable Entity — well-formed request that fails validation/business rules. • 429 Too Many Requests — rate limit exceeded. • 500 Internal Server Error — an unexpected failure; never expose internals here.
Field-level errors for validation failures
A 422 validation failure is far more useful to a client when it lists exactly which fields failed and why, rather than a single generic 'validation failed' message the client can't map back to specific form inputs. The convention used earlier in this module — an array or object keyed by field path (email, items[2].qty) with a message per field — lets the client highlight the exact offending inputs in a form, and lets it show every problem at once instead of the user fixing one field, resubmitting, and hitting the next error one at a time.
Field errors should use the same field path/naming the client sent, including array indices for nested data, so there's no translation step needed on the client to map an error back to the input that caused it.
Correlation IDs and never leaking stack traces
A correlation ID (sometimes called a request ID or trace ID) is a unique identifier generated for each incoming request — often accepted from an X-Correlation-Id header if the client already has one, or generated fresh otherwise — that gets attached to every log line produced while handling that request and echoed back in the response (both as a header and inside the error body). When a user reports 'I got an error', support or engineering can search logs for that exact ID and see precisely what happened server-side, instead of trying to reconstruct the request from a timestamp and a vague description.
In production, an API must never return a raw exception message, file path, or stack trace to the client — these leak internal implementation details (library versions, file system paths, sometimes even database credentials embedded in a connection-string exception message) that are a gift to an attacker probing your system. The correct pattern is: log the full exception with its stack trace server-side (tagged with the correlation ID), and return the client only a generic message plus that correlation ID, so engineers can look up exactly what happened without exposing it publicly. PHP's display_errors must be Off in production, and any custom exception handler must catch everything and format a safe, generic response for unexpected (500-class) errors specifically.
• Generate (or propagate) a correlation ID per request; log every message during that request with it attached. • Return the correlation ID in the error response so the client can quote it when reporting a problem. • Log the real exception + stack trace server-side only — never send it to the client. • In production PHP: display_errors = Off, and a top-level exception handler that always returns the safe generic envelope for unhandled exceptions. • Known, expected failures (validation, not-found, conflict) can return a specific message; only truly unexpected exceptions get the generic 'something went wrong' treatment.
The standard error envelope
"code" is a stable string the client's code can switch on; "message" is human-readable and may change wording over time. "fields" is present only for validation-style errors (422); other error categories omit it. "correlation_id" is always present so a report of this error can be traced back to server logs.
{
"error": {
"code": "validation_failed",
"message": "The request could not be validated.",
"correlation_id": "a1b2c3d4e5f6",
"fields": {
"email": "email must be a valid email address",
"items[1].qty": "qty must be a positive integer"
}
}
}Code Examples
ApiException subclasses carry an HTTP status and a stable application error code, so known failures (NotFoundException, ValidationFailedException, ConflictException) can safely show their specific message to the client. Anything that isn't an ApiException is treated as unexpected: the full exception, message, and stack trace are logged server-side with the correlation ID attached, but the client only ever receives a generic 'An unexpected error occurred.' in production — the raw exception message is only shown outside production, for local debugging. Expected result: {"error":{"code":"not_found","message":"order 123 was not found","correlation_id":"a1b2c3d4e5f6"}} The correlation ID is both echoed in the JSON body and set as an X-Correlation-Id response header, so it's visible to a human reading the JSON and to tooling (like a browser dev-tools network tab or an API gateway) that only inspects headers.
$safeMessage = $this->isProduction ? 'An unexpected error occurred.' : $e->getMessage();
<?php
declare(strict_types=1);
final class ApiException extends RuntimeException {
public function __construct(
public readonly int $status,
public readonly string $errorCode,
string $message,
public readonly ?array $fields = null,
) {
parent::__construct($message);
}
}
final class NotFoundException extends ApiException {
public function __construct(string $resource, int|string $id) {
parent::__construct(404, 'not_found', "$resource $id was not found");
}
}
final class ValidationFailedException extends ApiException {
public function __construct(array $fields) {
parent::__construct(422, 'validation_failed', 'The request could not be validated.', $fields);
}
}
final class ConflictException extends ApiException {
public function __construct(string $message, string $code = 'conflict') {
parent::__construct(409, $code, $message);
}
}
final class ErrorResponder {
public function __construct(
private bool $isProduction,
private LoggerInterface $logger,
) {}
public function respond(Throwable $e, string $correlationId): void {
if ($e instanceof ApiExc
…Exception to HTTP response mapping
Known exception types map to specific codes with safe messages; everything else is generalized and logged, never exposed.
Thrown exception Client sees (status + error.code)
────────────────── ──────────────────────────────────
ValidationFailedException ──► 422 validation_failed (+ fields)
NotFoundException ──► 404 not_found
AuthenticationException ──► 401 unauthenticated
ForbiddenException ──► 403 forbidden
ConflictException ──► 409 conflict / duplicate_email / ...
RateLimitException ──► 429 rate_limited
(anything else, unexpected) ──► 500 internal_error "An unexpected
error occurred." + correlation_id
(full trace goes to logs ONLY)Important Rules
Use one consistent JSON error envelope (error.code, error.message, error.correlation_id, optional error.fields) across every endpoint.
Pick the HTTP status by failure category (400/401/403/404/409/422/429/500) and pair it with a stable app-level error code.
Return field-level errors for validation failures, keyed by the same field path/name the client sent.
Generate or propagate a correlation ID per request; attach it to every server log line and to the error response.
Never send a raw exception message or stack trace to the client in production — log it server-side and return a generic message instead.
Set display_errors = Off in production PHP and install a top-level handler that catches every uncaught exception/error.
Why this matters at scale
Stripe's API error format ({"error": {"type", "code", "message", "param"}}) and GitHub's ({"message", "errors": [...]}) are widely copied precisely because a predictable, well-documented error schema lets every client library build one shared error-handling code path. Internally, correlation/request IDs (Stripe calls theirs Request-Id) are what support engineers ask for first when debugging a customer-reported failure — without one, reconstructing 'what exactly happened for this specific request' from logs alone is far slower.
catch (Throwable $e) { echo json_encode(["error" => $e->getMessage(), "trace" => $e->getTraceAsString()]); }
Log the message/trace server-side with the correlation ID; return only a generic message + correlation_id to the client in production
Echoing a raw exception message and stack trace to the client leaks file paths, library versions, and sometimes credentials embedded in error messages — exactly the reconnaissance information an attacker wants, and it happens by default if display_errors is left On in production.
Returning 200 OK with {"success": false, "error": "..."} for a failed request
Return the correct non-2xx status code (422, 404, 409, etc.) alongside the error envelope
Returning 200 for a failed operation breaks HTTP-aware tooling (monitoring, caches, generic client retry logic) that relies on the status code alone to know whether a request succeeded — the error envelope's content should never have to be inspected just to learn that.
Why should a production API never return a raw stack trace to the client? A. Stack traces are too large to send over HTTP B. They leak internal implementation details (file paths, library versions, sometimes secrets) useful to an attacker, and should be logged server-side instead C. JSON cannot encode stack trace data D. Clients can't parse stack traces
B. They leak internal implementation details (file paths, library versions, sometimes secrets) useful to an attacker, and should be logged server-side instead — A stack trace reveals internal structure and can leak sensitive detail; the safe pattern is to log it server-side tied to a correlation ID and return only a generic message and that ID to the client.
What is the purpose of a correlation ID in an API error response? A. It encrypts the response body B. It lets a specific client request be traced back to the exact server-side log entries that explain what happened C. It replaces the need for HTTP status codes D. It is only used for successful responses
B. It lets a specific client request be traced back to the exact server-side log entries that explain what happened — A correlation ID generated (or propagated) per request and attached to every log line for that request lets engineers look up precisely what happened server-side when a client reports an error quoting that ID.
Which HTTP status best fits a well-formed request that fails a business validation rule (e.g. an invalid enum value)? A. 400 B. 404 C. 422 D. 500
C. 422 — 422 Unprocessable Entity is the conventional status for a request that is syntactically well-formed but fails semantic/business validation, distinct from 400 (malformed request) and 500 (unexpected server failure).
Design the error envelope your API would return for: (a) a request to update an order that doesn't exist, (b) a request with an invalid 'status' value, and (c) an unexpected database connection failure in production. Specify status code, error.code, and whether fields/message detail is shown to the client.
(a) 404, error.code 'not_found', message 'order 123 was not found', no fields. (b) 422, error.code 'validation_failed', fields: {status: 'status must be one of: pending, shipped, cancelled'}. (c) 500, error.code 'internal_error', message 'An unexpected error occurred.', correlation_id present, no stack trace or DB details in the response — the real exception and trace are logged server-side keyed by that correlation_id.
(a) and (b) are known/expected failures with specific detail; (c) is unexpected and must be generalized with only a correlation_id, full detail logged server-side only.