Skip to main content

Errors & Rate Limits

HTTP status codes

CodeNameCauseAction
400Bad RequestInvalid parameter -- malformed date, unknown pair, unsupported intervalFix the request before retrying
401UnauthorizedMissing or invalid X-API-Key headerCheck your key is correct and included
403ForbiddenEndpoint not available on your plan (e.g., spread on Free)Upgrade your plan
404Not FoundNo data for the requested date or pairTry a different date or check /v1/pairs
429Too Many RequestsPer-minute rate limit or monthly quota exceededBack off and retry after X-RateLimit-Reset
503Service UnavailableData is stale beyond the freshness thresholdCheck /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:

HeaderDescription
X-RateLimit-LimitYour per-minute request cap
X-RateLimit-RemainingRequests remaining in the current window
X-RateLimit-ResetUnix 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 429 with a short Retry-After (typically under 60 seconds).
  • Monthly quota -- resets on the first of each calendar month (UTC). Exceeding this returns 429 with "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:

  1. Call /v1/status to identify which collector is down and which pairs are affected.
  2. Retry every 60 seconds until the pair returns to fresh status.
  3. If your use case can tolerate slightly stale data, pass max_age to /v1/rates/live with a higher value (e.g., max_age=900).