Error Contracts: How APIs Should Fail
Stable error codes, HTTP status discipline, validation shapes, and client-friendly failure modes for SaaS APIs.
When an API fails well, clients show the right message, support finds the incident, and engineers fix root cause without guessing which layer threw a generic 500. When it fails poorly, frontend code parses English sentences and users see “Something went wrong” for every case.
Error contracts are part of your public API—version them like success responses. This article defines envelopes, status codes, validation errors, and operational boundaries. It extends designing APIs with types that survive, aligns with API design frontend teams don't hate, and connects auth semantics from auth session patterns for SaaS.
One envelope, many failure modes
Standardize a JSON body across services:
{
"error": {
"code": "invoice_not_found",
"message": "Invoice not found.",
"details": { "invoiceId": ["unknown id"] }
}
}
code is stable snake_case or dotted identifiers—never rename without deprecation. message is safe for UI defaults but not for branching logic. details carries field-level validation maps or structured context. RFC 9457 (Problem Details for HTTP APIs) is a useful reference if you want interoperable type URIs; adapt to your stack without mandatory URIs if internal clients prefer simple codes.
HTTP status codes with discipline
| Status | Meaning for clients | |--------|---------------------| | 400 | Malformed request or failed validation | | 401 | Not authenticated | | 403 | Authenticated but not allowed | | 404 | Resource not found (or hidden as 404 for auth) | | 409 | Conflict (duplicate, state machine violation) | | 422 | Semantic validation (optional; some teams use 400) | | 429 | Rate limited—include retry guidance | | 500 | Unexpected server fault | | 503 | Temporary overload or dependency down |
Pick one policy for “exists but forbidden”: 403 versus 404 obfuscation. Document it; switching later breaks client assumptions.
Validation errors clients can render
Return details keyed by field path (billing.email, items[2].quantity). Arrays of strings per field support multiple issues. Mirror server Zod or JSON Schema paths so forms highlight inputs—see Zod and runtime truth at edges.
Do not leak stack traces or SQL in responses. Log those server-side with correlation ids.
Correlation and support
Include requestId in response headers (X-Request-Id) and optionally in the error body. Support staff matches user reports to logs. Clients should display request id in error UI for B2B SaaS.
Domain errors versus infrastructure errors
Map internal exceptions to public codes at a boundary layer—never let database driver messages reach clients. Domain errors (subscription_past_due) are actionable; infrastructure errors (dependency_timeout) trigger retries and generic user copy.
Rate limiting and 429
Retry-After headers or details.retryAfterSeconds help polite clients backoff. Document limits in API docs; sudden 429 without context frustrates integrators.
Partial success and batch endpoints
When some items in a batch fail, return a structured summary with per-id errors instead of a single opaque failure—frontend tables can mark rows without losing successful operations.
Webhooks and async failures
Synchronous APIs are not the only contract—job failures surfaced via polling endpoints should reuse the same code vocabulary. Background jobs and webhooks should persist failure reason codes for dashboards.
Internationalization
If message is English-only, clients may translate known code values. Alternatively, return machine code only and let UI own all strings—either approach works if documented.
Security-sensitive failures
Login failures should not confirm whether an email exists unless product explicitly requires it—balance UX with enumeration risk per OWASP authentication guidance. Same for password reset flows.
Client SDK patterns
TypeScript clients can narrow errors:
type ApiError = {
error: { code: string; message: string; details?: Record<string, string[]> };
};
Exhaustive switch on code for known recovery (redirect to billing, re-auth). Fall back to generic handling for unknown codes—forward compatibility.
Logging and metrics
Count errors by code and status, not by message text. Alert on spikes in 500 and on new codes after deploys. MDN’s HTTP response status overview helps onboard junior developers to semantics during code review.
Evolution
Adding new error codes is usually safe. Changing meaning of existing codes is breaking—bump API version or coordinate client releases. Deprecation notices belong in changelog and OpenAPI descriptions.
Testing failure paths
Contract tests should assert status, code, and details shape for representative failures—not only happy paths. Snapshot tests on messages are brittle; snapshot code and structure instead.
Include chaos cases: malformed JSON body (400 with invalid_json), missing auth header (401), wrong org on resource (403 or 404 per policy), and simulated upstream timeout mapped to 503. Frontend integration tests should stub these responses to verify toast and form behavior without relying on production faults.
Logging levels versus client payloads
500 responses stay generic; logs carry stack traces and internal ids. Avoid logging full request bodies that contain passwords or payment instruments. Structured logs with error.code mirror the client contract so dashboards group incidents correctly.
Mapping errors in BFF and Route Handlers
Next.js Route Handlers and BFF layers should translate upstream errors once—never leak third-party prose to browsers. Map payment provider failures to your code set (payment_declined, card_expired) while logging provider codes internally. Consistency at your edge matters more than exposing every vendor nuance to clients.
GraphQL and RPC alternatives
If you expose GraphQL, errors still need extension codes—not only message fields in the errors array. gRPC status details map naturally to internal codes; HTTP JSON APIs benefit from the same discipline even without protobuf. Pick one organizational error catalog across transports where possible.
Deprecation and sunset errors
When features retire, return a dedicated code (feature_disabled) with link to migration docs in details.helpUrl if helpful. Silent removal breaks clients; explicit failure with timeline respects integrators.
Load shedding and 503
Under stress, fail fast with 503 and retry guidance rather than timing out ambiguously. Clients distinguish retryable from fatal when status and body agree. Pair with circuit breakers on calling dependencies so your API does not become a pile of hung requests.
Timeouts versus errors
Clients cannot tell a hung request from a slow one without timeouts. Document recommended client timeout budgets per endpoint class. Server-side, return 504 or 503 when upstream dependencies exceed internal deadlines rather than holding connections open until the load balancer kills them—ambiguous hangs produce duplicate submits.
Documentation tables
Maintain a public table of code values, HTTP status, retryability, and user-facing copy suggestions. Internal runbooks link each code to likely causes and dashboards. New codes require a doc row in the same pull request as the implementation—reviewers catch orphan failures. Partners integrating against your API should subscribe to changelog notifications when codes change.
Consistency across microservices
If multiple services expose public JSON, publish a shared error schema package or lint rule in CI so code naming stays coherent (invoice_not_found everywhere, not InvoiceMissing in one service). Inconsistent catalogs force clients to special-case per route—exactly the glue code error contracts exist to eliminate. A shared linter rule catching new throw new Error('...') in HTTP handlers pays for itself quickly.
Closing
APIs that fail well give clients stable codes, correct HTTP semantics, field-level validation detail, and correlation for humans debugging production. Treat errors as typed outcomes shared with success DTOs—your frontend and partner integrations will stop guessing, and incidents will shrink to the fixes that actually matter.