Skip to content
pokemontcgapi.com

clients

Pokémon TCG API SDKs

A typed TypeScript client with no runtime dependencies, and an MCP server for agents. Both sit on the same endpoints and the same 615 sets.

npm install @pokemontcgapi/sdk
zero dependencies

Global fetch and nothing else, so it runs unchanged on Node 20+, Bun, Deno, Cloudflare Workers and in the browser. A bundled HTTP client would cost more than the function it replaces, and every CVE of its would become ours.

pagination that follows itself

Every list method returns a Page that is also an AsyncIterable. The cursor carries a signature of the sort order, so it must never be rebuilt by hand — the client follows the URL the API returned, which is exactly the mistake this removes.

errors you branch on

Ten classes rather than one type with a string field, so instanceof works and nobody has to re-type the taxonomy by hand. Rate limiting and quota exhaustion are separate classes because they look identical and are handled oppositely.

cards.ts
import { PokemonTcgApi } from "@pokemontcgapi/sdk";

const client = new PokemonTcgApi({ apiKey: process.env.PTCG_API_KEY });

// Both id forms resolve on the same route.
const card = await client.cards.get("base1-4", { include: ["prices"] });
console.log(card.id, card.name, card.index_eur);
// bs-4 Charizard 561.84

// A Page is an AsyncIterable: this walks every Japanese set,
// following links.next for you.
for await (const set of await client.sets.list({ region: "JP" })) {
  console.log(set.code, set.name, set.release_date);
}

endpoint → method

Nothing to translate.

The resources mirror the paths, so moving from the reference to the code needs no lookup table.

SDK method for each endpoint
endpointsdkreturns
GET /v1/cardsclient.cards.search(params)Page<Card>
GET /v1/cards/{id}client.cards.get(id, params)Card
GET /v1/cards/batchclient.cards.batch(ids, params)BatchResult<Card>
GET /v1/setsclient.sets.list(params)Page<CardSet>
GET /v1/sets/{code}client.sets.get(code)CardSet
GET /v1/sets/{code}/cardsclient.sets.cards(code, params)Page<Card>
GET /v1/artistsclient.artists.list(params)Page<Artist>
GET /v1/artists/{slug}client.artists.get(slug)Artist
GET /v1/referenceclient.reference.types()string[] — 11 values
GET /v1/referenceclient.reference.subtypes()string[] — 26 values
GET /v1/referenceclient.reference.supertypes()string[] — 3 values
GET /v1/referenceclient.reference.rarities()string[] — 67 values
POST /v1/vision/identifyclient.vision.identify(image, options)VisionResponse
GET /v1/statusclient.status()CatalogStatus
GET /v1/healthclient.health()Health

the three parts that matter

Paging, failing and not paying twice.

Paging

// A Page is an AsyncIterable. This walks all 379 Japanese sets.
for await (const set of await client.sets.list({ region: "JP" })) {
  console.log(set.code, set.name, set.release_date);
}

// An explicit ceiling is required — the catalogue is large enough that an
// unbounded materialisation is a mistake rather than a choice.
const page = await client.cards.search({ set: "bs", select: ["id", "name"] });
const first50 = await page.toArray({ max: 50 });

Failing

import {
  NotFoundError,
  RateLimitedError,
  QuotaExceededError,
} from "@pokemontcgapi/sdk";

try {
  await client.cards.get("nope-1");
} catch (error) {
  if (error instanceof NotFoundError) {
    // 404 with the id in error.details
  }
  if (error instanceof RateLimitedError) {
    // error.retryAfter — the client already waited and retried
  }
  if (error instanceof QuotaExceededError) {
    // a separate class on purpose: retrying will never help
  }
  // error.requestId is the only thing support can look up
}

Not paying twice

const client = new PokemonTcgApi({ cache: "etag" });

// The second call sends If-None-Match and gets a 304 with no body.
// A 304 consumes no quota, so a mirror pays only for what changed.
await client.reference.rarities();
await client.reference.rarities();
retries with jitter

Exponential backoff with full jitter on 429, 5xx and network failures, honouring Retry-After. Quota exhaustion is never retried. Without jitter, a thousand clients that take the same 429 all retry in the same millisecond.

ETag cache, opt-in

Stores strong ETags and replays 304s. Kept in memory and per-instance on purpose: a cache on disk inside an SDK is a source of bugs the caller cannot inspect.

types that admit what is empty

The gameplay fields are typed as nullable and documented with their measured fill rate, on the field itself. A type that promises attacks: Attack[] makes people write code that never runs against the half of the catalogue we hold no English text for.

What the types will not promise you

The gameplay fields — attacks, abilities, weaknesses, subtypes, retreat cost, legalities — are modelled and empty across the catalogue, and each one says so in its own doc comment. You will see it in your editor before you write the code, which is the only useful place to find out.

agents

Or skip the code entirely.

@pokemontcgapi/mcp gives Claude, Cursor, VS Code and any other MCP client seven read-only tools over the same catalogue — one command to install, and the model queries the data instead of recalling it.

questions

About the client.

Is there an official Pokémon TCG API TypeScript client?
@pokemontcgapi/sdk is the client we maintain for this API. It has no runtime dependencies, ships its own types, and covers every endpoint the service actually serves.
Does it work outside Node?
Yes. It uses the global fetch and nothing else, so Bun, Deno, Cloudflare Workers and browsers all work. In a browser, use a key scoped to catalogue reads rather than a secret one.
How do I page through an entire set?
Iterate the Page returned by any list method with for await — it follows links.next until there is none. Do not reconstruct the cursor: it carries a signature of the sort order and the API rejects a rebuilt one.
Is there a Python client?
Not yet. The API is plain REST with a single header, so httpx or requests works today, and the quickstart has runnable Python for every endpoint. Quickstart