Query API
Search the registry with the site's syntax and get JSON back. One endpoint, four routes, on query.kairosarchive.net.
Everything else on the API is a static file. This is the one part that computes, on its own hostname: GET https://query.kairosarchive.net/cards?q=… runs the same parser and evaluator the search page runs in your browser, over the same records, and returns a page of card records as JSON. The full search syntax applies, including unique:, sort: and order:. It exists for tools and bots; for anything heavier than a lookup, download the data and query it locally.
Routes
| Route | Answers |
|---|---|
/cards?q=… | A page of cards matching the query. Parameters: page (1-based), page_size (1 to 200, default 100), and unique, sort, order as alternatives to the words in the query. |
/cards/named?exact=… | One card by its exact name, case-insensitive. fuzzy=… instead takes the best match for a partial name. |
/cards/random | A random card; q=… narrows the pool to a query's matches. Never cached. |
/cards/autocomplete?q=… | Up to 20 card names for a partial name, ranked the way the site's search box ranks them. |
/cards/C000230 | A 302 to the static object, api.kairosarchive.net/v3/cards/C000230.json; likewise /cards/P000937. |
A query
curl --fail --location \
--user-agent 'my-card-tool/1.0 ([email protected])' \
'https://query.kairosarchive.net/cards?q=t:minion%20e:fire%20cost<=2'{
"object": "list",
"release": "v3.3.3",
"q": "t:minion e:fire cost<=2",
"unique": "cards", "sort": null, "order": null,
"total": 32, "page": 1, "page_size": 100, "total_pages": 1,
"has_more": false, "next_page": null,
"rules_text_total": 0, "rules_text_hits": [],
"data": [
{
"object": "card",
"codex_id": "C000140", "name": "Awakened Mummies", "type": "Minion", "category": "Spell",
"rarity": "Exceptional", "slot": "Exceptional", "subtypes": ["Undead"], "elements": ["Fire"],
"keywords": ["Burrowing"], "umbrellas": ["Evil"], "cost": 1, "attack": 3, "defense": 3, "power": 3, "life": null,
"thr_air": 0, "thr_earth": 0, "thr_fire": 1, "thr_water": 0,
"rules_text": "…", "has_back": false, "errata": true,
"set_codes": ["001", "002"], "printing_ids": ["P000573", "P000574", "P000575", "P000576"],
"default_printing_id": "P000575", "image_status": "lowres",
"api_url": "https://api.kairosarchive.net/v3/cards/C000140.json",
"kairos_url": "https://kairosarchive.net/cards/C000575/awakened-mummies",
"image_urls": { "small": "…", "normal": "…", "large": "…", "original": "…" },
"printing": { "printing_id": "P000575", "api_url": "…", "kairos_url": "…" }
}
]
}A record is the compact card the search page uses, plus its addresses, its image addresses, and printing: the printing the match was judged on when the query read one (a set, finish, product, artist or date term, a printing flag, or unique:prints), with that printing's facts and addresses; otherwise the card's default printing by id. With unique:prints or unique:art the same card appears once per matching printing.
rules_text_hits is the second group the search page shows: cards whose rules text, not their name, contains the bare words of a query, so a plain word never silently mixes the two. release names the registry release the answer came from. An id or a slug as the whole query answers with that one card.
Why a card matched
When a query asks about rules text, every record in data carries a matched object saying why it is there: the sentence that matched, and where inside that sentence. It is left out entirely when the query never read rules text, and on the routes that answer with a single card.
"matched": {
"text": "Submerge (This may attack targets that are submerged.)",
"ranges": [[0, 8]]
}A range is a pair of offsets into matched.text, half-open, so text.slice(0, 8) is the matched word. There can be several, and they never overlap. Offsets address the snippet, not the card's whole rules_text, which the sentence may have been cut from and marked with an ellipsis.
The ranges are worked out by the same matcher that judged the search, so r:merge marks the merge inside Submerge while r=merge matches no card at all. Mark them however suits you: the search page wraps them in <mark>, the Discord bot in bold. Matching the text again yourself is the one way to disagree with the result you were given.
From page JavaScript
Answers carry Access-Control-Allow-Origin: * and a five-minute Cache-Control, so a browser or a Worker can call the API directly.
const url = new URL("https://query.kairosarchive.net/cards");
url.searchParams.set("q", 'a:"Drew Tucker" unique:prints');
const page = await (await fetch(url)).json();
for (const card of page.data) console.log(card.name, card.printing.set_name, card.printing.finish);Errors
Every answer is JSON with an object field, errors included. A query the parser cannot read is a 400 with the parser's own messages in warnings; a query with no matches is a 200 with an empty data. A path the API does not serve is a JSON 404.
{ "object": "error", "status": 400, "code": "bad_query",
"details": "The query could not be read.",
"warnings": ["cost: \">3\" is not a number - try cost:3, cost>=3, …"] }| Status | code | When |
|---|---|---|
| 400 | missing_query | q is absent or blank |
| 400 | bad_query | the parser rejected the query; warnings says why |
| 400 | query_too_long | q is over 500 characters |
| 400 | bad_parameter | unique, sort, order or page_size is not a value the API takes |
| 400 | missing_parameter | /cards/named without exact or fuzzy |
| 403 | user_agent_required | the request sent no User-Agent |
| 404 | not_found | no such name, no random match, or no such route |
| 429 | rate_limited | more than 60 requests a minute from one address; Retry-After: 60 |
| 405 | method_not_allowed | anything but GET, HEAD or OPTIONS |
| 503 | data_unavailable | the card data could not be loaded; try again shortly |
The User-Agent rule applies here as everywhere on the API; the query checks it itself, so a request without one gets a JSON 403.
Fair use
The query API is for lookups by people and their tools: a bot answering a question in a channel, a script checking a few cards, a page showing a result. It is not a backend for another service. If your application answers searches, download the data, one file that changes a few times a year, and query it locally; the search grammar is open source and runs anywhere JavaScript does.
Two rules keep it that way. A request must carry a User-Agent naming your project, as everywhere on the API. And one address gets 60 requests a minute; past that the API answers 429 with Retry-After for the rest of the minute. Answers cache for five minutes, so a repeated query costs nothing.
What it reads, and how fresh it is
The API reads the same compact card and printing lists this site builds from the verified release, so an answer and the search page agree to the card. A new release rebuilds the site; the API picks the new lists up within ten minutes and says which release it answered from. Slug lookups resolve current slugs; for any slug ever issued, use /v3/slugs/{slug}.json.
Reference: registry API contract and published object definitions. Report a data or documentation issue.