CoinGecko 429'd on our 4th request โ€” mock its endpoints for dev & CI, keep the real API for prod

CoinGecko's keyless API is the default data source for every "build a crypto dashboard" tutorial โ€” no key, CORS open, rich shapes. It also has the tightest rate limit of any tutorial API we've tested. While writing this guide (September 2026) we sent a small burst of /simple/price calls with varying params, the way a dashboard with a coin picker would. Request 4:

$ curl "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd"
HTTP/2 429
{"status":{"error_code":429,"error_message":"You've exceeded the Rate Limit.
 Please visit https://www.coingecko.com/en/api/pricing to subscribe to our API
 plans for higher rate limits."}}

What the free tiers actually allow, from CoinGecko's own pages (checked September 2026):

None of this is a complaint โ€” live market data is genuinely expensive to serve, and for production you should use their API (or pay for it). But while you're building the dashboard โ€” hot-reloading forty times an hour, running Playwright suites, letting CI loose โ€” you don't need live prices. You need the exact response shapes, deterministically, at zero cost. That's a mock.

1. Capture once, mock forever

Pull real market data one time, then host it as your own API. Mockbird record ids are integers, so keep CoinGecko's slug in a coin_id field (one jq rename):

curl -s "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&per_page=3" \
  | jq '{coins: map(.coin_id = .id | del(.id))}' > db.json

curl -s -X POST "https://mockbird.mockbird.workers.dev/api/projects/import?name=coingecko-mock" \
  -H 'content-type: application/json' --data-binary @db.json

(One real call โ€” if it 429s, wait a minute; you only need it once. Bump per_page for more coins.) No signup, no key. The response has your project id and adminKey, plus one honest warning โ€” roi is a nested object, kept verbatim in the records but not in the flat field schema:

{"id":"YOUR_ID","adminKey":"โ€ฆ",
 "warnings":["coins.roi: nested value kept in records but not in the field schema"]}

Every field survives verbatim โ€” current_price, market_cap_rank, ath_change_percentage, the coin image URLs, all of it. And your copy answers with the full query toolkit the real endpoint doesn't have on the free tier:

$ curl "https://mockbird.mockbird.workers.dev/m/YOUR_ID/coins?symbol=btc"
[{"id":1,"coin_id":"bitcoin","symbol":"btc","name":"Bitcoin","current_price":81227,โ€ฆ}]

$ curl "https://mockbird.mockbird.workers.dev/m/YOUR_ID/coins?select=name,current_price,market_cap_rank"
[{"id":1,"name":"Bitcoin","current_price":81227,"market_cap_rank":1},
 {"id":2,"name":"Ethereum","current_price":2631.22,"market_cap_rank":2},
 {"id":3,"name":"Tether","current_price":0.999714,"market_cap_rank":3}]

$ curl "https://mockbird.mockbird.workers.dev/m/YOUR_ID/coins?current_price_gte=1000"
# โ†’ bitcoin, ethereum

Free bonuses the real API doesn't have: /m/YOUR_ID/types.ts generates TypeScript interfaces for your dashboard from the imported schema (?format=zod for Zod), and /graphql serves the same records with typed queries.

2. Byte-exact drop-in paths

If your code hardcodes CoinGecko's paths, reproduce them as custom routes so only the base URL changes. We verified each of these parse-equal against the live API's response while writing this:

curl -X POST "https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID/routes" \
  -H "x-admin-key: YOUR_ADMIN_KEY" -H 'content-type: application/json' \
  -d '{"path":"/api/v3/ping","body":{"gecko_says":"(V3) To the Moon!"}}'

curl -X POST "https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID/routes" \
  -H "x-admin-key: YOUR_ADMIN_KEY" -H 'content-type: application/json' \
  -d '{"path":"/api/v3/simple/price","body":{"bitcoin":{"usd":81240,"eur":70714},
       "ethereum":{"usd":2631.73,"eur":2290.75}}}'

Same for /api/v3/coins/markets with the captured array as the body (jq '{path:"/api/v3/coins/markets", body:.}' markets.json builds the payload). Then your app's fetch works unchanged:

$ curl "https://mockbird.mockbird.workers.dev/m/YOUR_ID/api/v3/simple/price?ids=bitcoin&vs_currencies=usd"
{"bitcoin":{"usd":81240,"eur":70714},"ethereum":{"usd":2631.73,"eur":2290.75}}

Honest caveat: these route bodies are static โ€” ?ids= and ?vs_currencies= are ignored, every caller gets the full object. Fetching a superset and reading the key you need is exactly what most dashboard code does anyway. If you need the query params to actually filter, use the /coins resource from ยง1.

3. Practice the 429 before production meets it

The best reason to mock a rate-limited API: you can finally test the limit. Your retry logic has almost certainly never seen a real 429 in CI โ€” you can't order one from CoinGecko without burning your quota (and remember, their errors count against you). Order one from your mock:

$ curl -i "https://mockbird.mockbird.workers.dev/m/YOUR_ID/api/v3/coins/markets?mock_ratelimit=3"
# requests 1โ€“3 โ†’ 200, with live x-ratelimit-limit / x-ratelimit-remaining headers
# request 4:
HTTP/2 429
retry-after: 34
x-ratelimit-limit: 3
x-ratelimit-remaining: 0
{"error":"simulated rate limit exceeded (mock_ratelimit=3 per 60s)","retry_after":34}

?mock_ratelimit=N allows N requests per 60s per client IP, then 429s with a real Retry-After โ€” point your countdown UI and backoff at it. For deterministic tests, ?mock_seq=429,429,200 fails exactly twice then succeeds โ€” assert your third attempt renders data. And to test your parser of CoinGecko's specific error shape, add a route that serves their exact 429 body (copied verbatim from the real response up top):

curl -X POST "https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID/routes" \
  -H "x-admin-key: YOUR_ADMIN_KEY" -H 'content-type: application/json' \
  -d '{"path":"/drills/429","status":429,"body":{"status":{"error_code":429,"error_message":"You'"'"'ve exceeded the Rate Limit. Please visit https://www.coingecko.com/en/api/pricing to subscribe to our API plans for higher rate limits."}}}'

Now expect(err.status.error_code).toBe(429) runs against the same bytes production will see. More rate-limit recipes: the rate-limit simulation guide.

4. Honest comparison

CoinGecko keylessCoinGecko Demo keyYour mock
Real live pricesโœ”โœ”โœ˜ frozen at capture โ€” by design
Signup / keynoneaccount + keynone
Rate limitIP-based, shared with your whole NAT (~5โ€“15/min)100/min, 10,000/month10,000 per day per project
Coin catalogue17,000+ coins, deep historywhatever you import
Deterministic for testsโœ˜ prices moveโœ˜โœ”
Can simulate 429 / errors / latencyโœ˜ (429s cost quota)โœ˜โœ” mock_ratelimit/mock_seq/mock_delay
Right forproductiondev, CI, demos, workshops

The pattern that uses each where it wins: base URL in an env var โ€” COINGECKO_BASE=https://api.coingecko.com in production, your /m/YOUR_ID in .env.test. Your quota gets spent on users, not on your test suite. Related: the CoinDesk BPI API is dead (where many of these tutorials started), mock stock quotes, mock fiat exchange rates, mock any third-party API.

โšก Skip the terminal: paste your captured JSON in the dashboard importer (CSV, OpenAPI, db.json, Postman, and HAR work too) โ€” or create a seeded sandbox in one click. No signup.