Skip to content
pokemontcgapi.com
Documentation

Caching & ETags

Conditional requests cost no credits — how to store ETags, revalidate, and pick a cache layer.

Every cacheable response carries a strong ETag and a Cache-Control. Sending the ETag back on your next request turns a full response into a 304 Not Modified, which touches no database row and costs no credits.

That is a deliberate incentive, not an oversight. A client that resynchronises often is the client we want, and charging for the privilege would push you towards polling less and being more stale.

The round trip

revalidate.sh
# First call — full body, note the ETag
curl -si "https://api.pokemontcgapi.com/v1/sets/sv3" -H "X-Api-Key: $PTCG_API_KEY" | grep -i etag
# etag: "o6DkBk61AqCBy1I5NtiqFLhjA8a"

# Second call — unchanged, so 304 and no body
curl -si "https://api.pokemontcgapi.com/v1/sets/sv3" \
  -H "X-Api-Key: $PTCG_API_KEY" \
  -H 'If-None-Match: "o6DkBk61AqCBy1I5NtiqFLhjA8a"'
# HTTP/2 304

The 304 still carries ETag and Cache-Control, so the next validation starts from a fresh baseline rather than from nothing.

That ETag is a real one

It was read from /v1/sets/sv3 on 2026-08-27 and will be stale the next time that set changes — which is the point of a validator. Copy the mechanism, not the string.

A caching client

cached-get.ts
const store = new Map<string, { etag: string; body: unknown }>();

export async function cachedGet(url: string, key: string): Promise<unknown> {
  const hit = store.get(url);

  const res = await fetch(url, {
    headers: {
      "X-Api-Key": key,
      ...(hit ? { "If-None-Match": hit.etag } : {}),
    },
  });

  // 304 means what we already hold is still current. No body is sent,
  // so reading one here would hang.
  if (res.status === 304 && hit) return hit.body;
  if (!res.ok) throw new Error((await res.json()).error.code);

  const body = await res.json();
  const etag = res.headers.get("ETag");
  if (etag) store.set(url, { etag, body });

  return body;
}

Cache policy by family

Read from the live API on 2026-08-27.

FamilyCache-ControlReasoning
Reference lists — /v1/referencepublic, max-age=86400These change when a set is released, not between requests.
Catalogue — cards, sets, artists, and prices via include=pricespublic, max-age=60, s-maxage=300, stale-while-revalidate=600Fresh quickly at the edge, tolerant of staleness while it refreshes behind the reader.

`Vary` never includes your API key

It looks prudent and it destroys shared caching: every customer would get a private copy of a response that is byte-identical for everyone. Catalogue responses do not depend on who is asking, so they are cached once for all of you.

What the ETag is computed from

A SHA-256 over the canonical serialised body, truncated to 27 base64url characters. It is a strong validator: identical ETag means byte-identical body.

  • The ETag changes when the response changes, which includes changes caused by your own parameters — select, include, lang and limit all produce different bodies and therefore different ETags.
  • Key by full URL, not by resource id. Two select lists on the same card are two cache entries.
  • Weak comparison (W/"…") is accepted on the way in, so an intermediary that weakened your validator does not break revalidation.
  • If-None-Match: * always matches, which is occasionally handy in tests.

Where to put a cache

In your process

An LRU keyed by URL is enough for read-heavy apps and takes ten lines, as above. Bound it — the catalogue is bigger than your heap.

At the edge

Catalogue responses are public, so a CDN or shared cache in front of your service works without configuration. stale-while-revalidate=600 means users get an instant answer while the refresh happens behind them.

In your database

If you mirror the catalogue, do not revalidate row by row. Every card carries updated_at and a monotonic row_version; store the highest updated_at you have imported and re-read only the sets whose cards moved past it. Sets change on a release schedule, so most nights that is a handful of requests rather than a full walk.

Compression

Responses are compressed with Brotli or gzip according to your Accept-Encoding. On 2026-08-27, GET /v1/cards?limit=50 was 40,568 bytes uncompressed, 2,133 with Accept-Encoding: br and 2,300 with gzip — a 95% reduction either way, because JSON with repeating keys is close to the best case for a dictionary coder.

Most HTTP clients negotiate this already. If yours does not, sending the header is a one-line change with a twenty-fold payoff on bandwidth.

The cheapest two changes

Send If-None-Match, and ask only for the select fields you actually render. Neither changes what your users see, and together they usually cut credit consumption by more than half.

view this page as markdown