Errors & Rate Limits
HTTP status codes
| Code | Name | Cause | Action |
|---|---|---|---|
400 | Bad Request | Invalid parameter -- malformed date, unknown pair, unsupported interval | Fix the request before retrying |
401 | Unauthorized | Missing or invalid X-API-Key header | Check your key is correct and included |
403 | Forbidden | Endpoint not available on your plan (e.g., spread on Free) | Upgrade your plan |
404 | Not Found | No data for the requested date or pair | Try a different date or check /v1/pairs |
429 | Too Many Requests | Per-minute rate limit or monthly quota exceeded | Back off and retry after X-RateLimit-Reset |
503 | Service Unavailable | Data is stale beyond the freshness threshold | Check /v1/status and retry in 60 seconds |
Error response format
All errors return a consistent JSON body:
{
"error": "rate_limit_exceeded",
"detail": "You have used 10/10 requests this minute",
"retry_after_seconds": 42,
"tier": "free",
"tier_limit": "10 req/min",
"upgrade_url": "https://moxiemetrx.com/pricing"
}
The error field is a machine-readable code. Use this for programmatic error handling rather than parsing the detail message.
Rate limit headers
Every response includes these headers regardless of status code:
| Header | Description |
|---|---|
X-RateLimit-Limit | Your per-minute request cap |
X-RateLimit-Remaining | Requests remaining in the current window |
X-RateLimit-Reset | Unix timestamp (seconds) when the window resets |
On a 429 response, the Retry-After header is also set to the number of seconds to wait.
Rate limits vs monthly quotas
There are two independent limits:
- Per-minute rate limit -- resets every 60 seconds. Exceeding this returns
429with a shortRetry-After(typically under 60 seconds). - Monthly quota -- resets on the first of each calendar month (UTC). Exceeding this returns
429with"error": "monthly_quota_exceeded". Upgrade your plan to continue.
Retry with exponential backoff
For 429 and 503 responses, use exponential backoff:
Python
import time
import requests
def get_with_backoff(url, headers, params, max_retries=5):
for attempt in range(max_retries):
response = requests.get(url, headers=headers, params=params)
if response.status_code == 200:
return response.json()
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 2 ** attempt))
time.sleep(retry_after)
continue
if response.status_code == 503:
time.sleep(2 ** attempt)
continue
response.raise_for_status()
raise Exception(f"Failed after {max_retries} retries")
JavaScript
async function getWithBackoff(url, headers, maxRetries = 5) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url, { headers });
if (response.ok) return response.json();
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get("Retry-After") ?? String(2 ** attempt));
await new Promise(r => setTimeout(r, retryAfter * 1000));
continue;
}
if (response.status === 503) {
await new Promise(r => setTimeout(r, 2 ** attempt * 1000));
continue;
}
throw new Error(`HTTP ${response.status}`);
}
throw new Error(`Failed after ${maxRetries} retries`);
}
Stale data (503)
A 503 response means the API has data for the requested pair but it is older than the freshness threshold (10 minutes). This indicates a collection outage on one or more upstream exchanges.
When you receive a 503:
- Call
/v1/statusto identify which collector is down and which pairs are affected. - Retry every 60 seconds until the pair returns to
freshstatus. - If your use case can tolerate slightly stale data, pass
max_ageto/v1/rates/livewith a higher value (e.g.,max_age=900).