Loading content...
An API's error responses reveal more about its quality than its success responses ever could. When everything works, any API feels easy. When things break—and they always break—the error response determines whether consumers can recover gracefully or spiral into frustrated debugging sessions.
Poorly designed error responses create cascading problems: users see cryptic messages, developers can't diagnose issues, support tickets pile up, and integration partners lose confidence in your service. Well-designed error responses transform failures into opportunities for recovery, learning, and even increased trust.
Error response design isn't an afterthought—it's a core API design discipline that separates professional-grade APIs from amateur ones.
By the end of this page, you will understand how to structure error responses for maximum utility, choose appropriate error codes and messages, include actionable metadata, design for different consumer types (machines vs humans), and apply these principles across different API styles.
A well-designed error response answers four fundamental questions that every consumer—whether human or machine—needs answered:
Let's examine how these manifest in a complete error response structure:
123456789101112131415161718192021222324252627282930313233
{ // WHAT HAPPENED: Machine-readable error identifier "code": "PAYMENT_DECLINED", // WHY IT HAPPENED: Human-readable explanation "message": "The payment method was declined by the card issuer", // ADDITIONAL CONTEXT: Structured details "details": { "reason": "insufficient_funds", "declineCode": "51", "cardBrand": "visa", "lastFourDigits": "4242" }, // WHERE IT HAPPENED: Trace information "requestId": "req_a1b2c3d4e5f6", "timestamp": "2024-01-15T14:30:00.000Z", // WHAT CAN I DO ABOUT IT: Actionable guidance "recoveryOptions": { "retryable": false, "suggestedActions": [ "Try a different payment method", "Contact your card issuer for details" ], "helpUrl": "https://docs.example.com/errors/payment-declined" }, // MACHINE-READABLE CATEGORIZATION "type": "client_error", "category": "payment"}Error codes are the stable backbone of error handling. Unlike messages (which may be localized or refined), codes are contracts. Get them right from the start—changing them later breaks consumers.
Properties of Well-Designed Error Codes:
INVALID_EMAIL always means email validation failed.PAYMENT.DECLINED, AUTH.TOKEN_EXPIRED, VALIDATION.FIELD_REQUIRED.USER_NOT_FOUND beats ERROR_404_A.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// GOOD: Hierarchical, descriptive, stable error codesenum ErrorCode { // Authentication domain AUTH_TOKEN_EXPIRED = "AUTH.TOKEN_EXPIRED", AUTH_TOKEN_INVALID = "AUTH.TOKEN_INVALID", AUTH_TOKEN_MISSING = "AUTH.TOKEN_MISSING", AUTH_INSUFFICIENT_PERMISSIONS = "AUTH.INSUFFICIENT_PERMISSIONS", // User domain USER_NOT_FOUND = "USER.NOT_FOUND", USER_ALREADY_EXISTS = "USER.ALREADY_EXISTS", USER_EMAIL_NOT_VERIFIED = "USER.EMAIL_NOT_VERIFIED", USER_ACCOUNT_SUSPENDED = "USER.ACCOUNT_SUSPENDED", // Validation domain VALIDATION_FIELD_REQUIRED = "VALIDATION.FIELD_REQUIRED", VALIDATION_FIELD_INVALID = "VALIDATION.FIELD_INVALID", VALIDATION_FIELD_TOO_LONG = "VALIDATION.FIELD_TOO_LONG", VALIDATION_FIELD_FORMAT = "VALIDATION.FIELD_FORMAT", // Payment domain PAYMENT_DECLINED = "PAYMENT.DECLINED", PAYMENT_EXPIRED_CARD = "PAYMENT.EXPIRED_CARD", PAYMENT_INSUFFICIENT_FUNDS = "PAYMENT.INSUFFICIENT_FUNDS", PAYMENT_FRAUD_DETECTED = "PAYMENT.FRAUD_DETECTED", // Rate limiting domain RATE_LIMIT_EXCEEDED = "RATE_LIMIT.EXCEEDED", RATE_LIMIT_QUOTA_EXHAUSTED = "RATE_LIMIT.QUOTA_EXHAUSTED", // Generic INTERNAL_ERROR = "INTERNAL.ERROR", SERVICE_UNAVAILABLE = "SERVICE.UNAVAILABLE",} // BAD: Vague, unstable, or confusing error codesenum BadErrorCodes { // Too vague—what kind of error? ERROR = "ERROR", FAILED = "FAILED", INVALID = "INVALID", // Exposes implementation details SQL_EXCEPTION = "SQL_EXCEPTION", NULL_POINTER = "NULL_POINTER", // Numeric codes lose meaning E001 = "E001", E002 = "E002", // Inconsistent formatting UserNotFound = "UserNotFound", payment_failed = "payment_failed", CARD_ERROR = "CARD_ERROR",}Once you publish an error code, consumers will hardcode it into their error handling. Changing USER_NOT_FOUND to USER.NOT_FOUND breaks their code. Plan your code structure carefully before release, and treat codes as versioned contracts.
Error messages serve humans—end users, developers debugging, and support agents investigating. Unlike codes (stable contracts), messages can and should be refined for clarity over time.
| Poor Message | Improved Message | Why It's Better |
|---|---|---|
| Error 500 | We couldn't process your request. Please try again, or contact support with reference #ABC123. | Actionable, includes support reference |
| Invalid input | The email address 'notanemail' is not valid. Please enter an address like 'name@example.com'. | Specific, shows the value, provides example |
| Unauthorized | Your session has expired. Please sign in again to continue. | Explains cause, guides to solution |
| Record not found | No order was found with ID 'ORD-12345'. Check the order ID and try again. | Confirms what was searched, next step |
| Rate limit exceeded | You've made too many requests. Please wait 30 seconds before trying again. | Specific wait time, actionable |
| Payment failed | The card ending in 4242 was declined. Please try a different payment method. | Identifies which card, suggests alternative |
123456789101112131415161718192021222324252627282930313233
// Message templates with dynamic detailsconst errorMessages = { // Include the problematic value VALIDATION_FIELD_REQUIRED: (field: string) => `The ${field} field is required`, // Show what was received vs expected VALIDATION_FIELD_FORMAT: (field: string, received: string, expected: string) => `Invalid ${field} format: '${received}' does not match expected pattern '${expected}'`, // Include limits VALIDATION_FIELD_TOO_LONG: (field: string, max: number, actual: number) => `${field} exceeds maximum length of ${max} characters (received ${actual})`, // Time-based info AUTH_TOKEN_EXPIRED: (expiredAt: Date) => `Your session expired at ${formatDate(expiredAt)}. Please sign in again.`, // Retry guidance RATE_LIMIT_EXCEEDED: (retryAfter: number) => `Rate limit exceeded. Please wait ${retryAfter} seconds before retrying.`, // User-friendly payment messages PAYMENT_INSUFFICIENT_FUNDS: (cardLast4: string) => `The card ending in ${cardLast4} was declined due to insufficient funds. Please use a different card or add funds.`,} as const; // Usagethrow new ApiError({ code: ErrorCode.VALIDATION_FIELD_TOO_LONG, message: errorMessages.VALIDATION_FIELD_TOO_LONG('description', 500, 1247), details: { field: 'description', maxLength: 500, actualLength: 1247 }});Consider providing both a user-friendly message and a developer-friendly developerMessage. End users see 'We couldn't find that order'; developers see 'Query returned 0 results for order_id=ORD-12345 in partition=orders-2024'. Same error, different audiences.
Beyond codes and messages, structured details enable sophisticated error handling. The right metadata helps consumers programmatically respond to specific conditions.
123456789101112131415161718192021222324252627282930
// Validation errors: per-field details{ "code": "VALIDATION.MULTIPLE_ERRORS", "message": "Request validation failed", "details": { "errors": [ { "field": "email", "code": "VALIDATION.FIELD_FORMAT", "message": "Invalid email format", "value": "not-an-email", "constraint": "^[\\w.-]+@[\\w.-]+\\.\\w+$" }, { "field": "age", "code": "VALIDATION.FIELD_RANGE", "message": "Age must be between 18 and 120", "value": 15, "constraint": { "min": 18, "max": 120 } }, { "field": "items[2].quantity", "code": "VALIDATION.FIELD_REQUIRED", "message": "Quantity is required", "value": null, "path": ["items", 2, "quantity"] } ] }}items[2].quantity is clearer than quantity.For HTTP APIs, status codes are the first signal of error type. Consumers check status codes before parsing response bodies. Choosing correctly enables proper handling at the HTTP layer.
| Status Code | Meaning | When to Use |
|---|---|---|
| 400 Bad Request | Malformed request syntax | Invalid JSON, missing required headers, unparseable request body |
| 401 Unauthorized | Authentication required | No credentials, invalid token, expired token |
| 403 Forbidden | Authenticated but not authorized | Valid auth but lacks permission for this resource/action |
| 404 Not Found | Resource doesn't exist | Requested entity not found (or intentionally hidden from unauthorized users) |
| 405 Method Not Allowed | HTTP method not supported | POST to read-only endpoint, DELETE where not permitted |
| 409 Conflict | Request conflicts with current state | Duplicate creation, edit conflict, version mismatch |
| 422 Unprocessable Entity | Syntactically valid but semantically invalid | Business rule violations, validation failures |
| 429 Too Many Requests | Rate limit exceeded | Quota exhausted, too many requests in time window |
| 500 Internal Server Error | Unexpected server error | Unhandled exceptions, bugs. Never intentionally returned. |
| 502 Bad Gateway | Upstream service error | Dependency returned invalid response |
| 503 Service Unavailable | Server temporarily unavailable | Maintenance mode, overloaded, dependency down |
| 504 Gateway Timeout | Upstream timeout | Dependency took too long to respond |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
// Map error codes to HTTP status codesconst errorCodeToHttpStatus: Record<string, number> = { // Authentication: 401 'AUTH.TOKEN_EXPIRED': 401, 'AUTH.TOKEN_INVALID': 401, 'AUTH.TOKEN_MISSING': 401, // Authorization: 403 'AUTH.INSUFFICIENT_PERMISSIONS': 403, 'AUTH.ACCOUNT_SUSPENDED': 403, // Not found: 404 'USER.NOT_FOUND': 404, 'ORDER.NOT_FOUND': 404, 'RESOURCE.NOT_FOUND': 404, // Conflicts: 409 'USER.ALREADY_EXISTS': 409, 'ORDER.ALREADY_PROCESSED': 409, 'CONCURRENT_MODIFICATION': 409, // Validation: 422 (or 400) 'VALIDATION.FIELD_REQUIRED': 422, 'VALIDATION.FIELD_INVALID': 422, 'VALIDATION.BUSINESS_RULE': 422, // Rate limiting: 429 'RATE_LIMIT.EXCEEDED': 429, 'RATE_LIMIT.QUOTA_EXHAUSTED': 429, // Server errors: 5xx 'INTERNAL.ERROR': 500, 'SERVICE.DEPENDENCY_FAILED': 502, 'SERVICE.UNAVAILABLE': 503, 'SERVICE.TIMEOUT': 504,}; // Unified error handlerfunction toHttpResponse(error: ApiError): HttpResponse { const statusCode = errorCodeToHttpStatus[error.code] ?? 500; return { status: statusCode, body: { code: error.code, message: error.message, details: error.details, requestId: getCurrentRequestId(), timestamp: new Date().toISOString(), }, headers: buildErrorHeaders(error, statusCode), };} function buildErrorHeaders(error: ApiError, status: number): Record<string, string> { const headers: Record<string, string> = {}; if (status === 429 && error.details?.retryAfter) { headers['Retry-After'] = String(error.details.retryAfter); } if (status === 401) { headers['WWW-Authenticate'] = 'Bearer realm="api"'; } return headers;}Some APIs use 400 for all client errors including validation. Others use 400 for syntactic issues (bad JSON) and 422 for semantic issues (invalid field value). Either is defensible—just be consistent across your entire API.
Different API styles have different conventions and expectations for error responses. Match your style to consumer expectations.
1234567891011121314151617181920212223242526
// Standard REST error envelopeHTTP/1.1 422 Unprocessable EntityContent-Type: application/problem+json { "type": "https://api.example.com/errors/validation-failed", "title": "Validation Failed", "status": 422, "detail": "The request contains invalid field values", "instance": "/orders/create", "traceId": "abc-123-def-456", "errors": [ { "field": "email", "code": "INVALID_FORMAT", "message": "Must be a valid email address" } ]} // Using RFC 7807 Problem Details standard// - type: URI identifying error type (can be documentation link)// - title: Short human-readable summary// - status: HTTP status code// - detail: Human-readable explanation// - instance: URI of the specific occurrenceError responses can inadvertently leak sensitive information that aids attackers. Security-conscious error design requires balancing helpfulness with information protection.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// User enumeration protection// BAD: Different errors reveal user existenceapp.post('/login', (req, res) => { const user = findUser(req.body.email); if (!user) { return res.status(404).json({ error: "User not found" // Reveals email not registered }); } if (!verifyPassword(user, req.body.password)) { return res.status(401).json({ error: "Invalid password" // Confirms email exists }); } // ... login success}); // GOOD: Same error for both casesapp.post('/login', (req, res) => { const user = findUser(req.body.email); const valid = user && verifyPassword(user, req.body.password); if (!valid) { return res.status(401).json({ code: "AUTH.INVALID_CREDENTIALS", message: "Invalid email or password" // No clue which is wrong }); } // ... login success}); // Internal error sanitizationfunction toErrorResponse(error: Error, requestId: string): ApiError { // Log full details internally logger.error('Request failed', { requestId, error: error.message, stack: error.stack, cause: error.cause, }); // Return sanitized response if (error instanceof ApiError) { return error; // Already sanitized } // Unknown errors get generic response return { code: 'INTERNAL.ERROR', message: 'An unexpected error occurred', requestId, // NO stack trace, NO internal details };}It's common to include detailed error information (stack traces, SQL) in development and staging environments while sanitizing production responses. Ensure this toggle is secure—an attacker shouldn't be able to trigger dev mode in production.
Error responses are a critical communication channel between your API and its consumers. Let's consolidate the key principles:
What's next:
Even the best-designed error responses are useless if consumers don't know they exist. The final page of this module explores error documentation: how to catalog errors, communicate expectations, and help consumers prepare for every failure mode your API can produce.
You now understand how to design error responses that serve as effective communication rather than just failure notifications. Great error responses transform frustrating debugging sessions into quick resolutions.