Card Orb API
Every route, what it needs, and what it says back. The contract itself is the source; this page only reads it.
Card Orb keeps one person's Pokémon card collection in Postgres, matches it against three card catalogues, prices it from TCGplayer, and serves it here.
**Two clients read this API and both are ours**: the web tool at cardorb.com and the iOS app. Nothing here is designed for a third party yet.
**Every failure is `{ "error": "<sentence>" }`** at a status a client can branch on. The sentence is for a person to read; the status is for the code.
**The version is in the path.** `/v1` changes only by addition: a new field, a new route, a new optional query. Anything that removes or renames is `/v2`.
Servers
https://api.cardorb.com: The API on its own host. `/v1/…` here is `/api/v1/…` on cardorb.com.https://cardorb.com/api: The same API under the web tool's own origin, which is what the browser uses.http://localhost:3000/api: Local development.
Collection
What the signed-in person owns, and the writes that change it.
GET /v1/collection
The whole collection, grouped by set · bearer token or session cookie
Every set the caller owns a card from, with every card and every copy. Prices included. An empty collection is `{ "sets": [] }`, not an error.
200The collection.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503This deployment has no database, or the database is not answering.
GET /v1/cards
One page of the collection as a flat list of copies · bearer token or session cookie
The shape a list with a search box wants; `GET /v1/collection` is the same collection grouped by set. Read from the same cached assembly, so a page is a filter over memory. A value the query cannot mean is a 400, never a guess. `facets` names every set and rarity among the owned copies, whatever the filters, for a filter menu — so a list page never needs the whole collection.
qquery: Matches the name or the set, anywhere, in any case.ownedquery: `true` the collection, `false` the wishlist, absent both.favoritequerypricedquery: `true` the copies with a price, `false` the ones nothing prices, absent both.collectionquery: A folder id. A rule folder answers with the owned copies its rule matches, whatever `owned` says. An id that is no folder of the caller's is a 404.setquery: A set, whole, by its name or its title; case does not matter. Repeat it for several (`set=Jungle&set=Fossil`): a card in any of them counts. At most 50.rarityquery: A rarity, whole, in the catalogue's words; case does not matter. Repeat it for several (`rarity=Rare&rarity=Holo%20Rare`): any of them counts. At most 50.numberquery: One card number, whole; with `set` it names one card's every row. Every spelling finds a row, whichever way the row stores it: `1`, `01` and `001` are one card, `SWSH020` and `020` are one card, case does not matter; a gallery's letters stay (`TG01` is not `1`).genquery: A generation, whole, as the catalogue names its series; case does not matter. Repeat it for several: any of them counts. At most 50.typequery: An energy type, whole, as the catalogue names it; case does not matter. Repeat it for several: any of them counts. At most 50.conditionquery: A copy's condition, whole, as written on it; case does not matter. Repeat it for several: any of them counts. A copy with no condition matches none. At most 50.finishquery: A copy's finish. Repeat it for several: any of them counts. A copy with no finish matches none. A value outside the list is a 400. At most 50.languagequery: A copy's language code (`en`, `ja`, `de`); case does not matter. Repeat it for several: any of them counts. A copy with no language is English and matches `en`. At most 50.sortquery: `set` is the assembly's order (set by set, number by number). `price` is the copy's own printing. `added` is the day it joined the collection. `dex` is the national Pokédex number; a trainer or energy has none and goes last. `change` is what the copy's price did between `from` and `to`, times the copies, biggest gain first (`order=asc`: biggest loss first); each card then carries `priceChange`, and a card without two readings in the window goes last with `priceChange: null`.fromquery: `sort=change` only, and required with it: the window's first day.toquery: `sort=change`: the window's last day; today when left out. Not before `from`.orderquery: Absent, each sort reads the way a person means it: `added` newest first, the rest up. A copy without the key (no price, no date) goes last either way.limitquery: Above 2000 is read as 2000: a whole collection in one page, for a Pokédex. Not a whole number, or below 1, is a 400.offsetquery: Not a whole number, or below 0, is a 400.facetsquery: `facets=0` leaves `facets` out of the answer, for a caller that will not read them (a count, a further batch on scroll): the pass over every item is skipped. Any other value, or none, and they come along. It was undocumented while the response schema marked `facets` required, so a generated client with a non-optional field could not decode the answer it had asked for.picturesquery: `pictures=0` leaves `printImage` off every item, for a caller that counts the copies rather than drawing them. The printings' pictures are a second read over the page's card ids, which costs nothing on a page of forty-eight and 1,191 ms on a page that is the whole collection. An item without `printImage` is what an item whose printing has no picture of its own already looks like, so a client shows the card's own scan. Any other value, or none, and the pictures come along.countsquery: `counts=1` adds `counts`, how many cards each filter option would leave. Any other value, or none, and it is left out and the answer is as before.
200The page, in the asked order (set order when none was), the total behind it, and the facets over the owned whole.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.404Nothing by that name.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
POST /v1/cards
Add a card · bearer token or session cookie
Writes one collection row. `BODY_LIMIT.card`, 8 kB.
200Added.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.404`collectionId` names no folder of the caller's.413The body is bigger than this route's `BODY_LIMIT`.415A write without `Content-Type application/json`, which is what forces a browser's preflight.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
OPTIONS /v1/cards
CORS preflight for a cross-origin write · no key
204Allowed. Carries the `Access-Control-Allow-*` headers.403That origin is not in `ALLOWED_ORIGINS`. Empty body.
POST /v1/cards/facts
What a form needs to know about many cards, in one request · no key
The facts `GET /v1/cards/{tcgId}` answers that a form reads (printings, runs, languages, foil patterns, pattern prints, and the sheet's rarity, illustrator, HP, stage and regulation mark), for up to 250 cards at once: a client asks for a page of tiles before anybody opens one, so a card's sheet opens with its choices in place. Read from the catalogue copy alone, so no price and no price per pattern print. Every id asked is a key of `cards`; `null` is not "no such card" but "ask `GET /v1/cards/{tcgId}`": the copy does not hold the card, or holds it without everything that route would answer. A read, so it changes nothing. Open: a caller who offers no credential is answered too, as `GET /v1/cards/{tcgId}` is. A credential that is offered and does not verify is still a 401. Every answer is private and uncached, the open one included, because a POST is never a shared cache's to hold.
200The facts, by id.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.413The body is bigger than this route's `BODY_LIMIT`.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503This deployment has no database, or the database is not answering.
GET /v1/cards/{tcgId}
One card, its printings and its price · no key
Open: a caller who names nobody is answered with the same card, because nothing on it is the caller's. Its price is a catalogue price, public since 2026-09-22. The credential decides only who may hold the answer. A credential that is offered and does not verify is still a 401.
tcgIdpath, required: TCGdex's card id, e.g. `swsh3-136`.languagequery: Which catalogue holds this id; left out, English. A Japanese id is a 404 in the English catalogue and the other way round. `languages` comes back empty for a Japanese one: the Western catalogues share the English ids, so a copy of it can only be its own language.
200The card.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.404Nothing by that name.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503This deployment has no database, or the database is not answering.
PATCH /v1/collection/items
Change the same inventory fields on many copies at once · bearer token or session cookie
The body `PATCH /v1/collection/items/{id}` takes, with `ids` beside it: one to a hundred row ids, none twice. One statement, so four rows of one kind change together instead of in four round trips. The same rules for a folder named in it. An id that is not there or not the caller's is simply not among the rows that come back; none at all is a 404.
200Changed. Each row as it now is, once — rows a change made the same kind are folded into one.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.404Nothing by that name.413The body is bigger than this route's `BODY_LIMIT`.415A write without `Content-Type application/json`, which is what forces a browser's preflight.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
OPTIONS /v1/collection/items
CORS preflight for a cross-origin write · no key
204Allowed. Carries the `Access-Control-Allow-*` headers.403That origin is not in `ALLOWED_ORIGINS`. Empty body.
POST /v1/collection/items/{id}/copies
One more copy of a row, as a row of its own · bearer token or session cookie
The same card and inventory as the row, with the differences given, `count` held, pulled now unless `acquiredAt` says when. The row is untouched. An empty body — no bytes at all, or `{}` — is one more identical copy. A copy that comes out the same kind as a row already held (same finish, foil pattern, condition, grade, language, binder and star) is folded into that row, whose quantity grows by `count`; the answer is that row. Rows are one per kind.
idpath, required: The row the copy comes from.
201The new row.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.404Nothing by that name.413The body is bigger than this route's `BODY_LIMIT`.415A write without `Content-Type application/json`, which is what forces a browser's preflight.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
OPTIONS /v1/collection/items/{id}/copies
CORS preflight · no key
idpath, required: The row the copy comes from.
204Allowed. Carries the `Access-Control-Allow-*` headers.403That origin is not in `ALLOWED_ORIGINS`. Empty body.
POST /v1/collection/items/{id}/split
Some of a row's copies as a row of their own · bearer token or session cookie
The row loses `count`; the copy is born with the differences and the row's acquired date. At least one difference; `count` at or above the quantity is a 400 (that is every copy; change the row).
idpath, required: The row some of whose copies are different.
201The new row and the row it came from, as they now are.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.404Nothing by that name.413The body is bigger than this route's `BODY_LIMIT`.415A write without `Content-Type application/json`, which is what forces a browser's preflight.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
OPTIONS /v1/collection/items/{id}/split
CORS preflight · no key
idpath, required: The row some of whose copies are different.
204Allowed. Carries the `Access-Control-Allow-*` headers.403That origin is not in `ALLOWED_ORIGINS`. Empty body.
PATCH /v1/collection/items/{id}
Change one copy's inventory fields · bearer token or session cookie
Filing it into a folder (`collectionId`) needs one filled by hand: a rule folder decides its own contents, and naming one is a 400; a folder that is not the caller's is a 404.
idpath, required: The collection row, from `Variant.id` or `BrowseCard.itemIds`.
200Changed. The row as it now is.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.404Nothing by that name.413The body is bigger than this route's `BODY_LIMIT`.415A write without `Content-Type application/json`, which is what forces a browser's preflight.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
DELETE /v1/collection/items/{id}
Remove one copy · bearer token or session cookie
Needs `Content-Type application/json` even without a body; that is what forces the preflight. The row it removed comes back in `card`, the same shape `PATCH` answers with. That is the only moment it can be read — the delete is a delete, nothing is kept — so a client offering an undo keeps it and posts it to `POST /v1/cards`, `acquiredAt` and all.
idpath, required: The collection row, from `Variant.id` or `BrowseCard.itemIds`.
200Removed. The row as it was.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.404Nothing by that name.415A write without `Content-Type application/json`, which is what forces a browser's preflight.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
OPTIONS /v1/collection/items/{id}
CORS preflight for a cross-origin write · no key
idpath, required: The collection row, from `Variant.id` or `BrowseCard.itemIds`.
204Allowed.403That origin is not in `ALLOWED_ORIGINS`. Empty body.
GET /v1/fields
The database's select options · bearer token or session cookie
The cheapest call there is, so it is also how a client learns whether its credential still works.
200The options.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
GET /v1/collection/export
The collection as a CSV in the shape Dex writes · bearer token or session cookie
Every copy held and every card wanted, one row per kind of copy with its count, in the columns and order Dex exports (semicolons between them), so the file goes into Dex, a spreadsheet or back into `POST /v1/import/csv` unchanged. The columns this app knows and Dex has none for follow Dex's: Condition, Language, Acquired (a day), Purchase price, Edition, Finish, Grade, Purchase date (a day) and Favorite ("Yes" or empty). UTF-8 with a byte order mark, CRLF line ends, named by the day in Content-Disposition.
200The file.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503This deployment has no database, or the database is not answering.
GET /v1/stats
The dashboard's numbers · bearer token or session cookie
200The counts.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503This deployment has no database, or the database is not answering.
GET /v1/folders
The caller's folders, oldest first, each with its kind, its rule and how many owned copies it holds · bearer token or session cookie
200The folders.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
POST /v1/folders
Make a folder, by hand or from a rule · bearer token or session cookie
`BODY_LIMIT.folder`, 8 kB. The name is trimmed and inner whitespace collapsed. With a `rule` the folder fills itself; its `count` in the answer is 0 until the next list.
200Made.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.413The body is bigger than this route's `BODY_LIMIT`.415A write without `Content-Type application/json`, which is what forces a browser's preflight.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
PATCH /v1/folders/{id}
Rename a folder, or change a rule folder's rule · bearer token or session cookie
idpath, required
200Changed.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.404Nothing by that name.413The body is bigger than this route's `BODY_LIMIT`.415A write without `Content-Type application/json`, which is what forces a browser's preflight.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
DELETE /v1/folders/{id}
Delete a folder · bearer token or session cookie
The copies filed in it stay in the collection and are taken out of the folder first.
idpath, required
200Deleted.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.404Nothing by that name.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
Catalogue
Every set and every card, whether owned or not. Read from Card Orb's own copy of the catalogues, written nightly; the caller's own counts are read fresh.
GET /v1/catalog/sets
Every set, with how much of each the caller holds · no key
Open: a caller who names nobody is answered with the sets and without any holdings, `ownedCount` and `wishlistCount` left out rather than zeroed. A credential that is offered and does not verify is still a 401.
languagequery: That language's own catalogue (TCGdex); left out, English. Sets and cards there have their own ids and names.
200The sets.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
GET /v1/catalog/sets/{setId}
One whole set, a page at a time · no key
Open: a caller who names nobody is answered with the set and its cards and without any holdings, the holding fields left out rather than zeroed. A credential that is offered and does not verify is still a 401.
languagequery: That language's own catalogue (TCGdex); left out, English.setIdpath, required: pokemontcg.io's set id, e.g. `swsh3`.pagequerypageSizequery: Clamped to 500.fromquery: yyyy-mm-dd, no later than today and no more than a year back: adds `priceChange` to every card, the move of its `printing` since that day. Left out, no card carries the field and no price line is read. Anything else is a 400.
200The page.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.404Nothing by that name.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
GET /v1/catalog/search
Find a card by name, number, set or type · no key
Two modes. With any of `name`, `number`, `set`, `type` it filters; otherwise `query` (two characters or more) is a free search. An English search reads the catalogue's nightly copy, rarity and types included; a Japanese search asks the catalogue itself. A set the catalogue added today is in the copy tomorrow. A word of `query` that is a set's printed code (SVI, PAL, MEW) narrows to that set beside other words ("pal 123"), unless a matching card holds it as a word of its name ("charizard ex") or the set holds no card the other words find. On its own it adds the set's cards after every name that starts with it. A code two sets share names both. Open: a caller who names nobody is answered with the matches and without any holdings, the holding fields left out rather than zeroed. A credential that is offered and does not verify is still a 401.
queryquerynamequerynumberquerysetquerytypequerypagequerypageSizequery: Hits a page, 250 at most. `page` counts pages of this size.
200The matches.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
GET /v1/catalog/index
The English catalogue as one document, for searching in the browser · no key
Every English card the nightly copy holds, compact: `sets` by id with name, era, release date, scan folder and printed code; `cards` as arrays of id, set id, number, name, rarity and types, with a seventh element only where the scan is not at `sets[set].image + "/" + number` (a stem, or null for none). Cards come newest set first, by number within one. Kept in the browser under its `ETag` and revalidated on every load; a request carrying `If-None-Match` gets 304. What is personal or daily about a hit comes from `GET /v1/catalog/cards`. Open: a caller who names nobody is answered with the same document, because it carries no holdings, no prices and no name. The credential decides only who may hold the answer. A credential that is offered and does not verify is still a 401.
200The document.304The document the caller holds is current.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.404Nothing by that name.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503This deployment has no database, or the database is not answering.
GET /v1/catalog/cards
These cards, with the caller's own answer and the day's price · no key
For a browser that searched `GET /v1/catalog/index` itself: the twenty hits on screen, with owned, wishlist, quantity and price attached, the same shape a search hit has. `ids` is comma-separated TCGdex ids, fifty at most; an id the copy lacks is left out. Open: a caller who names nobody is answered with the cards and without any holdings, `owned`, `wishlist`, `quantity` and `itemIds` left out rather than zeroed. The price stays, because what a card trades at is a fact about the card. A credential that is offered and does not verify is still a 401.
idsquery, required
200The cards, in the order asked.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503This deployment has no database, or the database is not answering.
GET /v1/catalog/movers
The cards whose market price moved most over the last seven days · no key
Across the whole English catalogue, not a collection (`GET /v1/movers` is the caller's own). Each printing is compared between its first and last reading in the seven days to the latest price day, after the same filtering every price line gets (a stray sale's figure, and a dip back for two figures within three weeks, held at the level before it), and ranked by what one copy moved in euros, never by percentage: a common that doubled is a bigger number and a smaller event than a Charizard that gained eight euros. A printing under a euro at either end of the window, with one reading in it, or that moved under ten cents is left out, and so is one whose figure on the window's last day is held (a stray sale, or a print run's market figure under half its own lowest listing, see `Price`). A card appears once, at the printing that moved most, named in `printing`. `up` is the largest gain first, `down` the largest loss first, ten each at most. Two empty lists mean nothing moved. Open: nothing in it is anybody's, so a caller who names nobody and a named caller are answered the same body, and every 200 may be held by a shared cache. A credential that is offered and does not verify is still a 401.
200The movers.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503The prices could not be read, or no database is configured. Never an empty list.
Value
What the collection is worth, over time.
GET /v1/cards/{tcgId}/prices
One card's price, day by day, over the last ninety days · no key
The nightly readings for a held card. A card with no readings answers an empty list, not a 404. Filtered as every change the API reports is read: a figure one odd sale set is held at the figure before it, and so, since 2026-09-23, is a dip of 40% or more (or a rise of 1⅔ times) that came back within 20% of the level before it inside three weeks, for two figures in a row. A dip at the end of the line, or with one figure back and nothing after it, has not shown it came back and stays. Open: a caller who names nobody is answered with the same line, because a card's price history is the catalogue's and public since 2026-09-22. The credential decides only who may hold the answer. A credential that is offered and does not verify is still a 401.
tcgIdpath, required: TCGdex's card id, e.g. `swsh3-136`.languagequery: The catalogue the id is from. English and Japanese ids collide (`neo4-106` is Shining Celebi in English and Lucky Stadium in Japanese), and each card has its own line. Left out, the id's own catalogue where only one has a card by that id, and `en` where both do. A copy's card is Japanese where `catalogue` on its item says `ja`.
200The points, oldest first.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503The readings could not be read. Never an empty `points`, which is documented above as the honest answer for a card whose history has not started yet.
GET /v1/value-history
The caller's collection value over time, oldest first · bearer token or session cookie
Needs a bearer token or a session. Without `folder`, the nightly readings of the whole collection, from the day its first card held now was added; days before the account's first reading are what the cards held now were worth on each day. With one, the same shape for that list, built from the daily per-card prices: a shorter history, starting where those do.
folderquery: A folder id, `favorites`, or `wishlist` (what the wished cards would cost).
200The series.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.404Nothing by that name.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503The readings could not be read. Never an empty `snapshots` — that payload means the account has never been snapshotted, and both clients draw it as such.
GET /v1/movers
The caller's cards whose price moved most over a period · bearer token or session cookie
Needs a bearer token or a session. Each owned card is compared between its earliest and latest reading in the window, per copy at its own printing, and ranked by the change times the copies held. `up` is largest gain first, `down` largest loss first. A card with one reading in the window, or a move under ten cents a copy, is left out. With `folder`, the same for that list alone; on the wishlist a card is priced at the printing wished and counts as one copy.
folderquery: A folder id, `favorites`, or `wishlist` (the wished cards, one copy each).daysquery: The period, as the value chart names them.topquery: How many each way.
200The movers.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.404Nothing by that name.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503The collection or its readings could not be read. Never an empty list.
Profile
The signed-in person's name, avatar and public flag.
GET /v1/profile
The caller's own profile · bearer token or session cookie
200The profile.401No viewer. Sign in, or send a bearer token.404Nothing by that name.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
PATCH /v1/profile
Change the display name, the public flag, or mark onboarding done · bearer token or session cookie
Same-origin only. At least one key; `onboarded` accepts only `true`.
200Changed. Echoes the keys that were applied.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.413The body is bigger than this route's `BODY_LIMIT`.500A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
POST /v1/profile/avatar
Set the avatar · bearer token or session cookie
Same-origin only. A PNG, JPEG or WebP as a data URL, at most 2 MB decoded. Thirty an hour.
200Set.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.413The body is bigger than this route's `BODY_LIMIT`.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.500A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
DELETE /v1/profile/avatar
Remove the avatar · bearer token or session cookie
200Removed.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.500A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
POST /v1/username
Claim a username · bearer token or session cookie
Same-origin only. Lower-cased and trimmed first; claiming your own name is a no-op that answers 200.
200Claimed.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.409Taken, or reserved.413The body is bigger than this route's `BODY_LIMIT`.500A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
GET /v1/usernames/{name}
Is this name free? · no key
Same-origin only, no viewer needed, sixty a minute. Answers 200 either way; a "no" carries the reason.
namepath, required
200The answer.403The request came from an origin this deployment does not allow.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.
Account
Signing up, signing in, passwords and deletion. Browser-only, same-origin.
POST /v1/session
Sign in · no key
Same-origin only; the browser's form. Sets the session cookie. Twenty attempts per address and five per address-and-email each fifteen minutes.
200Signed in.400The request was understood and refused. The sentence says why.401That email or password is not right.403Wrong origin, or the address is not confirmed yet (then `unconfirmed` is `true`).413The body is bigger than this route's `BODY_LIMIT`.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503This deployment has no database, or the database is not answering.
DELETE /v1/session
Sign out · no key
Clears the session cookie. Always 200, even with no session.
200Signed out.
POST /v1/signup
Create an account · no key
Same-origin only. Five per address each fifteen minutes. Sends a confirmation email; the account is `pending` until it is followed.
200Created, pending confirmation.400The request was understood and refused. The sentence says why.403Wrong origin, or new accounts are closed.409That address is already in use.413The body is bigger than this route's `BODY_LIMIT`.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503This deployment has no database, or the database is not answering.
POST /v1/confirmation
Send the confirmation email again · no key
Same-origin only. Always 200 so an address cannot be probed; over three per fifteen minutes it silently does nothing.
200Accepted, whether or not a mail went out.400The request was understood and refused. The sentence says why.403The request came from an origin this deployment does not allow.413The body is bigger than this route's `BODY_LIMIT`.503This deployment has no database, or the database is not answering.
POST /v1/password
Change the password · session cookie
Same-origin, session only. `currentPassword` is required unless the caller arrived through recovery.
200Changed.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.413The body is bigger than this route's `BODY_LIMIT`.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503This deployment has no database, or the database is not answering.
POST /v1/password/reset
Send a password reset email · no key
Same-origin only. Always 200; over three per fifteen minutes it silently does nothing.
200Accepted.400The request was understood and refused. The sentence says why.403The request came from an origin this deployment does not allow.413The body is bigger than this route's `BODY_LIMIT`.503This deployment has no database, or the database is not answering.
POST /v1/email
Change the email address · session cookie
Same-origin, session only. Ten per address each fifteen minutes. Supabase mails both addresses.
200Requested.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.413The body is bigger than this route's `BODY_LIMIT`.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503This deployment has no database, or the database is not answering.
DELETE /v1/account
Delete the account and everything in it · bearer token or session cookie
Needs the password in the body, as confirmation. Irreversible.
200Deleted.400The request was understood and refused. The sentence says why.401No viewer. Sign in, or send a bearer token.403Wrong origin, or that password is not right.413The body is bigger than this route's `BODY_LIMIT`.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.500A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
Public
The unkeyed routes — a public profile behind /user/<name>, and the species list, which is nobody's. No prices, no inventory, own rate limiter.
GET /v1/public/species
Every National Pokédex number and the name that goes with it · no key
No key, and no username: there is no person in this answer. The same 1,025 names for every caller, in Dex order, so a page drawing a Pokédex, a public profile's binder included, can label its slots. Sixty a minute per address; cached properly, since nothing here is anybody's.
200The species, by number.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.
GET /v1/public/{username}/collection
A public collection, without prices · no key
No key. Of each copy, only `rarity` and `owned` are published (R-API-002); `imageHigh` is dropped too. Sixty a minute per address; cached five minutes at the CDN.
usernamepath, required
200The collection.404Nothing by that name.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503This deployment has no database, or the database is not answering.
GET /v1/public/{username}/profile
The name to print and the picture, for a public profile's header · no key
No key. Sixty a minute per address; cached five minutes at the CDN.
usernamepath, required
200The profile's public face.404Nothing by that name.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.
GET /v1/public/{username}/cards
One page of a public collection as a flat list · no key
For a page that shows a hundred cards at a time and should not fetch the whole collection to do it. One entry per card with owned copies, nothing private. Narrowed by a search, a set or a rarity; sorted by set order or by name, since a public page carries no price and no date. `facets` names every set and rarity in the whole collection, for a filter menu. No key; sixty a minute per address; cached at the CDN.
usernamepath, requiredqquery: Matches the name or the set.setquery: A set, whole, by its name or its title; case does not matter. Repeat it for several (`set=Jungle&set=Fossil`): a card in any of them counts. At most 50.rarityquery: A rarity, whole, in the catalogue's words; case does not matter. Repeat it for several (`rarity=Rare&rarity=Holo%20Rare`): any of them counts. At most 50.collectionquery: One of the owner's public folders (`GET /v1/public/{username}/folders`): the copies filed in it, or the owned copies its rule matches. Any other id is a 404.listquery: A list beside the collection: `wishlist` is the cards the owner is looking for (`copies` the wishes), `favorites` the owned cards they starred. Each only while the profile shows it (`wishlistPublic`, `favoritesPublic`); otherwise a 404. A Pokédex is a binder, so it comes through `collection`. `collection` is ignored with a list.sortquery: `set` is the assembly's order (set by set, number by number). `added` is newest first, by the day each card was got; the dates themselves are not published, and a card nobody dated goes last.orderquerylimitqueryoffsetquery
200The page, the total behind it, how many sets the whole spans, and the facets over the whole.400The request was understood and refused. The sentence says why.404Nothing by that name.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503This deployment has no database, or the database is not answering.
GET /v1/public/{username}/folders
The folders a person shows on their public profile, oldest first · no key
Only folders their owner marked public, and only while the profile itself is public: a private profile is a 404 here as everywhere under this prefix. Each carries how many owned cards it holds, counted the way the public list counts them (one per card, not per copy), so a filter chip can say it. No key; sixty a minute per address; cached at the CDN.
usernamepath, required
200The public folders.404Nothing by that name.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503This deployment has no database, or the database is not answering.
GET /v1/public/{username}/cards/{tcgId}
One card from a public collection, without its price · no key
usernamepath, requiredtcgIdpath, required
200The card, with `price` and `market` both `null`.404Nothing by that name.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503This deployment has no database, or the database is not answering.
GET /v1/public/{username}/latest-pull
The most recent addition · no key
The one route meant to be read from another site, so the one that sends `Access-Control-Allow-Origin: *`. The newest owned, dated, non-excluded printing. 404 means there is nothing to show.
usernamepath, required
200The card.404Nothing by that name.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503This deployment has no database, or the database is not answering.
OPTIONS /v1/public/{username}/latest-pull
CORS preflight · no key
204Allowed for any origin.
Import
Loading a collection from a CSV file.
POST /v1/import/csv
Preview or commit a CSV import · bearer token or session cookie
A bearer token or a session. Without `commit` it is a dry run that answers with the header, the guessed column map, a sample, every row it would write, and how many rows you already hold. With `commit: true` it writes, ten times per person each fifteen minutes. 2 MB, 5,000 rows. `csv` is text, so its encoding is the caller's problem: read a UTF-16 file with a decoder before sending it. The delimiter is not — comma, semicolon and tab are sniffed from the header. An export this endpoint recognises (`source: "dex"`) is read by its own rules and ignores `map`: its columns are fixed, and three of them — a quantity of zero meaning "not owned", a folder meaning the wishlist, and a `Type` column that is not an energy type — cannot be expressed as a column mapping. Anything else is `source: "generic"`.
200The outcome, plus `source`. A preview also carries `header`, `guessed`, `skippedRows` and `rows`.400Refused. When the name and set columns could not be found, `header` and `guessed` come along so the client can ask.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.413The body is bigger than this route's `BODY_LIMIT`.415A write without `Content-Type application/json`, which is what forces a browser's preflight.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.500A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.502The collection could not be read to say what is already held, so nothing was written.503This deployment has no database, or the database is not answering.
GET /v1/imports
Recent imports · bearer token or session cookie
200The most recent imports, newest first.401No viewer. Sign in, or send a bearer token.403The request came from an origin this deployment does not allow.429Over this route's rate limit for this address. Every 429 carries `Retry-After`.503This deployment has no database, or the database is not answering.
Platform
The health check and the nightly price snapshot. Not for clients.
GET /v1/health
Is the database reachable? · no key
No key. Also the daily keepalive, so a free Supabase project does not pause.
200Fine.503The database is configured and not answering.
GET /v1/cron/snapshot
Record today's collection value for every account · cron secret
Vercel's cron, daily at 04:00 UTC, with `CRON_SECRET` as the bearer. Sixty seconds at most. Writes each account's value point and, where it needs one, its value history again. Card prices (`prices`) are written only for held cards with no TCGplayer product: every card TCGplayer sells gets its day's line from `/v1/cron/tcgplayer-prices`.
200Every account written.207Some accounts written; `failed` names the rest.401No viewer. Sign in, or send a bearer token.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
GET /v1/cron/warm
Assemble every account's collection so its caches are warm · cron secret
Vercel's cron, every ten minutes, with `CRON_SECRET` as the bearer. The first read after a deploy rebuilds every set; this pays it on nobody's screen.
200Every account assembled; `warmed` lists each with its sets and milliseconds.207Some accounts assembled; `failed` names the rest.401No viewer. Sign in, or send a bearer token.503This deployment has no database, or the database is not answering.
GET /v1/cron/tcgplayer-prices
Copy TCGplayer's English and Japanese shelves in, the latest figures and the day's price history · cron secret
Vercel's cron, daily at 21:15 UTC, with `CRON_SECRET` as the bearer. Five minutes at most. Reads tcgcsv's files for every English group once and writes `tcgplayer_prices`, which the collection prices every card from. Writes nothing at all when fewer than nine groups in ten answered, so the figures already stored stand. Then today's point in the price history for every card TCGplayer sells, per printing and in euros at the day's rate, Base Set's Shadowless runs under their own printings. Skipped without a dollar rate, and on any day but Saturday when the database is at 480 MB or more or its size could not be read (`history.skipped` says why). Last, up to three months entirely older than six months are thinned to one figure a week; `thinned` lists them, and is `null` when thinning failed, which does not fail the run.
200What the run wrote. `ok` is false when writing the history failed.401No viewer. Sign in, or send a bearer token.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
GET /v1/cron/print-pictures
Copy a picture of every printing TCGplayer sells as a product of its own · cron secret
Vercel's cron, daily at 23:00 UTC, with `CRON_SECRET` as the bearer. Five minutes at most. Pokémon Card 151's Japanese Poké Ball reverses take TCGdex's scans first, which are that print. The English patterned reverses and pattern holos the committed map names, and the printing products of TCGplayer's Japanese shelf (mirror holos, ball patterns) under the card their plain product is. Each product's photo is copied into our bucket once and written to `card_print_pictures`; a product already there is not asked again, and what the budget does not reach is copied the next night.
200What the run copied.401No viewer. Sign in, or send a bearer token.500A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.
GET /v1/cron/catalogue
Copy the English catalogue in, set by set, as far as the minute allows · cron secret
Vercel's cron, nightly, with `CRON_SECRET` as the bearer. Fills the copy the search reads (`GET /v1/catalog/search`): rarity and types included, so a search is one query and not two catalogue calls. Most needed first — a set the copy has never seen, then one whose count moved, then the oldest — and each set committed as it finishes, so a run cut short continues tomorrow where it stopped.
200What the run did; `left` is how many sets the budget did not reach.401No viewer. Sign in, or send a bearer token.502A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.503This deployment has no database, or the database is not answering.

