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

Yearly plans grant the monthly credits every month for twelve months.

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 once400
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 trial also has a clock: it ends 30 days after signup, whether or not the credits are spent. Every response on a trial key carries the end date in X-Trial-Expires-At (RFC 3339), and GET /v1/me reports it as quota.trial_expires_at next to quota.trial_expired; paid plans carry neither.

Every answer carries the meter — checked 2026-10-06

Since 28 August every metered answer, rejections included, carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Policy, plus X-Quota-Limit, X-Quota-Remaining, X-Quota-Reset and X-Credits-Cost. RateLimit-Reset is also present except on quota refusals with no automatic renewal or no future reset. RateLimit-Limit is the capacity of the bucket — the burst, so on the trial it reads 10 while the pace is 5 a second — and RateLimit-Policy states the pace itself in the IETF draft form, 5;w=1;burst=10, 300;w=60. X-Credits-Cost is what the call cost you: 0 on any answer that was refunded, which is every 4xx, 5xx and 304. If the meter itself is unreachable, paid plans are served uncounted and the answer carries X-RateLimit-Degraded: 1; the trial answers 503 METER_UNAVAILABLE with a Retry-After, because trial credits do not renew and an uncounted call there is a gift nobody gets back.

X-Quota-Remaining is the non-negative number of credits left under the current period limit after this response. A served call includes its charge; a refunded 4xx, 5xx or 304 shows the balance before that call. A 429 for rate, concurrency, the period limit or the daily cap reserves no credits, so the header shows the balance at the instant of refusal. A daily-cap 429 can still show period credits left. With overage, the header stays at 0 after the plan limit is spent; overage does not raise X-Quota-Limit. Plans without a period limit omit these quota headers. During a meter outage, X-RateLimit-Degraded: 1 marks the balance as an estimate from the last stored usage; the trial fails closed with the same estimate. A yearly plan can show an upward correction when its Redis quota key expires after at most 40 days and is reseeded from stored usage.

A 429 also carries X-Error-Code with the same value as error.code in the JSON body. Use it to distinguish RATE_LIMITED, CONCURRENCY_LIMIT and QUOTA_EXCEEDED in logs that record response headers only.

An exhausted trial allowance (429 QUOTA_EXCEEDED with details.renews: false) omits both Retry-After and RateLimit-Reset: waiting cannot replenish it, even if X-Quota-Reset contains a future date. A renewable quota with a missing or past reset omits both headers too, because no useful wait is known. A renewable quota with a future reset returns both headers with the seconds until that reset; its body keeps details.renews: true and details.resets_at. Quota exhaustion has no details.retry_after_s. The daily cap is different: it returns both headers and details.retry_after_s with the seconds until midnight UTC. Rate refusals keep the rate wait, concurrency refusals keep one second, and neither CARD_ALLOWANCE_EXCEEDED nor TRIAL_EXPIRED returns Retry-After.

Feature refusals (PLAN_REQUIRED and related 403s) and TRIAL_EXPIRED happen before the meter is acquired: they have X-Error-Code, but no quota or rate headers. CARD_ALLOWANCE_EXCEEDED is the one 403 decided after acquisition, by the handler, so it carries the full set of headers and a refunded X-Credits-Cost: 0. Authentication failures, unknown routes and strict query parameter errors can also happen before acquisition.

Model the meter on your side anyway: count your own calls against the cost table below, and treat a 429 as the authoritative signal rather than as a surprise.

Centering service capacity

Centering allows two measurements in progress and two more queued per deployment, separately from your account limits. Beyond that, the API returns 503 PROVIDER_UNAVAILABLE with Retry-After: 2; credits and trial reservations are refunded. Honour that wait and use backoff with jitter rather than retrying in a burst. See the centering guide.

Warnings before a limit

Successful metered responses add X-Cards-Warning: approaching_limit when the distinct-card count before the response is at least 80% of X-Cards-Limit. It uses the same snapshot as X-Cards-Used, so a page crossing 80% is reflected on the next request. It is exposed through CORS and omitted on failed responses, 304s and plans without a card allowance.

