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
# 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 304The 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
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.
| Family | Cache-Control | Reasoning |
|---|---|---|
Reference lists — /v1/reference | public, max-age=86400 | These change when a set is released, not between requests. |
| Catalogue — cards, sets, artists | public, max-age=60, s-maxage=300, stale-while-revalidate=600 | Fresh quickly at the edge, tolerant of staleness while it refreshes behind the reader. |
Catalogue with include=prices | private, max-age=300 | The 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 prices | private, max-age=300 | Same 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,langandlimitall produce different bodies and therefore different ETags. - Key by full URL, not by resource id. Two
selectlists 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.