Rate limits & credits
Two independent limits — requests per second and credits per period — and what happens when you cross either.
There are two separate meters and they fail differently. Rate is how fast you may ask; quota is how much you may consume in a billing period. Hitting either returns 429, but only one of them is worth retrying.
The limits themselves
Rate is per account, not per key: adding keys does not add throughput. The full plan comparison is on pricing.
| Plan | Requests per second | In flight | Credits | Daily cap |
|---|---|---|---|---|
| Trial | 5/s | 3 | 800 once | 200 |
| Developer | 30/s | 8 | 50,000 / mo | None |
| Growth | 80/s | 16 | 200,000 / mo | None |
| Professional | 160/s | 24 | 500,000 / mo | None |
| Enterprise | 400/s | 32 | Negotiated | None |
Three of those columns do different jobs. Requests per second is the pace. In flight is how many answers you may be waiting for at once, and on a paid plan it is the limit you are most likely to meet first: a worker pool of thirty threads hits it long before it hits the rate. Credits is the budget for the period.
The trial is the one row where credits are a *stock* rather than a rate: they are granted once and do not renew, so its daily cap is a brake against burning the whole trial in one loop rather than an anti-mirror control. The paid plans have no daily cap at all.
The limit headers depend on which service answers you — checked 2026-08-27
The service in production today sends ETag and X-Request-Id and nothing else about your usage, even though RateLimit-* and X-Quota-* are named in Access-Control-Expose-Headers. Write your client so that a missing header means "unknown" rather than "no limit": the replacement service sends all of them on every answer, rejections included, and the switch will be a dated entry on the changelog.
Until you see them, model the meter on your side: count your own calls against the cost table below, and treat a 429 as the authoritative signal rather than as a surprise.
The three 429s
| Code | Cause | What to do |
|---|---|---|
RATE_LIMITED | Too many requests per second or per minute. | Back off exponentially with jitter, honouring Retry-After if one is present. |
CONCURRENCY_LIMIT | Too many requests in flight at once, regardless of rate. | Shrink your worker pool. Retrying immediately makes it worse. |
QUOTA_EXCEEDED | Credits for the period are spent. | Retrying cannot succeed until the period rolls over. Enable overage, upgrade, or wait. |
Quota exhaustion is 429 and not 402 deliberately. Gateways, CDNs and corporate proxies handle 402 unpredictably — some cache it, some rewrite it — and an ambiguous status on the one response that costs a customer money is not a place to be clever.
Backing off correctly
Because no header tells you where you stand, the backoff has to work from the response alone. Honour Retry-After when it is there, fall back on exponential delay when it is not, and add jitter so a fleet of your own workers does not resynchronise into a thundering herd:
async function call(url: string, key: string, attempt = 0): Promise<Response> {
const res = await fetch(url, { headers: { "X-Api-Key": key } });
if (res.status !== 429) return res;
const { error } = await res.clone().json();
// Nothing recovers a spent quota except time. Fail loudly instead of
// spending the next hour proving it.
if (error.code === "QUOTA_EXCEEDED") throw new Error("quota exhausted");
if (attempt >= 5) throw new Error(error.code);
// Retry-After may be absent. Never let a missing header collapse the
// delay to zero — that turns a rate limit into a hot loop.
const advertised = Number(res.headers.get("Retry-After") ?? 0) * 1000;
const backoff = Math.max(advertised, 2 ** attempt * 250);
await new Promise((r) => setTimeout(r, backoff + Math.random() * 250));
return call(url, key, attempt + 1);
}A 304 is free
Conditional requests that revalidate cost zero credits and never touch the database. Storing ETags is the cheapest optimisation available to you — see caching and ETags.
What a request costs
Credits are per operation, not per row. A search that returns 250 cards costs the same as one that returns 3 — so a larger limit is strictly cheaper than more requests, up to the ceiling of 250.
| Endpoint | Cost |
|---|---|
| `GET /v1/cards` | 1 credit |
| `GET /v1/cards/{id}` | 1 credit |
| `GET /v1/cards/batch` | 1 credit per 25 ids, rounded up |
| `GET /v1/sets` | 1 credit |
| `GET /v1/sets/{code}` | 1 credit |
| `GET /v1/sets/{code}/cards` | 1 credit |
| `GET /v1/artists` | 1 credit |
| `GET /v1/artists/{slug}` | 1 credit |
| `GET /v1/series` | 1 credit |
| `GET /v1/sealed` | 1 credit |
| `GET /v1/sealed/{id}` | 1 credit |
| `GET /v1/reference` | free, no key needed, cached for a day |
| `GET /v1/status` | free, no key needed |
| `GET /v1/cards/{id}/prices` | 2 credits |
| `GET /v1/sealed/{id}/prices` | 2 credits |
| `GET /v1/prices/current` | 4 per 25 ids: a full batch of 50 costs 8 |
| `GET /v1/cards/{id}/prices/history` | 5 credits |
| `GET /v1/cards/{id}/prices/stats` | 2 credits |
| `GET /v1/prices/movers` | 3 credits |
| `GET /v1/prices/sources` | free, no key needed |
| `GET /v1/changes` | 1 credit |
| `GET /v1/bulk` | 1 credit |
| `POST /v1/vision/identify` | The most expensive call in the price list, and the only one that is not a row being read: it is the index of every catalogue image answering at once. |
Cheaper by construction
Two habits cut most bills in half: send If-None-Match so unchanged pages return 304, and ask only for the select fields you actually render. Both reduce what you spend without changing anything your users see.