Cardorb
Version 1.1.0 · openapi.yaml

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.

  • 200 The collection.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 This 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.

  • q query: Matches the name or the set, anywhere, in any case.
  • owned query: `true` the collection, `false` the wishlist, absent both.
  • favorite query
  • priced query: `true` the copies with a price, `false` the ones nothing prices, absent both.
  • collection query: 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.
  • set query: 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.
  • rarity query: 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.
  • number query: 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`).
  • gen query: A generation, whole, as the catalogue names its series; case does not matter. Repeat it for several: any of them counts. At most 50.
  • type query: An energy type, whole, as the catalogue names it; case does not matter. Repeat it for several: any of them counts. At most 50.
  • condition query: 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.
  • finish query: 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.
  • language query: 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.
  • sort query: `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`.
  • from query: `sort=change` only, and required with it: the window's first day.
  • to query: `sort=change`: the window's last day; today when left out. Not before `from`.
  • order query: 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.
  • limit query: 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.
  • offset query: Not a whole number, or below 0, is a 400.
  • facets query: `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.
  • pictures query: `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.
  • counts query: `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.
  • 200 The page, in the asked order (set order when none was), the total behind it, and the facets over the owned whole.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 404 Nothing by that name.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This 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.

  • 200 Added.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 404 `collectionId` names no folder of the caller's.
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 415 A write without `Content-Type application/json`, which is what forces a browser's preflight.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This deployment has no database, or the database is not answering.

OPTIONS /v1/cards

CORS preflight for a cross-origin write · no key

  • 204 Allowed. Carries the `Access-Control-Allow-*` headers.
  • 403 That 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.

  • 200 The facts, by id.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 This 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.

  • tcgId path, required: TCGdex's card id, e.g. `swsh3-136`.
  • language query: 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.
  • 200 The card.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 404 Nothing by that name.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 This 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.

  • 200 Changed. Each row as it now is, once — rows a change made the same kind are folded into one.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 404 Nothing by that name.
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 415 A write without `Content-Type application/json`, which is what forces a browser's preflight.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This deployment has no database, or the database is not answering.

OPTIONS /v1/collection/items

CORS preflight for a cross-origin write · no key

  • 204 Allowed. Carries the `Access-Control-Allow-*` headers.
  • 403 That 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.

  • id path, required: The row the copy comes from.
  • 201 The new row.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 404 Nothing by that name.
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 415 A write without `Content-Type application/json`, which is what forces a browser's preflight.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This deployment has no database, or the database is not answering.

OPTIONS /v1/collection/items/{id}/copies

CORS preflight · no key

  • id path, required: The row the copy comes from.
  • 204 Allowed. Carries the `Access-Control-Allow-*` headers.
  • 403 That 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).

  • id path, required: The row some of whose copies are different.
  • 201 The new row and the row it came from, as they now are.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 404 Nothing by that name.
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 415 A write without `Content-Type application/json`, which is what forces a browser's preflight.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This deployment has no database, or the database is not answering.

OPTIONS /v1/collection/items/{id}/split

CORS preflight · no key

  • id path, required: The row some of whose copies are different.
  • 204 Allowed. Carries the `Access-Control-Allow-*` headers.
  • 403 That 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.

  • id path, required: The collection row, from `Variant.id` or `BrowseCard.itemIds`.
  • 200 Changed. The row as it now is.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 404 Nothing by that name.
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 415 A write without `Content-Type application/json`, which is what forces a browser's preflight.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This 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.

  • id path, required: The collection row, from `Variant.id` or `BrowseCard.itemIds`.
  • 200 Removed. The row as it was.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 404 Nothing by that name.
  • 415 A write without `Content-Type application/json`, which is what forces a browser's preflight.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This deployment has no database, or the database is not answering.

OPTIONS /v1/collection/items/{id}

CORS preflight for a cross-origin write · no key

  • id path, required: The collection row, from `Variant.id` or `BrowseCard.itemIds`.
  • 204 Allowed.
  • 403 That 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.

  • 200 The options.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This 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.

  • 200 The file.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 This deployment has no database, or the database is not answering.

GET /v1/stats

The dashboard's numbers · bearer token or session cookie

  • 200 The counts.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 This 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

  • 200 The folders.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This 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.

  • 200 Made.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 415 A write without `Content-Type application/json`, which is what forces a browser's preflight.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This 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

  • id path, required
  • 200 Changed.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 404 Nothing by that name.
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 415 A write without `Content-Type application/json`, which is what forces a browser's preflight.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This 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.

  • id path, required
  • 200 Deleted.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 404 Nothing by that name.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This 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.

  • language query: That language's own catalogue (TCGdex); left out, English. Sets and cards there have their own ids and names.
  • 200 The sets.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This 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.

  • language query: That language's own catalogue (TCGdex); left out, English.
  • setId path, required: pokemontcg.io's set id, e.g. `swsh3`.
  • page query
  • pageSize query: Clamped to 500.
  • from query: 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.
  • 200 The page.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 404 Nothing by that name.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This 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.

  • query query
  • name query
  • number query
  • set query
  • type query
  • page query
  • pageSize query: Hits a page, 250 at most. `page` counts pages of this size.
  • 200 The matches.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This 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.

  • 200 The document.
  • 304 The document the caller holds is current.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 404 Nothing by that name.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 This 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.

  • ids query, required
  • 200 The cards, in the order asked.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 This 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.

  • 200 The movers.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 The 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.

  • tcgId path, required: TCGdex's card id, e.g. `swsh3-136`.
  • language query: 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`.
  • 200 The points, oldest first.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 The 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.

  • folder query: A folder id, `favorites`, or `wishlist` (what the wished cards would cost).
  • 200 The series.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 404 Nothing by that name.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 The 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.

  • folder query: A folder id, `favorites`, or `wishlist` (the wished cards, one copy each).
  • days query: The period, as the value chart names them.
  • top query: How many each way.
  • 200 The movers.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 404 Nothing by that name.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 The 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

  • 200 The profile.
  • 401 No viewer. Sign in, or send a bearer token.
  • 404 Nothing by that name.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This 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`.

  • 200 Changed. Echoes the keys that were applied.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 500 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This 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.

  • 200 Set.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 500 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This deployment has no database, or the database is not answering.