GET /v1/me adds data.usage_warnings, an array with code: NEAR_LIMIT, resource (credits or cards), used, limit, stale and an English message, from 80% of either finite limit. data.upgrade_suggestion is a recommended plan object or null. On the trial, guidance starts at 50 credits, 50 distinct cards or seven days before expiry; on paid plans it starts only at 80% of credits or cards. The same catalogue, price and eligibility rules as error upgrades apply. A paid account uses manage_url to change its existing subscription. Warnings are informational and change no allowance or quota.

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_EXCEEDEDTrial credits, monthly credits or the daily cap are spent.Check details: a daily cap resets at midnight UTC, a renewable quota at its future reset, and a spent trial allowance needs a plan. Only schedule a retry when a future reset is known.

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.

And one 403

When the trial’s 30 days are up, every credited route answers 403 TRIAL_EXPIRED before any credit is reserved. It is a 403 and not a 429 because no Retry-After reopens a trial: the message carries the date it ended and details.trial_expired_at, details.trial_days and details.plan, and the only way forward is a plan. The zero-cost routes — GET /v1/me, GET /v1/me/usage, the key routes and POST /v1/billing/checkout — keep answering, so the same key can read its own situation and subscribe. Subscribing clears the expiry; nothing is rotated.

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

Most calls cost one credit. Card and sealed-product lists are the exception: they cost 1 credit per 50 rows *requested* (the limit), rounded up, so the default page of 50 costs 1, a page of 250 costs 5, and a limit of 12 still costs 1. Ask for multiples of 50. A smaller page than the limit does not cost less, and include=index on a list costs the same as the bare list; include=prices is 4 per 25 rows. Set, series, artist and change lists stay at 1 per page.

EndpointCost
`POST /v1/accounts/free`free, no key needed
`GET /v1/cards`1 credit per 50 cards requested (the limit), rounded up: the default page of 50 costs 1, a page of 250 costs 5; include=index costs the same; with include=prices, 4 credits per 25: a page of 250 costs 40
`GET /v1/cards/{id}`2 with include=prices, the same as GET /v1/cards/{id}/prices
`GET /v1/cards/batch`1 per 25 requested ids; include=index: 1 per 50; include=prices: 4 per 25. Rounded up, including unresolved and repeated ids.
`GET /v1/sets`1 credit
`GET /v1/sets/{code}`1 credit
`GET /v1/sets/{code}/cards`1 credit per 50 cards requested (the limit), rounded up; include=index costs the same; with include=prices, 4 credits per 25
`GET /v1/artists`1 credit
`GET /v1/artists/{slug}`1 credit
`GET /v1/series`1 credit
`GET /v1/sealed`1 credit per 50 products requested (the limit), rounded up; include=index costs the same
`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`25 base; add ceil(top_k / 50) for include=index and 4 × ceil(top_k / 25) for include=prices (both add when requested together). With top_k 1–10: 26, 29 or 30 credits. An empty no_match costs only 25.
`POST /v1/centering/measure`25 credits; verified Free trial: 5 measure and 5 confirm calls, independently. Developer excluded.
`POST /v1/centering/confirm`5 credits; verified Free trial: 5 measure and 5 confirm calls, independently. Developer excluded.
`POST /v1/centering/grade`1 credits; verified Free trial: 5 measure and 5 confirm calls, independently. Developer excluded.
`GET /v1/centering/standards`1 credits; verified Free trial: 5 measure and 5 confirm calls, independently. Developer excluded.

Distinct cards per period

Credits count requests; a second limit counts how many *different* cards an account retrieves in a period. It exists so that the API can be used without being emptied: an app that keeps reading the same cards never meets it, while a loop that copies the catalogue stops after a few pages. Only the card routes count — GET /v1/cards, GET /v1/cards/{id}, GET /v1/cards/batch, GET /v1/sets/{code}/cards and the candidates of POST /v1/vision/identify. Sets, sealed products, artists and price routes do not.

PlanDistinct cards
Trial1,000 for the whole trial
Developer30,000 a month
GrowthNo limit
ProfessionalNo limit
EnterpriseNo limit

The page that crosses the limit is served in full. After that, cards you already retrieved keep answering — a single lookup, a batch, a page made only of known cards, a 304 — and a request that would add a new card answers 403 CARD_ALLOWANCE_EXCEEDED, uncharged, with details.cards_used, details.card_allowance, details.new_cards and the owner handoff in details.next_step. Every metered answer on a plan with a limit carries X-Cards-Limit and X-Cards-Used (the count before that answer); plans without a limit omit both. The count resets with the credit period, and immediately on an upgrade to Growth or above. GET /v1/me reports it as quota.card_allowance and quota.cards_used.

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