Skip to content

Admin API Endpoints

The admin site exposes these API endpoints for deck and collection editing. All endpoints require authentication.

For general admin API endpoints (authentication, config, audit log, etc.), see the admin command reference.

Every response body — success or refusal — carries user-facing prose as up to three fields:

FieldPresenceMeaning
messagealwaysThe sentence, rendered in English. What curl, scripts, and the MCP server read; it never follows the operator’s UI locale.
messageKeyoptionalThe message-catalog key message was rendered from — locale-invariant, so a client may match on it instead of on prose. Absent when the handler has no catalog entry for that sentence.
messageParamsoptionalThe parameters messageKey interpolates. Absent for a message that takes none.

The pair is additive: a client that ignores it sees exactly what it always did. The admin SPA prefers it, which is what relabels an alert already on screen when the UI language changes without a round trip. Match on messageKey, never on the English text.

Every route refuses a request with the same body, whatever the status:

{
"success": false,
"message": "",
"messageKey": "admin.api.…",
"messageParams": { "…": "" }
}

messageKey/messageParams follow the message triple above — present on a keyed refusal, absent on one whose prose has no catalog entry.

A handful of routes carry extra fields on failure, and only where they are a wire contract rather than duplication: Card Details adds card: null, Card Search keeps its paging fields and an empty cards array, and Card Autocomplete and Card Printings fold success and failure into one shape. A save that loses an optimistic-concurrency race additionally carries conflict: true with its 409.

POST /api/deck/create

Create a new deck file, named as the deck is named — see List file names. A name left with no usable file-name characters returns 400.

Request Body:

{
"name": "My Commander Deck",
"format": "commander"
}
FieldDescriptionRequired
nameDeck name (used to generate the slug)Yes
formatDeck format (default: "commander")No

format must be one of the canonical deck format keys — see Deck Format for the full list. An unrecognized value returns 400 and the deck is not created.

Response:

{
"success": true,
"message": "Created deck 'My Commander Deck'",
"slug": "My Commander Deck"
}

Create, rename, and delete answer identically for every list type — decks, collections, and wanted lists share one handler apiece, differing only in how a slug resolves to a file:

OperationSuccess body
Create{ success: true, message, slug }
Rename{ success: true, message, newSlug, newFilePath, oldFilePath }
Delete{ success: true, message, deletedFiles }

newFilePath/oldFilePath are the list’s paths after and before the rename; deletedFiles is every path the delete removed (the list plus whichever sidecars it had).

A refusal is the shared error envelope ({ success: false, message }, plus the optional messageKey/messageParams pair) at the status the refusal carries: 400 for a missing or invalid argument, 404 for a list that is not there, 409 for a target name already taken.

409 covers more than a byte-identical file name: create and rename refuse any name that resolves to an existing list of the same type — atraxa superfriends is refused while Atraxa Superfriends exists, with A deck named 'Atraxa Superfriends' already exists (it matches 'atraxa superfriends' under list-name folding). Renaming a list to another spelling of its own name (a capitalization or punctuation fix) is not a collision and succeeds, moving the file and its sidecars even on a case-insensitive file system.

POST /api/deck/:slug/rename

Rename a deck. Updates the frontmatter name field and renames the .md together with every sidecar it has — see List lifecycle responses.

Request Body:

{
"newName": "New Deck Name"
}

Response:

{
"success": true,
"message": "Renamed deck to 'New Deck Name'",
"newSlug": "New Deck Name",
"newFilePath": "decks/New Deck Name.md",
"oldFilePath": "decks/My Commander Deck.md"
}
DELETE /api/deck/:slug

Delete a deck (and every sidecar it has — see List lifecycle responses). Requires the full deck name to be provided as confirmation.

Request Body:

{
"confirmName": "My Commander Deck"
}

The confirmName must match the deck’s name field exactly. Returns 400 if they don’t match.

Response:

{
"success": true,
"message": "Deleted deck 'My Commander Deck'",
"deletedFiles": ["decks/My Commander Deck.md", "decks/My Commander Deck.md.sha256"]
}
GET /api/autocomplete?q=<query>

