Skip to content
pokemontcgapi.com
Documentation

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.

Rate, concurrency and credits by plan
PlanRequests per secondIn flightCreditsDaily cap
Trial5/s3800 once200
Developer30/s850,000 / moNone
Growth80/s16200,000 / moNone
Professional160/s24500,000 / moNone
Enterprise400/s32NegotiatedNone

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

CodeCauseWhat to do
RATE_LIMITEDToo many requests per second or per minute.Back off exponentially with jitter, honouring Retry-After if one is present.
CONCURRENCY_LIMITToo many requests in flight at once, regardless of rate.Shrink your worker pool. Retrying immediately makes it worse.
QUOTA_EXCEEDEDCredits 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:

backoff.ts
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.

EndpointCost
`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.

view this page as markdown