Designing a consistent error handling pattern for APIs
Errors and warnings are a minefield for developers, and very inconsistently implemented across APIsAPI A set of rules and protocols that allows different software applications to communicate with each other. APIs define the methods and data formats that applications can use to request and exchange information.. I've been thinking about what a consistent, developer-friendly approach to surfacing issues looks like, one that helps both developers and their end users.
This is focused on APIAPI A set of rules and protocols that allows different software applications to communicate with each other. APIs define the methods and data formats that applications can use to request and exchange information. responses and logs rather than native SDKsSDK A collection of software development tools, libraries, documentation, code samples, and guides that help developers create applications for a specific platform or framework..
Principles
- The client can see what happened. No mystery failures.
- The client can understand why and how to resolve it, with links to docs, support, and related resources.
- The client can communicate the issue to their user. The APIAPI A set of rules and protocols that allows different software applications to communicate with each other. APIs define the methods and data formats that applications can use to request and exchange information. should help them do this.
- The client isn't forced into complex change management. Breaking changes to error shapes are painful.
- Issue persistence is captured where appropriate. Some issues are transient, some are ongoing.
- Issue structure is consistent across contexts: APIAPI A set of rules and protocols that allows different software applications to communicate with each other. APIs define the methods and data formats that applications can use to request and exchange information. responses, webhooks, component callbacks, UI.
- It's clear where action is required vs where the issue is advisory.
The issues array
All non-success APIAPI A set of rules and protocols that allows different software applications to communicate with each other. APIs define the methods and data formats that applications can use to request and exchange information. responses should include an issues array.
This surfaces errors, warnings, and informational notices in a consistent structure, giving developers enough context to understand what went wrong, whether it's resolved, and where to go next.
Errors and warnings share the same shape and the same need (context, traceability, a path forward), so splitting them into separate arrays would force developers to check two places for information that belongs to the same moment in a request's lifecycle.
Example response
{
"issues": [
{
"issue": "payment.unauthorized.token_expired",
"severity": "error",
"correlationId": "4b3a2c1d-0000-0000-0000-abcdef123456",
"dateTime": "2024-11-01T12:34:56Z",
"active": false,
"message": {
"title": "Payment not authorised",
"detail": "This transaction couldn't be completed."
},
"links": {
"documentation": "https://docs.example.com/errors/unauthorized",
"portal": "https://support.example.com",
"retry": "https://api.example.com/..."
}
}
]
}
Fields
issue
Type: string (namespaced).
Required: Yes
The single machine-readable identity of the issue. Format: {domain}.{class}.{reason}, read left to right from broadest to most specific, e.g. payment.unauthorized.token_expired, payment.validation.missing_field.
{domain}: the resource or area the issue belongs to (payment,verification,account). Leading with it keeps codes unambiguous when issues from several resources flow through one channel, such as an aggregated webhook stream, whereunauthorized.token_expiredon its own wouldn't say what was unauthorised.{class}: the kind of problem:unauthorized,validation,conflict,rate_limit,internal. The broad bucket most error handling branches on. Expand the set as the taxonomy matures.{reason}: the specific cause within that class:token_expired,missing_field,invalid_format.
This one code carries the whole classification, and there is deliberately no separate type field. A standalone type would only restate the {class} segment, and two fields that must always agree are a bug waiting to happen. Consumers that need the class read it from the code instead.
Parsing, and strings vs enums. Read the code by splitting on . and matching prefixes: branch on payment.unauthorized, treat any segment you don't recognise as "more specific than I handle," and never assume a fixed depth. Keep the values as plain strings rather than a strict enum at first: the taxonomy will grow, and an exhaustive switch over an enum turns every new code into a breaking change for your consumers. Fall back to the {class} you do know, or to severity, when a {reason} is unfamiliar. Commit to an enum only once the set has genuinely stopped moving.
Namespaced codes also let you express hierarchy without proliferating top-level values.
payment.validation.missing_field and payment.validation.invalid_format are obviously related, where missing_field and invalid_format in isolation are just noise.
Prefer descriptive strings to numeric status and error codes.
correlationId
Type: string (UUID).
Required: Yes
A unique identifier for the request. Use this when raising a support ticket or querying logs; it's the fastest way to locate the issue on the server side.
The APIAPI A set of rules and protocols that allows different software applications to communicate with each other. APIs define the methods and data formats that applications can use to request and exchange information. should generate a correlationId for every request.
If the client supplies an X-Correlation-ID header, that value is echoed back, allowing them to correlate server logs with their own.
severity
Type: enum (string).
Required: Yes
Whether the issue blocks the request or is advisory.
| Value | Description |
|---|---|
error | Request failed; action required |
warning | Request succeeded (or partially succeeded); attention advised |
info | Informational notice only |
dateTime
Type: ISO 8601 string.
Required: Yes
When the issue occurred, in UTC.
active
Type: boolean.
Required: No
Whether the issue is still ongoing. This is key where issues are persistent or stateful rather than transient. Useful for platform-level problems (e.g. a service degradation) where the issue may resolve without developer action.
The idea is that a developer can disregard issues where active === false.
Some real-world examples: - A connected device goes offline, and stays an active issue until the connection is re-established. - A user's authorised access to a third-party data source expires or is revoked, and stays an active issue until re-authorisation.
Omit this field if resolution state cannot be reliably tracked.
A stale active: true is more harmful than no signal at all.
