The handful of habits that separate an integration that costs $29 a month from one that costs $400 and gets rate limited. None of it is exotic. It mostly comes down to asking for less, less often.
The single most expensive mistake is fetching cards one at a time. A page holds 100 results and
costs one credit; an id:(a OR b OR c) query hydrates a whole screen in one call.
// Don't: one request per card, prices you never render.
for (const id of cardIds) {
const card = await get(`/api/v1/pokemon/cards/${id}?include=prices`);
}
// 250 cards → 500 credits, 250 requests, guaranteed 429. // Do: one paged query, prices only where they're shown.
const { data } = await get(
'/api/v1/pokemon/cards' +
`?q=id:(${cardIds.join(' OR ')})&page_size=100`
);
// 250 cards → 3 credits, 3 requests. Use total_count to know when to stop, not an empty page. page × page_size is capped at 10,000. Past that, narrow the query rather than paging deeper (filter by expansion,
or sort by id and use the last id as a cursor). Ordering is always total — every sort carries a
unique id tiebreak — so a walk can’t skip or repeat rows between pages.
Remember lists are one language at a time (English by default), so a full-catalog sync is one
pass per language: add language=ja for the second, or language=all to
take both at once.
let page = 1, all = [];
while (true) {
const r = await get(`/api/v1/onepiece/cards?q=expansion.id:op01&page=${page}&page_size=100`);
all.push(...r.data);
if (all.length >= r.total_count || r.data.length === 0) break;
page++;
} One code is often several printings — base art, alternate art, special art, each language — and
they are separate objects with separate prices, sometimes 1000× apart. The suffix pattern
in the ids (…vaa, …vsaa) is a storage detail, not a contract, so
deriving the family from it will eventually miss one. Ask the API for the family instead.
// Don't: reconstruct the family from id suffixes.
const family = await get('/api/v1/onepiece/cards?q=id:EB01-001*');
// Do: ask for the printings of that code.
const { data } = await get('/api/v1/onepiece/cards/EB01-001/printings?include=prices');
// → EB01-001 $0.26, EB01-001vaa $57.69, EB01-001vsaa $769.05, + the ja printings A card’s own variants[] describes only that row’s finish; it is not the list of its
siblings. See List a card’s printings.
| Data | Changes when | Sensible TTL |
|---|---|---|
| Card, expansion & sealed-product metadata | A set is released or a printing is corrected | Days. Safe to treat as immutable and bust on a webhook |
| Images | Effectively never (URLs are content-addressed) | Cache aggressively; serve from your own CDN |
| Prices | On our recompute schedule, see market_updated_at | Hours. Polling faster returns the same numbers. |
| Population | Daily to weekly, per grading company | A day |
| Listings | Continuously for active asks; sold history is append-only | Minutes for active, hours for sold |
If you're re-reading the catalog to find out what changed, use webhooks instead. Events cost nothing; the polling that would have discovered them costs a credit per call. Poll only as a reconciliation safety net, daily rather than minutely.
Never call the API from a browser or a mobile app. The key would ship to every user, every page load would share one key's rate-limit window, and you'd have no cache layer. Proxy through your own backend, which is also where your cache belongs.
429 and 5xx deserve an
exponential backoff; 400 and 404 will fail identically forever.code, not on message text. Messages are written for
humans and get reworded; the machine codes in Errors are stable.is_stale. A price with is_stale: true is
backed by a thin sales window. Render it differently, or not at all, rather than presenting it
with the same confidence as a liquid card.pricing: null rather than assuming the object is there — and for a market of null inside one that is present. Whole catalogs sit in
that state: no Azuki product has sales behind it, so it reports an msrp and no
market figure at all.expansion.id:sv1 over matching on set names. Names get localized and
corrected, ids don't.rarity:"Illustration Rare". Unquoted multi-word values parse
as separate terms.name:*chu). They can't use the index and are rejected
with invalid_query.hp or types. It has power, counter and colors. Unknown fields return 400 with a suggestion rather than
silently matching nothing.language_code:ja rather than assuming a card id is
English-only. Every game indexes a different set — Pokémon en, ja and zh; One Piece en and ja — so read the languages
table on the game's own reference page rather than carrying one list across all of them.
Sealed product inherits its expansion's language and defaults to en the same way
the card lists do.Give batch jobs their own API key. An overnight backfill and your user-facing traffic share a
rate-limit window if they share a key, which means an import can take your product down. Separate
keys also make per-feature credit attribution trivial, since the X-Credits-Cost header tells you what each workload actually spends.