Search & filtering

The q parameter is a compact query language for finding cards, expansions and sealed product. Match on any field, combine conditions with AND/OR, exclude with a minus, and use wildcards and numeric ranges. It’s accepted by Search cards, Search expansions, List cards in an expansion and Search sealed products. The grammar is the same everywhere; what changes is which fields each resource registers, so the tables below are per resource.

The basics

A query is a set of field:value terms. A bare word with no field is matched against name. Matching is case-insensitive, and text fields match on substrings, so name:char finds “Charizard” and “Charmander”.

charizard                 → name contains "charizard"
name:charizard            → same, explicit
supertype:Trainer         → all Trainer cards
rarity:"Special Illustration Rare"   → quote values that contain spaces

Wrap a value in double quotes when it contains spaces. Use ! before a field for an exact (whole-value) match instead of a substring:

!name:pikachu             → name is exactly "Pikachu" (not "Pikachu V")

Combining terms

Multiple terms are ANDed together. Use OR (uppercase) and parentheses to group alternatives, and a leading - to exclude.

name:charizard subtypes:vmax            → charizard AND a VMAX
(subtypes:mega OR subtypes:vmax)        → either subtype
name:char* -types:water                 → char… but NOT Water type
supertype:Pokémon -rarity:Common        → Pokémon that aren't Common

Wildcards

* matches any run of characters. Put it anywhere except the start of a value.

name:char*                → starts with "char"
name:char*der             → starts "char", ends "der" (Charmander)
  • Leading wildcards (*zard) are not allowed. Plain text search already matches substrings, so use name:zard.
  • At most 3 * per value, and at least 2 literal characters.

Numeric & date ranges

Range syntax works on number and date fields. Square brackets are inclusive, curly braces are exclusive, and * is an open (unbounded) end.

hp:[150 TO *]                  → hp ≥ 150
hp:{150 TO 200}                → 150 < hp < 200  (exclusive)
raw_price:[100 TO 500]         → $100–$500 inclusive
expansion.release_date:[2024-01-01 TO *]   → released on/after 2024-01-01
card_count.total:[200 TO *]    → big sets (expansions search)

A plain value on a number field is an equality test: hp:150 means exactly 150.

Nested & list fields

Some fields are lists or nested objects. You query them with a dotted name; the term matches if any element matches.

subtypes:mega                  → one of the card's subtypes is "mega"
attacks.name:"G-Max Wildfire"  → has an attack with that name
abilities.text:draw            → an ability whose text mentions "draw"
expansion.id:sv1               → only cards in set sv1
weaknesses.type:Water          → weak to Water

Searchable card fields

Accepted on every game:

FieldTypeNotes
nametextDefault field, so a bare term matches on name
idexact idFull card id — one printing, e.g. sv3pt5-6. Case-insensitive
codeexact idPrinting-family code — the art and finish variants of one card in one language. code:op16-085 matches op16-085 and op16-085vaa, never op16_ja-085, which is its own code. Not a substitute for the printings endpoint — see below
subtypesarrayPokémon classification (VMAX, Mega); One Piece traits (Straw Hat Crew)
numberexact idPrinted number; ranges match the numeric part
raritytexte.g. Rare Holo, Special Illustration Rare
artisttextIllustrator name (alias: illustrator)
raw_pricenumberUngraded market price (USD); supports ranges
is_variant / is_holo / is_reverse / is_first_editionbooleantrue or false
abilities.name / abilities.texttextSearches within the card’s abilities
attacks.name / attacks.texttextSearches within the card’s attacks
attacks.damagenumberNumeric attack damage; supports ranges
weaknesses.type / resistances.typetexte.g. Water, Fire
expansion.idexact idRestrict to one set, e.g. sv3pt5
expansion.nametextSet name
expansion.series_idexact idSeries id, e.g. sv
expansion.release_datedateSet release date; supports ranges
languageexact ide.g. en, ja, zh. Also a top-level param — see Languages below

Pokémon fields

FieldTypeNotes
supertypetextPokémon, Trainer or Energy
typesarrayEnergy types, e.g. Fire, Water
hpnumberHit points; supports ranges

One Piece fields

FieldTypeNotes
typetextLeader, Character, Event, Stage or DON!!
costnumberPlay cost; supports ranges
powernumberBattle power; supports ranges
colorsarrayRed, Green, Blue, Purple, Black, Yellow
attributearraySlash, Strike, Ranged, Special, Wisdom. Dual attributes match either half
rulesarrayEffect text, one term per printed line, e.g. rules:Blocker
varianttextPrinting within a code: normal, altArt, specialAltArt, …
counternumberPrinted Counter; supports ranges. Partial coverage — see the One Piece card endpoints
lifenumberLeader Life; supports ranges. Leaders only, partial coverage
blockarrayPrinted Block icon: 1–5, or X for no numbered block. 77% coverage