DELETE /v1/profile/avatar

Remove the avatar · bearer token or session cookie

  • 200 Removed.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 500 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This 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.

  • 200 Claimed.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 409 Taken, or reserved.
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 500 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This 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.

  • name path, required
  • 200 The answer.
  • 403 The request came from an origin this deployment does not allow.
  • 429 Over 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.

  • 200 Signed in.
  • 400 The request was understood and refused. The sentence says why.
  • 401 That email or password is not right.
  • 403 Wrong origin, or the address is not confirmed yet (then `unconfirmed` is `true`).
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 This 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.

  • 200 Signed 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.

  • 200 Created, pending confirmation.
  • 400 The request was understood and refused. The sentence says why.
  • 403 Wrong origin, or new accounts are closed.
  • 409 That address is already in use.
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 This 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.

  • 200 Accepted, whether or not a mail went out.
  • 400 The request was understood and refused. The sentence says why.
  • 403 The request came from an origin this deployment does not allow.
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 503 This 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.

  • 200 Changed.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 This 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.

  • 200 Accepted.
  • 400 The request was understood and refused. The sentence says why.
  • 403 The request came from an origin this deployment does not allow.
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 503 This 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.

  • 200 Requested.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 This 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.

  • 200 Deleted.
  • 400 The request was understood and refused. The sentence says why.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 Wrong origin, or that password is not right.
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 500 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This 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.

  • 200 The species, by number.
  • 429 Over 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.

  • username path, required
  • 200 The collection.
  • 404 Nothing by that name.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 This 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.

  • username path, required
  • 200 The profile's public face.
  • 404 Nothing by that name.
  • 429 Over 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.

  • username path, required
  • q query: Matches the name or the set.
  • set query: 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.
  • rarity query: 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.
  • collection query: 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.
  • list query: 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.
  • sort query: `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.
  • order query
  • limit query
  • offset query
  • 200 The page, the total behind it, how many sets the whole spans, and the facets over the whole.
  • 400 The request was understood and refused. The sentence says why.
  • 404 Nothing by that name.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 This 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.

  • username path, required
  • 200 The public folders.
  • 404 Nothing by that name.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 This 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

  • username path, required
  • tcgId path, required
  • 200 The card, with `price` and `market` both `null`.
  • 404 Nothing by that name.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 This 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.

  • username path, required
  • 200 The card.
  • 404 Nothing by that name.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 This deployment has no database, or the database is not answering.

OPTIONS /v1/public/{username}/latest-pull

CORS preflight · no key

  • 204 Allowed 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"`.

  • 200 The outcome, plus `source`. A preview also carries `header`, `guessed`, `skippedRows` and `rows`.
  • 400 Refused. When the name and set columns could not be found, `header` and `guessed` come along so the client can ask.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 413 The body is bigger than this route's `BODY_LIMIT`.
  • 415 A write without `Content-Type application/json`, which is what forces a browser's preflight.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 500 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 502 The collection could not be read to say what is already held, so nothing was written.
  • 503 This deployment has no database, or the database is not answering.

GET /v1/imports

Recent imports · bearer token or session cookie

  • 200 The most recent imports, newest first.
  • 401 No viewer. Sign in, or send a bearer token.
  • 403 The request came from an origin this deployment does not allow.
  • 429 Over this route's rate limit for this address. Every 429 carries `Retry-After`.
  • 503 This 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.

  • 200 Fine.
  • 503 The 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`.

  • 200 Every account written.
  • 207 Some accounts written; `failed` names the rest.
  • 401 No viewer. Sign in, or send a bearer token.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This 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.

  • 200 Every account assembled; `warmed` lists each with its sets and milliseconds.
  • 207 Some accounts assembled; `failed` names the rest.
  • 401 No viewer. Sign in, or send a bearer token.
  • 503 This 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.

  • 200 What the run wrote. `ok` is false when writing the history failed.
  • 401 No viewer. Sign in, or send a bearer token.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This 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.

  • 200 What the run copied.
  • 401 No viewer. Sign in, or send a bearer token.
  • 500 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This 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.

  • 200 What the run did; `left` is how many sets the budget did not reach.
  • 401 No viewer. Sign in, or send a bearer token.
  • 502 A catalogue, the price guide or the database did not answer. A sentence a client can show, never a slug.
  • 503 This deployment has no database, or the database is not answering.