# Rate limits & credits

> Two independent limits — requests per second and credits per period — and what happens when you cross either.

Source: https://pokemontcgapi.com/docs/rate-limits

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](https://pokemontcgapi.com/pricing).

| Plan | Requests per second | In flight | Credits | Daily cap |
| --- | --- | --- | --- | --- |
| Trial | 5/s | 3 | 800 once | 400 |
| 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 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](https://pokemontcgapi.com/docs/centering).

## 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

| 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` | Trial 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`

```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](https://pokemontcgapi.com/docs/caching-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.

| Endpoint | Cost |
| --- | --- |
| [`POST /v1/accounts/free`](https://pokemontcgapi.com/docs/api/accounts/free) | free, no key needed |
| [`GET /v1/cards`](https://pokemontcgapi.com/docs/api/cards/search) | 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}`](https://pokemontcgapi.com/docs/api/cards/get) | 2 with include=prices, the same as GET /v1/cards/{id}/prices |
| [`GET /v1/cards/batch`](https://pokemontcgapi.com/docs/api/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`](https://pokemontcgapi.com/docs/api/sets/list) | 1 credit |
| [`GET /v1/sets/{code}`](https://pokemontcgapi.com/docs/api/sets/get) | 1 credit |
| [`GET /v1/sets/{code}/cards`](https://pokemontcgapi.com/docs/api/sets/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`](https://pokemontcgapi.com/docs/api/artists/list) | 1 credit |
| [`GET /v1/artists/{slug}`](https://pokemontcgapi.com/docs/api/artists/get) | 1 credit |
| [`GET /v1/series`](https://pokemontcgapi.com/docs/api/series/list) | 1 credit |
| [`GET /v1/sealed`](https://pokemontcgapi.com/docs/api/sealed/list) | 1 credit per 50 products requested (the limit), rounded up; include=index costs the same |
| [`GET /v1/sealed/{id}`](https://pokemontcgapi.com/docs/api/sealed/get) | 1 credit |
| [`GET /v1/reference`](https://pokemontcgapi.com/docs/api/reference/lists) | free, no key needed, cached for a day |
| [`GET /v1/status`](https://pokemontcgapi.com/docs/api/status/get) | free, no key needed |
| [`GET /v1/cards/{id}/prices`](https://pokemontcgapi.com/docs/api/prices/current) | 2 credits |
| [`GET /v1/sealed/{id}/prices`](https://pokemontcgapi.com/docs/api/prices/current-sealed) | 2 credits |
| [`GET /v1/prices/current`](https://pokemontcgapi.com/docs/api/prices/batch) | 4 per 25 ids: a full batch of 50 costs 8 |
| [`GET /v1/cards/{id}/prices/history`](https://pokemontcgapi.com/docs/api/prices/history) | 5 credits |
| [`GET /v1/cards/{id}/prices/stats`](https://pokemontcgapi.com/docs/api/prices/stats) | 2 credits |
| [`GET /v1/prices/movers`](https://pokemontcgapi.com/docs/api/prices/movers) | 3 credits |
| [`GET /v1/prices/sources`](https://pokemontcgapi.com/docs/api/prices/sources) | free, no key needed |
| [`GET /v1/changes`](https://pokemontcgapi.com/docs/api/changes/list) | 1 credit |
| [`GET /v1/bulk`](https://pokemontcgapi.com/docs/api/bulk/list) | 1 credit |
| [`POST /v1/vision/identify`](https://pokemontcgapi.com/docs/api/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`](https://pokemontcgapi.com/docs/api/centering/measure) | 25 credits; verified Free trial: 5 measure and 5 confirm calls, independently. Developer excluded. |
| [`POST /v1/centering/confirm`](https://pokemontcgapi.com/docs/api/centering/confirm) | 5 credits; verified Free trial: 5 measure and 5 confirm calls, independently. Developer excluded. |
| [`POST /v1/centering/grade`](https://pokemontcgapi.com/docs/api/centering/grade) | 1 credits; verified Free trial: 5 measure and 5 confirm calls, independently. Developer excluded. |
| [`GET /v1/centering/standards`](https://pokemontcgapi.com/docs/api/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.

| Plan | Distinct cards |
| --- | --- |
| Trial | 1,000 for the whole trial |
| Developer | 30,000 a month |
| Growth | No limit |
| Professional | No limit |
| Enterprise | No 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.