code: and /cards/{id}/printings answer different questions. They share the same id-stripping SQL, but not the same scope: a code carries its language, so the Japanese printing of OP01-016 has the code op01_ja-016 and can never match code:OP01-016 — not even with language=all.

code:OP01-016 &language=en    →  5 printings
code:OP01-016 &language=all   →  5 printings
/cards/OP01-016/printings     → 10 printings

Use code: to gather one language's variants inside a larger query. Use the printings endpoint when you want every printing of a card across languages.

Each of these filters under the name it comes back as, so a deck query reads the way the card does: colors:Black cost:[1 TO 3] type:Character. The three Pokémon fields above also resolve on One Piece — the sync overloads those columns, so supertype, types and hp are older spellings of type, colors and power. Both work; the One Piece name is the one to write. type is One Piece only: on /pokemon it returns a 400 pointing at types, because there the two are different things. Fields not listed anywhere here (e.g. national_pokedex_numbers) are not searchable and return a 400 rather than an empty result.

Searchable expansion fields

FieldTypeNotes
nametextDefault field
idexact idExpansion id, e.g. sv3pt5
series_idexact idSeries id, e.g. sv
languageexact ide.g. en, ja, zh
release_datedateSupports ranges
card_count.totalnumberTotal cards incl. secret rares; supports ranges
card_count.officialnumberPrinted count; supports ranges

Searchable sealed fields

Sealed product registers its own, much shorter field set. Card fields (rarity, hp, colors, …) are not in scope on /sealed and return a 400 with a suggestion, and there is no distinct: a product is a single row, not a family of printings.

FieldTypeNotes
nametextDefault field, so a bare term matches on name
idexact idProduct id. Case-insensitive, like card ids
product_typeexact idProduct family — see the closed list below. An unrecognised value is a 400, not an empty page
expansion.idexact idRestrict to one set, e.g. sv3pt5
expansion.nametextSet name
expansion.release_datedateThe set’s street date — a product has none of its own; supports ranges
languageexact idInherited from the expansion. Defaults to en, as card lists do
pricenumberCurrent market value (USD); supports ranges
is_on_salebooleantrue or false

product_type is a closed vocabulary — booster_pack, sleeved_pack, booster_box, booster_pack_case, bundle, pack_art_bundle, blister_pack, three_pack_blister_pack, elite_trainer_box, ultra_premium_collection, collection_box, special_box, tin, other — and ?product_type= is shorthand for the q term. Anything outside the list is rejected with a 400 rather than silently matching nothing.

There is no starter_deck. Starter decks are stored as other and nothing in the data separates them from the rest of that bucket, so a query for one returns an error, not an empty page — and no amount of filtering will conjure the value.

GET /api/v1/pokemon/sealed?q=product_type:booster_box expansion.id:sv3pt5
GET /api/v1/onepiece/sealed?q=price:[100 TO *]&orderBy=-price
GET /api/v1/azuki/sealed?include=prices

Sorting with orderBy

A comma-separated list of fields; prefix a field with - for descending. Up to 3 keys. Results always get a stable tiebreak on id.

orderBy=-release_date          → newest sets first
orderBy=-raw_price,name        → most expensive first, then A→Z

Sortable card fields: name, number, rarity, raw_price (alias market_price), release_date (alias expansion.release_date), expansion.id, id — plus hp on Pokémon and cost, power, counter, life on One Piece. Sortable expansion fields: name, release_date, id. Sortable sealed fields: name, price, release_date, id — note it is price there, not raw_price, since a product has no condition to qualify.

orderBy=cost                   → cheapest to play first (deck-builder pool)
orderBy=-power,name             → biggest hitters first, then A→Z
orderBy=-market_price           → most expensive first

Cards with no value for the sort key come last in both directions. Postgres would otherwise put them first on a descending sort, so “most expensive first” would open with every card we hold no price for.

That tiebreak makes every ordering a total one, the default included: id is unique, so no two rows tie and no page boundary can shift between requests. Walking every page of a result set is safe — it can’t skip or duplicate rows.

Languages

Every card exists once per printed language — OP15-079 and OP15_ja-079 are two objects, not one card with a translation. List endpoints therefore default to language=en; without it a search returns two catalogs interleaved, which is rarely what a client wants and always twice the payload.

(no language param)            → English only
language=ja                    → Japanese only
language=en,ja                 → both
language=all                   → every language we index
  • The applied value comes back as language in the list envelope, so you can always see which filter ran.
  • A language:, id: or expansion.id: term in q already pins the language, so the default steps aside rather than fighting it — q=id:OP15_ja-079 finds that card without any extra param.
  • /expansions/{id}/cards has no default at all: the expansion id already picks a language (EB01 vs EB01_ja).
  • Fetching one object by id (/cards/{id}) is never language-filtered.

