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. A 304 does not count towards the distinct-card allowance either. Responses that carry image URLs change once a week, when the image signature rotates: the ETag of a card page therefore changes weekly even if the card did not.

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, artistspublic, max-age=60, s-maxage=300, stale-while-revalidate=600Fresh quickly at the edge, tolerant of staleness while it refreshes behind the reader.
Catalogue with include=pricesprivate, max-age=300The rows depend on the plan behind the key, so a shared cache must not hand one customer the page of another. Since 2026-09-10.
Prices — /v1/cards/{id}/prices, /v1/prices/current, history, stats, movers, sealed pricesprivate, max-age=300Same reason: graded rows, locales and the depth of the history all follow the plan. Price points publish daily, though Cardmarket card figures are re-read daily for Western cards and about every 48 hours for Japanese and Chinese cards. Five minutes is plenty. Since 2026-09-10.

`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

For an initial card mirror, page GET /v1/cards?limit=250&orderBy=id by following links.next. Add q=set.region:JP or q=set.region:CN if you only need that print region. Use a set endpoint when you need a specific set. For ongoing synchronization, capture the change feed watermark before importing, then apply /v1/changes and fetch affected records. The migration guide explains checkpoints, deletions and expired cursors; ETags help with repeated reads but do not replace a change feed.

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