The React frontend's contract. TypeScript/Express, about 170 routes on the base
path (a count of the route registrations under apps/api/src on 2026-10-12),
plus the bare-origin OAuth and sitemap handlers. This file documents the share
the frontend uses; see Scope.
Scope. This documents the surface the web frontend's data layer consumes.
Registered on the same app but documented elsewhere, not repeated here: OAuth
2.1 + personal-access-token management (/oauth, /tokens) in
apps/mcp/SPEC.md; Deck-E's history and permission/account routes (/decke,
/me) — and his chat function (api/chat.mjs) — in DECKE-AGENT-SPEC.md;
the profile-avatar routes (/avatar) in DECISIONS.md 2026-08-10. Short
entries for /avatar, GET /decke/history and /dev/scan-flags are below
too, so their request and response shapes are findable here. GET /me
itself stays documented in DECKE-AGENT-SPEC.md; its /me/settings and
/me/showcase and /me/cards sub-routes are frontend surface and documented here (§Account).
Deployment modes:
| Mode | Base path | Port | Auth boundary |
|---|---|---|---|
| Self-host | /deckpal/api |
3700 (127.0.0.1) | reverse proxy with auth |
| Cloud (Vercel) | /api |
serverless | Supabase JWT (Bearer token) |
The base path is set by API_BASE_PATH (defaults to /deckpal/api). On Vercel
it is set to /api, and the Express app is mounted as a catch-all serverless
function at api/index.mjs. A self-host behind nginx at /deckpal/ leaves
API_BASE_PATH unset, because the web app it serves calls /deckpal/api; set
to /api there, the API answers on a path the web app never calls.
Pure-catalog reads (/series, /sets, /cards, /search, /dex, the PDF
exports), the collection write endpoints (§Collection) that mutate
collection_item + collection_event and recompute the affected set's
user_set_progress rows in one transaction, list/deck CRUD, the deck engine
(validate / import / export / test-hand / pricing / versions / battle logs),
the insights/gamification router, a perceptual-hash card scanner, and an
in-app bug reporter. All queries parameterized; pool sizing is role- and
backend-aware — see AGENTS.md B2 (a direct-Postgres self-host keeps the
historical cap of 3).
Base path: /deckpal/api (self-host) or /api (Vercel). Examples below
omit the host.
-
Identity. Every request resolves to one
user_id, and every per-user read and write below is scoped to it — "the requesting user". Cloud mode: the Supabase JWT (or personal access token) names the user (§Authentication). Self-host mode: no built-in auth — the reverse proxy is the auth boundary, and every request maps to the single local user. -
Money. Prices are per
(variant, source, currency). Amounts are returned in major units (e.g.800.43) with acurrencycode. A missing price isnull/ an emptypricesarray — never0. Every price carriespricedAt(the source's observation time, not fetch time). Sources:tcgcsv(TCGplayer bulk, USD),tcgdex-cardmarket(Cardmarket, EUR),tcgdex-tcgplayer. -
Images. The API returns image paths, never bytes. Card images resolve to
/deckpal/images/en/{serie}/{set}/{localId}/{low|high}.webp. Self-host serves that path fromapps/imageson 3701 (behind nginx at the same base); on cloud it is a serverless function that fills the object store on demand and302s to the public object URL. Since 2026-08-26 the SPA does not request that path first on cloud — it derives the public object URL itself and requests the object directly, keeping/deckpal/images/…as the fallback that fills a cold asset (DECISIONS.md 2026-08-26). The path in the payload is unchanged and remains the contract; what changed is only which URL the browser ends up fetching. A card the image tier cannot source answers a ~1 KB placeholder WebP with200andX-Placeholder: 1— a valid image, not an error, so a client cannot detect it viaonError. Set chrome (logoUrl,symbolUrl,backgroundUrl) are stored upstream URLs and may benull. -
PDF routes.
GET /decks/:id/pdf,GET /lists/:id/pdf, andGET /sets/:setId/checklist.pdfstreamapplication/pdfwith aContent-Disposition: attachmentfilename. They are mounted at the/deckpal/apibase before the/decks,/lists,/setsrouters so the…/pdfandchecklist.pdfpaths resolve to the PDF renderer (those routers define no such routes — explicit ordering removes the ambiguity). Rendered with pdfkit (pure-JS/ARM-safe);private, no-store. Letter is the default; append?paper=a4to any of the three routes for A4. -
Progress. Read from
user_set_progressfor the requesting user. Complete is a card fraction; Master and Grandmaster are(card,variant)pair fractions — the three totals differ (e.g. sv03.5: 207 / 373 / 384).pctis one-decimal, round-half-up.setLevel(0–5) is on the Complete goal only. No route here takes anumberedgoal: "the regular numbered set" (cards 1 to the printed totalcard_count_official, no secret rares; unlikeprintedCountit never falls back to the full card count) is computed live by theset_progressagent tool only — seeapps/mcp/SPEC.md§5 andresearch/SCHEMA.md§9.2. -
View state in the URL. Filter/sort/goal/ownership/pagination are query params by design; omit a param to accept its default.
-
Identifiers.
:setIdand:cardIdare TCGdex ids (sv03.5,base1,base1-4).:seriesSlugis the kebab slug (scarlet-violet).:speciesIdaccepts the numeric dex id or the slug (6orcharizard).:idon/lists,/decks, and the PDF routes is a UUID.:variantIdis the numericcard_variant.id.:v(deck version) and:logId/:itemIdare positive integers. -
Attribution. Every collection/deck/list write accepts an optional
source(writer handle, shape^[a-z0-9][a-z0-9._-]{0,39}$, defaultweb) and most accept a free-textnote/versionNote(trimmed, length-capped). The one exception isPOST /decks/import, wheresourcewas already the decklist syntax, so attribution rides aswriteSource. -
Idempotency-Key. The deck, list, battle-log and revert writes accept an optional
Idempotency-Keyheader (≤200 characters, else400). The write runs at most once per key for the requesting user, and a repeat answers with the first run's result plus"replayed": true. Without the header there is no replay and noreplayedfield. Deck-E and the agent tools send one; routes and rules: "Idempotency-Key— at most once per approved write", below. -
Errors.
{ "error": { "code", "message" } }with status 400 (bad request), 404 (not found), 500/503 (server/DB). Success bodies are documented per route. -
Rate limiting. A pre-auth ingress guard applies to all requests on the ordinary base-path API router (
/apion Vercel,/deckpal/apiself-host) — including unauthenticated catalog reads — at 600 requests/min per source IP per process, before token resolution and before the RLS pool is acquired. On Vercel the key is the validatedx-vercel-forwarded-for(orx-forwarded-for); off Vercel it is the raw socket peer (trust proxystaysfalse). The Stripe raw-body webhook and bare-origin OAuth discovery,/registerand/tokenhandlers sit outside this router;/mcpis a separate function. Session budgets run after auth and identity but before RLS request-connection acquisition:/tokens20/min,/avatar10/min,/oauth30/min,/bugs10/hour, all/admin120/min and all/me/credits180/min. The first three and the admin/credits routes require a browser session;/bugsuses its signed-in account identity. A request is charged once per applicable budget. Refusal is429with aRetry-Afterheader. Budgets are in-memory fixed windows per instance, reset on cold start, and are speed bumps rather than a distributed quota.Bare-origin OAuth discovery,
/registerand/tokenhave their own limits. Per source IP, checked before any body is read:/token6,000/min (a ceiling),/register60/min, and 1,200/min shared by the two discovery documents./tokenalso budgets each verified grant: 30/min per connection, and 120/min per IP for secrets that match nothing. Their refusals are429+Retry-Afterwith RFC 6749's{"error":"temporarily_unavailable","error_description":"…"}body, not the error shape above. The MCP transport has a global 300/min-per-instance admission counter before token resolution and a 60/min-per-token budget after. The Stripe webhook relies on signature verification and its retry behavior instead of an application limiter. SeeSECURITY.md→ Rate limiting. -
Connector tokens. A personal access token or OAuth connection (
Bearer dsk_…, contract inapps/mcp/SPEC.md§3b) reaches the same routes a session does, except the session-only ones:/tokens,/avatar,/oauth,/admin,/me/billing,/me/credits,/me/features,/me/decke-sharingand, since 2026-09-26,/decke,/me/showcaseand/me/settings(403 forbidden). A read-only connection (migration 075) or read-only personal access token (085;POST /tokenstakesscope: "full" | "read", contract inapps/mcp/SPEC.md§3b) is refused every method butGET,HEADandOPTIONSon every route with403,{ "error": { "code": "insufficient_scope" } }andWWW-Authenticate: Bearer error="insufficient_scope", exceptPOST /massentry(it builds cart links),POST /decks/check,POST /decks/oddsandPOST /decks/simulate, which write nothing (READ_ONLY_POSTSinapps/api/src/auth.ts). -
Body-size limits. Per route, most-specific first, immediately after the ingress guard and ahead of authentication:
/bugs12mb (the screenshot),/client-errors32kb (the crash beacon),/dev/scan-queueand/dev/scan-flags4200kb (photos whose maximum base64 form is exactly 4mb before the JSON wrapper),/decke2mb,/lists2mb,/decks512kb, and 100kb for every other route./registerand/tokenkeep their own 16kb parsers. Limits measure bytes on the wire, not characters; the text-heavy routes leave room for ASCII-escaped JSON as well as raw UTF-8. Oversized bodies receive413 payload_too_large. SeeSECURITY.md→ Body-size limits. -
Caching.
/series,/series/:seriesSlug,/sets/:setIdand/cards/:cardIdembed ownership for a signed-in caller, so the same URL answers differently by identity. Anonymous requests getCache-Control: public, max-age=…, stale-while-revalidate=600andVary: Authorization; signed-in requests stay private./searchhas no personalization branch, so its responses are unconditionally public and do not needVary: Authorization.
In cloud mode (SUPABASE_URL is set), the API verifies Supabase user JWTs:
- All requests pass through
authMiddleware, which verifies theAuthorization: Bearer <token>header using Supabase's public JWKS for ES256, orSUPABASE_JWT_SECRETfor legacy HS256, and attaches the user's UUID. - Public routes (no auth required):
GET /health,GET /public-config,GET /,GET /search,POST /client-errors, and the browsable catalog —/seriesand/series/:seriesSlug,/sets/:setId,/cards/:cardId(with its/legalityand/prices),/dexand/dex/:speciesId, and/insights/pokedexand/insights/pokedex/:speciesId. The catalog routes still embed the caller's ownership when a valid credential is sent, and omit every ownership field when none is (resolveOptionalIdentity,apps/api/src/index.ts). Outside this router, the Stripe webhook is public and authenticated by its signature, and the bare-origin OAuth discovery,/registerand/tokenhandlers are public by design. - Protected routes (require a valid JWT or connector token): everything
else — collection reads and writes, lists, decks, the rest of
/insights, cart links (/sets/:setId/massentryincluded), PDF exports (the set checklist included), scan, bugs,/me,/decke,/admin. Returns401 { "error": { "code": "unauthorized" } }without a valid token. - The user UUID comes from the JWT
subclaim. All SQL queries use this UUID asuser_id.
In self-host mode (neither SUPABASE_URL nor SUPABASE_JWT_SECRET), the auth middleware is a no-op.
The reverse proxy is the auth boundary. All requests pass through.
A tiny index so hitting the base is not a 404. Returns the service name and a flat
list of the registered endpoint paths (method-prefixed where the method is not
GET). public cached for 1 h.
{ "name": "deckpal-api",
"endpoints": [ "/health", "/series", "/series/:seriesSlug", "/sets/:setId",
"PATCH /collection/variants/:variantId", … ] }DB liveness + latest run per sync job. Never cached.
{ "status": "ok", "db": "up",
"syncs": [ { "job": "prices-tcgcsv", "status": "ok",
"finishedAt": "2026-07-24T23:01:18.752Z",
"sourceStamp": "2026-07-24T20:11:00+0000" } ] }503 { "status": "degraded", "db": "down", "error": … } if the DB is unreachable.
Added 2026-10-14. Public (no auth) and cacheable (Cache-Control: public, max-age=300). The agent playbooks: DeckPal's
step-by-step guides for an AI app, the same texts the playbook MCP tool returns
(packages/agent-tools/src/playbooks/texts.ts, served by apps/api/src/playbooks.ts). The /skill page renders them.
?format=markdown returns the same texts as one text/markdown document (the index, then each playbook, separated
by ---): the downloadable pointer skill's fallback for a client without the playbook tool, readable by a plain
fetch (the page needs JavaScript).
200 { "index": "# DeckPal coach: rules for every job\n…",
"playbooks": [ { "name": "battle-log", "text": "# Log a PTCG Live game…" }, "…" ] }Public, Cache-Control: no-store. The values a browser needs before it can
talk to Supabase, plus the deployment mode — only values already public in the
SPA bundle. pnpm dev reads it at startup to point a fresh clone at the live
backend (AGENTS.md B12).
{ "supabaseUrl": "https://<project>.supabase.co", "supabaseAnonKey": "sb_publishable_…",
"mode": "cloud", "bugReportsPublic": true,
"defaults": { "skin": "premium", "topbar": "cover" } }Self-host answers empty strings for both Supabase fields and "mode": "self-host". bugReportsPublic says whether a bug report becomes a public
GitHub issue; defaults are the app-wide appearance defaults an administrator
set (/admin/settings), falling back to the values shown.
The English-catalogue series list (newest-era ordering via sortOrder), each with
the requesting user's per-series completion rollup (owned/total cards summed across
the series' sets for the Complete goal, from the materialised user_set_progress).
Pokémon TCG Pocket (tcgp) is a separate game and is excluded. private cached.
firstReleaseOn is a calendar date in YYYY-MM-DD format, or null when
unknown. The series list and detail routes project their SQL DATE fields to
text before driver decoding and JSON serialization; these values carry no
time zone or time of day.
{ "series": [ { "slug": "scarlet-violet", "tcgdexId": "sv", "name": "Scarlet & Violet",
"firstReleaseOn": "2023-03-31", "sortOrder": 18,
"setCount": 24, "cardCount": 5123,
"repSetId": "sv01", "repHasSymbol": true,
"progress": { "owned": 0, "total": 5123, "pct": 0 } } ] }repSetId/repHasSymbol describe the series' namesake/flagship set (the set
sharing the series name, else the earliest non-promo set with a logo).
Sets in a series, each with the three-goal completion summary for the requesting user.
Zero-card catalogue artifacts (e.g. base/wp, miscellaneous/jumbo) are hidden.
The series' firstReleaseOn and each set's releasedOn use the same nullable
YYYY-MM-DD calendar-date contract as the series list. Real and upcoming sets
sort by release date descending, unknown dates last, then name for equal dates.
This normalization is scoped to these two series endpoints; timestamp fields
and the date representation of other catalog endpoints are unchanged.
{ "series": { "slug": "base", "tcgdexId": "base", "name": "Base", "firstReleaseOn": … },
"sets": [ { "setId": "base1", "slug": "base-set", "name": "Base Set",
"releasedOn": "1999-01-09", "isPromo": false,
"printedCount": 102, "secretCount": 0, "cardCountTotal": 102,
"logoUrl": "…", "symbolUrl": "…",
"progress": {
"complete": { "owned": 0, "total": 102, "pct": 0, "setLevel": 0 },
"master": { "owned": 0, "total": 102, "pct": 0 },
"grandmaster": { "owned": 0, "total": 409, "pct": 0 } } } ] }Set detail: header, the three goals, and the paginated card list.
Query params
| param | values | default |
|---|---|---|
goal |
complete | master | grandmaster |
complete |
own |
all | have | need | dupes |
all |
sort |
number | name | price | rarity | artist |
number |
dir |
asc | desc |
asc |
variant |
variant-kind code, repeatable (?variant=holo-unlimited&variant=…) |
none (all) |
q |
free text (card name or number, accent-insensitive) | — |
page, pageSize |
int ≥1, 1–250 | 1, 60 |
The active goal + variant filter narrow which (card,variant) pairs the
own/have/need/dupes predicate is judged over. price sort uses the primary
variant's TCGplayer/tcgcsv USD market (nulls sink last regardless of dir).
{ "set": { "setId": "sv03.5", "slug": "151", "name": "151",
"series": { "slug": "scarlet-violet", "name": "Scarlet & Violet", "tcgdexId": "sv" },
"releasedOn": "2023-09-21T…", "isPromo": false,
"printedCount": 165, "secretCount": 42, "cardCountTotal": 207,
"images": { "logoUrl": …, "symbolUrl": …, "backgroundUrl": null },
"marketValueUsd": 1985.09,
"mostExpensiveCard": { "cardId": "sv03.5-199", "name": "Charizard ex",
"number": "199", "marketUsd": 399.43 } },
"progress": { "complete": { "owned":0,"total":207,"pct":0,"totalQuantity":0,"setLevel":0 },
"master": { "owned":0,"total":373,"pct":0,"totalQuantity":0 },
"grandmaster": { "owned":0,"total":384,"pct":0,"totalQuantity":0 } },
"query": { "goal":"complete","own":"all","sort":"number","dir":"asc","variant":[],"q":null,"page":1,"pageSize":60 },
"pagination": { "page":1, "pageSize":60, "total":207, "pageCount":4 },
"cards": [ { "cardId":"sv03.5-1","number":"001","numberSort":"000000001","name":"Bulbasaur",
"category":"Pokemon","rarity":"…","artist":"…","variantCount":2,
"images": { "low":"/deckpal/images/en/sv/sv03.5/001/low.webp","high":"…/high.webp" },
"price": { "market": 0.42, "currency":"USD" },
"ownership": { "totalQuantity":0,"requiredCount":2,"ownedRequired":0,
"have":false,"need":true,"dupe":false } } ] }price is null when the representative variant has no price.
Card detail: attributes, types/subtypes/tags, attacks/abilities/weaknesses/ resistances, dex species links, image refs, and every variant with composed display name, resolved tier, TCGplayer buy URL, and per-variant prices across all sources/currencies.
{ "card": { "cardId":"base1-4","number":"4","numberSort":"000000004","printedTotal":102,"name":"Charizard",
"category":"Pokemon","rarity":"Rare","artist":"Mitsuhiro Arita","hp":120,
"stage":"Stage2","suffix":null,"evolvesFrom":"Charmeleon","retreat":3,"regulationMark":null,
"effect":null,"releasedOn":"1999-01-09T…",
"legal": { "standard":false,"expanded":false },
"flags": { "aceSpec":false,"radiant":false,"prismStar":false,"ruleBox":false },
"set": { "setId":"base1","name":"Base Set","slug":"base-set","logoUrl":…,"symbolUrl":… },
"series": { "slug":"base","name":"Base","tcgdexId":"base" },
"images": { "low":"/deckpal/images/en/base/base1/4/low.webp","high":"…" },
"types":["Fire"], "subtypes":[], "tags":[],
"attacks":[ { "name":"Fire Spin","cost":"…","damage":"100","effect":"…" } ],
"abilities":[], "weaknesses":[ { "type":"Water","value":"×2" } ], "resistances":[],
"species":[ { "speciesId":6,"slug":"charizard","name":"Charizard","generation":1 } ] },
"variants": [
{ "variantId":15,"kind":"holo-unlimited","displayName":"Holofoil","provenance":"Found in Booster Packs",
"tier":"standard","tierSource":"derived","isPrimary":true,"isSynthesized":false,
"source":"tcgdex","fillConfidence":null,"quantity":0,
"buyUrl":"https://www.tcgplayer.com/product/…?Printing=…&Condition=Near+Mint",
"prices":[ { "source":"tcgcsv","sourceLabel":"TCGplayer (via TCGCSV bulk)","marketplace":"tcgplayer",
"currency":"USD","market":800.43,"low":510,"mid":null,"high":null,"directLow":null,
"trend":null,"avg1":null,"avg7":null,"avg30":null,
"pricedAt":"2026-07-24T20:11:00.000Z","isFallback":false },
{ "source":"tcgdex-cardmarket","currency":"EUR","market":421.11, … } ] },
{ "variantId":16,"kind":"holo-shadowless-stamp-1st-edition","displayName":"1st Edition Holofoil Shadowless",
"tier":"special","isPrimary":false,"source":"tcgdex","buyUrl":"…","prices":[] } ] }tier: standard counts toward Master; special is Grandmaster-only.
source:"tcgcsv" variants are cross-filled reverse holos and count for real.
Empty prices ⇒ render "no price". buyUrl is null when there is no TCGplayer
mapping at all. quantity is the requesting user's owned count of that variant (0 if
unowned) — the initial value for the card-detail quantity stepper.
Observed market price over time, one series per PRINTING, at whatever GRAIN that stretch of history still exists in.
| param | kind | notes |
|---|---|---|
range |
30d|3m|6m|1y|18m|2y |
default 3m; same vocabulary as the Insights value chart |
currency |
USD|EUR|JPY |
default USD |
{ "currency":"USD", "range":"2y",
"series":[ { "variantId":15, "kind":"holo-unlimited", "displayName":"Holofoil", "tier":"standard",
"points":[
{ "grain":"month","start":"2025-03-01","end":"2025-03-31",
"open":780.10,"high":812.00,"low":749.55,"close":801.20,
"highOn":"2025-03-09","lowOn":"2025-03-22",
"mean":779.40,"median":781.00,"n":31 },
{ "grain":"week","start":"2026-02-23","end":"2026-03-01",
"open":800.43,"high":806.10,"low":795.00,"close":802.75,
"highOn":"2026-02-25","lowOn":"2026-02-28",
"mean":800.90,"median":801.10,"n":7 },
{ "grain":"day","start":"2026-08-15","end":"2026-08-15",
"open":812.34,"high":812.34,"low":812.34,"close":812.34,
"highOn":"2026-08-15","lowOn":"2026-08-15",
"mean":812.34,"median":812.34,"n":1 } ] } ] }Three tiers, one point shape. Daily rows forever do not fit the disk
(~6.6 GB/year at three TCGs), so history is kept daily for ~30 days, as weekly
OHLC buckets for ~6 months, and as monthly buckets forever — see
research/SCHEMA.md §7.5. A DAY is a degenerate bucket:
open = high = low = close, start = end = highOn = lowOn, n = 1. A client
that only wants a line reads close and never branches on grain.
Points are ordered oldest first, month → week → day, and are contiguous — the
rollup HALTS rather than rolling past a month it cannot finish, precisely so
that the daily floor never jumps over a month whose rows would then be visible
at no grain at all. The one edge that is not covered is the window's own left
edge: a bucket that starts before range begins is excluded, so a 1y or 2y
chart can begin up to a month after its nominal start. Two seams overlap by up
to six days each — the tiers are not a clean tiling (a month
bucket and its own week buckets describe the same days at two grains) and an
overlap was chosen over a gap deliberately: a chart with a hole in it reads as
missing data. n is the number of days actually observed in the bucket, so a
week with n: 5 had a two-day ingest gap.
What may be asserted from a bucket — this is a CONTRACT, and it ships verbatim in the endpoint's JSDoc because rollup destroys real information:
- MAY: open/close/high/low/mean/median of a bucket; the exact dates and
values of the period's high and low (
highOn/lowOnare true daily facts that survive the rollup); trend across buckets; volatility DERIVED from OHLC (Parkinson / Garman-Klass — there is no stored variance, becausecorr(stddev, high-low) = 0.9878measured over 633,431 real weekly buckets, and no VWAP, because TCGCSV supplies no volume). - MAY NOT: any specific day's price inside a week or month bucket other than the two extremes; the path between them; durations ("stayed under $5 for eleven days"); a second or third dip within one bucket.
"It dipped to $4.00 on the 12th" is licensed if and only if lowOn says the
12th and low says $4.00.
The card_price_history agent tool now exposes this history (it calls this
endpoint through ctx.api.get, registered in packages/agent-tools and served
through both the MCP and Deck-E adapters); it carries the block above unchanged
in its description. get_card continues to serve current prices only.
Public (legality is a property of the card), private, no-store. Per-format
eligibility for one card, fetched when the card modal's TCG tab opens rather
than carried on every /cards/:cardId read. Each format runs the deck engine's
card-scoped checks on a one-card list; Standard also consults the reprint
oracle, so a rotated printing with a legal reprint reads as legal.
{ "checkedAt": "2026-…",
"formats": [ { "format": "standard", "legal": false,
"reasons": [ "…" ] },
{ "format": "expanded", "legal": true, "reasons": [] },
{ "format": "glc", "legal": true, "reasons": [] },
{ "format": "unlimited", "legal": true, "reasons": [] } ] }checkedAt is the as-of date of the vendored format data. 404 for an unknown
card id.
The 12-filter advanced search. AND across fields, OR within a multi-value field.
| param | kind | notes |
|---|---|---|
cardType |
repeatable | supertype: Pokemon|Trainer|Energy |
energyType |
repeatable | Fire, Water, … (11) |
subType |
repeatable | vocabulary EMPTY in data (subtypes/tags not imported) |
set |
repeatable | set tcgdex id |
rarity |
repeatable | 40 values |
weakness / resistance |
repeatable | energy type |
retreat |
repeatable int | 0–5 |
hp |
repeatable int (exact) | plus hpMin / hpMax range |
attack |
free text | matches attack name/effect |
ability |
free text | matches ability name/effect |
artist |
repeatable | 413 values |
q |
free text | card name or number (accent-insensitive) |
legal |
standard|expanded|glc|unlimited |
only cards in that format's card pool: the deck validator's pool rule (legal mark, set allowance, basic Energy, or a fingerprint-identical legal reprint) run in SQL from formats.json. Bans and GLC's rule-box/ACE SPEC/carve-out rules are not applied. The response adds "legal": { "format", "rule" }, rule being the validator's sentence (null for unlimited). Anything else is 400. |
sort |
name|number|price|rarity|released |
default name |
dir, page, pageSize |
asc, 1, 60 (max 250) |
|
facets |
1 |
also return the available filter vocabularies |
{ "query": { … echoed … },
"pagination": { "page":1,"pageSize":60,"total":104,"pageCount":2 },
"cards": [ { "cardId":"swsh9-154","number":"154","name":"Charizard V","category":"Pokemon",
"rarity":"Ultra Rare","artist":"…","hp":220,"regulationMark":"F",
"set": { "setId":"swsh9","name":"Brilliant Stars" }, "releasedOn":"…",
"variantCount":2,"images": { … },
"price": { "market":334.37,"currency":"USD" } } ],
"facets": { // only when ?facets=1
"subType": { "label":"Sub-Type","populated":false,"values":[] },
"energyType": { "label":"Energy Type","populated":true,
"values":[ { "value":"Water","label":"Water","count":2837 }, … ] },
"hitPoints": { "label":"Hit Points","populated":true,"range":{ "min":30,"max":340 } },
"attack": { "label":"Attack Search","populated":true,"freeText":true }, … } }public cached for 2 min (pure catalog, no user state).
National-dex species list.
| param | values | default |
|---|---|---|
generation |
1–9 | all |
own |
all | captured | uncaptured |
all |
q |
free text (name or slug) | — |
page, pageSize |
int, 1–1025 | 1, 200 |
{ "completion": { "captured": 0, "total": 1025 },
"pagination": { "page":1,"pageSize":200,"total":151,"pageCount":1 },
"species": [ { "speciesId":6,"slug":"charizard","name":"Charizard","genus":"…",
"generation":1,"totalCardCount":124,"types":["fire","flying"],
"captured":false } ] }totalCardCount is computed live from card_species (the stored
dex_species.total_card_count column is unpopulated — 0 everywhere). captured
= the requesting user owns ≥1 card featuring the species.
A species and every card featuring it (card→species is many-to-many, so tag-team
cards appear on both species). sort = number|price|rarity|artist|
released, dir = asc|desc.
{ "species": { "speciesId":6,"slug":"charizard","name":"Charizard","genus":"…",
"generation":1,"types":["fire","flying"],"totalCardCount":124,"captured":false },
"cards": [ { "cardId":"pl4-1","number":"1","name":"Charizard","category":"Pokemon",
"rarity":"…","artist":"…","set": { "setId":"pl4","name":"…" },
"variantCount":3,"owned":false,"ownedQuantity":0,
"images": { … }, "price": { "market":67.67,"currency":"USD" } } ] }Per-user, backed by user_settings (005 + 049) and user_showcase (005).
These are the server-side home of what used to be device-only localStorage
preferences; the client treats localStorage as an offline cache of them
(apps/web/src/lib/settingsSync.ts).
Both sub-routes need a signed-in session; a connector token gets 403
(2026-09-26, security audit SEC-07).
The account's whole settings row, camel-cased. skin/topbar are null
when the account never chose — the app default applies.
{ "settings": { "defaultGoal": "complete", "displayCurrency": "USD",
"pricingEnabled": true, "showCollectionValue": true,
"binderPocketSize": 9, "binderStackVariants": true,
"binderAdditionalVariants": "inline",
"deckeHidden": false, "deckeSharePrompts": true,
"skin": null, "topbar": null,
"seriesSortKey": "recency", "seriesSortDir": "desc",
"seriesGroupOwned": true,
"ptcglName": "cheyras", "ptcglNameSource": "auto" } }ptcglName is the reader's PTCG Live screen name (migration 086), null until
it is typed or learned. ptcglNameSource is "manual" when typed and
"auto" when it filled itself in: once three logged PTCG Live games
attribute the same name to the reader, strictly more than any other, an empty
name is set to it after the next POST /decks/:id/logs. It is never changed
automatically once set. When it is set and appears in a pasted log as a
player (matched as the parser matches players, case and curly apostrophes
aside), adding the log, ranking it (/decks/log-preview) and its digest read
the log from that player's side unless the caller names one. For the digest
it also outranks the side stored when the game was logged, so setting the
name corrects an old guess.
Any subset of the fields above; returns the full updated row in the same
shape. Unknown values are a 400 (never a silent reset to the default);
displayCurrency is validated against the currency table; skin/topbar
accept null to mean "follow the app default". A body with no known field is
a 400. deckeSharePrompts is a strict boolean (default true) controlling
whether Deck-E may ask to share a chat; it does not itself share any content.
ptcglName takes a string (trimmed, at most 100 characters) and makes the
name "manual"; null or a blank string clears it, after which it can be
learned again. Before migration 086 is applied it answers 503.
The profile's featured cards in slot order (user_showcase, slots 1-based,
up to 8 — the profile UI uses 4).
{ "showcase": [ { "slot": 1, "cardId": "base1-4", "name": "Charizard",
"images": { "low": "…", "high": "…" } } ] }{ "cards": ["base1-4", null, "swsh3-20"] } — replaces the whole showcase in
one transaction. Each entry is a card id (resolved server-side to the card's
primary variant, exactly as the list bulk-add does) or null for an empty
slot. Unknown card id → 404; more than 8 entries → 400. Returns the GET
shape.
The signed-in account's browsable owned cards, one row per card across its owned variants. Pokémon TCG Pocket cards are excluded even if the account still owns them.
The Profile banner requests three; opening the showcase picker requests a larger
page, can load later pages on demand, and may search by card name. Optional query parameters are q (name
contains, case-insensitive), sort=value|recent (default recent), page
(default 1), and pageSize (default 48, maximum 100). The response includes
pagination: { page, pageSize, total, pageCount } and cards, whose rows have
cardId, name, images: { low, high }, quantity, and price (a USD market
price or null). Only cards with a positive owned quantity are returned. Ties
on price and name are ordered by card ID so page boundaries are stable.
The profile photo (migration 029, apps/api/src/routes/avatar.ts). A
signed-in session only — a connector token gets 403 — and 10 requests/min per
account. All three answer private, no-store.
GET→{ "avatarUrl": "https://…" | null, "enabled": true, "maxBytes": 3145728, "accept": ["image/jpeg", "image/png", "image/webp"] }. A deployment with no avatar storage answers{ "avatarUrl": null, "enabled": false, "maxBytes": 3145728 }.POSTtakes the raw image bytes as the body (not JSON or multipart), up to 3 MB. The type is decided from the bytes, never the declared content type; JPEG, PNG and WebP are accepted. The image is decoded, cropped to a 256×256 square and re-encoded as WebP before it is stored, and the previous photo is deleted.201 { "avatarUrl": "https://…", "enabled": true }.400for an empty or unsupported upload,413 too_largeover the limit.DELETEclears the photo →{ "avatarUrl": null, "enabled": true }.
POST and DELETE answer 501 avatar_unavailable when the deployment has no
avatar storage (self-host).
Cloud only, and only when Stripe is configured. /health's
billingGate: "configured" requires a fourth, the webhook secret. Three of four
is billingGate: "partial" — and read the boot warning, which says which of the
two partials it is: with the secret key present the tier takes cards and never
hears back, without it the tier is simply off. A fifth value,
billingGate: "mode-mismatch", means the secret key and the publishable key
name different Stripe MODES: every route answers 200 and every card
confirmation fails, because the browser is on one account and the server on the
other. It is reported, not enforced — the tier stays available, because turning
billing off on a prefix comparison would be a worse fault than the one it
prevents. See DEPLOYMENT.md. GET /me/billing, /visit, /prompt-ack and /refresh all
answer the same shape, and on a deployment with no Stripe that shape is
{ "available": false, … } rather than an error, because "is there a billing
tier here" is a legitimate question with a legitimate negative answer.
(/prompt-shown is the exception on purpose: it records an experiment exposure
and answers { "recorded": true | false }, which is the whole of what its
caller needs.) The endpoints that would move money say so instead:
400 "Billing is not configured on this deployment." A page can ask what the
tier is; nothing can quietly no-op a payment.
What the browser is trusted with, in full — the same inventory
routes/billing.ts's header and SECURITY.md carry, and if you change one,
change all three:
-
an
amountCents, validated to whole dollars within the floor and ceiling; -
an optional
expectedCents/expectedCancelAtPeriodEndonPUT /subscription— what the SCREEN was showing when the reader pressed the button. Disagreeing with the row is a 409stale_state, refused before anything touches Stripe. It is optional so a caller that omits it behaves as before; the shipping client always sends it.⚠️ Without it, two tabs are two mounts with no shared state: the reader answers $5 in one, and the other — still showing the same ask, its primary button reading "Continue with $0" — scheduled the end of the subscription just created and reported that nothing had changed; -
a
setupIntentId, on the one leg where a card was just entered, checked to belong to this account's Stripe customer AND to have succeeded; -
a
paymentIntentIdon/one-time/confirm, checked the same way plus the metadata marking it a one-off this flow created; -
an
attemptId, an opaque[A-Za-z0-9_-]{8,64}string, REQUIRED, because it goes into a Stripe idempotency key — which is what makes a retried gift one charge; -
a prompt
kind, validated againstonboarding|checkin|payment_issueand 400'd otherwise, and adismissedboolean on/prompt-ack; -
a
contexton the routes that record an outcome, checked against the four surfaces the analysis knows (onboarding|checkin|payment_issue|settings) and replaced withsettingsotherwise. On/prompt-shownand/prompt-ackit is not read at all: an exposure is filed under its validatedkind. Theforced-prefix that marks test traffic is the SERVER's to write — a client sending one is honoured only when the deployment is in Stripe test mode.⚠️ Free text here was not harmless: every CTE of the experiment's analysis filterscontext IN ('onboarding','checkin'), so an unrecognised string removed that account from the denominator and a client-writtenforced-removed its answer from the numerator. Executed over twenty identical accounts, either one moved a level cohort by 25%.None of this touches money, and none of it is a boundary: SECURITY.md's "an account can write a plausible event about itself" still covers what the RPC grant permits directly.
It never sends a customer id, a subscription id, a price, a payment-method id or
a status: every one of those is resolved server-side from the authenticated
user. /refresh accepts an amountCents and deliberately IGNORES it, recording
what Stripe says the subscription bills.
billing_account is SELECT-only to authenticated (migration 054), so the row
cannot be written with a direct UPDATE. SECURITY DEFINER functions 054 grants, and one of them takes a
stripe_customer_id — so an account can put an arbitrary, unheld customer id
in its own row. What makes that safe is not the column: every reader of it asks
Stripe whether the customer's metadata names this account before using it. The
write-once pin (059) and the UNIQUE index are depth behind that check. SECURITY.md
carries the full account.
No card data is stored or transits this API. The card is typed into Stripe's own cross-origin iframe (Payment Element); DeckPal receives a brand, four digits and an expiry, which is what these responses carry.
The common shape:
{ "available": true, "mode": "test", "publishableKey": "pk_test_…",
"presetsCents": [0, 300, 500, 1000, 2500], "oneTimePresetsCents": [300, 500, 1000, 2500],
"abVariant": "without_1", "minCents": 100, "maxCents": 50000,
"support": { "cents": 500, "currency": "USD", "status": "active",
"currentPeriodEnd": "2026-10-05T12:00:00.000Z",
"cancelAtPeriodEnd": false },
"card": { "brand": "visa", "last4": "4242", "expMonth": 9, "expYear": 2028 },
"prompt": { "due": null } }card describes whatever Stripe will bill, which is not always a card.
last4, expMonth and expYear are nullable, and brand carries the
PAYMENT METHOD TYPE when there is no card to describe — "brand": "link" with
"last4": null for a supporter paying through Link. card is null only when
there is genuinely nothing on file.
That distinction is not cosmetic. Until 2026-09-08 this object was gated on
last4 alone, so a Link supporter whose subscription was renewing perfectly
well read as "no card on file"; and the server refused their payment outright,
because two helpers required a card object where Stripe requires only a
chargeable method. A client rendering this must not assume digits exist —
brandLabel(brand) alone is the correct display when they do not.
presetsCents is NOT a constant: which ladder an account sees is the $1
experiment's arm (migration 055), assigned once and sticky per account, and
abVariant names it. Render what arrives. oneTimePresetsCents is the ladder
for the one-off follow-up — higher anchors, no $0 rung, and identical in both
arms, because one variable at a time.
prompt.due is onboarding | checkin | payment_issue | null, decided
server-side from a row the browser cannot edit (promptDue() in
apps/api/src/billing/store.ts). An account that is currently contributing is
never shown the check-in — not monthly, not ever. A broken payment still
surfaces, because that is help rather than an ask. There is no client-side "have I shown this"
flag: clearing site data must not restart the cadence, and signing in on a
second device must not re-ask a question already answered.
Read-only, authenticated history for the selected current Stripe customer. The server derives the customer from the caller’s support row or credit_customer_read(); browser-supplied user/customer ids are never accepted. Each request retrieves the customer and requires exact metadata.deckpal_user_id ownership plus the configured live/test mode before listing charges, always with Stripe’s customer filter.
Returns safe charge-attempt fields only: opaque row id, generic type, ISO date, integer provider-unit amount/currency, paid|authorized|pending|failed, refunded provider units, dispute flag, and an optional receipt URL restricted to credential-free HTTPS on exactly pay.stripe.com. Settled rows use Stripe’s actual amount_captured and cap refunds against it; failed/pending attempts and uncaptured authorizations retain the intended amount with an explicit non-paid status. Pages are 20 rows. The signed opaque cursor is bound to actor, kind, and current customer generation; malformed, forged, cross-kind, and replaced-account cursors are rejected generically. Responses are private, no-store.
Coverage is deliberately explicit: this reads the current billing account only; older replaced/deleted accounts may be missing. A missing current pointer is a successful empty current-account result. Provider/ownership/deleted-customer failures are explicit errors, never empty-history success. The support pointer is a parameterized SELECT under request RLS and an absent row returns null; this endpoint does not call the shared row-ensuring reader, create/recover customers, enrich invoices, write a ledger, or mutate Stripe/database state. Both Stripe reads use per-call timeout: 4000 and maxNetworkRetries: 0; the request-scoped pooled connection remains middleware-owned for the request lifetime.
The shape above. Pure read — no visit is counted.
The same body, and counts a session. A POST because it has a side effect: a GET that counts gets fired by anything that prefetches, and this counter decides when somebody is asked for money. At most one count per six hours, enforced in SQL, so a reload or a second tab is free. The app calls this once per page load.
{ "kind": "onboarding" | "checkin" | "payment_issue", "dismissed": true, "context": "checkin" } — records that the ask was PUT. Both a dismissal and a
completed answer buy the same full month of quiet; dismissed distinguishes
them for the experiment, because recording both against one exposure made the
two outcomes overlap. context carries the forced- label under the testing
override. Returns the common shape.
{ "context": "checkin" } → { "recorded": true }. The experiment's
DENOMINATOR, recorded when the modal actually mounts — not when the state is
fetched, because most loads show no modal at all. The arm is read server-side;
the client is trusted only with where the ask appeared.
{ "setupIntentId": "seti_—" } → the common shape plus settled.
Replaces the card and settles whatever failed: it sets the customer default,
CLEARS any subscription-level pin (Stripe charges the subscription's own method
in preference to the customer's, so without this a replaced card was never the
one charged), and pays the outstanding invoice rather than leaving it on
Stripe's multi-day retry clock. settled is the honest answer to "did that fix
it" — a new card can be declined too.
Deliberately NOT PUT /subscription with the same amount, which is what this
used to be: that path sets cancel_at_period_end: false, so replacing an
expiring card during the wind-down month after choosing $0 silently un-cancelled
the stop, and it recorded a fresh chose event for what was only a card fix.
{ "amountCents": 2500, "setupIntentId": "seti_—", "context": "checkin", "attemptId": "…" } —
the common shape plus paid and status. A single charge against the card on
file, offered as the follow-up when somebody answers $0. No subscription is
created. Recorded as chose_one_time, never chose — folding a one-off into
the recurring number overstates that account by 12x (migration 057).
attemptId is required and load-bearing: an opaque client string matching
[A-Za-z0-9_-]{8,64} that goes into the Stripe idempotency key alongside the
customer and the amount. The browser holds ONE of these across every retry of a
single press, so a network retry or a double click is one charge — and mints a
new one only after a settled outcome, because Stripe replays a stored response
(declines included) for 24 hours and a retry under the old id would never reach
the bank. Changing the amount changes the key, which is why the client freezes
the amount while an attempt is unresolved.
paid is false when the issuer wants the reader to confirm; clientSecret then
carries the challenge, and the browser must check the intent's own status
afterwards rather than assuming success. paid: false is also returned for a
processing intent, where the money may still leave — which is what status
is for. Treat status: "processing" as "do not pay again"; only a settled
refusal may be offered a retry.
{ "paymentIntentId": "pi_—", "context": "checkin" } — the common shape plus
paid. Called by the browser after it has completed a bank challenge on a
one-off. The server retrieves the intent itself and records the gift only if
Stripe says it succeeded; the client's word is not taken for anything.
Three checks, each of which has to be there: the intent must belong to this
account's Stripe customer, its metadata must mark it a one-off this flow created
(otherwise a subscriber could post their own first-invoice intent and have a
recurring charge counted as one-time support), and the event carries the intent
id as a dedupe_key, so replaying the call — deliberately, or as a browser
retry — records the gift exactly once (migrations 061/062).
Why it exists rather than re-posting /one-time: that request's idempotency key
would replay the original requires_action response instead of the settled one,
so the whole class of challenged gifts would be missing from the experiment —
and step-up rates vary by issuer and country, so it would have biased whichever
arm attracted more of them.
{ "clientSecret": "seti_…_secret_…", "publishableKey": "pk_…", "mode": "test" }
— a Stripe SetupIntent for the Payment Element, created against this account's
customer (made on first use). usage: off_session, so the bank collects any
strong-authentication challenge while the reader is looking at the page rather
than failing a renewal three weeks later.
{ "amountCents": 500, "setupIntentId": "seti_…" } — setupIntentId only on
the leg where a card was just entered. Whole dollars; 0 is valid and means
"stop", which cancels at the period end rather than immediately (they have paid
for the month they are in). An amount change uses proration_behavior: none, so
it takes effect on the next billing date and nothing is charged or refunded
today. Below minCents, not a multiple of 100, above maxCents, or missing →
400 with a message written for a person. A declined card → 400 carrying
Stripe's own decline copy.
Two more 400s, both refusals rather than failures. payment_in_flight: the
existing subscription is incomplete and its first payment is still settling.
Replacing it would cancel the subscription the money is on its way to — so the
charge lands where the stray sweep will never look for it — and bill a fresh
first month on top. subscription_paused: the subscription was paused from the
Stripe dashboard, which nothing in this app does. It is not modifiable, so any
amount change would build a second subscription beside it (double billing the
day it resumes) and 0 would silently do nothing while reporting success.
Returns the common shape, plus clientSecret when the bank wants the first
charge authenticated — the subscription exists as incomplete and confirming
that secret in the browser completes it. Ignoring it charges nobody anything;
Stripe expires the subscription within a day.
Re-reads Stripe and returns the common shape. Called after an authentication
challenge completed in the browser, where the subscription went from
incomplete to active without any request reaching this server.
{ "url": "https://billing.stripe.com/…" } — a Stripe-hosted billing portal
session for invoices, receipts and card management. The return URL is built
server-side and never taken from the request. 400 when there is nothing to
manage yet, and a 400 naming the missing dashboard configuration when the
portal has never been set up for the Stripe account.
Not part of the /api router. Mounted on the bare app ahead of the JSON
parser, because signature verification hashes the exact bytes Stripe sent, and
outside the RLS middleware, because a Stripe delivery carries no session.
Authenticated solely by the Stripe-Signature header; 503 when
STRIPE_WEBHOOK_SECRET is unset (never a fallback to trusting the body), 400
on a bad signature. Every handler is a full re-sync from Stripe rather than a
delta, so out-of-order delivery is harmless and a missed event repairs itself on
the next one.
billing_event is a two-phase ledger (063), not a seen-set. A replay of a
FINISHED event is a 200 no-op; a delivery arriving while another is still
processing the same event gets 409, so Stripe comes back rather than being
told "done" about work that may yet fail; and a claim left behind by an attempt
that died is reclaimable after five minutes. The distinction matters for the
terminal events — there is no next event after
customer.subscription.deleted on an immediate cancel, so a delivery dropped
there would leave the row claiming a payment that is not happening.
The only writers against collection_item. Each mutation runs in
one transaction: upsert collection_item to the new quantity, append a
collection_event for the non-zero delta, then recompute the affected set's
three user_set_progress rows and return them authoritatively. Idempotent
(setting the same quantity writes no event). Bodies are JSON; all queries
parameterized. Responses are private, no-store.
Tier drives which goals a variant advances (SCHEMA §5.3):
Complete = own the card in ≥1 variant of any tier (card fraction); Master =
own each (card, standard-variant) pair (over master_required_variant);
Grandmaster = own each (card, any-variant) pair. totalQuantity is per-goal
(sum over that goal's counted variants). Dupes = a card whose owned quantity ≥ 2.
Shared success body (returned by the three variant/card writers):
{ "variantId": 15, "quantity": 1, "delta": 1, "isFirstAcquisition": true,
"card": { "cardId": "base1-4",
"variants": [ { "variantId":15,"quantity":1 }, { "variantId":16,"quantity":0 } ],
"ownership": { "totalQuantity":1,"have":true,"need":false,"dupe":false } },
"setId": "base1",
"progress": { "complete": { "owned":1,"total":102,"pct":1,"totalQuantity":1,"setLevel":1 },
"master": { "owned":1,"total":102,"pct":1,"totalQuantity":1 },
"grandmaster": { "owned":1,"total":409,"pct":0.2,"totalQuantity":1 } } }The /cards/:cardId/have response omits variantId/delta/isFirstAcquisition
and adds cardId at top level; card, setId, progress are identical in shape.
Set the absolute owned quantity for a variant. :variantId is the numeric
card_variant.id. Body { "quantity": N, "source"?, "note"? } (integer 0–100000).
Setting 0 keeps a qty-0 row (SCHEMA §9.1). 404 if the variant doesn't exist;
400 on a bad quantity.
Adjust the owned quantity by a signed delta (default +1). Body
{ "delta": N, "source"?, "note"? } (non-zero integer; floors at 0 — decrementing
below 0 is a no-op with delta:0).
Tile-level Have/Need toggle. :cardId is the card tcgdex id. Body
{ "have": bool, "source"?, "note"? }. have:true owns the primary variant (sets
it to 1 only if currently 0; never lowers a higher quantity). have:false zeroes
every variant of the card (Need = own nothing, one event per zeroed variant).
One transaction, one recompute.
Internal nightly consistency sweep — recomputes the three user_set_progress rows
for every set that has progress rows, from the live catalog + collection
(bumps recomputed_at AND reconciled_at). On a quiet system this never changes
derived values; it exists to heal drift. One transaction per set, strictly
sequential (the API's request pool is deliberately small — AGENTS.md B2). Called by the
deckpal-sync reconcile cron over HTTP. Any request body is ignored.
{ "sets": 218, "ms": 412 }The collection activity log, newest first, each event resolved to human fields (card/set names, number, variant label, images). Powers the stream overlay ("just added Charizard, Base Set, #4") and an Activity view.
| param | values | default |
|---|---|---|
limit |
int 1–200 | 50 |
since |
ISO-8601 timestamp (events strictly newer) | — (invalid → 400) |
source |
writer handle (exact match, same shape rule as writes) | — (invalid → 400) |
The kind field is derived from the append-only delta + quantity_after +
is_first_acquisition (the table stores no categorical kind): added
(0→n, first acquisition), quantity-increased, removed (n→0),
quantity-decreased.
{ "events": [ { "eventId": 42, "occurredAt": "2026-07-30T…", "kind": "added",
"cardId": "base1-4", "cardName": "Charizard", "setId": "base1",
"setName": "Base Set", "number": "4", "variantId": 15,
"variantName": "Holofoil", "quantityDelta": 1, "newQuantity": 1,
"isFirstAcquisition": true, "source": "web", "note": null,
"images": { "low": "…", "high": "…" } } ] }Empty collection returns { "events": [] }.
Three list kinds, straight from the live schema (card_list.kind ∈ {dynamic, static, pokedex_binder}):
- dynamic — an ordered set of
card_variantreferences. Membership is the stored references; ownership is read through from the collection at read time and never stored on the list, so the progress cluster (owned/total, copies) auto-syncs with the collection live. - dynamic + rule ("smart list", migration 050) — the dynamic list carries a
saved query instead of stored rows: the
addMissingspec (setId,goal,finishes,rarity,rarityExclude,maxPriceUsd,pricedOnly) plusexclude(variant ids removed by hand) and a server-resolvedsetName. Membership is re-evaluated on EVERY read viamissingForGoal— own a card and it leaves the list. Item add/bulk-add/reorder are refused with a 400 ("membership is its rule"); DELETE of the syntheticrule-<variantId>item id records an exclusion instead of deleting a row.PATCH { "rule": null }PINS the list: the current evaluation is materialised into stored rows and the rule detached. Rule set/exclusion are undoable (list.rule.set,list.rule.excludein the mutation log). - static — an ordered bag of
card_variantreferences; duplicates allowed, each row carries its ownstatic_quantity(≥1). No collection tie, no progress. - pokedex_binder — one slot per dex species (
list_item.dex_id). Read-through "captured?" from the collection (owns ≥1 card of that species).
The requesting user's user_id is threaded everywhere. Writes go through withTx so
the item write + position bookkeeping are atomic. :id is a UUID;
400-as-404 on a non-UUID id.
All lists for the requesting user, favorite-first then updated_at DESC, each with
summary aggregates.
{ "lists": [ { "id": "47333f45-…", "kind": "dynamic", "name": "Wants",
"description": null, "visibility": "private", "isFavorite": false,
"coverRender": "full", "pocketSize": null,
"itemCount": 12, "progress": { "owned": 3, "total": 12, "pct": 25, "copies": 4 },
"marketValueUsd": 67.12,
"coverImage": { "low": "…", "high": "…" },
"coverImages": [ { "low": "…", "high": "…" } ],
"createdAt": "…", "updatedAt": "…" } ] }progress is null for static lists (no collection tie) and for smart
lists (owned is 0 by construction — an owned card is no longer missing, so
it is no longer a member; their tiles show count + cost-to-finish instead).
Summaries carry rule (the saved query, or null) and ruleEvaluatedAt;
a smart list's itemCount/marketValueUsd/coverImages come from evaluating
its rule at read time. coverImages is
up to 8 DISTINCT CARDS in list order with the explicit cover pick first —
the index tile's mosaic (distinct by card, because a static list holding four
copies of one card is a quantity, not four tiles). coverImage remains the
first-tile shorthand.
One list's summary plus its resolved items in position order. Card items carry
variant metadata, owned-from-collection quantity, market price, and per-item
routing slugs (a list spans many sets). pokedex_binder items are shaped as a
CardRow (cardId: "dex-<n>") with a representative card image resolved for the
species, so the same grid/binder view renders them.
{ "list": { …same summary shape as the index… },
"items": [ { "itemId": "…", "position": 0, "kind": "card",
"cardId": "sv01-25", "variantId": 30,
"variant": { "kind": "holo-unlimited", "displayName": "Holofoil",
"tier": "standard", "isPrimary": true },
"number": "25", "name": "…", "category": "Pokemon", "rarity": "…",
"artist": "…", "variantCount": 2,
"seriesSlug": "scarlet-violet", "setId": "sv", "setName": "…",
"images": { … }, "price": { "market": 1.20, "currency": "USD" },
"note": null, "staticQuantity": null, "ownedQuantity": 1,
"ownership": { "totalQuantity": 1, "requiredCount": 1,
"ownedRequired": 1, "have": true, "need": false, "dupe": false } },
{ "itemId": "…", "position": 1, "kind": "species", "dexId": 6,
"cardId": "dex-6", "number": "6", "name": "Charizard",
"category": "Pokemon", "generation": 1, "variantCount": 1,
"images": { … }, "price": null, "note": null,
"ownership": { "totalQuantity": 1, "requiredCount": 1,
"ownedRequired": 1, "have": true, "need": false, "dupe": false } } ] }Create a list. Body { "name" (required, ≤120), "kind"? = "dynamic"\|"static"\| "pokedex_binder", "description"? (≤2000), "visibility"? = "private"\|"public" }.
A pokedex_binder gets a default pocketSize of 9. 201 returns the summary.
{ "list": { …summary shape… } }Rename / edit description / visibility / favorite / cover / pocket size / reorder.
Body fields are all optional; only the ones present are applied.
{ "name"?, "description"?, "visibility"?, "isFavorite"?, "coverRender"? = "full"\|"art", "pocketSize"? = 4\|9\|12\|16, "coverCardVariantId"? (positive integer or null), "itemOrder"? (array of itemIds — rewrites positions 0..n-1; items not named keep a high position, appended after) }. Returns the summary.
Soft by default (migration 038): the list moves to the recycle bin
(GET /lists?deleted=true) and answers
{ "deleted": "<uuid>", "restorable": true, "batchId": … }; deleting it again
answers "alreadyDeleted": true. POST /lists/:id/restore brings it back.
?purge=true destroys the list and its items and answers
{ "purged": "<uuid>", "deleted": "<uuid>", "restorable": false }. 404 if
the list doesn't exist.
Add a card/variant (or a species slot for a binder). Body depends on the list kind:
- dynamic / static:
{ "cardVariantId" | "variantId" (positive int, required), "position"? (int ≥0, default end), "note"? (≤500), "staticQuantity"? (static only, int ≥1, default 1) }. - pokedex_binder:
{ "dexId" (positive int, required), "position"?, "note"? }.
dynamic and pokedex_binder dedupe silently (unique constraint) and return
200 { "itemId": null, "alreadyPresent": true, "list": … }; static always
inserts a fresh row (201). Returns the updated list summary.
{ "deleted": "<itemId>", "list": …summary or null… }. 404 if the item isn't in
the list. Bumps the list's updated_at.
Variant-scoped since migration 051. deck_card is one row per PRINTING
(PK (deck_id, card_variant_id); card_id kept denormalised, held honest by
a composite FK) — "2 Normal + 1 Reverse Holofoil" is two rows. Deck cards
carry variantId + variant {kind, displayName, tier, isPrimary}; owned
and price are that printing's own numbers, not a rollup/representative.
The card add/set/remove routes take an optional variantId (body, or
?variant= on DELETE): omitted on add = the card's primary printing; omitted
on PATCH targets the card's single deck row and 400s when several printings
make it ambiguous; omitted on DELETE removes the whole card, every printing.
Imports resolve to primary printings and say so (import.variantNote). The
ENGINE (validation/legality) and PTCGL export aggregate rows to card level —
game rules and Live lines are per card; the Mass Entry export stays per
printing on purpose (each row has its own TCGplayer token). Version snapshots
gain variantId/variantName; the version diff aggregates to card level
(a pre-051 snapshot reads as "primary, never a change") and reports
same-total printing swaps in its own printings array.
Persistence + validation + interchange on top of the verified deck engine in
apps/api/src/deck. deck is keyed by UUID; deck_card is keyed per
printing, (deck_id, card_variant_id), as above — the same card on two
import lines is summed into one row for its primary printing.
Unresolved import lines cannot be stored (card_id NOT NULL, FK) and are reported
to the caller, never dropped silently. Every write carries
optional source attribution and (for card ops + format-changing PATCH) a
versionNote that lands on the deck_version row.
Deck detail payload (returned by create, GET, PATCH, card ops, strategy,
revert): { deck, counts, cards, validation, cardRefs, glcTypes } — the deck
metadata (incl. version, strategyMd, totalCount, valueUsd, legal), the
{total, pokemon, trainer, energy, distinctNames} counts, the grouped card rows
(each with owned/have/price/images, plus setCode — the expansion code PRINTED
on the card, e.g. "PBL", resolved from the vendored PTCGL alias table and
null for sets that have no such code; setId beside it is TCGdex's internal
id, which is printed nowhere), the engine's ValidationResult, a
cardRefs map keyed by numeric card id (for in-place violation highlighting),
and the GLC type vocabulary.
Index, favorite-first then updated_at DESC. Each row carries record: { wins, losses, ties } aggregated over all versions (one query), plus the
same metadata shape as the detail's deck (validated under the stored format).
{ "decks": [ { "id": "…", "name": "Hide 'n' Sneak", "formatCode": "standard",
"formatName": "Standard", "isFavorite": false, "version": 3,
"totalCount": 60, "valueUsd": 124.30, "legal": true,
"record": { "wins": 3, "losses": 1, "ties": 0 }, … } ] }Create an empty deck (seeds the v1 snapshot). Body { "name" (required, ≤120), "formatCode"|"format"? = "standard"\|"expanded"\|"glc"\|"unlimited", "description"? (≤2000), "glcType"? (required for glc, defaults to the first type), "source"? }. 201 returns the full detail payload.
The full detail payload for one deck. 404 if the deck doesn't exist (non-UUID
ids are also a 404). Each card row reports owned, have, pinExact, and
ownedAs (the other printings whose copies satisfy this row, with set code,
number and quantity). Exact copies count first; legal gameplay-identical
ordinary printings of the same finish then count once across the deck. Basic
Energy matches by type. Promo and stamped variants remain distinct. Allocation
reserves pinned exact copies, then unpinned exact copies, then equivalents, with
ascending variant ID breaking ties for both deck rows and owned candidates. The
page and PDF share this order regardless of their display order.
Rename / edit description / format / glcType / favorite / cover render. Body
fields all optional: { "name"?, "description"?, "formatCode"|"format"?, "glcType"?, "isFavorite"?, "coverRender"?, "source"?, "versionNote"? }. A
format change alters what the list means and goes through the same auto-bump
path as a card edit; rename/favorite/cover changes never bump. Returns the detail
payload.
Soft by default (migration 038): the deck moves to the recycle bin
(GET /decks?deleted=true) with its cards, versions and battle logs intact,
and answers { "deleted": "<uuid>", "restorable": true, "batchId": … };
deleting it again answers "alreadyDeleted": true. POST /decks/:id/restore
brings it back. ?purge=true is the one hard delete: it cascades to
deck_card, deck_version and battle_log and answers
{ "purged": "<uuid>", "deleted": "<uuid>", "restorable": false }.
Additive upsert of a card (quantity clamped to 60). Body { "cardId"|"card" (required — tcgdex id or numeric catalogue id), "quantity"? = 1, "source"?, "versionNote"? }. On conflict the quantity is added (then clamped). Records
a version snapshot via the auto-bump rule. 201 returns the detail payload.
Set the absolute quantity, or pin an existing row to its exact printing.
Body { "quantity"? (int 0–60), "pinExact"? (boolean), "variantId"?, "source"?, "versionNote"? }; at least one of quantity and pinExact is
required. quantity: 0 removes the row. Pass variantId when the card has
more than one row in this deck. A quantity edit records a version snapshot;
pinning changes ownership matching without changing the card list snapshot.
:cardId is a tcgdex id or numeric catalogue id. Returns the detail payload.
Remove a card from the deck. :cardId is a tcgdex id or numeric catalogue id.
Body { "source"?, "versionNote"? } (optional). Records a version snapshot.
Returns the detail payload.
Run the legality engine and return { validation, cardRefs } without persisting
anything. Query ?format= overrides the stored format for the check (defaults to
the deck's format_code).
Read-only check for a proposed deck that has not been saved. Body has optional
format (default standard) and exactly one of cards ([{ name? | card_id?, quantity }]) or ptcgl_text. Bare names prefer an owned printing, then the
newest legal printing. Returns total and legality issues, evolution gaps,
resolved lines with owned copies and USD unit prices, cost to acquire missing
copies, and a normalized PTCG Live export. Unresolved names remain in lines
with resolved:false; no deck or collection data is written.
Read-only opening-hand, Prize and draw odds by seeded Monte Carlo (deck_odds;
model in apps/api/src/deck/odds.ts). Body has exactly one of deck_id (a deck
UUID; another user's or a deleted deck is 404), cards or ptcgl_text (the
same shapes and resolution as /decks/check, so an unsaved list can be tested),
plus optional queries (≤12), trials (1000..200000, default 50000), seed
(uint32, default a fixed 60 so a repeat call agrees) and format (which format's
printings an unsaved list's bare names resolve to, as in /decks/check; default
standard). The list may hold at most 120 cards. A list that mulligans a lot has
its games cut so the call deals an expected 2,000,000 opening hands at most (trials is then
below trials_requested, and a note says why). A query is
{ "label"?, "all_of": [Group, …1..6], "by_turn"? 0..10 = 0, "prized"? = false }
and a Group is { "cards"?: [card name in the deck], "kinds"?: ["basic" | "pokemon" | "supporter" | "item" | "tool" | "stadium" | "energy"], "count"? = 1 };
a group is met when at least count seen (or, with prized, prized) cards match
any listed name (case and accents ignored) or kind, and a query when every group
is. The game: shuffle, draw 7, redraw while no Basic Pokémon, set aside 6 Prizes,
then one draw per turn, turn 1 included, so by_turn N sees 7 + N cards and hand
odds are for the kept hand. Returns:
{ "deck": { "name": "Hide 'n' Sneak", "size": 60, "basics": 16, "distinct_names": 24 },
"method": "Monte Carlo, draw-only", "trials": 50000, "trials_requested": 50000, "seed": 60,
"mulligan": { "simulated": 0.1009, "exact": 0.0992, "avg_per_game": 0.113 },
"avg_basics_in_hand": 2.07,
"queries": [ { "label": "Shuppet", "zone": "hand", "by_turn": 0, "successes": 22048,
"p": 0.441, "margin95": 0.0044, "exact": 0.4435 } ],
"per_card": null, "per_card_turn": 2, "max_margin95": 0.0044, "warnings": [] }zone is hand, seen or prized; margin95 is the 95% Wilson score half-width;
exact is the closed form for a single-group query (else null). With no
queries, queries is [] and per_card lists each card name with opening,
by_turn (seen by turn per_card_turn), prized_any and prized_all (the closed
form, null for one copy). warnings holds at most 8 lines. 400 for an unknown card name in a query (the message lists the
deck's names), a list name the catalog cannot resolve, a list with no Basic
Pokémon, fewer than 7 or more than 120 cards, or a malformed body. A list that is not 60 cards,
or has more than 4 of a non-basic-Energy card, is still computed and listed in
warnings. Nothing is written.
Read-only battle simulation (@deckpal/sim; the simulate_battles agent tool). Body has exactly one
of deck_id (one of the caller's decks, by id or name; a name fragment must match exactly one deck),
cards or ptcgl_text (an unsaved list in POST /decks/check's shape, with optional format and
name; every line must resolve, else 400 naming the ones that did not), plus optional opponents
(1..8 of the caller's deck ids or names, or the built-in Standard meta pool: meta is the top 6 archetypes by
tournament share and meta:<archetype> is one of them; a name that matches none of the caller's decks also finds a
pool archetype, but a saved deck always shadows the pool. Default: up to 6 of their other decks, favourites and most
recently updated first, and when they have none or none of them can be played, the top 6 pool decks), games (per opponent, default 24, 2..200, rounded up to whole pairs),
speed (strong default, or fast) and seed (default 1; the same seed, decks and pilot replay the same games). Every deck played is 40–70
cards: the subject or a named opponent outside that is a 400 naming the deck and its count; a
default opponent outside it is skipped with a note. Each opponent is played in paired games — one
seed, seats swapped, so each deck goes first half the time — by a CPU pilot on both sides, inside one
25 s budget counted from the request's arrival and shared across the opponents. The budget is also a
hard deadline: a game still running when it arrives ends as a time-out draw, and no game starts after
it; a game is likewise abandoned as a time-out after 3,000 decisions. The report says when the budget,
not games, decided how many were played. The decks are read first and the request's database
connection is released before any game is played. Returns { text, report }: text is at most 5,000
characters (Deck-E clamps tool results at 6,000) and always contains the caveat (a continuation line follows it when the response carries a
continue_token), notes clamped to fit; report is the structured form (kind: "deckpal.simulation", simulated: true, per-matchup
stats with Wilson 95% intervals and n, card impact, per-deck coverage naming every approximated or
unplayable card, the standing caveat, and every note — decks that are not 60 cards, cards with no
catalogue data, skipped opponents). The meta pool (packages/sim/src/meta/data.ts, refreshed by hand with
packages/sim/scripts/meta-refresh.ts from Limitless's public tournament API) holds one representative 60-card list per current
archetype; a pool opponent is named Meta: <archetype> (<source>, <event date>, <record>) in the report, and a note gives the
pool's window and as-of date. Its cards are mapped to the catalogue by TCGdex id and name; a card the catalogue lacks is left
out with a note, and a list whose id is a different card is skipped with a note. Results are simulations, never real-game
statistics, and nothing is written. Concurrency: one run per account and two per server instance at a time — beyond that,
429 simulator_busy with Retry-After (seconds until the run in the way should finish) and a
message saying which.
- Paired comparison. At most one of
compare_with(another of the caller's decks, by id or name;400if it is the subject itself),compare_cardsorcompare_ptcgl_text(an unsaved whole list in the same shape ascards/ptcgl_text, resolved with the sameformat; every line must resolve), with optionalcompare_name(label for an unsaved list, default "Version B";400without a compare list). The subject is version A, the compare list version B; neither is a default opponent. Each opponent is played by both versions on the same seeds and seats, inside the same 25 s budget (so each version gets about half the games), stopping both after the same seed pair.reportis thenkind: "deckpal.simulation.comparison":overalland per-matchupspaired differences (diff= mean B − A game score per game, win 1, draw/time-out ½, loss 0;lo/hi= 95% t-interval with each seed's two seat-swapped games as one cluster;verdict"b"/"a"only when the interval excludes 0 with ≥6 seeds, else"none"),changes(card counts that differ),changedImpact,method, both versions' full reports (reportA,reportB) andcoverage. The text leads with aVERDICT:line and keeps the caveat; the same bounds, deadline and run gate apply. - Continuing a run (a bigger sample). One call plays only a few dozen games, so a run can be continued: every
response carries
continue_token(also the last line oftext, andreport.continuation = { token, calls, max_calls }). Send it back ascontinue_tokenwith the SAME inputs (the same deck, opponents, compare list,speed,seed; an omittedspeed/seedis taken from the token) andgames= games to ADD per opponent on this call. The new games are on new seeds — opponent i continues at its next seed pair, so N calls of g games play exactly the games one run of N·g would have — and the response is CUMULATIVE (a call the deadline ends mid-pair leaves that pair out of the totals and the next call replays it, so equality with one uninterrupted run is unconditional; an oddgamesis carried as a parity in the token so three calls of 3 equal one run of 9): the text starts "Cumulative over 3 calls: 92 games (30 added by this call)…" and every rate carries the interval at the cumulative n. For a paired comparison the token keeps the sums the paired interval needs, so the cumulativeoverall/matchupsdifference, interval and verdict are exactly what one long run gives. Cumulative reports leave out per-call detail that does not add up (card impact, changed-card impact, Prize-taker and liability rows, median first attack); the first call's report has them. The token issc1.<payload>.<mac>: deflated JSON (format version, issue time, calls so far, speed, seed, an identity of fingerprints of the subject's, version B's and each opponent's resolved card list plus the format, and the running totals per opponent), signed with HMAC-SHA256 over the caller's user id and the payload, under a key derived fromDECKE_APPROVAL_SECRET— it cannot be forged, edited, or used by another account. The decks are re-resolved on each call and must fingerprint the same: a deck edited, a different opponent set or order, or a changedspeed/seedis400naming what differs (start a fresh run without the token). Also400: a token that does not verify or parse, one older than 2 hours (each response issues a fresh one), one issued by an earlier deployment (whenVERCEL_DEPLOYMENT_IDis set), and a run that has used its 12 calls (the 12th response carries no token). WithDECKE_APPROVAL_SECRETunset no token is issued and a call carrying one is503continuation_unavailable. Each continued call takes its own 25 s budget, run gate and rate-limit slot; nothing is stored. Rate limit: 6 calls a minute per account (429rate_limitedwithRetry-Afterbeyond that).
Create a deck, or set an existing deck's card list, in ONE transaction. This is what
save_deck (Deck-E and MCP) writes through, and what Deck-E's deck widget uses to save
a revision as a new version. Body { "deckId"? (edit when present), "name"? (required to create), "formatCode"|"format"?, "glcType"?, "cards"?: [{ "cardId", "quantity" 1..60 }] (≤60 distinct; the whole intended list when present), "versionNote"?, "newVersion"? (boolean, edits only), "source"?, "idempotencyKey"? (≤200) }.
deckId must be one of the caller's own live decks (404 otherwise). Every cardId (a TCGdex id or a
catalogue id; two refs to one card are summed) is resolved before anything is written;
one that does not resolve fails the whole request with 400 naming every bad id, and
nothing is saved.
- An edit changes only what changed. A card whose total is unchanged is not
touched — its printing and
pinExactsurvive; a card held as one printing is resized in place; a removed card's rows go; a new card gets its primary printing. Changing the count of a card the deck holds as several printings would have to guess which, so it fails the whole request with400. - Format. Restating the deck's current format changes nothing (a GLC deck keeps
its Pokémon type); a real change behaves like
PATCH /decks/:id. - Retries. Without
idempotencyKeythe API derives one from the content (and, for an edit, the deck's state before the write). A stored result is replayed (200,"replayed": true) only while the deck it made is alive and unchanged since; once it was deleted or edited the key moves to its next generation, so a later identical save writes normally and a retry of THAT save replays it. - Versions. An ordinary edit follows the auto-bump rule: a new version only when the
current one has battle logs, otherwise the current snapshot is amended.
"newVersion": true(2026-10-10, the deck widget's "Save as new version") always lands a CHANGED list as the next version, so the list it replaces stays in the history; a list the deck already matches writes nothing (no version,updated_atuntouched). WithoutdeckIdit is a400— a new deck starts at v1.newVersionjoins the derived retry key only when it istrue, so keys derived without it are unchanged. - An
Idempotency-Keyheader wins over both and is exact: it replays its own result whatever happened to the deck since, and never moves to a new generation. That is the key Deck-E sends for an approvedsave_deck.
201 (create) or 200 returns the detail payload plus replayed and bumped (whether
this write created a new version; a replay repeats the stored answer, false for results
stored before 2026-10-10).
Paste a decklist and create a new deck from it. Body { "text" (required, ≤20000), "source"? = "ptcgl"\|"massentry" (the decklist syntax — defaults ptcgl), "writeSource"? (writer attribution, since source is taken), "formatCode"| "format"?, "glcType"?, "name"? (default "Imported Deck"), "dryRun"? }. Mass Entry set codes
(a third namespace — TCGplayer abbrevs) are resolved by name only. Same print on
two lines is summed (clamped to 60). A non-dry-run import with ANY unresolved line
writes nothing and returns 400 naming those lines (2026-09-29: they used to be
dropped silently); the dry run below still reports them for the dialog to fix or
remove; the web dialog removes the lines the reader chose to skip before it
imports. The write is one transaction. With an idempotencyKey a retry returns the deck
it already made ("replayed": true, same rule as /decks/save, including the exact
Idempotency-Key header winning over the body key); without one — the web
dialog — importing the same list twice on purpose still makes two decks.
201 returns the detail payload plus an import summary:
{ …detail payload…,
"import": { "source": "ptcgl", "resolvedEntries": 59, "distinctCards": 24, "totalCards": 58,
"unresolved": [ "\"2 Latias ex SSP 76\" — could not resolve to a catalogue card." ],
"unresolvedLines": [ "2 Latias ex SSP 76" ],
"glcType": null, "pendingTypeCardIds": [],
"formatIssues": [],
"warnings": [ { …non-UNRESOLVED import warnings… } ],
"variantNote": "…" } }unresolvedLines are the same lines verbatim, as pasted. "dryRun": true
resolves the text and writes nothing: 200 with only { "import": { … } }. The
web import dialog checks first, lists every unmatched line, and lets the reader
fix the text or import without those lines. Dry runs also return card-specific
formatIssues as { "cardId": "…", "reason": "…" } entries when a resolved card
is illegal in the requested format. They do not change which lines resolve or
which cards the import writes. The dialog uses them to recheck accepted Deck-E
corrections after a format change.
For GLC imports without an explicit glcType, the API infers the type only when
all resolved Pokémon share exactly one type. The summary reports it as glcType.
If the type is unknown, glcType is null and pendingTypeCardIds lists the
resolved Pokémon whose type-dependent correction cannot yet be decided; other
format issues remain available. A non-dry-run GLC import with unknown type
returns 400 rather than storing a guessed type. Supply glcType or edit the
list until its Pokémon establish one shared type.
Ask Deck-E to suggest repairs for unmatched PTCG Live lines. Authenticated
accounts need decke.use; this route makes no deck write. Body:
{ "text": "…" (required, ≤20000 characters and 60 card lines), "formatCode": "standard" }.
It charges one daily Deck-E turn. When paid credits are enabled, it accrues the
provider-reported cost as a fraction of a credit; whole credits leave the wallet
as the fractions add up. The response has
{ "fixes": [{ "lineIndex", "original", "replacement", "card": { "id", "name", "set", "number", "image" }, "reason", "confidence": "suggested" }], "unfixed": ["…"] }.
lineIndex is the zero-based physical line, so identical lines stay distinct.
Deck-E selects only from catalogue candidates; the server discards a proposed
replacement unless the regular importer resolves it to that exact card. The
reader reviews every suggestion and the web app checks the confirmed text again
before creating a deck. Uncertain lines remain unresolved. A daily-limit refusal
is 429; an empty paid wallet is 402, a held wallet is 423; loss of
entitlement is 403; model failure is 503.
Serialize the deck to interchange text. Query ?format=ptcgl|massentry (default
ptcgl). Returns { "format", "text", "warnings" }. PTCGL output uses real
PTCGL vocabulary (set codes, brace Energy, stripped zeros) with structured
warnings for anything Live cannot resolve; Mass Entry output uses the stored
per-variant token when present, else a bare name line.
Draw a randomized opening hand + prize cards from the deck. Query ?seed= (uint32;
omit for a random seed, which is returned). Returns:
{ "seed": 12345, "deckSize": 60, "basicPokemonCount": 14,
"mulligans": 0, "opponentDraws": 0, "mulliganChancePct": 12.3,
"hand": [ { "cardId": "…", "name": "…", "number": "…", "category": "Pokemon",
"isBasicPokemon": true, "image": "…" } ],
"prizes": [ …same shape… ],
"note": "Keepable opening hand (contains a Basic Pokémon)." }opponentDraws = this player's mulligan count (the opponent may draw one card per
extra mulligan). mulliganChancePct is the hypergeometric probability of a
mulligan for this deck's basic count.
Per-card and roll-up pricing for the deck against the collection. The deck's
selected variant sets the price; owned uses the same exact-first, format-legal
equivalent-print allocation as the deck page and PDF checklist.
Returns total / owned / missing value, a per-card cards array, and a missing
array (cards where owned < deck quantity) with buyUrl, a massEntry line, and
image. massEntryText joins the missing lines.
{ "currency": "USD", "totalUsd": 124.30, "ownedValueUsd": 80.10,
"missingValueUsd": 44.20,
"cards": [ { "cardId": "…", "name": "…", "number": "…", "setId": "…",
"quantity": 4, "owned": 2, "unitPrice": 1.20, "lineTotal": 4.80,
"currency": "USD" } ],
"missing": [ { "cardId": "…", "name": "…", "number": "…", "setId": "…",
"missingQty": 2, "unitPrice": 1.20, "lineTotal": 2.40,
"buyUrl": "https://www.tcgplayer.com/…",
"massEntry": "2 Charizard [SVI] 25", "image": "…" } ],
"massEntryText": "2 Charizard [SVI] 25\n…" }Body { "strategyMd": "# …" | null, "source"? } (≤40000 chars; null/''
clears). Updates deck.strategy_md and the current version's snapshot in
place — never bumps. Returns the full deck detail payload.
The version timeline, newest first.
{ "current": 3,
"versions": [ { "version": 3, "note": "added a second attacker", "source": "deckpal-mcp",
"createdAt": "2026-07-30T…", "cardCount": 60, "formatCode": "standard",
"battleLogs": { "total": 0, "wins": 0, "losses": 0, "ties": 0 },
"isCurrent": true } ] }One snapshot plus the diff vs v-1 (diff is null for v1).
{ "version": 2, "isCurrent": false, "formatCode": "standard",
"note": "…", "source": "web", "createdAt": "…", "strategyMd": "# …",
"cardCount": 60,
"cards": [ { "cardId": 123, "tcgdexId": "sv01-25", "name": "…", "quantity": 2 } ],
"battleLogs": { "total": 4, "wins": 3, "losses": 1, "ties": 0 },
"diff": { "added": [ { "name": "Dhelmise", "tcgdexId": "sv06-22", "quantity": 2 } ],
"removed": [ { "name": "Switch", "tcgdexId": "sv01-194", "quantity": 1 } ],
"changed": [ { "name": "Ultra Ball", "tcgdexId": "sv01-196", "from": 3, "to": 4 } ] } }Body { "toVersion": 1, "includeStrategy"?: true, "note"?, "source"? }.
Non-destructive: reconciles deck_card to the old snapshot and always creates
a new version, so the version it replaces keeps its list even when it has no
battle logs (a revert never amends in place — DECISIONS 2026-09-26). Note
auto-set to Reverted to v<k>. 400 when toVersion is already current.
Returns the deck detail payload plus
"revert": { "toVersion", "version", "bumped", "skippedCards": [ { "cardId", "tcgdexId", "name" } ] }
(skippedCards = snapshot entries whose print has vanished from the catalog —
near-impossible under ON DELETE RESTRICT — reported, never silently dropped).
?version= (filter to one version) &page= &pageSize= (default 50, cap 200).
Summaries only — never rawLog. totals covers the same filter scope.
{ "version": null,
"logs": [ { "id": 7, "deckVersion": 1, "result": "win", "opponent": "Robni16",
"opponentDeck": "Dragapult ex / Dusknoir", "turns": 14,
"opponentArchetype": "dragapult-ex", "origin": "ptcgl",
"prizes": { "me": 6, "opponent": 5 }, "notes": null,
"reviewMd": "## Turning point\n…",
"playedAt": "2026-07-30T…", "source": "web" } ],
"totals": { "total": 4, "wins": 3, "losses": 1, "ties": 0 },
"archetypes": [ { "opponentArchetype": "dragapult-ex", "games": 3,
"wins": 2, "losses": 1, "ties": 0, "lastPlayedAt": "…" } ],
"pagination": { "page": 1, "pageSize": 50, "total": 4, "pageCount": 1 } }archetypes is the all-versions record for the deck (not only the current page
or optional version filter), newest encounter first. Logs without a classified
opponentArchetype are omitted from that summary.
Body { "rawLog"?, "origin"?: "ptcgl"|"in_person"|"other", "result"?, "opponent"?, "opponentDeck"?, "opponentArchetype"?, "notes"? (≤2000), "reviewMd"? (≤12000), "playedAt"? (ISO), "playerName"?, "source"?, "dryRun"? }.
origin defaults to ptcgl. PTCG Live requires a non-empty rawLog (≤50000)
and runs the parser; in_person and other may omit rawLog but require an
explicit result because there is no game log from which to infer it.
Parser-derived result / opponent /
opponentDeck (deck guess)
fill any fields the caller omitted — explicit caller values always win over
the parser (2026-08-29, mergeLogFields). Attaches to the deck's current
version.
opponentArchetype is normalized server-side to a lowercase ASCII word key
(apostrophes removed, other separators collapsed to hyphens, ≤64 characters),
for example N's Zoroark ex → ns-zoroark-ex. notes are the reader's own
words; reviewMd is the assistant's markdown analysis (Deck-E's, or a connector client's).
400 when the parser cannot tell which player owns the deck and neither
playerName nor an explicit result was given (the message says which to pass).
"dryRun": true performs that same validation, current-version resolution,
deck-specific parse, owner identification, and explicit-field merge without an
insert. It returns the values the write would store, except playedAt: when
omitted, the preview reports its own clock and a later write uses its own now():
200 { "dryRun": true, "attachedToVersion": 2,
"preview": { "deckName": "Toolbox Slowking", "version": 2,
"result": "win", "opponent": "Robni16",
"opponentDeck": "Dragapult ex / Dusknoir",
"opponentDeckGuess": "Dragapult ex / Dusknoir",
"opponentArchetype": "dragapult-ex", "origin": "ptcgl",
"prizes": { "me": 6, "opponent": 5 }, "turns": 14,
"notes": null, "reviewMd": "## Turning point\n…",
"playedAt": "…", "confidence": "high" },
"parsed": { "players": { "me": "cheyras", "opponent": "Robni16" }, "…": "…" } }The non-dry-run response remains:
201 { "attachedToVersion": 2,
"log": { "id": 7, "deckVersion": 2, "result": "win", "opponent": "Robni16",
"opponentDeck": "Dragapult ex / Dusknoir", "turns": 14,
"opponentArchetype": "dragapult-ex", "origin": "ptcgl",
"prizes": { "me": 6, "opponent": 5 }, "notes": null,
"reviewMd": null,
"playedAt": "…", "source": "web", "createdAt": "…", "rawLog": "Setup…",
"parsed": { "players": { "me": "cheyras", "opponent": "Robni16" },
"confidence": "high", "result": "win", "wentFirst": "opponent",
"totalTurns": 14, "prizesTaken": { "me": 6, "opponent": 5 },
"knockouts": { "byMe": ["Dusknoir","…"], "byOpponent": ["…"] },
"opponentPokemon": ["Dreepy","…"], "myPokemon": ["Poltchageist","…"],
"opponentDeckGuess": "Dragapult ex / Dusknoir" } } }Added 2026-08-29 for battle-log deck inference. Body { "log" (required, ≤50000), "player_name"? } (camelCase playerName also accepted). Parses the
log without writing anything and scores it against the 40 most-recently-updated
non-deleted decks' current-version card lists (name overlap strengthened by
normalized PTCG Live card codes) — a per-user recency cap rather than a full
scan. When the reader is known (player_name, or their PTCG Live name playing
in the log) only that player's cards are scored, so a saved deck that matches
the opponent's cards does not rank; otherwise both players are considered, so
an unidentified owner still ranks.
200 { "parsed": { "result": "win", "opponent": "Robni16", "turns": 14,
"prizes": { "me": 6, "opponent": 5 }, "confidence": "high",
"myPokemon": ["Poltchageist","…"],
"opponentDeckGuess": "Dragapult ex / Dusknoir" },
"candidates": [ { "deckId": "…", "name": "Toolbox Slowking", "format": "standard",
"version": 3, "score": 11, "matchedNames": 9, "total": 26 } ] }candidates is sorted by score descending, capped at 5, and [] when nothing
scores above zero.
429 { "error": { "code": "rate_limited",
"message": "Too many log-preview requests — try again shortly." } }Rate-limited at 20 log-preview calls/min per user — the standard JSON error
envelope, not a bare status, and with no Retry-After header. The counter is best-effort per instance (not shared across
replicas), so a burst may slip through; retry after the pause rather than
re-pasting the log. Consumed by the agent tool add_battle_log when deck_id
is omitted.
The full row — same log shape as the 201 above (summary fields + nullable rawLog +
parsed + createdAt).
Added 2026-10-10. ?playerName= (optional). Reads the log's stored raw PTCG
Live log into a compact, model-sized digest (digestBattleLog in
apps/api/src/deck/battlelog.ts) — read-only, same ownership rules as the
detail route above. The side is, in order: playerName when given; the
reader's PTCG Live name when it plays in this log; the owner the row was
stored with (parsed.players.me); and only when none of those is known, the
deck's current card list.
200 { "logId": 7, "deckVersion": 2, "origin": "ptcgl", "result": "win",
"digest": {
"players": { "me": "PlayerA", "opponent": "PlayerB" },
"playerNames": ["PlayerB", "PlayerA"], "confidence": "high",
"result": "win", "wentFirst": "opponent", "totalTurns": 14,
"turns": { "me": 7, "opponent": 7 },
"mulligans": { "me": 0, "opponent": 1 },
"firstAttackTurn": { "me": 4, "opponent": 3 },
"finalPrizes": { "me": 6, "opponent": 5 },
"prizeTimeline": [ { "turn": 3, "side": "opponent", "prizes": 1,
"knockedOut": "Poltchageist", "score": { "me": 0, "opponent": 1 } }, "…" ],
"opponentCards": [ { "name": "Dragapult ex", "count": 16 }, { "name": "Judge", "count": 2 }, "…" ],
"myPokemonUsed": ["Poltchageist", "Shuppet", "…"],
"opponentArchetypeGuess": "Dragapult ex / Dusknoir",
"endReason": "prizes", "leadChanged": true, "closeGame": true,
"unknowns": ["the opponent's hand, and any card of theirs that was never shown",
"which cards were prized, on either side"] } }turn is the game's turn number counting both players (1 = the first
player's first turn, 0 = setup). firstAttackTurn is the first turn that side
dealt attack damage. opponentCards is every card the opponent showed —
played, attached, evolved into, attacked or used an Ability with, Knocked Out,
promoted, drawn or added to hand face-up, discarded face-up, or listed in a
revealed group (mulligans, searches, discards) — deduped, most-seen first;
using the Stadium already in play does not count as running it. endReason is
prizes | concede | deck-out | other (timeouts and anything unrecognised).
closeGame is true when the lead in prizes changed hands, or when the final
prize gap is two or less once someone has taken at least three (so an early
concession at 0–0 is not "close"). When the owner cannot be identified,
players is null/null and the per-side fields (turns, mulligans,
firstAttackTurn, finalPrizes) are null, never a guessed split;
unknowns then names the players to choose between. result at the top level
is the STORED result (explicit corrections win); digest.result is what the log
text alone says.
404 { "error": { "code": "not_found",
"message": "no game log to digest — this game was reported in person" } }Logs stored without raw text (origin: in_person or other) have nothing to
digest. Consumed by the agent tool battle_digest.
replay (added 2026-10-14) is the battle simulator's reading of the same
log (replayInsights in packages/sim/src/replay/insights.ts, via
apps/api/src/deck/replayDigest.ts), or null when it cannot read it. Never
an error. Sides are indexes into players; side 0 is the reader whenever the
digest named them, and me says which side that is (null when unknown).
"replay": {
"players": ["PlayerA", "PlayerB"], "me": 0,
"replay": { "completed": true, "replayed": 43, "actions": 43, "stoppedTurn": null,
"reason": null, "unscripted": [] },
"turns": [ { "turn": 3, "side": 1, "board": [
{ "active": { "name": "Poltchageist", "hp": 30, "maxHp": 30, "energy": [], "tools": [],
"conditions": [] },
"bench": [ "…" ], "prizesLeft": 6, "hand": 7 }, "…" ] }, "…" ],
"attacks": [ { "turn": 6, "line": 84, "side": 0, "attacker": "Dhelmise", "move": "Vengeful Anchor",
"target": { "side": 1, "name": "Mega Lucario ex" }, "printed": "30+", "dealt": 340,
"modifiers": ["4 discarded Pokémon: 140", "Weakness to Psychic: 170"],
"hpBefore": 340, "knockedOut": true }, "…" ],
"moments": [ { "turn": 8, "side": 1, "kind": "timer",
"text": "{p1} didn't take an action in time: the timer ended their turn 8" }, "…" ],
"finalBoard": [ "…" ],
"cards": [ [ { "name": "Dhelmise", "line": "Basic Psychic 140 HP · Vengeful Anchor (Psychic) 30+: …" } ], [ "…" ] ],
"ending": { "winner": 0, "reason": "prizes", "turn": 10, "note": null },
"assessment": { "score": 7, "depth": "deep", "reasons": ["+3 close: Prizes 5–6", "…"] },
"ms": 31 }-
turns: the board as each turn began (after the draw), from the engine's replay. HP counts HP effects (Tools, Stadiums). A turn after the replay stopped hasboard: null. -
attacks: from Live's own lines.printedis the card's printed damage, andmodifiersis Live's damage breakdown without the base and total lines. -
moments(kind):self_ko: Knocked Out on its owner's own turn;no_damage: an attack that prints damage did none (no failed coin flip; the cause is not asserted);blocked: "Effects of X did not affect Y";near_ko: left on 30 HP or less;timer: "didn't take an action in time";ending.
Their
textnames players as{p0}/{p1}, for the renderer to label. -
assessment: how much a review is worth (assessReview).nullwhen the reader is unknown.
Added 2026-10-14. ?playerName= (optional). This answers "what would the
strongest simulator pilot have done on this turn?" (reviewTurn in
packages/sim/src/replay/turnReview.ts). It is read-only, with the digest's
ownership rules and its side order. Steps:
- The stored log is replayed to the reader's first decision of turn
:turn. - The simulator's own-turn search runs over 3 determinised worlds and shortlists the best first actions.
- Each shortlisted action, the reader's own always among them, is searched again from the position it leads to, with the same budget.
It is seeded, so the same game gives the same review.
200 { "logId": 7, "turn": 6, "players": { "me": "PlayerA", "opponent": "PlayerB" },
"review": { "turn": 6, "found": true, "reason": null,
"readerDid": ["P0 played Stadium Prism Tower", "P0's Dhelmise attacked with Vengeful Anchor (340 to Mega Lucario ex)"],
"options": [ { "rank": 1, "first": "Bench Dhelmise", "line": ["Bench Dhelmise", "…", "Attack: Vengeful Anchor"],
"prizes": 3, "knockOuts": ["Mega Lucario ex"], "behindBest": 0, "winsIn": 0, "reader": false },
{ "rank": 3, "first": "Play Prism Tower", "…": "…", "behindBest": 0, "reader": true } ],
"offered": 17, "worlds": 3, "nodes": 7200, "hiddenInHand": 0, "ms": 316 } }behindBest: how much worse than the best option, in Prize cards of the engine's evaluation. Options within about 0.3 are effectively equal.line: the option's best line through the end of the turn, in one sampled world.hiddenInHand: how many cards in the reader's hand the log never showed, which the search had to guess.- No review. Two cases answer
found: falsewith areason: an opponent's turn, and a turn the replay could not reach. When the reader cannot be identified,reviewisnullandreasonasks forplayerName. - Errors.
404when the log has no raw text, as for the digest.429(rate_limited) beyond 20 reviews a minute per user. Consumed by the agent toolbattle_turn_review.
Body: any of { "result", "opponent", "opponentDeck", "opponentArchetype", "notes", "reviewMd", "playedAt" }.
Metadata only — the raw log and its version attachment are immutable. Explicit
null clears everything except playedAt; archetypes are normalized when set.
Returns { "log": … } (full shape). Every edit is recorded (2026-10-12) as a
battle_log.edit batch whose event holds the old and new value of every
editable field, so POST /mutations/revert can put them back.
Moves the log to the deck's recycle bin (battle_log_bin, migration 087;
2026-10-12 — before that a delete was permanent): { "deleted": 7, "restorable": true, "batchId": "…" }. ?purge=true deletes a live log or one
already in the bin for good: "restorable": false. Recorded as a
battle_log.delete or battle_log.purge batch; the event keeps a summary
(version, result, opponent, archetype, date), never the raw log. 404 if the
log is in neither place.
The deck's recycle bin, newest deletion first, at most 200:
{ "logs": [ { …summary row, "deletedAt": "…" } ] }.
Puts a deleted log back under the same id and the same deck version:
{ "log": … (full shape), "batchId": "…" }, recorded as battle_log.restore.
404 when the log is not in the bin (restored already, purged, or never
deleted).
Every cart route — these two, GET /lists/:listId/massentry and
POST /massentry (§Cart routes) — shares one builder
(apps/api/src/tcgplayer/massentry.ts). These are links, not a cart: the
reader opens them on TCGplayer, chooses printing and condition there, and
checks out there.
- Exact lines are
<qty>-<productId>, the TCGplayer product id fromcard_variant.tcgplayer_product_id, aggregated per product id (a normal and reverse printing that share one product become one line). TCGplayer resolves these against its own catalog, so no name, set code or collector number is involved. - Best-effort lines come from a curated per-variant
tcgplayer_mass_entrytoken (with its quantity replaced). The column is empty in the shipped catalog; it exists for forks. These go in their own URLs (bestEffortUrls), never mixed with exact lines, because Mass Entry is all-or-nothing: one line that fails to match adds nothing at all. - Unlinkable — a needed card with neither — is returned in
unlinkableand counted in a warning, never dropped silently and never guessed by name.
Lines are URL-encoded (spaces as +), joined with ||, and chunked at 1800
encoded characters per c= payload into an ordered list of URLs, exact
first — opening each adds to the same cart. Printing (normal/foil/reverse) and
condition can never be preselected by link; the response's note says so.
Cart link(s) for every card still needed to finish the set.
Query: goal = complete (default) | master | grandmaster; finish
(repeatable) = normal|reverse|holo|lenticular|metal limits which
printings are carted (on complete, which printing is picked for each missing
card — linkable first, then cheapest); rarity / rarity_exclude
(repeatable, case-insensitive).
{ "source": "set", "set": { "setId": "me05", "name": "Pitch Black" },
"goal": "complete", "finishes": null, "rarity": null, "rarityExclude": null,
"needed": { "cards": 81, "items": 81, "unlinkable": 0,
"exactLines": 81, "bestEffortLines": 0 },
"lines": [ "1-704758", … ],
"text": "1-704758\n…",
"urls": [ "https://www.tcgplayer.com/massentry?productline=Pokemon&c=…" ],
"exactUrls": [ "…" ], "bestEffortUrls": [],
"unlinkable": [ { "name": "…", "number": "…", "setId": "…", "variant": null } ],
"warnings": [], "note": "Printing (normal/foil/reverse) and condition (NM/LP/…) cannot be preselected by link — …" }needed.cards counts Mass Entry lines (the name is kept for compatibility);
needed.items counts copies.
Cart link(s) for the deck's missing cards — same math as /decks/:id/pricing:
each row's deck_card.quantity minus the copies the ownership allocation
credits to that row (exact copies, then gameplay-identical printings; see
GET /decks/:id).
{ "deck": { "id": "47333f45-…", "name": "Hide 'n' Sneak (Dhelmise)" },
"needed": { "cards": 7, "items": 23, "unlinkable": 0,
"exactLines": 7, "bestEffortLines": 0 },
"lines": [ "3-<productId>", "4-<productId>", … ],
"text": "3-<productId>\n…",
"urls": [ "https://www.tcgplayer.com/massentry?productline=Pokemon&c=…" ],
"exactUrls": [ "…" ], "bestEffortUrls": [],
"unlinkable": [ { "name": "…", "number": "…", "setId": "…", "variant": "…" } ],
"warnings": [], "note": "…" }/decks/:id/pricing's per-missing-card massEntry field and massEntryText
use the same line vocabulary. An unlinkable card's massEntry is null and it
is left out of massEntryText.
Tip surfaced by the UI/MCP, not the API: after the cart fills, TCGplayer's own Cart Optimizer (inside the cart) has a consolidation mode that finds the fewest sellers — or one seller with everything — so it ships in one package.
Read-only, parameterized (ids come from the URL), each streams
application/pdf with a Content-Disposition: attachment filename and
private, no-store. Rendered with pdfkit (pure-JS/ARM-safe). Mounted at the
/deckpal/api base before the /decks, /lists, /sets routers.
A printable deck list for :id (UUID). Loads the deck + cards, runs the legality
engine (validateDeck, with a reprint oracle for pool-checked formats) for the
verdict, and renders Pokémon/Trainer/Energy sections with owned counts, set codes
(PTCGL alias), per-section counts, a have/quantity column, and a generated-at stamp.
404 for a non-UUID id or a missing deck.
A printable export of list :id (UUID). Renders the list items (card rows with
variant label + owned flag; pokedex_binder species as Pokédex #<n>), the
item/owned counts, and progress for dynamic/pokedex_binder lists (static
lists have no progress). 404 for a non-UUID id or a missing list.
A printable set checklist. One row per card (number and name) with
an owned checkbox; owned = any variant has qty ≥ 1 (the Complete goal's
card-fraction semantics, matching /sets/:setId). Printed and secret cards have
separate labels. Header carries set name, id, series, release date, and a
{ owned, total, pct } progress rollup.
Mounted at /insights. Every GET is read-only over the collection; the one write
is POST /value/snapshot, which the deckpal-sync daily cron calls over HTTP
(sync must not import this app — its db.ts opens a 2-connection pool at module
load). All private, no-store.
Trainer level + collection value + dex summary in one payload.
{ "trainer": { "level": 4, "levelProgress": { "current": 120, "needed": 150, "pct": 80 },
"uniqueMode": "pairs", "totalCards": 412, "uniqueCards": 318,
"uniquePairs": 401 },
"collectionValue": [ { "currency": "USD", "total": 1985.09, "pricedVariants": 312, "quantity": 412 } ],
"pokedex": { "captured": 87, "total": 1025, "pct": 8.5 } }uniqueMode (cards or pairs) decides whether trainer level counts distinct
cards or distinct (card, variant) pairs.
Collection value over time + top movers. Query: range =
30d|3m|6m|1y (default 30d); currency = USD|EUR|JPY
(default USD).
{ "currency": "USD", "range": "30d",
"current": { "currency": "USD", "total": 1985.09, "pricedVariants": 312, "quantity": 412 },
"series": [ { "observedOn": "2026-07-30", "total": 1980.00, "pricedVariants": 310 } ],
"movers": [ { "cardId": "…", "name": "…", "direction": "up",
"fromMinor": 1200, "toMinor": 1800, "pct": 50 } ] }Internal daily snapshot — appends today's per-currency totals to
collection_value_point for the requesting user. Idempotent per
(user, observed_on, currency): a same-day re-run inserts nothing
(ON CONFLICT DO NOTHING) and reports inserted: 0. Any request body is
ignored. Returns the snapshot result ({ currencies, inserted, observedOn }).
The gamified species grid (a richer view than /dex). Query: generation (1–9),
own = all|captured|uncaptured (default all), q (free text),
page/pageSize (1–1025, default 200). Returns the grid payload produced by
insights/pokedex.speciesGrid (species rows with capture state + level/shiny
roll-ups).
One species' cards + capture/level/shiny detail. :speciesId accepts the numeric
dex id or the slug. 404 when no such species. Returns the
insights/pokedex.speciesDetail payload.
Session and permission required. Scanner endpoints require current
scanner.usepermission and an active application session in every deployment, including cloud preview. Missing scanner authority is hidden with404 not_found; connector/PAT credentials cannot use this private tool. The web route and navigation use the same server permission. Existing owner/QA environment lists are imported once into database roles; they are no longer a continuing runtime bypass.
Offline card scanner: image → catalog match. Send the raw image bytes as the
request body with an image/* Content-Type — not multipart, not base64
(curl --data-binary @photo.jpg -H 'Content-Type: image/jpeg' …). Max upload
4 MB (the hosted platform rejects a larger request body before the API sees it;
the web client downscales bigger photos client-side, which also converts iOS
HEIC). Query: k = 1–25 top matches (default 5); quality = low|high
(which indexed hash set to match against, default low).
Computes the query image's 64-bit dHash, ranks the whole indexed hash set by
Hamming distance (0 = identical, 64 = opposite) across multiple rotation/keystone
probes (the min distance wins), and hydrates card metadata for the top k. The
ranking is a single SQL query — bit_count(hash_bits # probe) over
card_image_phash, native Postgres 14+, no extension and no in-process index —
so an indexer run takes effect immediately and the endpoint works identically on
a long-lived server and a serverless function. Measured: 22.6k rows × 34 probes
in ~69 ms of server time. matched is true only when the best distance is
within the confidence threshold (9 — re-measured over 389 degraded scans, so
96.9% of correct scans fire and every tested junk frame is rejected) and
the best card has no same-art printing (apps/api/src/scan/artFamilies.ts —
e.g. Base Set and its Base Set 2 reprint, which a whole-card hash cannot tell
apart). When the distance is within the threshold and only that same-art guard
said no, the response is matched: false with printingOpen: true: the
card is likely right, its printing is for the reader to choose from matches.
printingOpen is absent in every other response. Separately, the hash never
names a WotC-era card (Base Set through the e-Card sets) on its own —
matched: false without printingOpen — because their shared yellow frame
dominates a whole-card hash (hashMayNameAlone); the resolve leg still can.
Read-only.
{ "query": { "algo": "dhash8v3", "hash": "f0e1…08" },
"matched": true, "threshold": 9, "indexSize": 23104,
"matches": [ { "cardId": "sv03.5-199", "name": "Charizard ex", "number": "199",
"setId": "sv03.5", "setName": "151", "rarity": "Ultra Rare",
"images": { "low": "…", "high": "…" },
"distance": 3, "confidence": 0.953 } ] }confidence is bit-similarity (1 - distance/64, 1.0 = identical hash). When no
hashes are indexed yet the response is matched: false with a note to run the
scan indexer.
Loads the identity model on the answering instance and runs it once on a blank
crop, so the scanner's first capture does not pay for the model load (field
telemetry: ~1.5 s warm against 4-8 s cold). The scanner calls it when it opens,
every four minutes while it stays open and visible, and on returning to the tab.
Same gate as every scan route. Always 200:
{ "embed": "warm", "ms": 512, "loadMs": 310 }embed is warm, off (this deployment has no embedding matcher; the client
stops asking), or error (logged server-side; the next capture reports its own
outcome).
The embedding matcher. 404 unless SCAN_EMBED_MATCH=true — an unset flag
means this deployment does not have this endpoint, and GET /health reports
scanEmbed.
The body is the CROP, exactly like POST /api/scan: the raw bytes of the
rectified card, Content-Type: image/*, not multipart and not base64. The model
runs SERVER-SIDE (owner ruling, 2026-09-05), so the phone downloads no model and
computes no vector, and there is deliberately no way to send one — an endpoint
that accepts an arbitrary point in the index is a broader capability than one
that accepts a photograph of a card.
curl --data-binary @crop.jpg -H 'Content-Type: image/jpeg' \
'https://deckpal.app/api/scan/embed?k=5&margin=0.05'
| query | default | meaning |
|---|---|---|
k |
5 | candidates to return, 1..25 |
margin |
0.05 | background beyond the card on EACH side, as a fraction of the card's own dimension — rectify.ts's CAPTURE_MARGIN. Send 0 for a tight crop. Range 0..0.25. |
margin is refused outside its range rather than clamped: the server cannot
measure how much table is in a picture, and a wrong value yields a perfectly
plausible vector of the wrong rectangle.
A body this server cannot decode is a 400. A deployment with no checkpoint at
SCAN_EMBED_MODEL_PATH is a 500 whose message names the missing file. A
deployment that has not run migration 051 answers indexSize: 0 with a note
and never a 500 — "not migrated yet" is an operator state, not a fault.
stamp in the response names the vector space that answered. Results from two
stamps are not comparable, and a client comparing across a model change needs
that and nothing else to know it.
{ "stamp": "e1:clip-vit-b32-openai", "indexSize": 23546,
"identity": { "level": "confident", "cardId": "me04-024", "similarity": 0.783,
"margin": 0.1275, "modelId": "clip-vit-b32-openai" },
"variant": { "level": "unknown", "reason": "no-variant-model",
"requiresUserChoice": true },
"matches": [ { "cardId": "me04-024", "name": "Avalugg", "number": "024",
"setId": "me04", "setName": "Chaos Rising", "rarity": null,
"seriesId": "me", "images": { "low": "...", "high": "..." },
"similarity": 0.783, "variantCount": 3 } ] }There is no matched and no confidence, deliberately. Identity and
variant carry separate verdicts and are never blended (owner ruling,
2026-09-04); a client asking "is this settled" reads
identity.level === 'confident' && !variant.requiresUserChoice, which is two
decisions because there are two. identity.level is confident / uncertain /
none; none still reports the similarity it rejected, and still returns the
candidates, because declining to claim is not refusing to show the work.
variant.level is unknown in every response this build can produce — nothing
here measures a printing — and requiresUserChoice is what the verify UI must
branch on.
When nothing is embedded yet the response is indexSize: 0 with a note to run
tools/embed-catalog.
The OCR resolution ladder: printed-text fields read off a captured card ->
identity. JSON body: { "fields": { "name"?, "number"?, "denominator"?, "setCode"?, "bodyLines"?: string[] }, "priorMatches"?: [{ "cardId", "distance" }], "vectorMatches"?: [{ "cardId", "similarity" }] }. All fields optional; an
ABSENT denominator is meaningful (some sets print none) and an empty
bodyLines must be omitted, not sent. bodyLines (<= 24 lines, <= 200 chars
each) is the whole-card escalation: the client sends it only when name and
number extraction both failed; the family-text rung (rung 9) runs last and can
never override a rung that resolved from name/number/badge.
vectorMatches is POST /api/scan/embed's matches, fed back as evidence
exactly the way priorMatches is POST /api/scan's — same bounds (50 max),
similarity in -1..1. It is IGNORED unless SCAN_EMBED_MATCH=true, and
with the flag off this endpoint returns byte-for-byte what it returned before
the embedding matcher existed: same keys, no similarity field on a match,
same ordering.
Response: { "matched", "confident", "resolvedBy": "badge+number"|"number+denominator"|"name+number"|"family-text"|"vector"|"corroborated"|"prior-only", "printingOpen"?: true, "matches": [...same card shape as /scan...] }. Contract deviations from
/scan: distance/confidence are number|null (a card resolved by its
printed key was never nominated by phash — null means "no phash opinion",
where 0 would claim an identical hash); a similarity field (number|null)
appears on each match ONLY when the flag is on; and for
resolvedBy: "family-text" a matched: false response CARRIES a non-empty
matches list when body text resolved a multi-printing family — the candidates
for the picker; never auto-add one. confident is an identity claim only;
variant/printing confidence is a separate dimension and never appears here. A
badge whose printed denominator contradicts the read one is rejected (falls
down the ladder); sets that print no denominator are eliminated when a
denominator WAS read. 400 on malformed shapes with specific messages.
Read-only.
The two vector verdicts. "vector" means the image answered alone, on the
calibrated gate of the active checkpoint (packages/matching THRESHOLDS:
similarity >= 0.65 AND top1-top2 margin >= 0.03, or the wide tier's
similarity >= 0.45 AND margin >= 0.12, for deckpal-card-b32-v2, the
fine-tuned model of #288; 0.74 / 0.02 for the zero-shot
clip-vit-b32-openai before it); it is the LAST rung that can name a card, below every
rung that read something printed. "corroborated" means two independent
signals named one card while neither was sufficient alone — the 014/198
Steenee-or-Floragato case, where the printed key genuinely cannot choose and the
vector's own top-1 is one of the two. A confident OCR rung is never reviewed by
the vector alone: where they disagree, the printed key wins and the response is
identical to the flag being off. The one exception (2026-10-10) is a key
resolved on the printed NUMBER (badge+number, number+denominator) whose
card the printed NAME does not agree with. If the name agrees with the
vector's decisive top-1 and that card carries the number read, is in the
badge's set, or (for number+denominator only) is in a set of the denominator
read, the answer is "corroborated" on that card. If it fits none of them, the
answer becomes unconfident and lists both cards. If the name together with the
denominator names other cards (for badge+number: other cards in the badge's
own set), the answer becomes unconfident and lists the keyed card with them, and
a vector top-1 inside that list still corroborates it, exactly as on any other
key-narrowed list. That is how a dropped digit (23/197 read off 223/197)
that lands on a real card stops being a confident wrong answer. The key stands
unchanged when no name was read, when the name agrees with the keyed card, when
the read is a whole-word piece of the keyed card's own name (Charizard EX off
M Charizard EX), when the vector's own top-1 (showable or better) or a
near-exact phash is the keyed card, and when a garbled name agrees with nothing.
A phash distance <= 2 can CONFIRM the vector and can never substitute for it.
The printing guard (printingOpen). When the top card shares its picture
with another printing (apps/api/src/scan/artFamilies.ts), a confident answer
that no printed key decided — vector, corroborated, name-family,
family-text — comes back matched: true, confident: false, with the
same-art printings listed right after the top match and "printingOpen": true.
So confident: false on a vector or corroborated answer can now mean
"right card, printing open", not only "unsure". The printed-key rungs
(badge+number, number+denominator, name+number, name+denominator) are
never reopened, nor is a corroborated tie-break inside such a key's
candidates when the key already excluded every same-art printing (87/114
names bw1-87 or xy11-87; a same-art 2011bw-12 prints another number) —
except for a printed twin: a Celebrations Classic Collection reprint prints
its original's number (CC002 Charizard reads 4/102) under a CCnnn catalogue
number, so a number key cannot tell them apart and the printing stays open.
Basic Energy opens the printing too. For a basic Grass, Fire, Water,
Lightning, Psychic, Fighting, Darkness, Metal or Fairy Energy, the other
printings of its type are type mates. "Basic" is the catalogue's
energy_type 'Normal', so special energies with the same names are not
included. The eras' designs differ in ways the image model does not separate,
so a printed key decides the printing, or the reader does. Type mates only add
choices: the list is what it would have been, and they fill the slots left
under 25, so a card's own same-picture printings always stay selectable.
printingOpen is absent whenever the guard did not fire, so those responses
are byte-identical to before it.
Note: setCode here is the code PRINTED on 2023+ cards (SVI, DRI, ...) — a
different namespace from PTCGL codes (PR-SV vs SVP), see
apps/api/src/scan/data/printed-set-code.json. Rung 9 needs card_text
populated (migrations 049/050 + a catalog sync); until then it skips silently
and every other rung behaves identically. The vector rung needs card_embedding
(migration 051 + tools/embed-catalog) and skips the same way — a missing table
or a missing vector extension is one log line and the pre-vector ladder, not a
500.
The top-nav "Report a bug" button posts here. Body (JSON, 12 MB limit for the
screenshot data URL):
{ "text" (required, ≤20000), "page"? (current route), "userAgent"?, "viewport"?, "screenshot"? (a data:image/(png|jpeg|webp);base64,… URL, ≤8 MB decoded), "kind"? ("bug" or "feature") }.
400 when text is missing/empty, the screenshot is not a valid image data URL,
its bytes are not a PNG, JPEG, or WebP image, or it exceeds 8 MB. The server
strips the page's query string and fragment before saving or publishing it. It
ignores any screenshot sent for an admin, profile, or credits page, including
mixed-case or encoded spellings of those routes.
Cloud mode (GITHUB_TOKEN + GITHUB_REPO set): inserts a bug_report row
(user id and email from the JWT, stored privately — never in the public issue),
then creates a GitHub issue labelled in-app-report. If Supabase Storage is
configured, the screenshot is uploaded privately under the Report-ID. The public
issue says whether a screenshot was saved but never includes a screenshot URL.
The returned issue number is stored on the DB row. If GitHub is unreachable
the row still persists and the response is 202 with a note.
201 { "id": "<uuid>", "issueUrl": "https://github.com/…/issues/42", "issueNumber": 42 }
202 { "id": "<uuid>", "note": "Report saved but GitHub issue creation failed." }Filesystem mode (either GitHub setting absent): persists each report as a folder under the
repo's issues/ dir (a developer artefact, not user data — the fix-issues
skill walks that dir). No DB.
201 { "id": "2026-07-30T12-34-56_abc123", "saved": "issues/2026-07-30T12-34-56_abc123/" }The web app's React error boundaries (apps/web/src/components/ErrorBoundary.tsx)
post here automatically when they catch an uncaught render exception — no user
action, and never a substitute for /bugs (which is only ever human-initiated).
Unauthenticated: a crash on a signed-out page is just as worth knowing about.
Body (JSON, all fields optional strings):
{ "route"?, "message"?, "stack"?, "buildId"? }, truncated server-side to 300 /
500 / 4000 / 100 chars respectively. Rate-limited at 20/min per source IP. The
handler only console.errors a single structured line — no DB row, no
screenshot, no GitHub issue, no user identity — and always answers 204 with
an empty body, regardless of what it was sent.
subType/tagsvocabularies are empty —card_subtypeandcard_taghave 0 rows; the subtype/tag filters return nothing until imported. All other 11 filter vocabularies are populated (Card Type 3, Energy Type 11, Set 218, Rarity 40, Weakness 11, Resistance 9, Retreat 6, HP range 30–340, Artist 413).dex_species.total_card_countis unpopulated (0 for all 1025 species); the API computes the count live fromcard_speciesinstead of trusting the column.user_dex_statehas 0 rows for the default user (nothing owned yet), so all species readcaptured:false— correct given an empty collection.- Prices are sparse on vintage/special variants — e.g. Charizard base1-4's
three non-primary variants carry no price; the buy URL for the
1999-2000 Copyrightvariant isnull(no TCGplayer product mapping). Rendered as "no price", never 0.
Apply many collection changes as ONE transaction. This is what log_cards calls;
the per-variant endpoints above still exist and are the right shape for a UI
stepper.
Response:
{
"batchId": "…", "replayed": false, "applied": 99, "unchanged": 0,
"items": [{ "variantId": 37183, "cardId": "me05-001", "setId": "me05",
"before": 1, "after": 2, "delta": 1, "requestedDelta": 1,
"clamped": false }],
"progress": { "me05": { "complete": {…}, "master": {…}, "grandmaster": {…} } },
"folded": [{ "variantId": 37183, "from": [0, 4] }],
"duplicateOf": { "batchId": "…", "at": "…", "note": "…" } // when applicable
}Ordering is defined. Repeated variants fold in input order: deltas
accumulate, an absolute quantity discards everything before it for that
variant, and a delta after an absolute adjusts that value — identical to applying
the items one at a time. folded reports which input indices merged.
Idempotency. A caller-supplied idempotencyKey is honoured indefinitely, and
an Idempotency-Key header means the same when the body has none (the body key
wins when both are sent).
Otherwise the server derives one from the batch's resolved contents plus a
15-minute time bucket. Either way a duplicate returns the ORIGINAL response with
replayed: true and writes nothing. An identical batch outside the window is
applied AND flagged via duplicateOf.
The response is sent after COMMIT (see commitRequestTx), so a 200 from this
endpoint means the writes are durable.
Caps are 400s: 250 items, 40 distinct sets. Distinct sets are the cost driver — each one is a full-set progress recompute.
The history feed, newest first. Filters: since, until, source, tool,
idempotency_key, note (substring), entity_type, entity_id, limit,
page.
{ "total": 42, "page": 1, "pageSize": 25,
"batches": [{ "batchId": "…", "source": "deckpal-mcp", "tool": "collection.batch",
"note": "…", "status": "committed", "idempotencyKey": "…",
"revertsBatchId": null, "startedAt": "…", "finishedAt": "…",
"eventCount": 99, "revertedEventCount": 0 }] }?idempotency_key=… is how a client that lost its response asks what landed.
One operation in full — the batch (including its stored response) and every
event, each with before, after, requestedDelta, effectiveDelta,
clamped, and undoneByEventId.
{ "batchId": "…", // or eventId, or since (+until, source),
// or entityType + entityId (+at)
"strategy": "inverse", // "inverse" (default) | "restore"
"force": false, // apply conflicted items / re-undo something
"dryRun": true, // DEFAULT
"note": "…", "source": "deckpal-mcp" }Returns a plan with one entry per targeted event: the action, from/to/delta
for quantities, a conflicts array, and exact. Items with conflicts are
skipped unless force. Conflicts are raised when the original change clamped,
when the inverse would clamp, or when a later event asserted an absolute quantity
on the same entity. The revert is itself a logged batch.
Deck card-list and battle-log events recorded since 2026-10-12 (deck.cards
with "v": 2, battle_log.*) carry full state. Undoing a deck.cards change
puts the earlier name, format and card list back as a new deck version (a
deck's creation is undone by moving the deck to Recently deleted). Undoing a
battle-log create moves the log to the bin, a delete restores it, a restore
bins it again, and an edit puts the old field values back. A conflict is
raised when the deck or log has changed since the event. Older events of those
kinds, and purges, come back with skipped and a reason; nothing is guessed.
Deck-E sends this header on every write of a tool call the reader approved. It
derives the value from the SDK-signed tool call, the requesting user and the
signed input. A tool call that makes several writes sends one key per write
request. Since 2026-10-12 the shared agent tools (the MCP server, and Deck-E
calls that were not signed here) also send one on the writes a retry would
double — add_battle_log, a deck_history revert, an edit_list create or
bulk add, a forced revert — derived from the request in a 30-minute window
(a repeat in the next window reuses a committed key, so up to an hour)
(packages/agent-tools/src/writeKey.ts); Deck-E's signed key replaces it. Any
client may send its own. A key names one write, scoped to the requesting user,
and is honoured indefinitely. It is never bucketed and never reinterpreted.
| Route | Notes |
|---|---|
POST /decks/save, POST /decks/import |
Wins over the body idempotencyKey and never moves to a new generation. |
PUT /decks/:id/strategy |
A replay leaves a guide written since then alone. |
POST /decks/:id/revert |
A replay makes no further version. Every revert records a deck.revert batch, keyed or not (2026-10-12). |
POST /decks/:id/logs |
Ignored on dryRun. A replay re-reads the stored log; 410 gone if it has been deleted since. |
PATCH /decks/:id/logs/:logId, DELETE /decks/:id/logs/:logId, POST /decks/:id/logs/:logId/restore |
Every request records a battle_log.* batch, keyed or not (2026-10-12). Its events never hold the raw log. |
DELETE /decks/:id, POST /decks/:id/restore |
|
POST /lists, PATCH /lists/:id, DELETE /lists/:id, POST /lists/:id/restore |
POST /lists answers 410 gone on a replay whose list has been deleted since. |
POST /lists/:id/items/bulk, DELETE /lists/:id/items/:itemId |
Ignored on dryRun. A static list is a bag, so this is what stops a replay doubling it. |
POST /mutations/revert |
Ignored on dryRun. |
POST /collection/batch |
Used only when the body has no idempotencyKey. |
- First run. The key is claimed in the write's own transaction, on its
mutation_batchrow, before anything changes. It commits only if the write commits. A keyed request that changes nothing ("already deleted", "nothing to restore") still records its key. A replay of an approved restore must not undo a delete the reader made afterwards. - Replay. A repeat answers with the first run's result plus
"replayed": true(status200where the first run returned201) and writes nothing. The new body does not matter. Where the original response is a live view (a deck or list payload), it is rebuilt from the current state. - Concurrent duplicates. The second request waits on the unique index, then
replays the first's committed result. If the first rolls back, the second
runs.
409 batch_in_flightis the answer only when the result cannot be read yet. - Without the header, nothing changes: the same transaction, the same
unkeyed batches (or none), and no
replayedfield. The keyed batch is visible inGET /mutations?idempotency_key=…like any other.
GET /sets/:setId/massentry?goal=&finish=&rarity=&rarity_exclude=GET /lists/:listId/massentry?missing_only=truePOST /massentry—{ items: [{ variantId | cardId, quantity? }] }(≤500)GET /decks/:id/massentry
All four return the same shape:
{ "source": "set" | "list" | "items",
"needed": { "cards": 111, "items": 151, "unlinkable": 0,
"exactLines": 111, "bestEffortLines": 0 },
"lines": ["1-704758", "2-704760"],
"text": "1-704758\n2-704760",
"urls": [...], "exactUrls": [...], "bestEffortUrls": [...],
"unlinkable": [{ "name": "…", "number": "…", "setId": "…", "variant": null }],
"warnings": [...], "note": "…" }Lines are <qty>-<productId>, aggregated per TCGplayer product id.
exactUrls and bestEffortUrls are never mixed: Mass Entry is all-or-nothing,
so a guess that misses would otherwise void the whole cart.
POST /lists/:id/items/bulk—{ items?: [...], addMissing?: { setId, goal, finishes?, rarity?, rarityExclude?, maxPriceUsd?, pricedOnly? }, dryRun?, source? }. One transaction, one batch, whatever the count.POST /lists/:id/restore,DELETE /lists/:id?purge=trueGET /lists?deleted=true— the recycle bin
POST /decks/:id/restore,DELETE /decks/:id?purge=trueGET /decks?deleted=true— the recycle bin
DELETE /lists/:id and DELETE /decks/:id are now soft by default and answer
{ deleted, restorable: true, batchId }; ?purge=true answers
{ purged, restorable: false }.
Paths are relative to /api on cloud or /deckpal/api on self-host. These
private APIs require an active application session and return no-store.
PAT/OAuth connector tokens do not acquire administration, wallet or sharing
authority. Server-side SQL reauthorizes mutations under the governance lock.
Success follows commit; stale revisions return 409 and invalid bodies return
400. Structural role/target gates supplement ordinary permissions.
GET /me returns singular role:{id,key,name,tier}, isOwner,
accessRevision, actorCapabilities and features, alongside existing
identity/permission flags. Deprecated roles[] is read compatibility only:
Owner is projected as the existing Superadmin summary. Never authorize from
that facade. Role definitions include protectedIdentity, canEdit,
canDelete, editablePermissions and revision; user rows include
server-derived actions and assignableRoleIds.
| Method/path | Authority | Request / purpose |
|---|---|---|
GET /admin/overview |
admin.access |
Permitted overview counts and readiness. |
GET /admin/users |
users.read |
search,status,role,limit,offset; singular assigned role. |
GET /admin/users/:id |
users.read |
Identity/activity, role, revision and target actions. |
PUT /admin/users/:id/role |
Structural roles.assign |
{roleId,expectedRevision,expectedRoleRevision,reason}. Admin can modify only current User/Superuser targets to those same built-ins. |
PUT /admin/users/:id/roles |
Compatibility only | {roleIds,expectedRevision,reason}; rejects zero/multiple IDs and applies the same one-role target ceiling. Use the singular endpoint. |
PATCH /admin/users/:id/status |
users.manage plus target gate |
{suspended,expectedRevision,reason}. |
POST /admin/users/:id/revoke-tokens |
users.manage plus target gate |
{reason}; tokens and pending OAuth codes. |
GET /admin/roles |
roles.read |
Definitions, editable permission catalog and tier ceilings. |
POST /admin/roles |
Superadmin/Owner | {name,description,permissions,tier}; custom tier 10/20/30/40, default 40 if omitted. |
PATCH /admin/roles/:id |
Superadmin/Owner | {name,description,permissions,expectedRevision}; identity/key/tier immutable; Owner not editable. |
DELETE /admin/roles/:id |
Superadmin/Owner | {expectedRevision}; nonbuilt-in and unassigned only. |
GET / PUT /admin/settings |
settings.read / settings.write |
Read defaults; write {settings:{skin,topbar},expectedRevision}. |
GET /admin/audit |
audit.read |
Bounded actor/action/target filters. |
GET /me/features |
Session | {features:FeatureAccess[]}. |
PATCH /me/features/:key |
Session + lifecycle eligibility | {optedIn,expectedRevision}; revision must be current positive safe integer. |
GET / PATCH /admin/features[/:key] |
Superadmin/Owner | Read catalog; update {lifecycle,expectedRevision,reason}. |
GET / PUT /me/decke-sharing |
Active session | Retired: both answer {enabled:false,retired:true,mode:"per_chat",explanation} and change nothing. Chats are shared one conversation at a time (/decke/improvement/consent, /decke/feedback). |
GET /admin/users/:id/ai-override |
credits.read |
{userId,revision,unlimited,markupBps,effectiveMarkupBps,globalPolicyRevision,updatedAt,canEdit}. |
PUT /admin/users/:id/ai-override |
Owner only | {expectedRevision,unlimited,markupBps,reason}; markup null or integer0–100000bps; zero is valid. |
FeatureAccess is {key,label,lifecycle,revision,optedIn,eligible,enabled,reason}.
Released is available to active users; beta requires personal opt-in for every
tier; experiments allow tier 20+ opt-in and automatic Superadmin/Owner access;
disabled allows no new work. Raw role grants do not override the resolver.
Admin directory/audit limits default 50/max 100; one admin budget is 120 requests per 60 seconds per account/API instance. Wallet routes have180/60 seconds; database Checkout quotas remain60 requests/hour and10 new orders/hour. 429 includes Retry-After. Neither in-memory budget is a distributed quota.
| Method/path | Response |
|---|---|
GET /admin/ai-usage |
{items,total,limit,offset,aggregate:{knownUsd,knownCount,unknownCount}}; filters userId,conversationId,category,status,from,to,modelId,buildSha,buildPr,costSource. |
GET /admin/ai-usage/requests/:id |
{request,operations,contentStatus:"retired",content:null}: metadata only (migration 078). |
GET /admin/ai-usage/conversations/:id |
Bounded {items:requestDetails[],total,limit,offset}. |
GET /admin/ai-usage/conversations |
Cursor-paged all-chat cost rollup with no transcript content; filters from,to,userId,model, limit max 100. |
GET /admin/ai-usage/observations |
`days=7 |
POST /decke/history |
Existing personal asked,answered,tools,finishReason plus conversationId,seq,exchangeId. Supplied exchange must match an owned accepted request; duplicate position remains immutable. Returns {ok,recorded,id,buildPr,buildSha}. |
GET /decke/history |
Own conversations, newest activity first: {conversations:[{id,title,turns,startedAt,updatedAt,buildPrMin,buildPrMax,buildSha}]}; limit 1–200, default 40. |
GET /decke/history/:id |
{id,title,startedAt,turns:[{seq,exchangeId,usage,asked,answered,tools,buildPr,buildSha,finishReason,at}]}. usage is that turn's AiUsageRow[], empty for legacy/unlinked turns. Votes are not here; read them from GET /decke/feedback/:conversationId. |
GET /decke/feedback/:conversationId |
Own votes for one conversation: {items:[{seq,vote,comment}]}. |
DELETE /decke/history/:id |
Deletes one own conversation and its turns → {ok:true}. If that conversation was shared, the same transaction revokes the share and deletes its improvement copy (migration 079 trigger). Usage metadata remains. |
POST /decke/improvement/consent |
{conversationId,share,source,shareAll?} grants or declines one-chat sharing; source is decke_ask, feedback or reader. A grant backfills available redacted history. shareAll:true (only with share:true and source:"decke_ask") also turns on the always-share preference. Returns {status,source}. |
DELETE /decke/improvement/consent/:conversationId |
Stops sharing and deletes that conversation's saved improvement copy. |
GET /decke/improvement/mine |
Own raw conversation ids currently shared, for History badges. |
POST /decke/telemetry |
Shared-chat event batch (1–200, max 512 KiB); returns 202 {recorded} and otherwise stores nothing. |
PUT /decke/feedback |
{conversationId,seq,vote,comment?,share?}; feedback stays personal and an explicit share:true grants one-chat consent. |
GET /admin/decke-improvement |
Admin/capable-token corpus list; contract filters, cursor, limit max 100, JSON or NDJSON. |
GET /admin/decke-improvement/:id |
Full pseudonymous detail; ?format=markdown renders a readable transcript and telemetry timeline. |
GET /admin/decke-improvement/search?q= |
Literal corpus search, query length 2–100 and limit max 50. |
The /decke/history routes need a signed-in session and Deck-E access: an
account without Deck-E gets 403 forbidden, not an empty list. They answer
private, no-store.
Usage list/conversation page sizes default 25/max 100, offset max1000000.
Categories are response|research|planning; status is
started|completed|failed|cancelled|abandoned. Request rows include IDs,
nullable conversation/sequence, start/end, full server build SHA/nullable PR,
pricing/override revisions, charge mode, charged credits, operation count,
nullable input/output tokens and cost. Operations additionally include
model/provider/tool key and nullable cache-read/write/reasoning tokens.
AiCost is {source,usd,currency:"USD",coverage}. Source is
provider_reported|token_rate_estimate|unknown; this implementation supplies
validated Gateway-reported costs or unknown, not guessed rate estimates.
usd is a bounded decimal string or null; coverage is complete/partial/unknown.
A known subtotal is not a complete total when some operations are unpriced.
Aggregate knownCount/unknownCount count provider operations; the list total counts
parent requests. Financial summary estimates support both historical flat and
current effective-policy snapshots, and missing estimates count as unpriced.
Usage is server-authored independently of client history, and it is metadata
only. Lists, observations, conversation pages and request details never carry
conversation content: since migration 078, decke_usage_detail answers
contentStatus: "retired" and content: null for every request. 078 retired
migration 071's account-wide sharing switch in place (it turned the switch off
for every account and deleted the excerpts it had collected), and
GET/PUT /me/decke-sharing now only report that.
Usage reads require an active application session, tier 40 or higher and current
admin.access; a custom role loses them as soon as that permission is removed.
Contributor is denied.
Reading what a reader said is a separate mechanism, per conversation:
- Consent is per chat. A reader shares one conversation when Deck-E asks
(
POST /decke/improvement/consentwithsource:"decke_ask", the only source the web app sends there; the route also accepts"reader") or with feedback (PUT /decke/feedbackwithshare:true). Nothing is shared without one of those, except under the next point. - Always share is the
deckeShareAllpreference on/me/settings(defaultfalse, migration 079). While it is on, each new conversation with no recorded decision is shared automatically (sourcealways); it never overrides a decision already made.deckeSharePrompts(defaulttrue) decides whether Deck-E asks at all. - Stopping is
DELETE /decke/improvement/consent/:conversationId, or deleting the conversation from History; either removes the saved improvement copy. - Reading the shared, pseudonymised copies is
/admin/decke-improvement, which needs tier 40,admin.accessanddecke.improvement.read, from a browser session or a token minted with the improvement capability. Copies are kept 180 days.
| Method/path | Permission | Request / purpose |
|---|---|---|
GET / PUT /admin/credits/settings |
credits.read / credits.manage |
GET → {policy,revision,updatedAt,overageBuffer:{balance,coveredTotal},estimateNotice,chargeNotice}; write {policy, expectedRevision}, where policy must be the same version as the stored one (see below). |
GET / POST /admin/credits/packs |
credits.read / credits.manage |
List packs; create {name,credits,priceCents,currency:"usd",active}. |
PATCH /admin/credits/packs/:id |
credits.manage |
Same pack fields plus expectedRevision. |
GET /admin/credits/payment-status |
credits.read |
refresh=true bypasses the 60-second readiness cache; returns ready/reason/requiredEvents/checkedAt. |
GET /admin/credits/users/:id |
credits.read |
The raw wallet {balance,heldCredits,debt,purchaseHold} (balance a decimal string) plus paginated events. |
POST /admin/credits/users/:id/adjustments |
credits.manage |
{delta,reason,idempotencyKey}; nonzero integer delta, bounded ±1,000,000. |
POST /admin/credits/users/:id/resolve-hold |
credits.manage |
{reason}; only eligible closed lost-dispute holds. |
GET /admin/credits/summary |
credits.read |
`days=7 |
GET /admin/credits/orders |
credits.read |
status, user, limit, offset. |
GET /me/credits |
Session | Own wallet and quote; shape below. |
GET /me/credits/events |
Session | Own paginated credit/debt statement; finance-only provider estimates are excluded. |
GET /me/credits/orders/:id |
Session | Own server-confirmed frozen order state. |
POST /me/credits/checkout |
Session + eligible account | {packId,idempotencyKey} only; returns hosted url and orderId. |
Wallet reads recover expired, unstarted reservations idempotently. Reservations locked by a concurrent transaction are skipped and can recover on a later read; a successful response does not promise every busy reservation was refunded.
Credit list limits default to 25 and cap at 100. Attempt keys contain 8–100 letters, digits, underscores or hyphens; financial reasons contain 3–500 characters. Checkout permits 60 requests/hour and 10 new order attempts/hour per account. Browser-supplied amounts/customer/session IDs are not accepted.
The policy is metered (version 2) since migration 081. Its fields
(normalizePolicy, apps/api/src/credits/policy.ts, and
credit_validate_policy in SQL) are exactly:
{ "version": 2, "enabled": true, "microUsdPerCredit": 10000, "markupBps": 0,
"lowBalance": 100, "legHoldCredits": 25, "legHoldMinCredits": 3,
"overageBufferMaxCredits": 2000 }All are whole numbers: microUsdPerCredit 1–1,000,000,000, markupBps
0–100,000, lowBalance 0–1,000,000, legHoldCredits 1–10,000,
legHoldMinCredits 1 up to legHoldCredits, overageBufferMaxCredits
0–10,000,000. The values shown are what 081 seeds (it converted a stored flat
policy by keeping enabled, microUsdPerCredit, markupBps and lowBalance
and adding the last three). A reply settles at its reported model cost:
credits = costUsd × 1,000,000 × (10,000 + markupBps) / (microUsdPerCredit × 10,000), kept to 12 decimal places. Whole credits leave the wallet and the
remainder (under one credit) is carried per user; an operation with no reported
cost is charged nothing. Before a paid reply starts it holds
min(legHoldCredits, balance) whole credits (Deep Think holds a multiple,
migration 083) and is refused with reason insufficient below
legHoldMinCredits, or debt / payment_hold. Settlement returns the unused
hold at once; a cost beyond the wallet is covered by the overage buffer, never
recorded as debt. A request's charge mode is paid, unlimited (an owner
override, which spends zero) or daily (credit charging is off, so Deck-E's
daily allowances apply instead of credits).
The flat (version 1) policy is legacy. Its shape, {enabled, microUsdPerCredit, markupBps, estimatedMicroUsd:{chatTurn,analysis,planDeck}, lowBalance}, priced each operation by ceiling division with a minimum of one
credit. The code still reads it, but a deployment migrated past 081 stores
version 2, and PUT /admin/credits/settings refuses a policy whose version
differs from the stored one (normalizePolicyUpdate), so a flat policy cannot
be saved over a metered one. The two paths refuse each other in SQL: a flat
charge against a v2 revision fails with "Flat pricing retired", and metered
accounting refuses a non-v2 revision.
GET /me/credits answers, under a metered policy:
{ "mode": "metered", "balance": 41.25, "balanceExact": "41.250000000000",
"heldCredits": 0, "holdCredits": 25, "holdMinCredits": 3,
"debt": 0, "purchaseHold": false, "enabled": true, "lowAt": 100,
"unlimited": false, "overrideRevision": 0, "packs": [ … ],
"purchasesEnabled": true, "purchaseUnavailableReason": null,
"pricingRevision": 7, "chargeNotice": "…" }balance is the spendable balance rounded to four places for display;
balanceExact is the exact decimal string; heldCredits is what open replies
currently hold. Under a legacy flat policy the same route answers
mode: "flat" with prices:{chatTurn,analysis,planDeck} and no hold fields
(shapeWalletAccounting, apps/api/src/credits/view.ts).
Historical balances/events and pending order terms are unchanged by policy edits. Credits use USD card-only Checkout, separate from support subscriptions/gifts; signed webhook reconciliation, never a return URL, fulfills an order. See ADMINISTRATION.md for charge/refund/debt semantics.
Frames flagged in the scan harness, with their metadata and an optional
comment (apps/api/src/dev/scanFlags.ts). These routes require labeler access
(the scanner.label permission) and answer 403 otherwise. Cloud uses the
/api prefix. Without an object store (self-host) the list is empty and the
writes answer 501 storage_unavailable.
POST /:{ png, meta }—pngis a base64 PNG andmetaan object, together at most 3 MB decoded (the route's parser accepts 4200 KB on the wire). Stores<id>.pngand<id>.json, where the id is the capture time in epoch milliseconds →{ ok: true, id }.POST /:id/comment:{ comment }(at most 4 KB) →{ ok, id, comment, updatedAt }. Re-posting edits the comment;404if the flag doesn't exist.GET /:{ flags: [{ id, files, size, uploadedAt, comment, label }] }, newest first.?limit=(1–5000, default 1000) bounds the list;?meta=1also reads each flag's metadata summary intolabel(otherwisenull).GET /:file: one stored object —<id>.png,<id>.jsonor<id>.comment.json— with its own content type.DELETE /:id: removes the frame, its metadata and its comment, permanently →{ ok, id, removed };404if nothing was there.
These routes require labeler access in production. Cloud uses the /api prefix.
GET /:{ photos: [{ id, name, source, addedAt, size }] }, oldest first. Each logical photo appears once under its original ID, including when a repair has left both original and replacement objects. Sidecar-only remnants do not appear as photos; missing metadata receives fallback values. The list is built from one object-listing snapshot, so a photo removed while it was being built can appear once and then answer 404 when opened.GET /:id.jpg: reads the surviving photo, preferring its JPEG replacement. Either physical ID resolves the same family. Missing bytes return 404; unavailable storage returns 502. Responses are not cached.POST /: accepts{ jpg, name, source, repairOf? }.jpgis base64 JPEG, capped at 3 MiB decoded. A normal upload gets an unused timestamp ID. A repair uses an existing original HEIC and returns its deterministic replacement ID only after both JPEG and metadata are stored. Repeating a repair completes interrupted work without creating another replacement.DELETE /:id: discards the entire family. A partial failure returns an error; surviving photos remain listed and the request can be retried. An already absent family succeeds.DELETE /:id?repairCleanup=1: removes only original objects, after checking that the replacement JPEG and metadata are complete. Otherwise returns 409.POST /migrate-captures: moves a time-boxed batch (about 20 s) of captures written before 2026-09-28 — bothdev-flags/anddev-queue/— from the publiccard-artbucket into the privatedev-capturesbucket, and answers{ ok, listed, moved, preserved, gone, failed, failures, remaining, done }. Each object is copied create-only, read back and compared (length and SHA-256), and only then deleted from the public bucket; a failure leaves the public copy for the next call. Idempotent and safe to call concurrently. The labeler and harvest pages call it in the background untildone.502if the private bucket or a listing is unavailable (nothing moved).
Queue photos, like the labels under /dev/scan-flags, live in the private
dev-captures bucket and are read with the server's key; responses carry
Cache-Control: private, no-store. Objects not yet moved are still read (and
deleted) through the same routes. See SECURITY.md, "Scanner and labeler captures
are private".
POST /collection/batch { "items": [ // 1–250, at most 40 DISTINCT SETS { "variantId": 37183, "delta": 1 }, { "variantId": 37184, "quantity": 3 } ], "source": "deckpal-mcp", // ^[a-z0-9][a-z0-9._-]{0,39}$, default "web" "note": "Aug 2026 pack haul", // ≤500 chars, stored on every event "idempotencyKey": "…", // optional; see below "dryRun": false // true previews and consumes no key }