The envelope
Every non-2xx response carries the same JSON envelope, regardless of which endpoint you hit:
json
{"error": {"code": "quota_exceeded","message": "Monthly request quota exceeded.","request_id": "req_01HT...","details": { "resets_at": "2026-05-01" }}}
error.codeis a stable, machine-readable string. Switch on this, not onerror.message.error.messageis a human-readable summary, subject to wording changes without notice.error.request_idmatches theX-Request-Idresponse header — include it when you file a support ticket.error.detailsis endpoint- and code-specific; see each row below.
The full code list
| HTTP | error.code | Meaning |
|---|---|---|
| 400 | invalid_parameter | Missing or malformed query parameter. |
| 401 | invalid_api_key | Key missing, malformed, or revoked. |
| 403 | plan_forbidden | Valid key, but your plan doesn't include this endpoint or feature (e.g. hourly-data). |
| 404 | not_found | No such route. Unknown exchanges, symbols, or tokens return an empty data: [] instead. |
| 409 | key_limit_reached | Tried to mint a third active key. |
| 415 | unsupported_media_type | Asked for a response format we don't produce. |
| 422 | unprocessable | Valid shape but semantically invalid (e.g. start > end). |
| 429 | rate_limited | Per-second burst limit. Honour Retry-After and try again. |
| 429 | quota_exceeded | Monthly hard cap. Wait until details.resets_at. |
| 500 | internal_error | Server bug. File a ticket with request_id. |
| 503 | temporarily_unavailable | Upstream DB degraded. Retry with backoff. |
How to retry
- Retry (with exponential backoff and jitter):
429 rate_limited,503 temporarily_unavailable, transport-level failures. - Don't retry: any other 4xx code. Fix the request.
- Stop until reset:
429 quota_exceeded. Readdetails.resets_at.
Example: idiomatic TypeScript handler
typescript
async function callZm(path: string, body: Record<string, unknown>) {const url = `https://zealous.markets/api${path}`;for (let attempt = 0; attempt < 4; attempt++) {const res = await fetch(url, {method: 'POST',headers: {Authorization: `Bearer ${process.env.ZM_KEY}`,'Content-Type': 'application/json',},body: JSON.stringify(body),});if (res.ok) return res.json();const body = await res.json().catch(() => ({}));const code = body?.error?.code;if (code === 'rate_limited' || res.status === 503) {const retryAfter = Number(res.headers.get('retry-after') ?? 1);await new Promise((r) => setTimeout(r, retryAfter * 1000 * 2 ** attempt));continue;}throw new Error(`${res.status} ${code}: ${body?.error?.message} (req=${body?.error?.request_id})`,);}throw new Error('too many retries');}