Cards vs printings

Every printing is its own row — base art, alternate art, each parallel — so a search returns printings, and total_count counts printings. For a card-shaped list, pass distinct=code: one row per card, and total_count counts cards.

GET /onepiece/expansions/EB01/cards                  → 107 printings
GET /onepiece/expansions/EB01/cards?distinct=code    → 61 cards
  • Without it, a page of 30 is ~17 cards, and a code whose printings straddle a page boundary appears on two pages — so paging over cards is not possible.
  • The row kept is the base printing. Use its printings endpoint to fan back out to the alternates and their prices.
  • One caveat, worth knowing before you hit it: the representative is picked before your filters, so a filter that varies between printings of one code (rarity, raw_price, is_holo, variant) can match only a parallel and still return nothing. Every gameplay field — type, cost, power, colors, rules, block — is shared by all printings of a code, so deck-building queries are unaffected.
  • distinct belongs to the two endpoints that return cards — /cards and /expansions/{id}/cards. An expansion and a sealed product are each a single row, with no family to collapse, so neither /expansions nor /sealed reads it.

Paging & trimming the response

  • page (default 1) and page_size (default 100, max 100; pageSize is accepted too). page × page_size can’t exceed 10,000, so narrow with q to go deeper.
  • An out-of-range page_size is a 400, never a silent clamp — if you asked for 250 and got 100 rows back, check the spelling of the param you sent.
  • Every list response includes total_count so you can compute the last page.
  • select=id,name,rarity returns only those top-level fields (id is always included).
  • include=prices adds raw + graded pricing under each card’s pricing object — on /sealed it adds the single factory-sealed market value instead. Either way it costs nothing extra.

Limits & errors

A rejected query returns 400, and the machine-readable error code says which kind of wrong it was: parse_error for syntax the grammar can’t read (an unclosed quote or parenthesis, a missing term), unknown_field for a field this resource doesn’t register, invalid_value for a value that doesn’t fit the field it was given to, and query_too_complex for anything over the caps below. When the problem is at a specific spot, details.position is the 0-based character offset, and an unknown field comes back with a suggestion.

GET /api/v1/pokemon/cards?q=subtype:mega

400 {
  "success": false,
  "message": "unknown field \"subtype\". Did you mean \"subtypes\"?",
  "error": "unknown_field",
  "details": { "position": 0 }
}
  • Max query length 512 characters.
  • Max 20 terms and parenthesis nesting up to 5 deep.
  • Unknown fields, leading wildcards, and unquoted spaces are rejected rather than ignored.
  • All four caps — length, terms, nesting, and orderBy's 3-key limit — return 400 with error: "query_too_complex", and so do the wildcard guardrails (a leading *, more than three per value, fewer than two literal characters). A hand-typed query rarely reaches them; a generated filter reaches 20 terms easily, so count as you build and split into several narrower calls rather than one wide one.

Worked examples

# Charizard VMAX/Mega cards with at least 300 HP, priciest first, with prices
GET /api/v1/pokemon/cards?q=name:charizard (subtypes:vmax OR subtypes:mega) hp:[300 TO *]&orderBy=-raw_price&include=prices

# Non-Common Water Pokémon in Scarlet & Violet era sets
GET /api/v1/pokemon/cards?q=types:Water -rarity:Common expansion.series_id:sv

# One Piece Leaders that are red, sorted by name
GET /api/v1/onepiece/cards?q=type:Leader colors:Red&orderBy=name

# A One Piece deck-building pool: cheap black characters with a Blocker,
# one row per card, cheapest first
GET /api/v1/onepiece/cards?q=type:Character colors:Black cost:[1 TO 3] rules:Blocker&distinct=code&orderBy=cost

# Standard-legal pool for the current rotation (blocks 2-5), minus the DON!! rows
GET /api/v1/onepiece/cards?q=(block:2 OR block:3 OR block:4 OR block:5) -type:"DON!!"&distinct=code

# Japanese printings only, and the base art of one code rather than its parallels
GET /api/v1/onepiece/cards?q=expansion.id:OP15_ja&language=ja
GET /api/v1/onepiece/cards?q=expansion.id:EB01 variant:normal

# English sets from 2024 onward with 200+ cards, newest first
GET /api/v1/pokemon/expansions?q=language:en release_date:[2024-01-01 TO *] card_count.total:[200 TO *]&orderBy=-release_date

Every endpoint page has a live “Try it” runner with a q box. Edit any example above and run it against staging.