# 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

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

`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

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`](https://pokemontcgapi.com/docs/api/cards/search) | 1 credit |
| [`GET /v1/cards/{id}`](https://pokemontcgapi.com/docs/api/cards/get) | 1 credit |
| [`GET /v1/cards/batch`](https://pokemontcgapi.com/docs/api/cards/batch) | 1 credit per 25 ids, rounded up |
| [`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 |
| [`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 |
| [`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) | 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.
