Errors
One body shape for every failure, a stable code that does not change meaning, and the correct HTTP status. Rate limiting is a 429, not a 400 dressed up as one.
The error body
The OpenAPI document declares a single Error schema, and every failure uses it: an error object with a required code and message, and an optional details. The code is a string a client can branch on; the message is for a human and may grow more specific without the code changing.
{
"error": {
"code": "quota_exceeded",
"message": "Daily quota exceeded for the \"free\" plan on the \"words\" endpoint: 1001 of 1000 requests used today. Quota resets at 00:00 UTC. Upgrade your plan at /developers for a higher limit.",
"details": { "plan": "free", "endpoint": "words", "used": 1001, "limit": 1000 }
}
}The quota example carries details because that path sets it. Most failures do not — missing_api_key, for one, is only code and message:
{
"error": {
"code": "missing_api_key",
"message": "No API key supplied. Send your key in the X-API-Key header. Get a free key at /developers."
}
}Internal failures are deliberately vague. A handler that throws returns internal_error with the message “Something went wrong handling that request.” — never a stack trace and never a database error.
Every code, with its status
The codes and the statuses are read from the shared table in @ozituma/core, which is what lib/api.ts and every route consult, so this table cannot go out of step with the responses.
| Status | Code | When |
|---|---|---|
| 401 | missing_api_key | No key was sent. readApiKey looks for X-API-Key first, then Authorization: Bearer <key>, and rejects the request when neither carries a value. |
| 401 | invalid_api_key | The key is not known, has been revoked, has expired, or its developer is suspended. authenticateApiKey returns null for all four, so revocation is reported through this code. |
| 401 | revoked_api_key | Defined in the shared code table and mapped to 401. The current authenticateApiKey path collapses revoked, expired and unknown keys into invalid_api_key, so this code is not emitted today. |
| 429 | quota_exceeded | The key has used its whole daily allowance for the metered endpoint. The response carries Retry-After in seconds until 00:00 UTC and a details object with plan, endpoint, used and limit. |
| 429 | rate_limited | POST /api/v1/developers only: more than 5 key requests from one IP address in a 15-minute window. The response carries Retry-After: 900. |
| 404 | not_found | No entry matches that id or slug, or no word of the day exists for the requested language. |
| 400 | invalid_parameter | A query or body parameter is missing, malformed, or outside its range. The message names the parameter. |
| 400 | unsupported_language | The language value is not an active language code or URL slug. The message lists the codes that are available. |
| 500 | internal_error | An unexpected server error. The message is generic on purpose; a stack trace or a SQL error is never returned to a caller. |
The two 429s are different
Both are HTTP 429 and both mean “slow down”, but they are not the same limit and a client should treat them differently.
quota_exceeded— a metered request has used the key’s whole daily allowance for that endpoint.Retry-Afteris the number of seconds until 00:00 UTC. Waiting is the only fix.rate_limited—POST /api/v1/developersonly. More than five key requests have come from one IP address in a fifteen-minute window.Retry-Afteris900, the whole window.
Headers come with failures too
A failure from a metered route still carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Used once the key has been resolved — a quota rejection, a 404, a bad parameter — so a client that handles the error has the numbers it needs without a second call. The two 401s are the exception: a request with no key, or an unusable one, is refused before any quota is computed, so it carries no rate-limit headers.