Error catalog
Every error uses the same body shape and a stable machine-readable code, so you can handle failures the same way across every endpoint.
The error body
Failures return HTTP status plus a JSON body with a stable status: "error", a request_id for support correlation, a timestamp, and an error object with a code, a human message, and optional details. Branch on error.code, never on the message text (messages may be reworded; codes are stable). One legacy exception: the token-quota 402 uses a flat body where error is a string, so detect it by status 402 — see The 402 exception below.
{
"status": "error",
"request_id": "b3f1c2a0-7d4e-4a11-9c8e-2f0a1b6d5e77",
"timestamp": "2026-07-16T06:12:44.001Z",
"error": {
"code": "INVALID_PARAM",
"message": "period must be one of A, Q, TTM",
"details": null
}
}Some errors carry a pointer to a next step inside error.details or in the message — for instance, a not-yet-covered company points you at the coverage endpoint and a fallback. Successful responses use the mirror shape: status: "success" with data and the same request_id.
Error codes
| HTTP | error.code | When it happens |
|---|---|---|
| 400 | INVALID_PARAM | A parameter is missing, malformed, or has an unaccepted value. The message names the offending parameter. |
| 401 | UNAUTHORIZED | No API key was supplied, or the key is invalid. Send your key as an X-API-Key header or ?apiKey= query parameter. |
| 402 | error: "insufficient_tokens" | Your token balance cannot cover this call. Top up on the pricing page. Failed calls (4xx/5xx) are auto-refunded. NOTE: this is the one error that does NOT use the envelope below — see "The 402 exception". |
| 404 | NOT_FOUND | The requested resource does not exist — for example an accession or filename that is not present. |
| 404 | NOT_COVERED | A real company whose standardized financials are not yet available. The response includes a pointer to the coverage endpoint and a fallback. (A typo’d ticker vs a real-but-unprocessed company are being split into distinct codes.) |
| 405 | METHOD_NOT_ALLOWED | The HTTP method is not supported on this path (e.g. POST to a GET-only endpoint). |
| 429 | RATE_LIMIT_EXCEEDED | You exceeded a rate limit. Whether the response carries a Retry-After header depends on which limiter tripped — see the panel below. |
| 500 | INTERNAL_ERROR | An unexpected server error. The request_id lets support correlate it in the logs. Token cost is auto-refunded. |
The 402 exception
One error does not use the envelope above: the token-quota 402. It returns a flat body where error is a string — there is no error.code, request_id, or timestamp on it. Detect it by the status code 402 together with error === "insufficient_tokens", not by error.code:
{
"error": "insufficient_tokens",
"current_balance": 0,
"required_cost": 25,
"next_refill_at": "2026-08-01T00:00:00Z",
"plan": "free",
"upgrade_url": "https://finradar.ai/pricing"
}Why it is different
error.code; only the 402 carries the balance / cost / refill fields inline so a client can show the shortfall and the upgrade link directly.Rate limits and Retry-After — the honest version
Every over-limit response is HTTP 429 with error.code = "RATE_LIMIT_EXCEEDED". Whether it also tells you how long to wait depends on which limiter tripped:
- App-wide default limiter — its 429 does carry
Retry-AfterplusX-RateLimit-Limit / Remaining / Reset. Back off using those. - Gateway-enforced families — Company Financials and XBRL financial statements are rate-limited by IP at the gateway. A gateway 429 carries neither
Retry-AfternorX-RateLimit-*headers. Use a fixed backoff for these. - Login lockout — the brute-force protection on the login endpoint returns a 429 that does carry
Retry-After.
Do not assume Retry-After is always present
Retry-After on every 429, it will read nothing on the gateway-enforced families. Treat a missing header as “use my own backoff.”The exact per-family limits and enforcement layers are on the API Policies page, and are also served machine-readably at /api/v1/policy.