Best practices

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.

Hydrate in bulk, not in loops

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.

Page correctly

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++;
}

Ask for printings, don’t guess at ids

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.

Cache by how fast the data actually moves

DataChanges whenSensible TTL
Card, expansion & sealed-product metadataA set is released or a printing is correctedDays. Safe to treat as immutable and bust on a webhook
ImagesEffectively never (URLs are content-addressed)Cache aggressively; serve from your own CDN
PricesOn our recompute schedule, see market_updated_atHours. Polling faster returns the same numbers.
PopulationDaily to weekly, per grading companyA day
ListingsContinuously for active asks; sold history is append-onlyMinutes for active, hours for sold

Push, don't poll

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.

Keep the key on your server

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.

Handle failure like it will happen

  • Retry only what's retryable. 429 and 5xx deserve an exponential backoff; 400 and 404 will fail identically forever.
  • Branch on code, not on message text. Messages are written for humans and get reworded; the machine codes in Errors are stable.
  • Expect 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.
  • Handle missing pricing. Not every object has a market value. Code for 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.

Write queries that survive

  • Prefer expansion.id:sv1 over matching on set names. Names get localized and corrected, ids don't.
  • Quote exact phrases: rarity:"Illustration Rare". Unquoted multi-word values parse as separate terms.
  • Avoid leading wildcards (name:*chu). They can't use the index and are rejected with invalid_query.
  • Read the game's field note on its reference page before filtering. A One Piece card has no hp or types. It has power, counter and colors. Unknown fields return 400 with a suggestion rather than silently matching nothing.
  • Filter by language with 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.

Separate your workloads

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.