Consistent Error Responses
An API's error responses are part of its public contract — clients write code against them. If every endpoint shapes errors differently (sometimes a string, sometimes { error }, sometimes { message }), clients can't handle failures generically and your API feels unreliable. This page defines a single error envelope, maps failures to the right status codes, and centralizes it all in one error handler.
Pick one error shape and use it everywhere
// Every error response, from every endpoint, looks like this:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{ "field": "email", "message": "must be a valid email" }
]
}
}Use the status code that fits
Status | Meaning | When |
|---|---|---|
| Bad Request | Malformed syntax, unparseable JSON |
| Unauthorized | Missing/invalid authentication |
| Forbidden | Authenticated but not allowed |
| Not Found | Resource (or route) does not exist |
| Conflict | Duplicate, version conflict |
| Unprocessable Entity | Well-formed but fails validation |
| Too Many Requests | Rate limit exceeded |
| Internal Server Error | Unexpected server fault |
401 vs 403 — a frequent mix-up
401 Unauthorized | 403 Forbidden | |
|---|---|---|
Means | Who are you? (not authenticated) | I know you, but no. |
Fix for client | Log in / send valid credentials | Nothing — you lack permission |
Example | No/expired token | User token on an admin route |
Centralize it in the error handler
One handler shapes every error
class ApiError extends Error {
constructor(status, code, message, details) {
super(message)
this.status = status; this.code = code; this.details = details
}
}
const badRequest = (msg, details) => new ApiError(400, 'BAD_REQUEST', msg, details)
const notFound = (msg = 'Not found') => new ApiError(404, 'NOT_FOUND', msg)
// Last middleware — turns any thrown error into the standard envelope:
app.use((err, req, res, next) => {
if (res.headersSent) return next(err)
const status = err.status ?? 500
if (status >= 500) console.error(err) // log server faults fully
res.status(status).json({
error: {
code: err.code ?? 'INTERNAL_ERROR',
message: status >= 500 ? 'Internal Server Error' : err.message,
...(err.details && { details: err.details }),
},
})
})Throw typed errors from anywhere
app.get('/orders/:id', async (req, res) => {
const order = await db.orders.find(req.params.id)
if (!order) throw notFound('Order not found') // → 404 envelope
if (order.userId !== req.user.id) {
throw new ApiError(403, 'FORBIDDEN', 'Not your order') // → 403 envelope
}
res.json(order)
})Also handle 404 for unknown routes
// After all routes, before the error handler — same envelope:
app.use((req, res) => {
res.status(404).json({
error: { code: 'NOT_FOUND', message: `Cannot ${req.method} ${req.path}` },
})
})Error response checklist
One envelope shape across the entire API.
A machine-readable
codeplus a humanmessage.Status code matches the failure (
4xxclient,5xxserver) — never200.401for unauthenticated,403for unauthorized.Field-level
detailsfor validation failures (422).Generic message + server-side logging for
5xx; no internals leaked.