Search for card names using the in-memory card cache. The query is split on whitespace and every term must appear in the name, in any order — the same matching the CLI prompts use, so in tre finds “In the Trenches”. Matching ignores case, accents, and punctuation (jotun matches Jötun Grunt; jaces archivist matches Jace's Archivist).

Returns up to 20 results, ranked by how directly each name answers the query: a name the query spells out in full comes first (the front face of a double-faced card counts as its whole name), then names the query prefixes (sol ri → “Sol Ring”), then names whose words the terms begin — in order, then in any order — and finally names matched mid-word (in tre → “Kin-Tree Warden”). Equally ranked names are alphabetical.

Query Parameters:

ParameterDescriptionRequired
qSearch query (minimum 2 characters)Yes

Response:

{
"success": true,
"names": ["Sol Ring", "Soltari Champion"]
}
GET /api/deck/:slug?view=<full|cards|summary>&section=<name>&nameContains=<terms>&limit=<n>&offset=<n>

Load a deck, at the depth view asks for. The same parameters apply to Load Collection and Load Wanted List.

ParameterDescriptionRequired
viewfull (default), cards, or summary. Anything else is a 400 naming the threeNo
sectionExact ## Section heading, matched case-sensitively. A section that does not exist yields no entries, not an errorNo
nameContainsWhitespace-separated name terms; every term must appear, in any order (as /api/autocomplete matches)No
limitMax entries returned. A positive integer; anything else is a 400No
offsetEntries to skip before limit applies. A non-negative integer (0 is allowed)No

view is what makes the filters worth using. summary and cards return before the changelog-name pass, the Scryfall card/printing/price load, and the mana-symbol fetch — the expensive part of a load — so a filtered read costs the server almost nothing rather than merely returning less. full still applies the filters and then loads card data for the filtered names only.

totalCount is always present: the number of entries that matched before limit/offset applied — the list’s whole line count when nothing was filtered — so a client can page. offset/limit count lines, and a line is never split: a 4 Lightning Bolt deck entry travels whole.

Any of section, nameContains, limit, or offset makes the body a slice: the response then carries "partial": true and no contentHash. That is deliberate — the deck and wanted save routes persist the payload they are handed, so saving a slice back would truncate the file. Reload without the filters to get a hash you can save with. This applies to every view, summary included: a filtered summary’s counts describe the slice, and the hash is the token the save routes read as “this is the whole file”.

warnings is always present on all three views: everything in the file a re-serializing write would not reproduce, as an array of messages (empty for a clean file). That is the body lines the parser could not read — malformed card lines, but also prose, comments, or any other text the list grammar does not model — plus one summary entry per file holding a fenced code block (Fenced code block content (N line(s)) — …). A fenced block parses cleanly, so it produces no per-line warning; it is reported here because the canonical serializers cannot emit it. One thing a write does not reproduce is deliberately not listed: a deck’s empty extras section (## Maybeboard, ## Tokens), which holds nothing to lose and is cleared by the next write rather than blocking it. warnings is an always-present array rather than an optional one on purpose — a list holding an unreadable line would otherwise load as merely shorter, and a client that never checks an optional field would never learn the difference.

A non-empty warnings also blocks saving that list. The three save routes re-serialize the whole file from parsed entries, so anything the parse could not carry is content the write would delete — releasing any &N ids it held back into the reuse pool for some other card. Rather than let that happen, a save whose baseline (the file as it stands on disk) yields any warnings is refused with 400, naming the file and each entry. The file is left untouched; fix the line (or remove the fenced block) and retry. MCP mutations surface the same refusal as a tool error.

A missing list is a 404 whose message names GET /api/lists as the way to find the real slugs. A slug carrying a path separator is a 400 (Invalid list slug) on all three routes.

Response (view=full, the default):

{
"success": true,
"view": "full",
"deck": { "name": "...", "sections": [] },
"totalCount": 42,
"cards": { "Sol Ring": {} },
"printings": { "Sol Ring": [] },
"cardsCardKingdom": { "Sol Ring": {} },
"symbolMap": { "{W}": "https://..." },
"frontMatter": {},
"slug": "my-deck",
"contentHash": "...",
"warnings": []
}

A full deck load also carries lowestPriceCards, lowestPriceCardsEur and lowestPriceCardsTix — the cheapest printing per card name, per currency.

cardsCardKingdom (and, on decks, lowestPriceCardsCardKingdom) is Card Kingdom’s own printing pick for each card name: the printing CK actually sells, chosen at CK’s prices, which a client displays instead of the Scryfall pick while the Card Kingdom price store is selected. Both are sparse — a card CK stocks no printing of has no entry, and the client falls back to the Scryfall pick — and both are absent entirely unless priceSources includes cardkingdom and a buylist feed is cached. Nothing is downloaded to answer a load: with no cached feed the fields are simply absent.

Response (view=cards):

{
"success": true,
"slug": "my-deck",
"view": "cards",
"deck": { "name": "...", "sections": [] },
"frontMatter": {},
"totalCount": 42,
"contentHash": "...",
"warnings": []
}

Front matter travels with the deck’s cards view because the save route re-sends it; a collection or wanted list returns entries + sectionOrder instead. A deck and a collection additionally carry a top-level labels — the list’s front-matter default, a deck’s being proxy alone — and each of their cards may carry its own labels override. A narrowed request replaces contentHash with "partial": true.

Every non-summary load also carries customArt when the list has any: a { "<cardId>": { "file": … } | { "url": … } } record of the raw custom art references for the cards in the body (clients derive display URLs themselves, since an editor needs the path the user typed). It is omitted when none of the returned cards has art.

Problems with the .art.json sidecar — it cannot be read, or it holds art filed under a card id the whole list no longer has — come back as a separate artWarnings array rather than failing the load. It is deliberately not folded into warnings: that channel means card lines the parser could not read, and the save routes refuse a list that has any, while bad custom art blocks nothing. artWarnings is omitted when the sidecar is clean or absent. The orphan check ignores the filters, so a paged read never reports the cards it did not ask for.

Response (view=summary):

{
"success": true,
"slug": "my-deck",
"view": "summary",
"counts": {
"entryCount": 42,
"cardCount": 99,
"sections": [{ "name": "Commander", "entryCount": 1, "cardCount": 1 }]
},
"contentHash": "...",
"warnings": []
}

entryCount is lines, cardCount is copies (summed quantity). Collections and wanted lists hold one card per line, so the two are equal there. A summary honours section/nameContains and ignores limit/offset — the counts describe the whole filtered set. A narrowed summary is "partial": true with no contentHash, like every other narrowed view.

GET /api/card-printings?name=<cardName>&limit=<n>

Get the printings of a card, newest first. Uses the card cache with fallback to the Scryfall API.

Query Parameters:

ParameterDescriptionRequired
nameExact card nameYes
limitMax printings returned. A positive integer; anything else is a 400No

limit is opt-in: omitting it returns every printing, which is what the public/hosted site’s printing pickers depend on. limit and totalPrintings count distinct printings (set + collector number): with an all_cards-backed cache (a non-English defaultLanguage) a printing can hold several card objects — one per language, each carrying its lang — and every language object of an included printing rides along, so a client never sees a printing with half its languages missing. When limit truncates the list, totalPrintings reports how many distinct printings there were.

There is deliberately no includePrices parameter — dropping a printing’s price block is a projection, and each client projects what it needs from one honest response (the MCP get_card_printings tool does exactly that).

Response:

{
"success": true,
"printings": [{ "id": "...", "set": "2xm" }],
"totalPrintings": 37,
"languages": ["en"],
"complete": true
}

languages summarizes every language the card’s full printing list exists in (before any limit truncation), en first, folding an absent lang to en["en"] for any default_cards-backed lookup.

complete is false when the card cache holds no printing list for the name and the response came from the single-card Scryfall fallback: the one printing returned is whatever that lookup found, not the card’s only printing. A client must not present such a list as exhaustive — run ritual cache preload-all to get a real one.

GET /api/card-price?name=<cardName>

Get price data for a card including representative and cheapest printings for all currencies. If the cached data is more than 24 hours old, fresh data is fetched from Scryfall and the cache is updated. A card name with no printings returns 404.

Query Parameters:

ParameterDescriptionRequired
nameExact card nameYes

Response:

{
"success": true,
"printings": [{ "id": "...", "set": "2xm", "prices": { "usd": "1.23" } }],
"representative": { "id": "...", "set": "2xm" },
"lowestPriceCard": { "id": "...", "set": "a25" },
"lowestPriceCardEur": { "id": "...", "set": "a25" },
"lowestPriceCardTix": { "id": "...", "set": "vma" }
}
FieldDescription
representativeThe USD representative printing (recent, mid-priced)
lowestPriceCardThe cheapest USD printing across all printings
lowestPriceCardEurThe cheapest EUR printing
lowestPriceCardTixThe cheapest MTGO Tix printing
GET /api/card-details?name=<cardName>

Everything Ritual knows about one card: oracle text, type line, mana cost and CMC, colors and color identity, keyword abilities, format legalities, and Scryfall Tagger oracle/art tags. The local card cache is read first, falling back to a single-card Scryfall fetch when the cache holds no printings for the name.

Oracle-level fields are identical across printings, so the response describes the card — the identity fields (set, collectorNumber, prices) come from its most recent printing, and printingCount reports how many printings were found. printingsComplete is false when that count came from the single-card fallback rather than the cache’s own printing list, in which case printingCount is always 1 and means nothing about the card. Set codes are returned lowercase.

colors, keywords, and legalities are only present on cards written by a cache from this version onward; run ritual cache preload-all to backfill them.

Query Parameters:

ParameterDescriptionRequired
nameExact card nameYes

A name that matches nothing returns 404 with a message pointing at /api/autocomplete for resolving a partial name. A missing or blank name is a 400. Every error response keeps the success shape — success: false and card: null — plus a message.

Response:

{
"success": true,
"card": {
"scryfallId": "...",
"name": "Lightning Bolt",
"set": "2xm",
"collectorNumber": "129",
"rarity": "uncommon",
"releasedAt": "2020-08-07",
"finishes": ["nonfoil"],
"prices": { "usd": "1.23", "eur": "0.99", "tix": "0.03" },
"manaCost": "{R}",
"cmc": 1,
"typeLine": "Instant",
"oracleText": "Lightning Bolt deals 3 damage to any target.",
"colorIdentity": ["R"],
"layout": "normal",
"colors": ["R"],
"keywords": [],
"legalities": { "commander": "legal", "standard": "not_legal" },
"oracleTags": ["burn"],
"artTags": ["lightning"],
"printingCount": 42
}
}

A multi-faced card also carries faces, one { name, manaCost, typeLine, oracleText } object per face.

GET /api/card-search?q=<query>&page=<n>&limit=<n>&warm=<true|false>

Run a raw Scryfall search query and return one page of card summaries, most popular first — the same lookup the scry CLI command performs.

Exactly one page is fetched per request. Walk further pages by incrementing page while hasMore is true.

This route carries both halves of what used to be two routes (the cache-warming POST /api/search-cards has been folded in and removed).

By default the route returns Scryfall’s page verbatim and touches no cache: tokens, Arena-only printings, and Art Series cards are not filtered out, the order is Scryfall’s own, and a page carries up to 175 cards.

With warm=true it instead:

  • filters the page to real printings and maps them to the cache’s card shape;
  • writes each result into the local card cache under any name the cache does not already hold, leaving an already-cached name untouched (this is a warm-up, not a refresh);
  • promotes a card whose whole name the query spells out ahead of Scryfall’s popularity order;
  • caps the result at 20 cards unless limit says otherwise.

The response’s warmed field says which contract ran. The error contract is the strict one in both modes — see below.

Query Parameters:

ParameterDescriptionRequired
qScryfall search queryYes
page1-based page number (defaults to 1)No
limitMax cards returned, at most 175. Defaults to 20 when warm=true, otherwise the whole pageNo
warmtrue or false (default). Any other value is a 400 — it is validated, never coercedNo

A missing or blank q is a 400; q is trimmed before it is sent on. page and limit may be omitted or left blank; any other value that is not a positive integer is a 400.

A Scryfall 404 (no matches) is a 200 with an empty cards array — an empty result set is not an error. A query Scryfall itself refuses (a syntax error, an unknown filter) is a 400 whose message carries Scryfall’s own explanation; a failure on Scryfall’s side (a 5xx, a network error) is a 500. Every error response keeps the success shape — success: false, the requested page, hasMore: false, an empty cards array — plus a message. This holds for warm=true too: a Scryfall server error is a 500, never an empty 200.

Response:

{
"success": true,
"page": 1,
"hasMore": true,
"totalCards": 412,
"warmed": false,
"cards": [
{
"scryfallId": "...",
"name": "Lightning Bolt",
"set": "2xm",
"collectorNumber": "129",
"rarity": "uncommon",
"releasedAt": "2020-08-07",
"finishes": ["nonfoil"],
"prices": { "usd": "1.23" },
"manaCost": "{R}",
"cmc": 1,
"typeLine": "Instant",
"oracleText": "Lightning Bolt deals 3 damage to any target.",
"colorIdentity": ["R"]
}
]
}

totalCards is absent when Scryfall reported no matches.

GET /api/cache/status

Report the card cache’s size, freshness, tag coverage, and source — the same payload ritual cache status --output json prints. Diagnostic only: asking never refreshes or writes the cache. Tag presence is checked over a bounded sample of cached cards, not a full scan, so a configured cache server is never asked for its whole contents.

Response:

{
"success": true,
"empty": false,
"cardCount": 31240,
"lastCardRefresh": "2026-07-28T04:00:00.000Z",
"priceAgeHours": 6,
"priceStale": false,
"tagsPresent": true,
"source": "local",
"defaultLanguage": "en",
"cardBulkType": "default_cards",
"bulkTypeStale": false
}
FieldDescription
emptyWhether the cache holds no cards at all
cardCountDistinct card names cached (each holds an array of printings)
lastCardRefreshISO-8601 time of the last bulk refresh, or null until one has run
priceAgeHoursWhole hours since that refresh (prices ride in the bulk data), or null
priceStaletrue when prices are older than 24 hours, or their age is unknown
tagsPresentWhether any sampled card carries oracle/art tags
sourcelocal, or cache-server when a cache server is configured
defaultLanguageThe configured defaultLanguage
cardBulkTypeWhich bulk built the cache (default_cards/all_cards), or null when no ingest has recorded provenance
bulkTypeStaletrue when the cache’s bulk disagrees with defaultLanguage — a full refresh is needed (see bulk selection)
GET /api/price/summary

Price every deck, collection, and wanted list from the local card cache and return per-list, per-type, and grand totals plus any list-parser warnings — the same payload as price --summary --output json. Prices are read strictly from the cache: when it is empty the endpoint returns 503 without downloading anything (run ritual cache preload-all first), and a card the cache does not hold is reported as unpriced rather than fetched from Scryfall one card at a time. lastRefreshedAt is the cache’s last bulk-refresh time in Unix milliseconds, or null when unknown.

Query Parameters:

ParameterDescriptionRequired
typeOnly price deck, collection, or wanted listsNo
currencyusd, eur, or tix (default: the configured defaultCurrency)No
sourcetcgplayer (Scryfall USD), cardmarket (Scryfall EUR), or cardkingdom (Card Kingdom NM retail from the cached feed)No

An unknown type, currency, or source returns 400. A source implies its currency (tcgplayer/cardkingdomusd, cardmarketeur), so a conflicting explicit currency is a 400 too. source=cardkingdom reads the cached buylist feed — strictly cache-backed like everything else here, so with no feed downloaded it returns 503 with the refresh advice rather than falling back to Scryfall — and the response then carries "source": "cardkingdom" beside "currency": "usd", with printings Card Kingdom does not sell reported unpriced. The parameter is an explicit request and is deliberately not gated on the priceSources config key (the same way ritual sell is never gated): it needs only a cached feed.

mode discriminates the two price bodies — "summary" here, "list" on Price List — so a client that can receive either reads one field to know which it got.

unpricedCount counts copies whose price the data could not supply, and only those. Cards that carry no price by rule — a proxy label, custom art, or both — are priced at 0 and counted in cardCount like any other card, but are deliberately left out of unpricedCount: they are not a gap in the price data, and a client showing “2 unpriced” for a deck of deliberate proxies would be reporting a problem that does not exist.

Response:

{
"success": true,
"mode": "summary",
"currency": "usd",
"lastRefreshedAt": 1752600000000,
"lists": [
{
"type": "deck",
"name": "my-deck",
"cardCount": 100,
"total": 245.1,
"lowestTotal": 199.9,
"unpricedCount": 2
}
],
"typeTotals": [
{
"type": "deck",
"listCount": 1,
"cardCount": 100,
"total": 245.1,
"lowestTotal": 199.9,
"unpricedCount": 2
}
],
"totals": {
"listCount": 1,
"cardCount": 100,
"total": 245.1,
"lowestTotal": 199.9,
"unpricedCount": 2
},
"warnings": []
}
GET /api/price/:type/:slug

Price a single list and return its summary plus every priced card entry (in file order) — the same payload as the CLI’s single-list price <name> --output json view. :type is deck, collection, or wanted; :slug is the list’s file basename, matching the load endpoints. Takes the same currency and source query parameters as the summary endpoint, with the same 503 when the card cache is empty (or when source=cardkingdom finds no cached feed). An unknown slug returns 404.

Response:

{
"success": true,
"mode": "list",
"currency": "usd",
"lastRefreshedAt": 1752600000000,
"list": {
"type": "deck",
"name": "my-deck",
"cardCount": 100,
"total": 245.1,
"lowestTotal": 199.9,
"unpricedCount": 2
},
"cards": [
{
"listType": "deck",
"listName": "my-deck",
"section": "Main",
"name": "Sol Ring",
"quantity": 1,
"set": "c21",
"collectorNumber": "263",
"pinned": true,
"price": 2.5,
"lowest": 1.1,
"lowestSet": "cma",
"lowestCollectorNumber": "215",
"lowestFinish": "nonfoil",
"cmc": 1,
"edhrecRank": 1,
"typeLine": "Artifact",
"fileOrder": 0
}
],
"warnings": []
}

An entry that could not be priced also carries unpricedReason, absent on every priced card. It is one of no-printings, printing-not-found, currency-unavailable, finish-unpriced-in-currency, no-price-data — the data gaps, which unpricedCount counts — or one of the two by-rule reasons, proxy and custom-art, which it does not (see Price Summary). custom-art wins when a card is both. The by-rule entries still carry their printing and their set/collectorNumber: a proxy is a proxy of a card, and custom art replaced the picture, not the card.

GET /api/sell/report

Match listed cards against the locally cached Card Kingdom buylist and report what CK is buying, the cash quote per Near Mint copy, and their quantity caps — the same payload as sell --output json. Strictly cache-backed: the card cache and a downloaded feed are prerequisites (503 otherwise, each naming its remedy), and this endpoint never downloads anything — that is Sell Refresh’s job.

Copies that are priceless by rule — labeled proxy, wearing custom art, or both — are dropped before matching, so they are never quoted and never counted as cards the buyer declined. A proxy is not a card CK would take, and a copy wearing art of its own is not the printing a quote would be for. They are dropped rather than merged, so an otherwise-identical real copy in the same list keeps its own quote and its own quantity.

ParameterDescriptionRequired
typeMatch every deck, collection, or wanted list (default: every collection)No
listsComma-separated type:slug refs to match exactly these lists (overrides type); unknown → 404No
setsComma-separated set codes to filter toNo
minMinimum per-copy offer (USD)No

Response:

{
"success": true,
"feedCreatedAt": "2026-08-04 06:06:09",
"feedRetrievedAt": 1785850800000,
"filters": { "sets": ["fdn"], "minPrice": 0.5 },
"lists": [
{
"type": "collection",
"name": "Red Binder",
"cardCount": 45,
"sellableCount": 12,
"totalValue": 123.45,
"notBuyingCount": 30,
"noMatchCount": 3
}
],
"entries": [
{
"listType": "collection",
"listName": "Red Binder",
"section": "Main",
"name": "Arahbo, the First Fang",
"quantity": 1,
"set": "fdn",
"collectorNumber": "294",
"finish": "nonfoil",
"condition": "NM",
"pinned": true,
"status": "buying",
"matchVia": "scryfall-id",
"ckProductId": 316734,
"ckSku": "FDN-0294",
"ckName": "Arahbo, the First Fang",
"ckEdition": "Foundations Variants",
"ckVariation": "0294 - Borderless",
"ckUrl": "https://www.cardkingdom.com/mtg/foundations-variants/arahbo-the-first-fang",
"ckFinish": "nonfoil",
"priceBuy": 1.5,
"priceRetail": 3.49,
"qtyBuying": 25,
"sellableQuantity": 1,
"value": 1.5,
"fileOrder": 1
}
],
"totals": {
"listCount": 1,
"cardCount": 45,
"sellableCount": 12,
"totalValue": 123.45,
"notBuyingCount": 30,
"noMatchCount": 3
},
"warnings": []
}

status is buying, not-buying (the product exists but CK’s buy quantity is 0), or no-match (with noMatchReason: no-printings, printing-not-found, or not-on-buylist). matchVia names the join key that located the product (scryfall-id, sku, or name), ambiguous is set when several products matched (the quote is the best-paying one), and an entry with pinned: false (an unpinned deck/wanted line) is quoted at the best-paying printing, whose set/collector/ckFinish it reports. sellableQuantity draws from a per-product budget of CK’s qtyBuying — entries sharing a product never sum past their cap — and value prices only those copies.

GET /api/sell/cart

The entries CK is buying, rendered as their sell-cart CSV import format (card name, edition, foil, quantity, no header row — CK’s own listing titles, variant note included, quantities capped at their buy limits), over the same ?type=/?lists=/?sets=/?min= parameters and 503 prerequisites as Sell Report. The capability behind the CLI’s sell --output csv.

Response:

{
"success": true,
"csv": "\"Arahbo, the First Fang (0294 - Borderless)\",Foundations Variants,false,3\n...",
"titleCount": 12,
"cardCount": 31,
"warnings": []
}

warnings flags CK’s upload caps (500 unique titles / 5,000 cards) and etched foils the format cannot express (exported as foil).

POST /api/sell/refresh

Download Card Kingdom’s pricelist feed (~70 MB) when the cached copy is stale (older than a day) or missing; ?force=true redownloads regardless. The one sell route that reaches the network.

Response:

{
"success": true,
"refreshed": true,
"feedRetrievedAt": 1785850800000,
"feedCreatedAt": "2026-08-04 06:06:09",
"productCount": 149978,
"warnings": []
}

A failed download returns 502 only when no feed is cached at all; with a stale cache the call answers 200 with the stale feed’s stamps, refreshed: false, and the failure in warnings. So refreshed: false with empty warnings means the cache was still fresh, and with a warning it means you are still on the stale feed.

POST /api/buylist/quotes

The buyer’s current offer for specific printings, keyed by set:collectorNumber:finish (set lowercased). Use this to price an arbitrary set of cards — a trade, a selection, whatever a page is displaying — without building a whole Sell Report. Strictly cache-backed, like every sell read path: 503 with the remedy when no feed has been downloaded.

Request:

{
"buyer": "cardkingdom",
"printings": [{ "set": "dsk", "collectorNumber": "136", "finish": "nonfoil", "scryfallId": "" }]
}

buyer defaults to cardkingdom (the only buyer today). scryfallId is optional but is the primary join key when the caller has it; set/collectorNumber always form the response key and drive the sku fallback for the ~0.5% of Card Kingdom products with no Scryfall id. The optional language is the entry’s language code (absent means English): the buyer feeds are English-only, so a non-en printing is never matched — its key is simply absent from quotes, never quoted at the English product’s price. At most 500 printings per request.

Response:

{
"success": true,
"buyer": "cardkingdom",
"quotes": {
"dsk:136:nonfoil": {
"priceBuy": 2.5,
"qtyBuying": 8,
"priceRetail": 5.99,
"qtyRetail": 12,
"buying": true,
"finish": "nonfoil",
"matchVia": "scryfall-id",
"productId": 281234,
"name": "Overlord of the Balemurk",
"edition": "Duskmourn: House of Horror",
"variation": "298 - Borderless",
"url": "https://www.cardkingdom.com/mtg/..."
}
},
"feedCreatedAt": "2026-08-04 06:06:09",
"feedRetrievedAt": 1785850800000,
"stale": false,
"productCount": 149978
}

quotes is sparse: a requested printing the buyer has no product for is simply absent. buying is false when Card Kingdom publishes a price but has paused buying (qtyBuying: 0) — that is not money you can get today, so treat it as no offer. Both cases read as “not on the buylist”, which is the rule the sites’ On buylist chip and grouping use: roughly half of CK’s catalog is paused at any time, so a present quote is not by itself an offer. variation is CK’s variant note for the matched product, present only when they publish one; a client rendering a cart CSV row builds CK’s listed title from name (variation). priceRetail/qtyRetail are the buyer’s own NM retail price and stock — what the sites’ Card Kingdom price view displays; a qtyRetail of 0 means out of stock with the listed price standing, and a priceRetail of 0 means no published retail price.

This route is also mounted by the public site server (ritual serve --api), unauthenticated, and answers 404 there too unless sell mode is on or priceSources includes cardkingdom. The public site no longer calls it — sell mode reads buy prices baked into each list’s data — so it is there for other clients; the admin editors are the one client that still quotes live, since they price cards as they are added. The public server deliberately has no refresh route: an unauthenticated endpoint must never be able to trigger a ~70 MB download.

GET /api/buylist/status

Which buyers this server can quote against and how fresh the cached feed is, without quoting anything. Backs the admin Refresh Cache page’s buylist card.

Response:

{
"success": true,
"buyer": "cardkingdom",
"buyers": ["cardkingdom"],
"feedCreatedAt": "2026-08-04 06:06:09",
"feedRetrievedAt": 1785850800000,
"stale": false,
"productCount": 149978
}

503 with the remedy when no feed has been downloaded — a normal first-run state, not an error.

POST /api/deck/:slug/save

Save deck changes. Writes the updated deck file and appends to the changelog. Pass the optional boolean continueSession to merge this save into the previous save’s changelog entry (bumping its timestamp) instead of opening a new one — the editor sets it on every save after the first within an editing session.

set-label changes (and label-carrying adds and deck cards) are accepted here, validated against what a deck line can carry: proxy alone. Any other label — or an illegal combination — is a 400 and nothing is written.

Request Body:

{
"changes": [{ "id": "...", "timestamp": 123, "action": "add", "cardName": "Sol Ring" }],
"deck": { "name": "...", "sections": [] },
"frontMatter": {},
"contentHash": "",
"continueSession": false,
"validateCardNames": false
}

All three save routes parse the file as it stands on disk before applying anything, and refuse with 400 when that parse yields any warnings — a line the parser cannot read, or a fenced code block, is content the re-serializing write would delete along with any &N ids it held. The message names the file and each entry (each entry states its own extent, so no aggregate line count is claimed). Nothing is written. Fix the line, or remove the fenced block, and retry; GET /api/{type}/:slug reports the same list in its warnings field.

All three save routes validate every language a request carries — a set-language change requires its language field, an unknown code anywhere is a 400 naming the offender and listing the 17 valid Scryfall codes, and en on an entry folds to no token on the written line (a bare line always means English).

The three save routes and both Move Selected Cards / Remove Cards routes accept an optional boolean validateCardNames, default false. It is validated, never coerced: any non-boolean value is a 400.

With it set, every card name the request mentions is checked against the local Scryfall card cache before anything is written:

  • A name already present in the affected list is accepted with no lookup at all — the file is the authority on what is in it, which is what keeps a custom, proxied, or unreleased card removable and editable.
  • Any other name must be one the cache knows. An unknown one is a 400 naming up to three of the closest cached spellings.
  • An empty cache is also a 400, with a message naming both remedies (the MCP refresh_cache tool, or ritual cache preload-all). This differs from Price List’s 503 deliberately: validateCardNames: true is a precondition the client asserted, so a request the server cannot satisfy as asked is a client error — whereas a cold cache blocks the price routes’ whole purpose regardless of what the caller asked.

Off by default so the admin UI’s behavior is unchanged: it only ever sends names it read from a list, and it cannot assume a warm cache. The MCP write tools set it on every request they build (the import tools excepted — their engines already report per-row failures).

Response:

{
"success": true,
"message": "Saved 3 changes to My Deck",
"contentHash": "",
"droppedNotes": [],
"effects": [
{
"action": "added",
"cardId": 7,
"name": "Sol Ring",
"section": "Main",
"quantity": 1,
"printing": { "set": "c21", "collectorNumber": "167" }
}
]
}

All three save routes answer with an effects array describing what the save did to individual card lines, and a contentHash for the next save. Each entry is { action, cardId, name, section?, quantity, printing?, previousCardId? }, where action is added, removed, or updated.

previousCardId appears only on an updated effect whose line was renumbered: another entry in the same save arrived claiming its &N (a cross-list move carrying its source id, a replayed change bundle), so the serializer handed the older line a fresh number. Without it, a card the list has held all along would be reported as newly added.

The response is the only place these ids can appear: a card’s persistent &N id is allocated at serialization time, inside the save, so a client that added a card cannot know the id its line got until the response says so. That is what removes the follow-up load an MCP agent (or any API client) would otherwise make just to learn one number. Set codes inside printing are lowercase, per the project’s data-payload convention.

A save also re-files the list’s custom-art sidecar as part of the same write, and it reads the changes to do it, not the file it produced: a card the payload removes loses its art even when the payload re-adds the same card and the new line takes the same &N back. A client that wants art on a card it is adding therefore holds the reference until the save answers, then aims a Card Art write at the id the effects report (following previousCardId where a line was renumbered) — the admin editors’ add-card dialog does exactly this.

When that re-filing could not happen — the list’s own .art.json cannot be read, or neither can that of a list this save’s cross-list moves (Move to list…) are sending cards to — the save still succeeds and reports the problem in an artWarnings array, one message per sidecar it had to leave alone. The field is omitted when everything re-filed cleanly, and it carries the same channel name the load routes use for sidecar problems, so a client reads one field on both.

It is a warning rather than a failure on purpose: the card lines were written correctly, and the only casualty is that art may now sit under an &N the save freed or renumbered. The remedy is to fix the sidecar by hand — the message names the file and the parse failure — and the art then applies again, or comes off the list with the next art write.

GET /api/collections

Returns the list of available collections.

Response:

{
"success": true,
"collections": ["my-collection", "trade-binder"]
}
GET /api/collection/:slug

Load a collection with full card data, printings, and mana symbol map. Accepts the same list load parameters as Load Deck; the cards view returns entries + sectionOrder rather than a deck. The top-level labels is the collection’s default card labels (absent when none are declared), and an entry’s own labels is its per-card override — effective labels are the override when present, else the default.

Response:

{
"success": true,
"view": "full",
"entries": [
{ "name": "Sol Ring", "set": "2xm", "collectorNumber": "270", "labels": ["keep"], "cardId": 1 }
],
"sectionOrder": ["Main"],
"labels": ["sale", "trade"],
"totalCount": 42,
"cards": { "Sol Ring": {} },
"printings": { "Sol Ring": [] },
"symbolMap": { "{W}": "https://..." },
"slug": "my-collection",
"contentHash": "...",
"warnings": []
}
POST /api/collection/:slug/save

Save collection changes. Writes the updated collection file and creates a changelog entry. Pass the optional boolean continueSession to merge this save into the previous save’s changelog entry (bumping its timestamp) instead of opening a new one — the editor sets it on every save after the first within an editing session. Every collection entry must carry a printing: an add, move-to, or set-printing change missing set or collectorNumber returns 400 and leaves the file untouched. A change whose target entry does not exist (matching is exact and case-sensitive on name, with cardId taking priority) also returns 400 naming the unapplied changes, and nothing is written — a save must never report success while dropping changes. The optional validateCardNames flag applies here too. set-label changes (and label-carrying adds) are accepted here — their labels are validated against the label vocabulary, the labels a collection carries, and the keep/proxy exclusivity rule (400 on an illegal combination) and normalized to canonical order before the write; the file’s front-matter block always rides through a save untouched.

Request Body:

{
"changes": [{ "id": "...", "timestamp": 123, "action": "add", "cardName": "Sol Ring" }],
"contentHash": "",
"sectionOrder": ["Main", "Trade Binder"],
"continueSession": false
}

The handler re-parses the file and replays the changes itself — no entry list is sent. The optional sectionOrder gives section display order (including empty sections); when omitted, the file’s parsed order is kept.

Response:

{
"success": true,
"message": "Saved 3 changes to My Collection",
"contentHash": "",
"droppedNotes": [],
"effects": [{ "action": "removed", "cardId": 4, "name": "Lightning Bolt", "quantity": 1 }]
}

See effects for what the array reports and why it is the response’s job.

POST /api/collection/create

Create a new collection file, named as the collection is named — see List file names.

Request Body:

{
"name": "My Collection"
}

Response:

{
"success": true,
"message": "Created collection 'My Collection'",
"slug": "My Collection"
}
POST /api/collection/:slug/rename

Rename a collection. Replaces the first # <Title> line in the file and renames the .md together with every sidecar it has — see List lifecycle responses.

Request Body:

{
"newName": "Renamed Collection"
}

Response:

{
"success": true,
"message": "Renamed collection to 'Renamed Collection'",
"newSlug": "Renamed Collection",
"newFilePath": "collections/Renamed Collection.md",
"oldFilePath": "collections/My Collection.md"
}
DELETE /api/collection/:slug

Delete a collection file (and every sidecar it has — see List lifecycle responses). Requires confirmName to match the parsed # Title exactly.

Request Body:

{
"confirmName": "My Collection"
}

Response:

{
"success": true,
"message": "Deleted collection 'My Collection'",
"deletedFiles": ["collections/My Collection.md", "collections/My Collection.md.sha256"]
}
GET /api/wanted

Returns the list of available wanted lists.

Response:

{
"wantedLists": [{ "slug": "high-priority", "name": "High Priority" }]
}
GET /api/wanted/:slug

Load a wanted list with full card data, printings, and mana symbol map. Accepts the same list load parameters as Load Deck; the cards view returns entries + sectionOrder rather than a deck.

Response:

{
"success": true,
"view": "full",
"entries": [{ "name": "Sol Ring", "set": "2xm", "collectorNumber": "270", "cardId": 1 }],
"sectionOrder": ["Main"],
"totalCount": 42,
"cards": { "Sol Ring": {} },
"printings": { "Sol Ring": [] },
"symbolMap": { "{W}": "https://..." },
"slug": "high-priority",
"contentHash": "...",
"warnings": []
}
POST /api/wanted/:slug/save

Save wanted list changes. Writes the updated wanted list file and appends to the changelog. Pass the optional boolean continueSession to merge this save into the previous save’s changelog entry (bumping its timestamp) instead of opening a new one — the editor sets it on every save after the first within an editing session. The optional validateCardNames flag applies here too.

Request Body:

{
"changes": [{ "id": "...", "timestamp": 123, "action": "add", "cardName": "Sol Ring" }],
"entries": [{ "name": "Sol Ring", "set": "2xm", "collectorNumber": "270" }],
"contentHash": "",
"continueSession": false
}

Response:

{
"success": true,
"message": "Saved 3 changes to high-priority",
"contentHash": "",
"droppedNotes": [],
"effects": [{ "action": "added", "cardId": 9, "name": "Sol Ring", "quantity": 1 }]
}

See effects for what the array reports and why it is the response’s job.

POST /api/wanted/create

Create a new wanted list file, named as the wanted list is named — see List file names.

Request Body:

{
"name": "Holiday Wishlist"
}

Response:

{
"success": true,
"message": "Created wanted list 'Holiday Wishlist'",
"slug": "Holiday Wishlist"
}
POST /api/wanted/:slug/rename

Rename a wanted list. Replaces the first # <Title> line in the file and renames the .md together with every sidecar it has — see List lifecycle responses.

Request Body:

{
"newName": "Renamed Wishlist"
}

Response:

{
"success": true,
"message": "Renamed wanted list to 'Renamed Wishlist'",
"newSlug": "Renamed Wishlist",
"newFilePath": "wanted/Renamed Wishlist.md",
"oldFilePath": "wanted/Holiday Wishlist.md"
}
DELETE /api/wanted/:slug

Delete a wanted list file (and every sidecar it has — see List lifecycle responses). Requires confirmName to match the parsed # Title exactly.

Request Body:

{
"confirmName": "Holiday Wishlist"
}

Response:

{
"success": true,
"message": "Deleted wanted list 'Holiday Wishlist'",
"deletedFiles": ["wanted/Holiday Wishlist.md", "wanted/Holiday Wishlist.md.sha256"]
}
GET /api/card-index?name=&listType=&slug=&set=

Returns every list (deck, collection, wanted) and every physical card across them, used by the Move Cards page and by any client that needs to find where a card physically lives. The lightweight cards payload carries no Scryfall data; each card’s key is a path-free session identifier echoed back on commit. Deck entries with quantity > 1 expand to one card per copy (copyIndex).

Query Parameters: every filter is optional, and they intersect. A blank value is treated as absent, not as “match nothing”.

ParameterDescription
nameWhitespace-separated name terms, matched as autocomplete matches them: case-, accent-, and punctuation-insensitive, in any order (in tre finds “In the Trenches”)
listTypedeck, collection, or wanted; anything else is a 400
slugExact list slug (the file basename); a value with a path separator is a 400
setSet code, matched lowercase — LEA and lea both match a card stored as lea. A malformed code (anything but letters and digits) is a 400

Only cards is filtered. lists is always the full roster, because clients render move destinations from it.

warnings is always present (possibly empty), so a client can tell “nothing went wrong” from “this server does not report warnings”. It names each list file that could not be fully read — an unparseable card line, or a deck file that could not be read at all — so an empty result is never silently wrong. One bad file never fails the whole index; the other lists are still returned.

Response:

{
"success": true,
"lists": [{ "type": "collection", "slug": "binder", "name": "Binder" }],
"cards": [
{
"key": "collection:binder:1:0",
"listType": "collection",
"listSlug": "binder",
"name": "Lightning Bolt",
"set": "lea",
"collectorNumber": "161",
"finish": "nonfoil",
"condition": "NM",
"note": "signed",
"cardId": 1,
"copyIndex": 0
}
],
"warnings": ["decks/burn.md: could not be read or parsed; its cards are missing from the index."]
}

set, collectorNumber, finish, condition, language, and note are each present only when the card line carries them.

POST /api/move/commit

Apply a batch of queued moves atomically. The move state is rebuilt from disk and each move is applied via the shared move engine, writing the source/destination files and their changelogs. The optional printing fields override the destination printing (used when a printing-less card is moved into a collection); the optional language overrides the card’s language on arrival (en clears the token — a bare line means English), and without it the card’s existing language rides along. The optional toSection (deck destinations only — 400 otherwise) places the card in that deck section, matched by exact name and created when missing; without it the default section is used. Moves whose cardKey or destination can no longer be resolved are skipped and reported. When git auto-commit is enabled, the written files are committed in a single commit, the same as the editor save endpoints.

Request Body:

{
"moves": [
{
"cardKey": "collection:binder:1:0",
"toType": "deck",
"toSlug": "my-deck",
"toSection": "Sideboard",
"set": "2xm",
"collectorNumber": "270",
"finish": "nonfoil",
"condition": "NM",
"language": "ja"
}
]
}

Response:

droppedNotes lists each note discarded by a deck quantity-merge (the card landed on an existing line whose single note slot already held a different value — the existing note wins).

{
"success": true,
"moved": 1,
"requested": 1,
"skipped": 0,
"droppedNotes": [{ "cardName": "Sol Ring", "cardId": 3, "note": "from trade" }],
"warnings": [],
"message": "Moved 1 card."
}

warnings is always present (possibly empty): it names each list file that could not be fully read while the card index this route resolves against was rebuilt, so a skipped move is never silently unexplained.

POST /api/move/selected

Move a batch of selected cards across lists atomically — backs the cross-list Move all selected multi-select action. Each item addresses its source card by list + identity (the same cardId/copyIndex scheme as Remove Cards) and its destination by toType + toSlug. The optional printing fields, language, and toSection behave exactly as in Commit Moves. Cards or destinations that can no longer be resolved — or whose destination is the list they already live in — are skipped and reported. The optional validateCardNames flag applies here too.

Request Body:

{
"moves": [
{
"listType": "deck",
"listSlug": "my-deck",
"name": "Sol Ring",
"cardId": 1,
"copyIndex": 0,
"toType": "collection",
"toSlug": "binder",
"set": "c21",
"collectorNumber": "167"
}
]
}

Response:

{
"success": true,
"moved": 1,
"requested": 1,
"skipped": 0,
"droppedNotes": [],
"warnings": [],
"message": "Moved 1 card."
}

warnings is always present (possibly empty): it names each list file that could not be fully read while the card index this route resolves against was rebuilt, so a skipped move is never silently unexplained.

POST /api/remove/commit

Remove a batch of cards across lists atomically — backs the cross-list Remove all selected multi-select action. The state is rebuilt from disk, each requested card is resolved to its physical key and marked for removal, and the source files and their changelogs are written in a single pass. Deck copies are addressed by copyIndex (one item per copy); collection and wanted entries use their cardId at copyIndex 0. Cards that can no longer be resolved are skipped and reported. The optional validateCardNames flag applies here too. When git auto-commit is enabled, the written files are committed in a single commit (Remove N cards).

Request Body:

{
"removes": [
{
"listType": "collection",
"listSlug": "binder",
"name": "Lightning Bolt",
"cardId": 1,
"copyIndex": 0
}
]
}

Response:

{
"success": true,
"removed": 1,
"requested": 1,
"skipped": 0,
"warnings": [],
"message": "Removed 1 card."
}

warnings is always present (possibly empty): it names each list file that could not be fully read while the card index this route resolves against was rebuilt, so a skipped removal is never silently unexplained.

GET /api/lists

Returns every list (deck, collection, wanted) as a slug-keyed summary. The single canonical enumeration endpoint — it populates the Change History page’s list picker and the cross-list move targets.

Response:

{
"success": true,
"lists": [{ "type": "deck", "slug": "my-deck", "name": "My Deck" }]
}
GET /api/diff?a=<[type:]name>&b=<[type:]name>&by=<name|printing>

Compare two lists (any mix of deck, collection, and wanted list) and return the matched identities with per-side quantities plus the entries only one side has. Exposed as the MCP diff_lists tool; shares its engine with the diff CLI command — see that page for the identity rules (nonfoil folding, the no-printing bucket, all sections included).

Query Parameters:

ParameterDescriptionRequired
aFirst list, resolved like CLI list arguments; a deck:/collection:/wanted: prefix pins the typeYes
bSecond list, same form as aYes
byIdentity to compare by: name (default) or printingNo

A missing a/b, an invalid by, or a name that resolves to no list (or ambiguously) returns 400.

Response:

{
"success": true,
"a": { "listType": "deck", "slug": "burn", "name": "Burn" },
"b": { "listType": "collection", "slug": "binder", "name": "Binder" },
"by": "name",
"matches": [
{
"name": "Lightning Bolt",
"a": {
"quantity": 2,
"printings": [
{ "set": "lea", "collectorNumber": "161", "finish": "nonfoil", "quantity": 2 }
]
},
"b": {
"quantity": 1,
"printings": [
{ "set": "lea", "collectorNumber": "161", "finish": "nonfoil", "quantity": 1 }
]
}
}
],
"onlyInA": [
{
"name": "Fireblast",
"quantity": 1,
"printings": [{ "set": "vis", "collectorNumber": "78", "finish": "foil", "quantity": 1 }]
}
],
"onlyInB": [],
"warnings": []
}

matches includes identities whose quantities are equal on both sides — clients decide what counts as interesting. warnings carries list parse warnings from either side.

GET /api/history/:type/:slug

Returns the parsed change sets of a list’s change log (newest first) plus the raw change lines a “rewrite with defaults” would produce. :type is deck, collection, or wanted. The list file is read only to derive defaultLines; a list with no change log yet returns an empty sets array. A set that is followed by hand-written non-change text carries it in a trailing array (absent otherwise) — the text is preserved through an edit-and-save round trip (each line kept as written, re-emitted after the set’s change lines; blank lines between them are not kept).

Response:

{
"success": true,
"header": "# Changelog for My Deck",
"sets": [
{
"timestamp": "2026-05-29T12:00:00.000Z",
"lines": ["- Added \"Sol Ring\" (LEA:1) &1"],
"trailing": ["NOTE TO SELF: the FNM tuning session."]
}
],
"defaultLines": ["- Added \"Sol Ring\" (LEA:1) &1"]
}
POST /api/history/:type/:slug/save

Overwrite the list’s change log with the supplied change sets. Each set needs a valid ISO-8601 timestamp and a lines array of strings, each starting with - . A set may also carry a trailing array of preserved hand-written lines — these must not start with - or ## (they would be re-parsed as change lines or set headers on the next load), and are written back verbatim after the set’s change lines. Only the .changes.md file is written; the list’s own .md is never touched, and the existing header is preserved. When git auto-commit is enabled, the change log is committed (Rewrite change history for <slug>).

Request Body:

{
"sets": [
{
"timestamp": "2026-05-29T12:00:00.000Z",
"lines": ["- Added \"Sol Ring\" (LEA:1) &1"]
}
]
}

Response:

{
"success": true,
"message": "Saved 1 change set.",
"setCount": 1
}
PUT /api/metadata/:type/:slug

Write a list’s YAML front matter. Decks take the deck vocabulary below; both decks and collections take labels (their default card labels). Wanted lists define no front-matter keys, so :type of wanted is a 400. Shares its engine with the metadata CLI command.

Only the fields present in the body are written; every other front-matter key (including user-authored ones) round-trips untouched. A field sent as null is deleted, as is a description sent as an empty string. The markdown body below the front matter is left byte for byte as it was — card lines are never re-serialized and no card IDs are assigned. No changelog entry is written: the change log is card-level, and metadata is not a card change.

Request Body (deck):

{
"description": "A ramp deck.",
"tags": ["ramp", "budget"],
"format": "commander",
"sourceId": "123456",
"sourceUrl": "https://archidekt.com/decks/123456",
"contentHash": "abc123..."
}

Request Body (collection):

{
"labels": ["sale", "trade"],
"contentHash": "abc123..."
}
FieldValidation
descriptionDeck only. String (trimmed) or null; an empty string clears it
tagsDeck only. Array of non-empty strings (trimmed, deduplicated, order preserved); null or [] clears the key
formatDeck only. A deck format name, canonicalized (EDHcommander); null clears it and the deck falls back to section inference
sourceIdDeck only. Non-empty string or null
sourceUrlDeck only. An http/https URL or null
labelsDeck and collection. Array of sale/trade (combinable) or keep/proxy (each alone), case-insensitive, normalized to canonical order — a deck accepts proxy alone; null or [] clears the default (removing an empty block)
contentHashOptional concurrency token from the list’s load endpoint; a non-string value is a 400

name is rejected with a 400 pointing at POST /api/deck/:slug/rename, which also renames the file and its sidecars. created and lastSynced are stamped by Ritual (deck creation and deck sync respectively) and are likewise rejected, as is any unknown field — a deck-only field on a collection (and vice versa) is an unknown field. A collection write refuses (400) when the file’s existing front matter cannot be read as a YAML mapping, since merging over keys it cannot see would clobber them.

Setting sourceId together with an archidekt.com sourceUrl is what makes a deck sync-linked, so these fields change which decks POST /api/deck-sync operates on.

The two must name the same Archidekt deck once merged over what the file already carries — a sync addresses the deck by sourceId while every surface shows sourceUrl, so a mismatched pair would push one deck’s cards into another. A request that produces one is a 400 (sourceUrl names Archidekt deck 999 but sourceId is 123. …) and writes nothing. A sourceUrl on another service is not constrained: its sourceId follows that service’s own scheme.

When contentHash is supplied and no longer matches the file, the response is 409 with "conflict": true — the same optimistic-concurrency contract the editor save endpoints use. Omit it for a plain read-modify-write. Because the write updates the file’s hash, an editor that had the deck open sees a conflict on its next save rather than silently clobbering the new metadata.

Response:

{
"success": true,
"slug": "my-deck",
"frontMatter": {
"name": "My Deck",
"format": "commander",
"created": "2026-01-01T00:00:00.000Z",
"description": "A ramp deck.",
"tags": ["ramp", "budget"]
},
"contentHash": "def456..."
}

An unknown deck is a 404.

When git auto-commit is enabled, the deck file and its .sha256 hash sidecar are committed with the message Update metadata for deck <slug>.

PUT /api/art/:type/:slug

Set or clear one card’s custom art on any list type. Like the metadata route this is a direct write: no change event, no changelog entry, and no contentHash round trip — card lines and the <list>.art.json sidecar are disjoint files, so the write is safe alongside an editor’s pending card edits.

Request Body:

{ "cardId": 5, "art": { "file": "proxies/sol-ring.jpg" } }
FieldValidation
cardIdRequired. A card line’s &N id (a positive integer) that the list actually holds
artRequired. { "file": "<art-dir-relative path>" } (ending in .avif/.gif/.jpeg/.jpg/.png/.webp), { "url": "<http(s) URL>" }, or null to clear the card’s art. Exactly one of file/url, and no other key, may be present

Any other field is a 400 naming it — a typo must not look like a write that changed nothing.

A cardId the list does not hold yet — a card an editing session has added but not saved — is a 400: write the card’s line first, then aim this route at the id the save’s effects report.

The reverse direction needs no route: the save endpoints and the move/remove routes re-file this sidecar themselves. A save or removal that drops a card drops its art (including a removal the same save re-adds the card after), a renumbered line takes its entry with it, and a cross-list move carries the entry to the destination list’s sidecar under the new line’s &N (a copy that merges onto a line the destination already had keeps that line’s own art). The sidecars land in the same auto-commit as the list files they describe. See Art follows the card.

Response:

{
"success": true,
"slug": "my-deck",
"cardId": 5,
"art": { "file": "proxies/sol-ring.jpg" },
"message": "Set custom art on 'my-deck'"
}

Refusals are 400 for a malformed body, a cardId the list does not hold, a reference that does not parse (a backslash, an absolute path, a .. escape, a file whose extension is not one the art route serves, a non-http(s) URL), a file with no image behind it in the configured art directory (the message names the exact path that was checked), or an existing .art.json that cannot be read — the route refuses rather than overwrite it, since that would erase art for cards the request never mentioned. An unknown list is a 404.

Clearing the last card’s art removes the sidecar rather than writing {}. When git auto-commit is enabled the sidecar is committed with the message Update custom art for <type> <slug> — but only when a file was actually written or removed.

The images themselves are served read-only, behind the same login, at GET /art/<relpath> from the configured artDir, which is what lets the editor preview a local file. Only .avif, .gif, .jpeg, .jpg, .png, and .webp are served — the same allowlist a file reference is validated against, so anything that parses is answerable; any path leaving the art directory is a 404.

GET /api/deck-sync

Returns every Archidekt-linked deck that can be synced — those whose front matter has both an Archidekt sourceUrl and a sourceId — plus the stored Archidekt login. Backs the Sync Decks page and the MCP get_sync_status tool.

Response:

{
"success": true,
"decks": [
{
"slug": "Winota Stax",
"name": "Winota Stax",
"sourceId": "12345",
"sourceUrl": "https://archidekt.com/decks/12345",
"lastSynced": "2026-07-20T12:00:00.000Z"
}
],
"archidekt": {
"loggedIn": true,
"username": "someuser",
"accessTokenExpiration": "2026-07-24T18:00:00.000Z",
"accessTokenValid": true,
"refreshTokenExpiration": "2026-08-20T12:00:00.000Z",
"refreshTokenValid": true,
"loginRequired": false
}
}

lastSynced is null for a deck that has never synced.

POST /api/deck-sync

Sync decks with Archidekt, using the same engine as the deck-sync CLI command. Exposed as the MCP sync_decks tool. Requires a stored Archidekt login; without one the response is 401 with loginRequired: true.

Request Body:

{
"direction": "pull",
"decks": ["Winota Stax"],
"dryRun": false
}
FieldDescriptionRequired
directionpull (Archidekt → local) or push (local → Archidekt). Any other value returns 400.Yes
decksDeck slugs or names, resolved like CLI list arguments. Omitted or empty syncs every linked deck.No
dryRunReport what would sync without writing files or pushing changes (default false).No
ignoreUnreadableLinesSync decks whose files hold content a rewrite cannot reproduce — unreadable lines or a fenced code block — deleting it (default false).No
onlyadditions or removals — apply just one side of each deck’s diff, relative to the sync destination (see Change Filter). Omitted applies every change; any other value returns 400.No
forcePush a deck whose remote copy changed since its recorded sync, overwriting those remote changes (default false). Must be a boolean or 400. A pull ignores it.No
syncPrintingsAlso sync each card’s exact printing — set, collector number, and foil/etched finish (default false). Must be a boolean or 400. See Printing Sync.No

A push refuses any deck whose Archidekt updatedAt is newer than the sourceUpdatedAt its last sync recorded — pushing would silently revert the remote edits. Such a deck is reported failed with Remote deck changed since last sync (…) — pull first, or pass --force to overwrite remote changes., and the rest of the run continues. A pull of that deck records the new baseline (even when it finds no card changes), after which the push succeeds; force: true overrides the guard outright. See Divergence Guard.

A sync rewrites each deck file, so a line the parser cannot read — or a fenced code block — would be deleted by the save. There is nobody to prompt over HTTP, so such decks fail (N unreadable lines would be dropped by a sync) unless the request sets ignoreUnreadableLines — the API equivalent of the CLI’s --yes. The affected decks and their exact lines are reported both in report.unreadable and, on the stream, as a progress frame with kind: "unreadable-lines" emitted before the decision is applied. A dryRun request is exempt: it writes nothing, so those decks are previewed rather than refused.

Response:

{
"success": true,
"message": "Pulled 2 decks, 1 skipped.",
"summary": {
"clauses": [
{
"message": "Pulled 2 decks",
"messageKey": "admin.api.deckSync.pulled",
"messageParams": { "count": 2 }
},
{
"message": "1 skipped",
"messageKey": "admin.api.deckSync.skipped",
"messageParams": { "count": 1 }
}
]
},
"report": {
"direction": "pull",
"decks": [
{ "name": "Winota Stax", "status": "synced" },
{ "name": "Oops All Soldiers", "status": "synced", "reason": "no changes" },
{
"name": "Borrowed Deck",
"status": "skipped",
"reason": "you do not own Archidekt deck 12345"
}
],
"failedCount": 0,
"unreadable": []
}
}

summary is required on a completed run: the same sentence as message, split into ordered keyed clauses so a client with a translator renders it in the reader’s locale — with that locale’s plural categories and list separators — instead of re-parsing the English. Each clause is a message triple; clauses carry no final punctuation, since the renderer supplies the terminator. message stays byte for byte what it always was.

success reports whether the run could be performed, not whether every deck synced — a run with per-deck failures still returns 200 with success: true and a non-zero report.failedCount, so callers can read each deck’s status and reason (plus, when the request set syncPrintings, its printingsChanged count — or, when it did not, the printingsUnaligned card names whose printings the two sides disagree about). report.unreadable lists any deck whose file holds lines the parser could not read ({ name, file, warnings }), so a caller that never sees the stream can still show what a retry with ignoreUnreadableLines would delete. When git auto-commit is enabled, deck files written by the run are committed (Sync decks with Archidekt (<direction>)).

GET /api/deck-sync/stream?direction=pull&deck=<slug>&deck=<slug>&only=additions&dryRun=true

The same sync as POST /api/deck-sync, streamed as server-sent events. EventSource can only issue a bodyless GET, so the request arrives as query parameters: direction is required, deck repeats once per deck (omit entirely to sync all), only takes additions or removals (omit it to apply every change), and dryRun / ignoreUnreadableLines / force / syncPrintings take true or false (any other value is rejected, so a flag that decides whether files are written can never be misread as “no”).

Three event types are emitted:

EventPayload
progressOne step of the run: { kind: "deck-start", deck, index, total }, { kind: "log", level, deck, message } (deck is null for run-level lines), { kind: "deck-result", result }, or { kind: "unreadable-lines", decks: [{ name, file, warnings }] }.
done{ message, messageKey?, messageParams?, summary, report } — the same message triple, keyed summary, and report the JSON endpoint returns.
error{ message, loginRequired } for a run that produced no report (bad parameters, no Archidekt login, or an unexpected failure).

Failures are reported inside the stream rather than as an HTTP status, since EventSource exposes no response body for a non-2xx open.

GET /api/collection-sync

Returns every collection list a run can be scoped to, the stored Archidekt login, when the account last synced, and the list a pull adds new cards to.

Response:

{
"success": true,
"lists": [{ "slug": "binder", "name": "Blue Binder" }],
"archidekt": {
"loggedIn": true,
"username": "someuser",
"accessTokenExpiration": "2026-07-24T18:00:00.000Z",
"accessTokenValid": true,
"refreshTokenExpiration": "2026-08-20T12:00:00.000Z",
"refreshTokenValid": true,
"loginRequired": false
},
"lastSynced": "2026-07-26T12:00:00.000Z",
"pullTarget": "Inbox",
"csvThreshold": 25
}

lastSynced is account-level rather than per-list — an Archidekt account has one collection while ritual has many collection lists — and is null until a run applies something for real. A dry run records nothing, and neither does a run that stopped without writing (an ambiguous removal nothing could place — see Sync Collection below), so the stamp always means “the lists and the account agreed at this time”. pullTarget is the collectionSync.pullTarget config key, the list a pull adds new cards to unless the request names another. csvThreshold is how many new printings a push adds one at a time before the CSV import path takes over — reported so a caller can explain (or decide) the csv field without restating the number.

POST /api/collection-sync

Sync the account’s Archidekt collection with the local collection lists, using the same engine as the collection-sync CLI command. Requires a stored Archidekt login; without one the response is 401 with loginRequired: true. A login that predates recording which account it belongs to is refused the same way — a collection is fetched by numeric user id, so signing in again is the fix.

Request Body:

{
"direction": "pull",
"lists": ["Blue Binder"],
"into": "Inbox",
"removalPriority": ["Long Box", "Blue Binder"],
"dryRun": false
}
FieldDescriptionRequired
directionpull (Archidekt → local) or push (local → Archidekt). Any other value returns 400.Yes
listsCollection list slugs or names, resolved like CLI list arguments. Omitted or empty compares the whole collection; the remote side is always the entire Archidekt collection.No
intoThe list a pull adds new cards to, created if it does not exist. A name two lists answer to fails the run before anything is fetched or written. Omitted uses the collectionSync.pullTarget config key. A push ignores it.No
onlyadditions or removals — apply just one side of the diff, relative to the sync destination. Omitted applies every change; any other value returns 400.No
removalPriorityCollection list names in priority order — the only lists an ambiguous removal may take copies from (see below). Must be an array of non-blank names or 400. A push ignores it.No
csvUpload a push’s new cards as one CSV import instead of adding them one at a time (see below). Must be a boolean or 400. A pull ignores it.No
dryRunReport what would sync without writing files or touching Archidekt (default false).No
ignoreUnreadableLinesSync lists whose files contain lines the parser cannot read, dropping those lines (default false).No

A csvFile field is rejected with 400: writing a CSV to a path the caller names is a CLI affordance (--csv-file), not something this API does. Refused rather than ignored, so a caller mirroring the CLI’s flags is told rather than watching its additions be uploaded instead.

Creating a record for a printing Archidekt does not have costs a search plus a create, both paced, so a first push of a real collection would take hundreds of requests. csv: true sends those additions through Archidekt’s own collection importer instead — one upload, with every row built from the local Scryfall cache — exactly as the CLI’s --csv does, however few there are.

There is nobody to prompt over HTTP, so a push adding more new printings than csvThreshold without csv: true fails before writing anything to Archidekt: the guidance lands in report.errors, report.csv stays null, and no record is created, grown, or deleted. A dryRun never needs the flag — over the threshold it reports the upload it would make and resolves nothing, which is what keeps a first preview from being rate limited. Quantity changes and removals never ride the CSV (removals use Archidekt’s bulk-delete endpoint), and additions whose printing the local cache does not hold cannot become rows: they are added one at a time and counted in report.csv.uncached.

The rows are keyed by the Scryfall ids the local card cache holds, and there is nobody here to ask about it either, so a run taking this path treats cache freshness as --refresh auto: an empty or day-old cache is redownloaded before the file is built, reported as a log event on the stream and in the run’s messages.

A pull removal is ambiguous when only some of a printing’s copies are going and those copies live in several lists — nothing says which list the card physically left. (Taking every copy, or copies held in a single list, never is.) There is nobody to prompt over HTTP, so removalPriority is the caller’s decision made up front: copies come only from the lists it names, walking them in the order given. Names are matched exactly, never by the substring rule other list lookups use, and an unknown name fails the run. Without a priority — or with one that cannot cover a removal — the run fails and writes nothing at all: the reason lands in report.errors, report.ambiguous carries each removal with its per-list copy counts, no list file is touched, and the account’s lastSynced is left alone. A dryRun request never fails on an ambiguity itself; it reports it instead. (An unknown removalPriority name still fails a dryRun request — that is a bad argument rather than an unresolved removal.)

A pull rewrites each list file and a push treats those files as the truth, so a line the parser cannot read would be lost either way. There is nobody to prompt over HTTP, so such lists fail unless the request sets ignoreUnreadableLines — the API equivalent of the CLI’s --yes. The affected lists and their exact lines are reported both in report.unreadable and, on the stream, as a progress frame with kind: "unreadable-lines". A dryRun request is exempt: it writes nothing, so those lists are previewed rather than refused.

Response:

{
"success": true,
"message": "Pulled +1 added, -0 removed into \"Inbox\".",
"summary": {
"clauses": [
{
"message": "Pulled +1 added, -0 removed into \"Inbox\"",
"messageKey": "admin.api.collectionSync.pulled",
"messageParams": { "added": 1, "removed": 0, "into": "Inbox" }
}
]
},
"report": {
"direction": "pull",
"into": "Inbox",
"dryRun": false,
"lists": [{ "name": "binder", "status": "synced", "added": 1, "removed": 0, "pending": 0 }],
"failedCount": 0,
"errors": [],
"unreadable": [],
"ambiguous": [],
"localIncomplete": false,
"csv": null,
"totals": { "added": 1, "removed": 0, "skipped": 0, "pending": 0 }
}
}

summary is required, and works exactly as it does for Sync Decks: the same sentence as message split into keyed clauses for a client that renders it in the reader’s locale.

success reports whether the run could be performed, not whether every list synced — a run with per-list failures still returns 200 with success: true and a non-zero report.failedCount. report.errors carries failures that belong to the run rather than to one list (the collection fetch, or deleting records for cards no list holds any more), and report.ambiguous every removal a pull could not place on its own — reported whether a removalPriority placed them or the run failed on them. Counts are in copies, not lists, since one card can live in several.

report.localIncomplete is true when a list in scope did not make it into the comparison — an unresolvable name, a file that could not be read, or one held back for unreadable lines. The local side is then short of cards it really holds, so the run withholds exactly the changes that shortfall would manufacture: a pull adds nothing (those cards would be duplicated into the target list) and a push removes nothing (they would be deleted from Archidekt). Fix or accept the listed lists and run again. When git auto-commit is enabled, list files written by the run are committed (Sync collection with Archidekt (<direction>)).

report.csv describes what the CSV import did with a push’s new cards, and is null on any run that did not take that path (every pull, a push that added nothing new, and one refused for lacking csv: true). Every shape carries cards (copies), rows (one per printing), and uncached (additions the cache could not resolve, added one at a time instead), plus:

statusExtra fieldsMeaning
uploadedchunks, failures[], unconfirmedChunksImported; failures names the rows Archidekt refused
planneddestination (upload)What a dryRun would have done — nothing was sent
failedmessageThe whole import failed; the rest of the run still applied
emptyNo row could be keyed at all: uncached covers every new card

unconfirmedChunks counts chunk responses Ritual could not read: their rows are counted as imported because nothing said otherwise, so a non-zero value means part of that outcome is assumed rather than confirmed (the run log carries what Archidekt replied).

Each entry of failures is { row, card, ambiguous, notFound, errors } — the 0-based row of the uploaded CSV, the card it carried, and why it was dropped. The lists holding those cards are reported as failed. (exported — the CLI’s --csv-file outcome — cannot occur here, since the request parser refuses csvFile. report.totals.pending therefore stays 0 on this surface.)

GET /api/collection-sync/stream?direction=pull&list=<slug>&into=Inbox&only=additions&removalPriority=<slug>&csv=true&dryRun=true

The same sync as POST /api/collection-sync, streamed as server-sent events. EventSource can only issue a bodyless GET, so the request arrives as query parameters: direction is required, list repeats once per list (omit entirely to sync the whole collection), removalPriority repeats once per list in priority order (the order of the parameters is the priority; a blank one is rejected), only and into are omitted (or empty) to accept their defaults, and csv / dryRun / ignoreUnreadableLines take true or false (any other value is rejected, so a flag that decides whether files are written — or how a large batch of cards reaches Archidekt — can never be misread as “no”). A csvFile parameter is rejected here too.

Three event types are emitted:

EventPayload
progressOne step of the run: { kind: "list-start", list, index, total }, { kind: "log", level, list, message } (list is null for run-level lines), { kind: "list-result", result }, or { kind: "unreadable-lines", lists }.
done{ message, messageKey?, messageParams?, summary, report } — the same message triple, keyed summary, and report the JSON endpoint returns.
error{ message, loginRequired } for a run that produced no report (bad parameters, no Archidekt login, or an unexpected failure).

Failures are reported inside the stream rather than as an HTTP status, since EventSource exposes no response body for a non-2xx open.

POST /api/import-csv

Import cards from CSV text into a deck, collection, or wanted list. Used by the admin site’s Import CSV page and exposed as the MCP import_csv tool; shares its parsing, normalization, and column-mapping engine with the import CLI command’s CSV mode.

Request Body:

{
"listType": "collection",
"name": "Red Binder",
"mode": "append",
"content": "Name,Set,Collector Number,Quantity\nSol Ring,C19,221,2",
"columns": "name=1,set=2,collector-number=3,quantity=4",
"hasHeader": true
}
FieldDescriptionRequired
listTypedeck, collection, or wantedYes
nameNew list name (create/overwrite) or an existing list to append to (append)Yes
modecreate (default — fails if the list exists), overwrite, or appendNo
formatDeck format; required when creating or overwriting a deckNo
contentRaw CSV textYes
columns1-based column mapping spec, e.g. name=1,set=2,collector-number=3Yes
hasHeaderWhether the first row is a header row (default true)No

format, when given, must be one of the canonical deck format keys — see Deck Format. An unrecognized value returns 400.

Response:

{
"success": true,
"message": "Appended 2 card(s) to collection 'Red Binder'",
"cardCount": 2,
"failures": [
{
"lineNumber": 3,
"raw": "No Printing,,",
"reason": "Missing set code (required for collections)"
}
],
"failedCount": 1,
"warnings": ["Skipped header row: Name,Set,Collector Number,Quantity"]
}

In a deck, rows naming the same card and the same printing merge into one line with their quantities summed, whether the list is created, overwritten, or appended to — the same file always produces the same list. Collections and wanted lists keep one line per physical copy in every mode, so N rows of the same printing stay N lines.

warnings reports what hasHeader caused, since there is no wizard here to ask: the row that was skipped as a header, plus a second entry when that row does not look like one (… — set hasHeader to false to import it as a card.). It is empty when hasHeader is false.

cardCount, failures, failedCount, and warnings are always present. Rows that fail validation are returned in failures while the valid rows still import, and success is a pure envelope flag: a request where every row failed is still a 200 carrying cardCount: 0 and the per-row report, since the report is the whole point of the call. The response is 400 only when the request is invalid (bad body shape, unknown listType/format, an unparseable column spec, a mapped column number the file has no column for (Column 99 (mapped to 'name') does not exist: the file has 6 column(s)), an unparseable CSV, no data rows, or an append to a list that does not exist). Appends record each added card in the list’s changelog. When git auto-commit is enabled, the list file (and changelog) are committed.

POST /api/import-changes

Apply a change bundle exported from the site editor to the underlying lists. Used by the admin site’s Import Changes page and exposed as the MCP import_change_bundle tool; shares its apply engine with the import-changes CLI command.

Request Body: the exported JSON, verbatim — a ritual-change-bundle covering one or more lists:

{
"format": "ritual-change-bundle",
"version": 1,
"exportedAt": "2026-06-04T00:00:00.000Z",
"lists": [
{
"kind": "deck",
"slug": "Winota Stax",
"name": "Winota Stax",
"changes": [{ "id": "a1", "timestamp": 1, "action": "add", "cardName": "Counterspell" }]
}
]
}

Response:

{
"success": true,
"message": "Applied 1 change across 1 list",
"failedCount": 0,
"lists": [
{
"kind": "deck",
"slug": "Winota Stax",
"name": "Winota Stax",
"applied": 1,
"conflicts": []
}
]
}

Each list is loaded fresh and its changes re-targeted to the current card IDs (by ID when it still exists, otherwise by card name). Changes whose target card no longer exists — or whose action cannot apply to that list, such as a commander change aimed at a collection — are skipped and reported in that list’s conflicts ({ change, reason }, where reason is "target-not-found" or "not-applicable"). A list that fails to load or save carries an error string (and applied: 0) without stopping the remaining lists, and is counted in failedCount. success stays true on a partial import — the request was processed, and the per-list report is what says which lists failed. Read failedCount (and each list’s error), not the envelope. move-from changes also write their destination lists, and every applied list gets a changelog entry — the same save path as the editors. The response is 400 when the body is not a valid change bundle.

POST /api/export

Render a CSV, JSON, plain-text, or Markdown export of cards from decks, collections, and wanted lists. Exposed as the MCP export_cards tool; shares its engine with the export CLI command. By default the rendered export is returned inline as a string; with write: true it is written to a server-named file instead.

Request Body: every field is optional. With no lists and no cards, every list is exported.

{
"lists": [{ "type": "deck", "name": "Winota Stax" }],
"cards": ["sol ring"],
"filters": {
"name": "sol",
"set": "c21",
"finish": "foil",
"conditions": ["NM", "none"],
"labels": ["trade"]
},
"format": "csv",
"columns": ["name", "set", "collectorNumber", "quantity"],
"header": true,
"quoteAll": false,
"dialect": "ritual",
"preset": "trade-sheet",
"write": false
}

lists names resolve like CLI list arguments (the optional type pins an ambiguous name). Each cards entry is whitespace-separated name terms; every entry across all lists whose name matches all terms is added (deduplicated against the selected lists). filters.conditions takes condition grades and/or none (cards with no condition marked), matching the CLI’s --condition semantics; filters.labels takes label values (sale, trade, keep, proxy) and/or none (unlabeled), matched against each deck and collection card’s effective labels like the CLI’s --labels (wanted entries carry no labels and never match). preset starts from a saved or built-in export preset — the built-in archidekt preset needs no config, and a saved preset of that name shadows it; the explicit fields override the preset’s values.

format is one of csv (default), json, text (one flat merged decklist, quantities aggregated), or md (canonical list markdown without &N ids) — see export formats. columns, header, quoteAll, and dialect shape csv/json output only and are ignored for text/md (unlike the CLI, the route does not reject the combination). dialect is the value vocabulary for finish and condition: ritual (the default — nonfoil/foil/etched, NMDMG) or archidekt (Normal/Foil/Etched under a Variant header, and NM|LP|MP|HP|D) — see dialects. An unknown dialect or preset is a 400. A selected scryfallId column is resolved from the local Scryfall cache.

Response: the body is discriminated by mode.

Content mode (the default, or write: false):

{
"success": true,
"mode": "content",
"format": "csv",
"entryCount": 2,
"warnings": [],
"content": "Name,Set,Collector Number,Quantity\nSol Ring,C21,263,1\n..."
}

File mode (write: true):

{
"success": true,
"mode": "file",
"format": "csv",
"entryCount": 2,
"warnings": [],
"path": "exports/Binder-20260728.csv",
"bytes": 214
}

write must be a boolean; anything else is a 400.

The file lands under an exports/ directory in the base dir, which init-site adds to .gitignore. The server picks the name — <scope>-<YYYYMMDD>.<ext>, where scope is the single selected list’s sanitized name, cards for a card-pick-only export, or all-lists otherwise, and the date is UTC. A name already taken gains the lowest free -2, -3, … suffix, so a write never overwrites an earlier export. path is base-dir-relative by design: a relative path cannot be walked outside the workspace by a caller that trusts it. The written file is newline-terminated, byte-identical to what the CLI’s export --out writes.

warnings carries list parse warnings, cards terms that matched nothing, and — when the scryfallId column is selected — one entry per printing the local Scryfall cache does not hold (that cell renders empty). The response is 400 for an unknown list, preset, column, dialect, or filter value.