# Search cards

> Query 52,337 cards with Lucene syntax, keyset pagination, field projection and price sorting.

- **Endpoint**: `GET /v1/cards`
- **Cost**: 1 credit
- **Minimum plan**: free
- **Cache-Control**: `public, max-age=60, s-maxage=300, stale-while-revalidate=600`

Source: https://pokemontcgapi.com/docs/api/cards/search

The main read path. `q` accepts a Lucene-style query grammar — field terms, phrases, booleans, wildcards and ranges — documented in full in [query syntax](https://pokemontcgapi.com/docs/query-syntax).

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.

Every row carries `index_eur` and `last_price_at`, so a price-sorted list is one request with no `include` and no second round trip. 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](https://pokemontcgapi.com/docs/query-syntax). |
| `set` | string | — | Comma-separated set codes, slugs or alternate ids. A shortcut for `q=set.id:(a OR 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. |
| `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: `prices`, `translations`, `images`, `set`, `artist`. Each one is an extra join, so ask only for what you render. `include=prices` is how you get the per-source price breakdown. `legalities` is **not** an accepted value and is rejected with 400 `INVALID_INCLUDE`. |
| `lang` | string | — | Locale for the card name: `en`, `ja`, `fr`, `de`, `es`, `it` — the six 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. 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**

```bash
curl -s -G "https://api.pokemontcgapi.com/v1/cards" \
  --data-urlencode "q=name:charizard" \
  --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"
```

**TypeScript**

`request.ts`

```ts
const url = new URL("https://api.pokemontcgapi.com/v1/cards");
url.searchParams.set("q", "name:charizard");
url.searchParams.set("orderBy", "-index_eur");
url.searchParams.set("limit", "2");
url.searchParams.set("select", "id,name,number,rarity,set_code,set_name,release_date,artist_name,index_eur,last_price_at");

const res = await fetch(url, {
  headers: { "X-Api-Key": process.env.PTCG_API_KEY ?? "" },
});

if (!res.ok) {
  const { error } = await res.json();
  throw new Error(`${error.code}: ${error.message} (${error.request_id})`);
}

const { data, meta } = await res.json();
```

**Python**

`request.py`

```python
import os, httpx

res = httpx.get(
    "https://api.pokemontcgapi.com/v1/cards",
    params={"q": "name:charizard", "orderBy": "-index_eur", "limit": "2", "select": "id,name,number,rarity,set_code,set_name,release_date,artist_name,index_eur,last_price_at"},
    headers={"X-Api-Key": os.environ["PTCG_API_KEY"]},
)
res.raise_for_status()
payload = res.json()
```

## Example response

`200 OK` · `Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600`

`response.json`

```json
{
  "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&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_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 | `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 | `LIMIT_EXCEEDED` | `limit` is above 250, or a batch request carries more than 100 ids. |

The full taxonomy, with what to do about each code, is on the [errors](https://pokemontcgapi.com/docs/errors) page.

> **Unknown parameters are ignored**
>
> A query parameter this endpoint does not know is dropped silently — it is not an error and it does not change the result. A typo in a filter name therefore returns a full, unfiltered page rather than a 400, so check the parameter names in the table above before concluding that a filter does nothing.

## Paging a full result set

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.

`page-everything.ts`

```ts
let next: string | null =
  "https://api.pokemontcgapi.com/v1/cards?set=bs&orderBy=number&limit=250";

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](https://pokemontcgapi.com/docs/objects/card) states the coverage of every field with the date it was measured.
