Search cards
Query the whole card catalogue with Lucene syntax, keyset pagination, field projection and price sorting.
/v1/cards1 credit per 50 cards requested (the limit), rounded up: the default page of 50 costs 1, a page of 250 costs 5; include=index costs the same; with include=prices, 4 credits per 25: a page of 250 costs 40free planCache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600
No key yet? One call gets you one: POST /v1/accounts/free with an Idempotency-Key header and your email address returns a working key in the response. Keep the original Idempotency-Key and body for response replay for up to 24 hours, under the conditions in the quickstart. The trial is 800 credits over 30 days once the address is confirmed, no card; paid plans start at 29 € a month. See the quickstart and the pricing.
The main read path. q accepts a Lucene-style query grammar — field terms, phrases, booleans, wildcards and ranges — documented in full in query syntax.
Collector number uses an exact match first. Only if the first page is empty does a simple positive number term retry without leading zeros; number:N/M also checks the set’s printed total when known. Vintage Japanese cards may use set position as number: search their species with nationalPokedexNumbers. meta.hints appears only when a normalized retry succeeds or a still-empty result has a Pokédex suggestion.
Results are ordered by id unless you say otherwise, and every sort ends with id as a tiebreaker. That is not a detail: without it, two cards sharing a sort key have undefined relative order between queries, and paging silently duplicates and drops rows.
A bare row is catalogue: name, number, rarity, set, game text. The composite index — index_eur and last_price_at — comes onto the rows with include=index, at the same 1 credit per 50 cards requested as the bare list, and a price-sorted list is then one request with no second round trip. The list itself is priced per 50 rows requested since 2026-09-30, and every card it returns counts towards the distinct-card allowance of the plan (1,000 on the trial, 30,000 a month on Developer, none from Growth up): a full mirror is a Growth job. Sorting on those two fields without the include is a 400 that says so; selecting them without it leaves them out and says so in meta.withheld. The example below sorts the whole catalogue by our composite index.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
q | string | — | Lucene query. Omit it to list everything. Full grammar in query syntax. |
set | string | — | Comma-separated set codes, slugs or alternate ids. A shortcut for q=set.id:a OR set.id:b that does not need quoting. |
orderBy | string | id | Up to 4 keys, comma-separated, - for descending: id, name, number, rarity, hp, index_eur, last_price_at, updated_at, created_at, release_date, set, artist. A fifth key is QUERY_TOO_COMPLEX; an unsortable name is INVALID_PARAMETER. id is appended as tiebreaker whether you ask for it or not. |
order_by | string | — | Accepted snake_case spelling of orderBy; the same sort keys and cursor rules apply. |
select | string | — | Comma-separated card fields to return. id is always included. camelCase and snake_case both resolve, so nationalPokedexNumbers works and comes back as national_pokedex_numbers. An unknown name is INVALID_SELECT_FIELD, with the full list in details.valid_fields. |
include | string | — | Comma-separated relations to expand: index, prices, translations, images, set, artist. Each one is an extra join, so ask only for what you render. include=index puts index_eur and last_price_at on the rows of a list or batch, at 1 credit per 50 cards requested. Without it they are not there: a select that names them gets rows without them and meta.withheld: ["index"], and an orderBy on them is 400 INVALID_PARAMETER. include=prices is how you get the per-source price breakdown, index included, and it makes the call a price call: 4 credits per 25 cards on a list or batch, 2 on a single card, and the rows are filtered by your plan exactly as on the price routes, with meta.withheld (the X-Plan-Withheld header on a single card) naming what was held back. A single card carries its index whatever you include. legalities is not an accepted value and is rejected with 400 INVALID_INCLUDE. |
lang | string | — | Locale for the card name: en, fr, de, ja, it, es, pt, zh — the eight with translation rows in the catalogue. A card with no translation in the requested locale falls back to English rather than to null. |
limit | integer | 50 | Rows per page, 1 to 250. Card and sealed lists cost 1 credit per 50 rows requested, rounded up: 50 costs 1, 51 costs 2, 250 costs 5, so ask for multiples of 50. Above 250 you get LIMIT_EXCEEDED — follow links.next instead of raising it. |
cursor | string | — | Opaque keyset cursor from links.next. Never construct one; it carries the sort order it was issued for and is rejected if orderBy changes mid-run. |
Headers
| Name | Type | Default | Description |
|---|---|---|---|
X-Api-Key required | string | — | Your API key. Authorization: Bearer <key> is accepted as an alias. |
If-None-Match | string | — | Send back the ETag you stored. The API returns a strong ETag on every catalogue response, and a 304 is a header exchange: no body, no database row read. |
Example request
curl -s -G "https://api.pokemontcgapi.com/v1/cards" \
--data-urlencode "q=name:charizard" \
--data-urlencode "include=index" \
--data-urlencode "orderBy=-index_eur" \
--data-urlencode "limit=2" \
--data-urlencode "select=id,name,number,rarity,set_code,set_name,release_date,artist_name,index_eur,last_price_at" \
-H "X-Api-Key: $PTCG_API_KEY"Example response
200 OK · Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600
{
"data": [
{
"id": "nde-107",
"name": "Shining Charizard",
"number": "107",
"rarity": "Rare Shining",
"index_eur": 5894.78,
"last_price_at": "2026-08-26T19:03:20.955Z",
"set_code": "nde",
"set_name": "Neo Destiny",
"release_date": "2002-02-28",
"artist_name": "Hironobu Yoshida"
},
{
"id": "e5-089",
"name": "Charizard",
"number": "089",
"rarity": "Secret Rare",
"index_eur": 5371.99,
"last_price_at": "2026-08-26T19:03:20.955Z",
"set_code": "e5",
"set_name": "Mysterious Mountains",
"release_date": "2002-10-04",
"artist_name": null
}
],
"meta": { "limit": 2, "count": 2, "has_more": true },
"links": {
"next": "https://api.pokemontcgapi.com/v1/cards?q=name%3Acharizard&include=index&orderBy=-index_eur&limit=2&cursor=eyJrIjpbNTM3MS45OV0sImlkIjoiZTUtMDg5Iiwicy..."
}
}Errors
Every error body carries error.code, error.message and error.request_id. Switch on the code, never on the message.
| Status | Code | When |
|---|---|---|
| 401 | MISSING_API_KEY | No X-Api-Key header and no bearer token on a route that requires one. |
| 401 | INVALID_API_KEY | The key does not match any account. |
| 400 | INVALID_PARAMETER | A query parameter has the wrong type or an unsupported value — a non-integer limit, an unknown lang, an unknown region, a sort key that is not sortable. |
| 400 | INVALID_QUERY | The q parameter does not parse, or names a field that does not exist. |
| 400 | QUERY_TOO_COMPLEX | A structural limit was exceeded — the query has too many clauses or nests too deep, or orderBy carries more than 4 keys. |
| 400 | QUERY_UNSUPPORTED | The syntax is valid but cannot run: a leading wildcard, or a range on a field that is neither numeric nor a date. |
| 400 | INVALID_CURSOR | The cursor is malformed, or was issued for a different orderBy than the one on this request. |
| 400 | INVALID_SELECT_FIELD | A name in select is not a card field. |
| 400 | INVALID_INCLUDE | A name in include is not a known relation. |
| 400 | LIMIT_EXCEEDED | limit is above 250, or a batch request carries more than 100 ids. |
| 429 | QUOTA_EXCEEDED | Trial credits, monthly credits or the daily cap are exhausted, and details says which: daily_cap with retry_after_s is the trial daily cap, which resets at midnight UTC; renews: true with resets_at is the monthly quota of a paid plan; renews: false is the trial allowance, which never comes back. details.next_step carries the plan, prices and owner action, or email verification when it unlocks the full trial. |
| 403 | PLAN_REQUIRED | The feature is outside the plan (movers require Growth; photo recognition has 5 calls after Free email confirmation, then requires Growth), or the account subscription is canceled or suspended. Centering requires Growth or higher; Developer is excluded. After email confirmation the trial includes 5 measure and 5 confirm calls independently; grade and standards use trial credits without separate call counters. Before email confirmation, the message starts with “Confirm your email…”. Follow details.next_step when present. |
| 403 | TRIAL_EXPIRED | The trial has ended: the account is on the trial and its 30 days from signup are over, and this route costs credits. details.trial_expired_at is the date, details.trial_days the length, and details.next_step the owner handoff. Zero-cost routes — /v1/me, /v1/me/usage, the key routes and /v1/billing/checkout — still answer. |
| 403 | CARD_ALLOWANCE_EXCEEDED | The account has retrieved as many distinct cards as its plan covers in the period — 1,000 for the whole trial, 30,000 a month on Developer; Growth and above have no limit — and this request would add new ones. details.cards_used, details.card_allowance and details.new_cards give the numbers, details.resets_at the next period on paid plans, and details.next_step the owner handoff. Only the card routes count; the page that crossed the limit was served in full, and cards already retrieved keep answering. |
The full taxonomy, with what to do about each code, is on the errors page.
Unknown parameters return 400
An unknown query parameter returns 400 INVALID_PARAMETER before credits are reserved. details.param names the rejected parameter, details.valid_params lists the accepted names, and details.did_you_mean suggests a likely correction when one exists. Use cursor, not page or offset.
Paging a full result set
For a complete card import, start at GET /v1/cards?limit=250&orderBy=id. Add q=set.region:JP or q=set.region:CN for one print region. Use a set filter or /v1/sets/{code}/cards when you only need a particular set. See the quickstart for dated request counts.
Follow links.next until it is absent. Do not rebuild the URL yourself and do not change orderBy between pages — the cursor carries the sort order it was issued for, and a mismatch is rejected with INVALID_CURSOR rather than quietly skipping rows.
let next: string | null =
"https://api.pokemontcgapi.com/v1/cards?limit=250&orderBy=id";
while (next) {
const res = await fetch(next, { headers: { "X-Api-Key": key } });
const page = await res.json();
for (const card of page.data) upsert(card);
next = page.links?.next ?? null;
}What the catalogue holds
This endpoint searches identity, printing and price: name, number, rarity, HP, set, series, region, artist and the composite index. Rules text — attacks, abilities, subtypes, flavour text — is declared in the schema and carries no rows today, so a query that filters on it matches nothing. The card object states the coverage of every field with the date it was measured.