Crypto API rate limits: 429 is a pause, not a dead end

When an AI trading agent gets HTTP 429 from a crypto API, do not mint a new key and do not retry in a tight loop. Read Retry-After, sleep that many seconds, then retry the same request. That is the whole per-minute answer.

The second question is whether backoff is enough. A 429 means you hit the burst or daily quota on this key. A 403 means the endpoint is plan-gated. Different errors, different next steps: wait, or upgrade the same X-API-Key. This is the agent path, not generic exponential backoff.

Limits are per key (scope: "api_key"), not per account:

TierBurstDailyWhat it is for
Free10 req/min1,000/day (100 until the email is confirmed)Live market-wide data, no card
Pro30 req/min10,000/dayFull-universe quant, gamma, whales, books, indicators
Pro Plus120 req/min50,000/dayHistorical / bulk / Parquet backtesting

A 429 on /api/* is JSON, never HTML, and carries fields so you never parse the sentence:

{
  "detail": {
    "error": "rate_limit_exceeded",
    "message": "Per-minute rate limit of 10 requests exceeded on this Free API key.",
    "limit": 10,
    "window": "1m",
    "tier": "free",
    "scope": "api_key",
    "retry_after": 43
  }
}

window tells you whether you burned the minute or the day — "1m" versus a daily-quota 429. The message names the tier, so a paid key can never be misread as a free-tier limit.

If the key is still unverified Free, a daily 429 also carries upgrade_available, upgrade_message and verified_daily_limit. Confirming the email lifts that same key from 100 to 1,000 requests/day, turns Pro on for 24 hours, and emails a discount code. An agent cannot click the link for you, but POST /api/v1/auth/resend-verify re-sends it and GET /api/v1/auth/keys/me reports email_verified.

Read Retry-After, then sleep

Every 429 sends a Retry-After header (integer seconds) and the full X-RateLimit-* set. Prefer the header over guessing, and fall back to detail.retry_after if a proxy stripped it.

import time
import requests

BASE = "https://cryptodataapi.com/api/v1"
HEADERS = {
    "X-API-Key": "cdk_live_yourkey",
    "User-Agent": "cryptodataapi-python/1.0",
}


def get(path, *, max_retries=3, **params):
    url = f"{BASE}{path}"
    for attempt in range(max_retries + 1):
        r = requests.get(url, headers=HEADERS, params=params, timeout=30)
        if r.status_code != 429:
            return r
        wait = r.headers.get("Retry-After")
        if wait is None:
            body = r.json().get("detail") or {}
            wait = body.get("retry_after", 60)
        time.sleep(int(wait))
    return r  # last 429 - caller decides whether to upgrade

Do not invent your own exponential backoff first. Jitter is fine when Retry-After is missing; when it is present, honour it.

HTTP 429 vs 403: rate limit vs plan-gated

Treat these as different branches. Mixing them up is how agents mint a second key, or retry a call that will never succeed on Free.

429 — rate limit. The key is allowed on this path; you went over 10/min or over the daily cap. Back off. Optionally upgrade if remaining-day is already near zero and the agent still has work.

403 — plan-gated. The key is valid and not over quota. This endpoint or coin scope needs a higher tier:

{
  "detail": {
    "error": "pro_required",
    "message": "This data requires a Pro or Pro Plus tier API key. See https://cryptodataapi.com/pricing",
    "required_tier": "pro",
    "pricing_url": "https://cryptodataapi.com/pricing"
  }
}

Branch on required_tier ("pro" or "pro_plus") and pricing_url. Sleeping on a 403 does nothing — the next call is still 403.

401 — no key or invalid key. Different again. Unauthenticated 401s carry mint_url (POST /api/v1/auth/keys) and WWW-Authenticate: ApiKey … header="X-API-Key". Mint once, then keep that key.

def classify(response):
    code = response.status_code
    detail = (response.json() or {}).get("detail") or {}
    if code == 429:
        return "rate_limit", detail
    if code == 403:
        return "plan_gated", detail  # required_tier, pricing_url
    if code == 401:
        return "auth", detail        # detail.get("mint_url")
    return "ok", detail

A 403 on /api/v1/quant/market or /api/v1/quant/gex means Pro is required — not that you are being throttled.

Watch remaining-day headers before you hit the wall

Successful authenticated responses carry the same header set a 429 does. Read them on the 200s, so the 429 never arrives:

HeaderMeaning
X-RateLimit-Limit-MinuteBurst cap for this key (Free 10, Pro 30, Pro Plus 120)
X-RateLimit-Remaining-MinuteCalls left in the current minute
X-RateLimit-Reset-MinuteUnix timestamp when the minute window resets
X-RateLimit-Limit-DayDaily cap (unverified Free 100, verified Free 1,000, Pro 10,000)
X-RateLimit-Remaining-DayCalls left today
X-RateLimit-Reset-DayUnix timestamp when the daily window resets (UTC midnight)

Header names are case-insensitive; most clients see x-ratelimit-remaining-day.

def remaining(response):
    h = {k.lower(): v for k, v in response.headers.items()}

    def n(name):
        v = h.get(name)
        return int(v) if v is not None else None

    return {
        "remaining_minute": n("x-ratelimit-remaining-minute"),
        "remaining_day": n("x-ratelimit-remaining-day"),
        "limit_minute": n("x-ratelimit-limit-minute"),
        "limit_day": n("x-ratelimit-limit-day"),
        "reset_minute": n("x-ratelimit-reset-minute"),
        "reset_day": n("x-ratelimit-reset-day"),
    }

If remaining_minute is 1 and you are about to fan out, batch or wait. If remaining_day is in the low tens and the agent still has a session's worth of work, that is the free-vs-Pro decision — not another GET.

You can also ask the key about itself with GET /api/v1/auth/keys/me. daily_limit and per_minute_limit are the effective caps, not the tier headline: an unverified Free key reports 100/day even though the published Free tier is 1,000.

One caveat: not every 403 echoes X-RateLimit-*. Trust remaining-day on 200s and 429s, and do not require those headers on a 403 — required_tier is enough to decide.

Free vs Pro: when backoff is the wrong move

Backoff is right when:

Upgrading is right when:

Free is the live market-wide product at 10/min and 1,000/day. Pro is the same key with 3× the burst, 10× the daily allowance, and the per-coin quant suite, — current price on the pricing page. Do not mint a second Free key to dodge the cap — limits are per key by design, and it leaves you managing two identities for one agent.

Upgrade the same key with x402 agent-subscribe

This is the agent self-subscribe path: one endpoint, gasless USDC over x402, no dashboard, no human in the loop. Pass the existing X-API-Key. Key resolution is explicit:

Omit the header and you can accidentally mint. Send it and you cannot.

  1. Discover. POST with no x-payment header returns HTTP 402 with accepts[] (network, asset, amount) and a PAYMENT-REQUIRED header.
  2. Sign. Off-chain EIP-712 USDC payment, zero gas. Base (eip155:8453, recommended), Ethereum or Solana. Plans: monthly/annual = Pro, monthly_plus/annual_plus = Pro Plus. The 402 carries the exact amount in atomic USDC units (6 decimals), so read it from the response rather than hard-coding a figure. Live amounts: GET /api/v1/payments/plans.
  3. Settle. Re-POST with the signed x-payment header and your existing key. The key you already hold is now Pro.
# Discover the 402 (price + networks). Send the key you already have.
curl -X POST https://cryptodataapi.com/api/v1/payments/agent-subscribe \
  -H "Content-Type: application/json" \
  -H "X-API-Key: cdk_live_yourkey" \
  -d '{"plan":"monthly"}'

# Then re-POST with the signed x-payment header to settle.
# monthly = Pro (30 req/min, 10,000/day). monthly_plus = Pro Plus.
SUBSCRIBE = f"{BASE}/payments/agent-subscribe"


def upgrade_same_key(plan="monthly"):
    r = requests.post(
        SUBSCRIBE,
        headers={**HEADERS, "Content-Type": "application/json"},
        json={"plan": plan},
        timeout=30,
    )
    if r.status_code == 402:
        # Sign r.json()["accepts"] off-chain, then:
        #   headers["x-payment"] = signed_payload
        #   r = requests.post(SUBSCRIBE, headers=headers, json={"plan": plan})
        return r
    r.raise_for_status()
    return r

Card checkout (POST /api/v1/payments/stripe/checkout) is a browser redirect — not for an unattended agent. After settlement, GET /api/v1/auth/keys/me reports Pro limits on the same key. No MCP re-add, no re-install.

A practical agent loop

Six rules, in order of when they fire:

  1. Authenticate once. X-API-Key on every call, and send a named User-Agent — Python's default urllib string is blocked at the edge.
  2. Prefer one fat call. GET /api/v1/daily first; append ?format=markdown for an LLM.
  3. Read remaining-day on every 200. If remaining-minute is 0, wait for reset. If remaining-day is low, upgrade rather than discovering it via a 429.
  4. On 429: sleep Retry-After. If window is daily, or you have retried the minute twice, upgrade. If email_verified is false and the cap is 100, resend verify — cheaper than Pro.
  5. On 403: do not retry. If required_tier is "pro", call agent-subscribe with {"plan":"monthly"} and the same key.
  6. On 401: mint once at POST /api/v1/auth/keys. Never mint inside a 429 handler.

An AI trading agent that hits a rate limit either waits, confirms its email, or pays. It does not open a new key.

When to upgrade to Pro

If the agent is already doing useful work on Free, the next step is not another market-data tutorial. It is headroom: 30 requests a minute and 10,000 a day, on the key you already have, plus the Pro endpoints a live agent actually calls.

Caps, 429 behaviour and Retry-After are documented in the API docs. Any change to the error shape lands on the changelog, and every /api/* response carries an X-API-Version header so you can detect a shape change without diffing payloads.

Related reading: the candles + funding + OI spine for a trading agent, and minting a free key with no signup form.