📄 Docs ⚙️ Dashboard 💳 Pricing 🔑 Get API Key 🏠 Main Site ↗

Collector Stash Market API v2

Programmatic access to real-time TCG card prices, market intelligence, marketplace listings, user collections, decks, and AI-powered card recognition across 20 trading card games. The Data API v2 is the current, recommended surface — a normalised multi-source model with cross-referenced IDs, per-source EU/US pricing and 220M+ historical price points. The legacy catalogue endpoints below remain available for existing integrations.

Every response is JSON. The API is REST-shaped, uses standard HTTP status codes, and is HTTPS-only. The base URL for every endpoint below is:

https://collectorstashmarket.com
Free tier: 500 requests/day, no credit card. Enable developer access in one click.

Catalogue size (live)

Current counts are available programmatically via /api/health or /api/market/stats. For the normalised v2 dataset, use the live counter endpoint /api/v2/stats.

MetricValue
Cards indexed500k+
Price data points220M+ historical (v2)
Sets / expansions7,500+
TCGs covered15
Price sourcesMultiple — see Prices section & v2 price sources
Refresh cadenceHot cards refreshed frequently; full catalogue updated multiple times daily
New: the Data API v2 exposes a fully normalised catalogue (games → sets → cards → printings → variants) with multi-source EU / US / graded prices and cross-reference IDs to Cardmarket, TCGplayer, PriceCharting, MTGJSON, Scryfall and YGOPRODeck. Read-only, no auth required.

Supported TCGs

SlugGame
pokemonPokémon TCG
mtgMagic: The Gathering
yugiohYu-Gi-Oh!
fabFlesh and Blood
onepieceOne Piece Card Game
dragonballDragon Ball Super CCG
lorcanaDisney Lorcana
starwarsStar Wars Unlimited
digimonDigimon Card Game
keyforgeKeyForge
grandarcGrand Archive
sorcerySorcery: Contested Realm
unionarenaUnion Arena
gundamGundam Card Game
riftboundRiftbound

See /api/stats/detailed for the current per-TCG card counts.

Authentication

Three authentication modes are supported. Most public catalogue endpoints work without any credentials; user-specific endpoints (collection, wishlist, alerts, listings, messages) require either a session cookie or an API key.

1 — Session cookie browser apps

Issued by POST /auth/login. The server sets a signed tcg_session cookie scoped to collectorstashmarket.com. Web pages and the mobile app use this transparently.

2 — Bearer token recommended for servers

Authorization: Bearer csm_your_key_here

API keys are managed at developer.collectorstashmarket.com/dashboard. Keys start with csm_ and are shown only once at creation; store them in environment variables.

3 — X-API-Key header

X-API-Key: csm_your_key_here

4 — Query parameter do not use in production

GET /api/search?q=Charizard&api_key=csm_your_key_here
Query-param keys leak into server logs, browser history, and HTTP referrers. Only use this for quick prototyping.

Identifying yourself

Every authenticated response sets at least one of these headers, useful for debugging:

X-RateLimit-Limit: 500
X-RateLimit-Remaining: 423
X-RateLimit-Reset: 1746489600

Language redirects (302)

HTML-style URLs without a language prefix (/tcgs/pokemon, /pokemon/sets, /cards/top, …) may 302-redirect to the localised variant (/en/..., /nl/...) for browsers. Always pass -L to curl, or allow_redirects=True in Python / redirect:"follow" in fetch. Endpoints rooted at /api/, plus /tcgs, /decks, /users/, /health, /stats, /auth/, /seller/, /dl/ never redirect.

Tiers & rate limits

Two segments — collectors (consumer) and shops / businesses. Values mirror app/tiers.py exactly; see the pricing page for the full feature matrix.

TierSegmentReq / dayReq / minKeysRecognition / dayPrice (monthly)
FreeCollectors500301€0
StarterCollectors5,00060325€9 / mo
ProCollectors25,0001205250€29 / mo
Power CollectorCollectors100,000300101,000€59 / mo
Shop BasicBusiness250,000500101,000€99 / mo
Shop ProBusiness500,0001,000152,500€249 / mo
EnterpriseBusiness1,000,0003,0002510,000€499 / mo
UnlimitedBusiness50€999 / mo

Annual billing is roughly 20% cheaper — toggle on the pricing page. The legacy Business tier is still resolvable for accounts already on that plan but new sign-ups are routed to Shop Basic / Shop Pro instead.

Daily counters reset at midnight UTC. Anonymous (no key, no cookie) callers get 30 req/min keyed by IP. Authenticated session-cookie users (i.e. the website itself) bypass the API limiter entirely.

When you exceed the limit you get 429 Too Many Requests with a Retry-After: <seconds> header. The pricing page has the full feature matrix.

Live values. Tier limits are enforced server-side at runtime. The pricing page and these docs always reflect the current tiers.

CORS policy

The API ships with a strict Access-Control-Allow-Origin allow-list (no *) so credentialed routes can't be hijacked from a third-party origin. Today the allow-list is:

  • https://collectorstashmarket.com + www + developer. subdomains
  • Localhost developer ports: 3000, 5173, 8000

If you need your origin added, open a ticket via /contact or set CORS_EXTRA_ORIGINS on a self-hosted deployment. For pure server-to-server use (no browser), CORS doesn't apply — call us from any origin with your API key.

Errors

All non-2xx responses follow this shape:

{ "detail": "Card not found" }

FastAPI validation errors (422) include a per-field error list:

{
  "detail": [
    {
      "loc": ["query", "limit"],
      "msg": "ensure this value is less than or equal to 200",
      "type": "value_error.number.not_le"
    }
  ]
}
StatusMeaningTypical cause
200OKSuccess
201CreatedPOST created a resource
204No ContentDELETE succeeded
301Moved PermanentlyCanonical-URL redirect (e.g. /dev → developer portal)
400Bad RequestInvalid or missing JSON body field
401UnauthorizedMissing or invalid key / session
403ForbiddenTier too low, not the resource owner, or not admin
404Not FoundCard / set / TCG / row id does not exist
409ConflictDuplicate (alert exists, listing already sold, …)
422UnprocessableField validation (range, regex, type)
429Too Many RequestsRate limit exceeded — see Retry-After
500Server ErrorBug on our side — please report

Quickstart

Three working examples to get you live in under a minute.

curl

API_KEY=csm_xxx
curl -H "Authorization: Bearer $API_KEY" \
     "https://collectorstashmarket.com/api/search?q=Charizard&limit=5"

Python (requests)

import requests, os

api = "https://collectorstashmarket.com"
headers = {"Authorization": f"Bearer {os.environ['CSM_KEY']}"}

r = requests.get(f"{api}/api/search", params={"q": "Charizard", "limit": 5}, headers=headers)
r.raise_for_status()
for card in r.json()["items"]:
    print(card["name"], "→", card["best_price"])

JavaScript (fetch)

const KEY = process.env.CSM_KEY;
const url = "https://collectorstashmarket.com/api/search?q=Charizard&limit=5";

const r = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
const { items } = await r.json();
items.forEach(c => console.log(c.name, "→", c.best_price));

Endpoint legend

Every endpoint card below carries a method badge and a set of access tags:

  • free works on the free tier and anonymously where applicable
  • auth needs a session cookie or API key
  • starter+ requires Starter tier or higher (or session cookie)
  • pro+ requires Pro tier or higher
  • admin requires is_admin=true on the user account

Each endpoint shows the request schema, response shape, and three runnable examples (curl, JS fetch, Python requests). Click any endpoint header to expand it (already expanded on first load).

Data API v2 new

The Data API v2 (prefix /api/v2) serves a fully normalised TCG catalogue: games → sets → cards → printings → variants → price points, with multi-source prices split by region (EU / US / graded) and cross-reference IDs to every upstream source. It is built to power card-detail pages, valuation tools and bulk catalogue sync.

Read-only & public. All /api/v2 endpoints are GET-only and require no authentication — they behave like /api/public/stats. They are additive: the v1 endpoints documented below are unchanged. Responses are plain JSON dicts (no {success, data} envelope). Base URL:

https://collectorstashmarket.com/api/v2

Why v2?

  • One canonical card with explicit printings (language / print-run) and variants (finish / foil / edition).
  • Region-split prices: EU raw (€, Cardmarket), US raw ($, TCGplayer / PriceCharting), and graded ($, PriceCharting) in one response — never mixed into a single number.
  • Cross-reference IDs (cardmarket_product_id, tcgplayer_product_id, pricecharting_id, mtgjson_uuid, scryfall_id, ygoprodeck_id) so you can join to your own data.
  • Catalogue scale (live, normalised store): ~447k cards · ~683k variants · 1,894 sets · 20 games with data · 220M+ historical price points. Fetch the current numbers from /api/v2/stats rather than hard-coding.

Pagination

List endpoints (/games/{slug}, /sets/{id}) take ?page= (1-based) and ?size=. Responses include page, size and a total count (sets_total / cards_total).

Caching & errors

Responses carry a Cache-Control: public, max-age=… header (120–300s). A missing game/set/card returns 404 with {"detail": "…"}. When the normalised store is temporarily unavailable the endpoints return 503 csm_data_unavailable — callers should fall back to the legacy catalogue.

v2 — price sources & grading semantics

Every variant groups its price_points into three arrays: eu, us and graded. Read the source, currency and as_of on each entry — do not assume a single source or currency.

Regions & sources

ArrayRegion / currencySourcesMeaning
euEU · EUR (€)Cardmarket European raw (ungraded) prices. price_typeavg, avg1, avg7, avg30, trend, low.
usUS · USD ($)TCGplayer (via tcgcsv), PriceCharting, CardTrader, JustTCG North-American raw prices. price_typemarket, low, mid, high, direct_low, raw_us, last_sold.
gradedUS · USD ($)PriceCharting Graded-slab prices keyed by grading_company + grade.

Graded semantics — read carefully

The graded array mixes PriceCharting's aggregate grade buckets with explicit grading-company prices. Always branch on grading_company:
grading_companygradeWhat it actually is
psa10, 9, …Explicit PSA-graded price.
bgs10, 9.5, …Explicit BGS / Beckett-graded price.
cgc / sgc / acevariesExplicit price for that grading company (when available).
other7 / 8 / 9 / 9.5PriceCharting cross-company aggregate for that numeric grade — NOT PSA. Treat as "graded ~grade N, company-agnostic".
otherpc17 / pc18PriceCharting internal "box / bracket" buckets — grade meaning is unknown; surface raw or hide. Do not relabel as PSA/BGS.
raw(none)Ungraded — appears in eu/us, not graded.
Rule of thumb: show psa/bgs with their company label; show other grades 7–9.5 as a neutral "Graded N" aggregate; do not present pc17/pc18 as a consumer grade.

Outliers & freshness

All v2 price reads exclude rows flagged is_outlier = true. Each entry carries its own as_of date — the latest value per (source, price_type, grading_company, grade) is returned for the headline; use /cards/{id}/history for the full series. Empty arrays (not null) mean "no data for that region".

See also the public data-sources page for upstream attribution.

GET/api/v2/statsCatalogue counters (homepage / hero)free

Pre-aggregated counters for the normalised store. Instant — never runs a live COUNT. Cached 300s.

Response

{
  "cards": 456979,
  "price_points": 222302688,
  "sets": 4873,
  "games": 20,
  "variants": 1372194,
  "images": 240832,
  "updated_at": "2026-06-05T04:59:15.039100+00:00"
}
curl https://collectorstashmarket.com/api/v2/stats
const stats = await (await fetch("https://collectorstashmarket.com/api/v2/stats")).json();
import requests
stats = requests.get("https://collectorstashmarket.com/api/v2/stats").json()
GET/api/v2/gamesList games (deduped, only games with cards)free

One row per real game. Legacy duplicate slugs (e.g. mtg, one_piece) are dropped automatically; navigate off the returned slug.

Response

{
  "items": [
    { "slug": "lorcana", "name": "Disney Lorcana",
      "active": true, "cards": 3056, "sets": 23 },
    { "slug": "magic-the-gathering", "name": "Magic: The Gathering",
      "active": true, "cards": 118342, "sets": 1241 },
    { "slug": "one-piece", "name": "One Piece Card Game",
      "active": true, "cards": 11408, "sets": 128 },
    { "slug": "pokemon", "name": "Pokémon TCG",
      "active": true, "cards": 93357, "sets": 1312 },
    { "slug": "yugioh", "name": "Yu-Gi-Oh!",
      "active": true, "cards": 99598, "sets": 1022 }
    // … 20 games total
  ]
}
cards / sets may be null for a game whose per-game counter has not been aggregated yet — render defensively.
curl https://collectorstashmarket.com/api/v2/games
import requests
games = requests.get("https://collectorstashmarket.com/api/v2/games").json()["items"]
GET/api/v2/games/{slug}Game header + paginated setsfree

Path / query

slugstringrequirede.g. pokemon
pageintdefault 11-based
sizeintdefault 501–200

Response

{
  "slug": "pokemon", "name": "Pokémon TCG", "active": true,
  "cards": 93357, "sets": 1312,
  "page": 1, "size": 50, "sets_total": 1312,
  "sets_list": [
    { "id": 1043, "code": "BS", "name": "Base Set", "series": null,
      "release_date": "1999-01-09", "total_cards": 109,
      "card_count": 108, "is_promo": false, "language": "en" }
    // … 1312 sets, paginate with page/size
  ]
}
curl "https://collectorstashmarket.com/api/v2/games/pokemon?page=1&size=50"
GET/api/v2/sets/{set_id}Set header + paginated cardsfree

Path / query

set_idintrequiredglobal set id (e.g. 1043)
pageintdefault 1
sizeintdefault 601–250

Response

{
  "id": 1043, "code": "BS", "name": "Base Set", "series": null,
  "game": { "slug": "pokemon", "name": "Pokémon TCG" },
  "release_date": "1999-01-09", "total_cards": 109,
  "is_promo": false, "language": "en",
  "page": 1, "size": 3, "cards_total": 108,
  "cards": [
    { "id": 99763, "name": "Alakazam", "collector_number": "1/102",
      "rarity": null, "card_type": null,
      "thumbnail_url": "/static/images/csm/pokemon/132843.avif" },
    { "id": 441074, "name": "Blastoise", "collector_number": "2/102",
      "rarity": null, "card_type": null,
      "thumbnail_url": "/static/images/csm/pokemon/441074.avif" },
    { "id": 441075, "name": "Chansey", "collector_number": "3/102",
      "rarity": null, "card_type": null,
      "thumbnail_url": "/static/images/csm/pokemon/441075.avif" }
    // … 108 cards total, paginate with page/size
  ]
}
thumbnail_url is null when no image is available — show a placeholder. Images are served locally as AVIF (with a WebP fallback) from the main domain (https://collectorstashmarket.com/static/images/csm/…) — never an external Cardmarket/TCGplayer URL. Note the thumbnail can point at a sibling printing's image (e.g. Alakazam 99763 resolves to 132843.avif) where that printing carries the canonical art.
curl "https://collectorstashmarket.com/api/v2/sets/1043?page=1&size=60"
GET/api/v2/cards/{card_id}Full card: editions, variants, EU/US/graded prices, cross-refsfree

The flagship endpoint. Returns the card with its cross-reference IDs, images, and every printing → variant with prices grouped into eu / us / graded (see price sources & grading).

Path

card_idintrequiredcanonical card id (resolve via /api/v2/cards)

Response (Charizard, Base Set 4/102 — abridged)

{
  "id": 441076,
  "name": "Charizard",
  "collector_number": "4/102",
  "rarity": null,
  "card_type": null,
  "game": { "slug": "pokemon", "name": "Pokémon TCG" },
  "set":  { "id": 1043, "code": "BS", "name": "Base Set" },
  "cross_refs": {
    "cardmarket_product_id": 273699,
    "tcgplayer_product_id": 106999,
    "pricecharting_id": 715593,
    "mtgjson_uuid": null, "scryfall_id": null, "ygoprodeck_id": null
  },
  // Card-level gallery. Served locally as AVIF (WebP fallback) — never an external URL.
  "images": [
    { "url": "/static/images/csm/pokemon/435825.avif", "source": "tcgcsv",
      "width": 714, "height": 1000, "format": "avif+webp", "is_placeholder": false },
    { "url": "/static/images/csm/pokemon/99766.avif",   "source": "tcgcsv",
      "width": 325, "height": 450,  "format": "avif+webp", "is_placeholder": false },
    { "url": "/static/images/csm/pokemon/441076.avif",  "source": "tcgcsv",
      "width": 610, "height": 835,  "format": "avif+webp", "is_placeholder": false }
  ],
  "printings": [
    {
      "id": 99766, "language": "en", "print_run": "unlimited",
      "collector_number": "4/102", "is_promo": false,
      "variants": [
        {
          "id": 99766, "finish": "holofoil", "variant_label": "standard",
          "print_rarity": null, "is_ex": null, "is_v": null,
          // Variant-specific art when it differs from the card gallery; null = use card images.
          "image_url": null, "image_is_fallback": null,
          "prices": {
            // EU rows: every Cardmarket price_type for this variant, EUR.
            "eu": [
              { "source": "cardmarket", "price_type": "avg30",
                "currency": "EUR", "amount": 342.27, "as_of": "2026-06-05" },
              { "source": "cardmarket", "price_type": "avg7",
                "currency": "EUR", "amount": 249.14, "as_of": "2026-06-05" },
              { "source": "cardmarket", "price_type": "low",
                "currency": "EUR", "amount": 114.90, "as_of": "2026-06-05" },
              { "source": "cardmarket", "price_type": "raw_eu",
                "currency": "EUR", "amount": 335.61, "as_of": "2026-06-05" },
              { "source": "cardmarket", "price_type": "trend",
                "currency": "EUR", "amount": 296.50, "as_of": "2026-06-05" }
            ],
            // US rows: one or more sources (PriceCharting loose + TCGplayer via tcgcsv), USD.
            "us": [
              { "source": "pricecharting", "price_type": "raw_us",
                "currency": "USD", "amount": 385.25, "as_of": "2026-06-04" },
              { "source": "tcgcsv", "price_type": "low",
                "currency": "USD", "amount": 499.00, "as_of": "2026-06-03" },
              { "source": "tcgcsv", "price_type": "raw_us",
                "currency": "USD", "amount": 572.03, "as_of": "2026-06-03" }
            ],
            // Graded price-guide rows: PriceCharting, one per grading_company + grade, USD.
            "graded": [
              { "source": "pricecharting", "grading_company": "bgs",
                "grade": "10",   "currency": "USD", "amount": 39111.00, "as_of": "2026-06-04" },
              { "source": "pricecharting", "grading_company": "other",
                "grade": "7",    "currency": "USD", "amount": 710.00,   "as_of": "2026-06-04" },
              { "source": "pricecharting", "grading_company": "other",
                "grade": "8",    "currency": "USD", "amount": 1008.00,  "as_of": "2026-06-04" },
              { "source": "pricecharting", "grading_company": "other",
                "grade": "9",    "currency": "USD", "amount": 3038.72,  "as_of": "2026-06-04" },
              { "source": "pricecharting", "grading_company": "other",
                "grade": "9.5",  "currency": "USD", "amount": 4051.00,  "as_of": "2026-06-04" },
              { "source": "pricecharting", "grading_company": "psa",
                "grade": "10",   "currency": "USD", "amount": 30085.73, "as_of": "2026-06-04" }
              // … "other" pc17 / pc18 PriceCharting sub-grades also returned
            ]
          }
        }
      ]
    },
    {
      // Second printing: Shadowless commands a large premium. Third printing
      // (1st_edition, id 441226) is also returned — trimmed here for brevity.
      "id": 132840, "language": "en", "print_run": "shadowless",
      "collector_number": "4/102", "is_promo": false,
      "variants": [
        {
          "id": 132840, "finish": "holofoil", "variant_label": "standard",
          "print_rarity": null, "is_ex": null, "is_v": null,
          "image_url": null, "image_is_fallback": null,
          "prices": {
            "eu": [
              { "source": "cardmarket", "price_type": "trend",
                "currency": "EUR", "amount": 2704.38, "as_of": "2026-06-05" }
              // … avg30 / avg7 / low / raw_eu as above
            ],
            "us": [
              { "source": "pricecharting", "price_type": "raw_us",
                "currency": "USD", "amount": 972.69, "as_of": "2026-06-04" }
            ],
            "graded": [
              { "source": "pricecharting", "grading_company": "psa",
                "grade": "10", "currency": "USD", "amount": 30100.00, "as_of": "2026-06-04" }
              // … bgs 10 + "other" 7/8/9/9.5/pc17/pc18 rows
            ]
          }
        }
      ]
    }
    // … "1st_edition" printing (PSA 10 ≈ $350k) omitted for brevity
  ]
}
A card can have multiple printings (per language / print-run) and each printing multiple variants (per finish / edition). Render a printing + variant selector and re-read the matching prices block. cross_refs values are null where that source has no match.
curl https://collectorstashmarket.com/api/v2/cards/441076
import requests
card = requests.get("https://collectorstashmarket.com/api/v2/cards/441076").json()
for pr in card["printings"]:
    for v in pr["variants"]:
        eu = v["prices"]["eu"][0]["amount"] if v["prices"]["eu"] else None
        print(pr["language"], v["finish"], "EU€", eu)
GET/api/v2/cards/{card_id}/historyPrice-history series for a chartfree

Path / query

card_idintrequired
variant_idintoptionaldefaults to the card's first variant
regionstringdefault euone of eu, us, jp, global
daysintdefault 901–3650

Response

{
  "points": [
    // One point per (date, source, price_type). For a single calendar day
    // you get several rows — one for each Cardmarket series (low / avg7 /
    // trend / avg30 / raw_eu). Group by price_type to draw separate lines.
    { "date": "2026-06-04", "amount": 114.90, "source": "cardmarket", "price_type": "low" },
    { "date": "2026-06-04", "amount": 249.14, "source": "cardmarket", "price_type": "avg7" },
    { "date": "2026-06-04", "amount": 296.50, "source": "cardmarket", "price_type": "trend" },
    { "date": "2026-06-04", "amount": 342.27, "source": "cardmarket", "price_type": "avg30" },
    { "date": "2026-06-04", "amount": 335.61, "source": "cardmarket", "price_type": "raw_eu" },
    { "date": "2026-06-05", "amount": 114.90, "source": "cardmarket", "price_type": "low" },
    { "date": "2026-06-05", "amount": 249.14, "source": "cardmarket", "price_type": "avg7" },
    { "date": "2026-06-05", "amount": 296.50, "source": "cardmarket", "price_type": "trend" },
    { "date": "2026-06-05", "amount": 342.27, "source": "cardmarket", "price_type": "avg30" },
    { "date": "2026-06-05", "amount": 335.61, "source": "cardmarket", "price_type": "raw_eu" }
    // … one block like this per day back to (today − days)
  ]
}
region selects the series: eu → Cardmarket EUR (the price_types above), us → PriceCharting/tcgcsv USD, jp / global where available. Group points by price_type to render one line per series; amounts are in the currency implied by the region. Pass variant_id to chart a specific printing/finish (defaults to the card's first variant).
curl "https://collectorstashmarket.com/api/v2/cards/441076/history?region=eu&days=90"

TCGs legacy

Legacy catalogue. Rooted at TCGs, each identified by a slug. Every card and set belongs to exactly one TCG. These endpoints remain stable and available for existing integrations, but new development should use the normalised Data API v2 above (cross-referenced multi-source IDs, per-source EU/US pricing, 220M+ price points).

GET/tcgsList all TCGsfree

Response

{
  "items": [
    { "id": 1, "slug": "pokemon", "name": "Pokémon TCG", "publisher": "The Pokémon Company",
      "logo_url": null, "website_url": "https://www.pokemon.com/tcg",
      "description": "...", "created_at": "2024-09-12T10:00:00Z" },
    ...
  ]
}
curl https://collectorstashmarket.com/tcgs
const r = await fetch("https://collectorstashmarket.com/tcgs");
const { items } = await r.json();
import requests
items = requests.get("https://collectorstashmarket.com/tcgs").json()["items"]
GET/tcgs/{slug}Get a single TCGfree

Path parameters

slugstringrequirede.g. pokemon, mtg, yugioh

Response

{ "id": 1, "slug": "pokemon", "name": "Pokémon TCG", "publisher": "...",
  "logo_url": null, "website_url": "...", "description": "..." }
curl https://collectorstashmarket.com/tcgs/pokemon
const tcg = await (await fetch("https://collectorstashmarket.com/tcgs/pokemon")).json();
import requests
tcg = requests.get("https://collectorstashmarket.com/tcgs/pokemon").json()

Sets

Each TCG groups cards into sets (also called expansions). Set IDs are globally unique across all TCGs.

GET/{tcg_slug}/setsList sets for a TCGfree

Path / query

tcg_slugstringrequirede.g. pokemon
limitintdefault 50max 200
offsetintdefault 0

Response

{ "items": [
    { "id": 1, "tcg_id": 1, "name": "Base Set", "code": "BS",
      "release_date": "1999-01-09", "total_cards": 102, "logo_url": null }
  ],
  "total": 165, "limit": 50, "offset": 0 }
curl "https://collectorstashmarket.com/pokemon/sets?limit=10"
const sets = (await (await fetch(
  "https://collectorstashmarket.com/pokemon/sets?limit=10"
)).json()).items;
import requests
sets = requests.get(
    "https://collectorstashmarket.com/pokemon/sets",
    params={"limit": 10},
).json()["items"]
GET/{tcg_slug}/sets/{set_id}Get a single setfree

Response

{ "id": 1, "tcg_id": 1, "name": "Base Set", "code": "BS",
  "release_date": "1999-01-09", "total_cards": 102 }
curl https://collectorstashmarket.com/pokemon/sets/1
const set = await (await fetch("https://collectorstashmarket.com/pokemon/sets/1")).json();
set_obj = requests.get("https://collectorstashmarket.com/pokemon/sets/1").json()
GET/api/sets/{set_id}/statsAggregated stats for a setfree

Response

{ "set_id": 1, "card_count": 102, "min_price": 0.05, "max_price": 5800.00,
  "avg_price": 32.41, "total_value": 3308 }
GET/api/sets/{set_id}/top-cardsTop-valued cards in a setfree

Query

limitintdefault 10max 50

Response

[ { "card_id": 891, "name": "Charizard", "number": "4", "rarity": "Holo Rare",
    "image_url": "/static/images/pokemon/891.webp", "best_price": 894.61,
    "tcg_slug": "pokemon" }, ... ]
curl "https://collectorstashmarket.com/api/sets/1/top-cards?limit=5"
const top = await (await fetch(
  "https://collectorstashmarket.com/api/sets/1/top-cards?limit=5"
)).json();
top = requests.get(
    "https://collectorstashmarket.com/api/sets/1/top-cards",
    params={"limit": 5}
).json()

Cards

The card is the central object of the API. Every card has a stable integer id, belongs to one set, and carries metadata (name, number, rarity, type, image URL) plus a price book aggregated across sources.

GET/{tcg_slug}/cardsList cards for a TCGfree

Query

limitintdefault 50max 200
offsetintdefault 0
set_idintoptionalFilter by set
raritystringoptional
namestringoptionalSubstring (case-insensitive)

Response

{ "items": [{ "id": 891, "card_set_id": 4, "name": "Charizard",
    "number": "4", "rarity": "Holo Rare", "card_type": "Pokémon",
    "image_url": "https://...", "local_image_url": "/static/images/pokemon/891.webp",
    "attributes": {...} }],
  "total": 20543, "limit": 50, "offset": 0 }
curl "https://collectorstashmarket.com/pokemon/cards?set_id=4&limit=10"
const cards = (await (await fetch(
  "https://collectorstashmarket.com/pokemon/cards?set_id=4&limit=10"
)).json()).items;
cards = requests.get(
    "https://collectorstashmarket.com/pokemon/cards",
    params={"set_id": 4, "limit": 10},
).json()["items"]
GET/{tcg_slug}/cards/{card_id}Get a card with current pricesfree

Response

{ "id": 891, "name": "Blaine's Charizard", "number": "2",
  "rarity": "Rare Holo", "card_type": "Pokémon",
  "image_url": "...", "local_image_url": "/static/images/pokemon/891.webp",
  "attributes": {...},
  "prices": [
    { "source": "tcgplayer", "market_price": 894.61, "currency": "USD", "variant": "1st_edition_holo" },
    { "source": "cardmarket", "market_price": 737.33, "currency": "EUR", "variant": "normal" }
  ]
}
GET/api/recent-cardsRecently added cards (last 7d)free

Query

limitintdefault 20max 100
tcg_slugstringoptional
GET/api/new-cardsRecently added cards (date-filtered)free

Query

sincedateoptionalYYYY-MM-DD; default 30 days ago
limitintdefault 50max 200
tcg_slugstringoptional
GET/api/compareCompare up to 3 cards by idfree

Query

idscomma-separated intsrequirede.g. 891,11172,9669
curl "https://collectorstashmarket.com/api/compare?ids=891,11172,9669"
const data = await (await fetch(
  "https://collectorstashmarket.com/api/compare?ids=891,11172,9669"
)).json();
data = requests.get(
    "https://collectorstashmarket.com/api/compare",
    params={"ids": "891,11172,9669"},
).json()
GET/cards/topTop-valued cards across all TCGsfree

Query

limitintdefault 25max 100
tcg_slugstringoptional
GET/api/cards/{card_id}/marketplace-urlsExact buy-now URLsfree

Query

variantstringdefault normalnormal | holo | 1st_edition_holo | …

Response

{ "card_id": 891, "variant": "normal",
  "cardmarket_url": "https://www.cardmarket.com/en/Pokemon/Products/Singles/Base-Set/Charizard",
  "tcgplayer_url":  "https://www.tcgplayer.com/product/...",
  "ebay_url":       "https://www.ebay.com/sch/i.html?_nkw=Charizard+Base+Set" }
GET/api/cards/{card_id}/evolution-chainEvolution chain (Pokémon only)free

Response

{ "card_id": 891, "chain": [
  { "stage": "Basic",  "name": "Charmander", "card_id": 12 },
  { "stage": "Stage 1","name": "Charmeleon", "card_id": 56 },
  { "stage": "Stage 2","name": "Charizard",  "card_id": 891 }
]}

Sealed products

Sealed booster boxes, ETBs, and bundles are tracked alongside singles. Each product has its own price history.

GET/{tcg_slug}/sealedList sealed productsfree

Query

limitintdefault 50
offsetintdefault 0
set_idintoptional
GET/{tcg_slug}/sealed/{product_id}Sealed product detail with price historyfree
curl https://collectorstashmarket.com/pokemon/sealed/1
const p = await (await fetch("https://collectorstashmarket.com/pokemon/sealed/1")).json();
p = requests.get("https://collectorstashmarket.com/pokemon/sealed/1").json()
GET/api/searchCross-TCG card searchfree

Query

qstringrequiredFree-text card name, number, or set
tcg_slugstringoptionalRestrict to one TCG
set_idintoptional
raritystringoptional
min_pricefloatoptionalUSD market price
max_pricefloatoptional
limitintdefault 50max 200
offsetintdefault 0

Response

{ "items": [
  { "id": 891, "name": "Blaine's Charizard", "number": "2",
    "rarity": "Rare Holo", "card_type": "Pokémon",
    "display_image_url": "/static/images/pokemon/891.webp",
    "set_name": "Gym Challenge", "set_code": "GYM2",
    "tcg_slug": "pokemon", "best_price": 737.33,
    "price_change_7d": null }
], "total": 41 }
curl "https://collectorstashmarket.com/api/search?q=Charizard&tcg_slug=pokemon&limit=5"
const r = await fetch(
  "https://collectorstashmarket.com/api/search?q=Charizard&tcg_slug=pokemon&limit=5"
);
const { items } = await r.json();
items = requests.get(
    "https://collectorstashmarket.com/api/search",
    params={"q": "Charizard", "tcg_slug": "pokemon", "limit": 5},
).json()["items"]

Common errors

// 422 — q is missing
{ "detail": [{"loc":["query","q"], "msg":"field required", "type":"value_error.missing"}] }

// 429
{ "detail": "Rate limit exceeded. Retry after 42 seconds." }

Prices

Every card carries a price book sourced from up to six platforms (TCGPlayer, Cardmarket, eBay graded/raw, PriceCharting, CardTrader, Scryfall). Prices are normalised to remove obvious outliers; cross-source aggregations live under Price intelligence.

GET/api/cards/{card_id}/pricesCross-source price comparisonfree

Response

{ "card_id": 891,
  "sources": [
    { "source": "tcgplayer",  "market_price": 894.61, "low_price": 1070.99,
      "high_price": 2140.0, "currency": "USD", "variant": "1st_edition_holo",
      "recorded_at": "2026-05-06T08:49:14Z" },
    { "source": "cardmarket", "market_price": 737.33, "low_price": 70.0,
      "high_price": 692.02, "currency": "EUR", "variant": "normal",
      "recorded_at": "2026-05-06T08:35:59Z" }
  ]
}
curl https://collectorstashmarket.com/api/cards/891/prices
const data = await (await fetch(
  "https://collectorstashmarket.com/api/cards/891/prices"
)).json();
console.log(data.sources.map(s => `${s.source}: ${s.market_price} ${s.currency}`));
data = requests.get("https://collectorstashmarket.com/api/cards/891/prices").json()
for s in data["sources"]:
    print(f'{s["source"]:11s} {s["market_price"]:>10.2f} {s["currency"]}')

Price history

Daily snapshots are kept indefinitely. The history endpoint returns a clean time-series suitable for plotting.

GET/api/cards/{card_id}/price-historyPrice history + 7d/30d/90d analysis (merged across all sources)free

Query

daysintdefault 907, 30, 90, 180, 365
sourcestringoptionalLimit to one source
variantstringoptional

Response

{ "card_id": 891,
  "snapshots": [
    { "date": "2026-04-08", "source": "tcgplayer", "market_price": 870.0 },
    ...
  ],
  "analysis": {
    "change_7d":  1.2,
    "change_30d": -3.4,
    "change_90d": 18.7,
    "volatility": 0.041,
    "trend": "rising"
  }
}
GET/api/cards/{card_id}/price-history-multiAll (variant × grading_company × grade) series in one payloadfree

Single-call multi-series payload that backs the card-detail "everything in one chart + table" panel. Series with < 5 daily points are skipped, the rest are sorted by sample-size descending. Daily-median bucketed so a stray $0.01 listing can't drag a point to zero. Cached 10 min.

Response

{ "card_id": 891,
  "series": [
    { "key": "normal_raw", "label": "Normal · Raw",
      "variant": "normal", "grading_company": null, "grade": null,
      "currency": "USD", "total_points": 1241,
      "points": [{ "date": "2021-05-12", "price": 312.50 }, ...]
    },
    { "key": "normal_PSA_10", "label": "Normal · PSA 10",
      "variant": "normal", "grading_company": "PSA", "grade": "10",
      "currency": "USD", "total_points": 612,
      "points": [...]
    }
  ],
  "total_series": 8,
  "min_points": 5
}
GET/api/cards/{card_id}/price-history-snapshotsDaily ungraded market history (PPT-fed)free

Per-day historical market price for a card. Reads from price_snapshots — populated by the pokemon_price_tracker scraper's --include-history mode and our own daily snapshot job. Surfaces the multi-year history you can plot directly.

Query

sourcestringdefault pokemon_price_trackerAny source we write into price_snapshots (e.g. pokemon_price_tracker_ebay).
variantstringoptionalRestrict to one variant (normal / holofoil / reverse_holo / …).
daysintdefault 180Window length, max 365.

Response

{ "card_id": 891,
  "source": "pokemon_price_tracker",
  "variant": null,
  "days": 180,
  "points": [
    { "date": "2025-12-01", "variant": "holofoil", "currency": "USD",
      "grading_company": null, "grade": null, "market_price": 412.30 }
  ],
  "count": 1 }
GET/api/cards/{card_id}/ebay-soldRecent eBay sold-comp aggregates (from PPT ebay block)free

Daily eBay sold-comp aggregates plus the most-recent per-grade summary. Sourced from pokemon_price_tracker's eBay block (price_snapshots + card_prices rows with source='pokemon_price_tracker_ebay').

Query

daysintdefault 90Window length, max 365.
grading_companystringoptionalPSA / CGC / BGS / SGC / ...
gradestringoptional"10", "9.5", ...

Response

{ "card_id": 891,
  "days": 90,
  "grading_company": null,
  "grade": null,
  "sales": [
    { "date": "2026-05-10", "grading_company": "PSA", "grade": "10",
      "average_price": 1280.0 }
  ],
  "summary": [
    { "grading_company": "PSA", "grade": "10",
      "average_price": 1295.0, "median_price": 1280.0,
      "min_price": 980.0, "max_price": 1620.0, "count": 47 }
  ],
  "total_sales_points": 47,
  "total_grades": 1 }
GET/api/cards/{card_id}/price-aggregatesCompact multi-window price aggregates (1d/7d/30d/90d/1y + ATH + verdict)free

Side-rail payload for the card-detail page. One call replaces five fan-out trend probes. Scope is filterable per (variant, grading_company, grade). Cached 10 min.

Query

variantstringdefault normalVariant slug. Pass auto to fall back to the best-covered ungraded variant.
grading_companystringoptionalEmpty = ungraded scope. PSA / CGC / BGS / SGC.
gradestringoptionalRequires grading_company.

Response

{ "card_id": 891,
  "scope": { "variant": "normal", "grading_company": null, "grade": null },
  "current": { "price": 484.10, "currency": "EUR", "recorded_at": "..." },
  "deltas": {
    "d1":  { "abs": 0.0,  "pct": 0.0,  "anomaly": false },
    "d7":  { "abs": 2.3,  "pct": 0.5,  "anomaly": false },
    "d30": { "abs": -8.7, "pct": -1.8, "anomaly": false },
    "d90": { "abs": null, "pct": null, "anomaly": false },
    "d365":{ "abs": null, "pct": null, "anomaly": false }
  },
  "all_time_high": { "price": 612.00, "date": "2025-09-19" },
  "sales_volume_30d": 41,
  "point_count": 6,

  // 2026-05-27 — additive payload extensions
  "market_verdict": {
    "price": 484.10, "currency": "EUR",
    "confidence": "medium",
    "agreeing_sources": 1, "considered_sources": 2,
    "spread_pct": 20.3, "region_bias": "eu",
    "sources": [
      { "source": "cardmarket", "label": "Cardmarket",
        "median": 484.10, "sample_size": 6,
        "weight": 2.0, "agrees": true, "delta_pct": 0.0 },
      { "source": "pricecharting", "label": "PriceCharting",
        "median": 385.78, "sample_size": 1159,
        "weight": 0.8, "agrees": false, "delta_pct": -20.3 }
    ]
  },
  "buy_sell_spread": {
    "currency": "USD",
    "tiers": {
      "loose": { "buy": 240.5, "mid": 320.7, "sell": 410.9 },
      "cib":   { "buy": 360.6, "mid": 480.8, "sell": 612.0 },
      "new":   { "buy": 540.0, "mid": 700.0, "sell": 880.0 }
    }
  },
  "velocity": { "label": "Hot", "sample_30d": 47, "tier": "high" }
}
2026-05-27: market_verdict, buy_sell_spread and velocity were added. Each is independently null when the underlying data isn't available (e.g. buy_sell_spread is null on graded scopes; velocity needs at least one sold-comp window).
GET/api/cards/{card_id}/recent-salesSold-comp rows (deduped per day × variant × grader × grade)free

Per-sale rows when eBay-scraped data exists for the window (with title, image_url, outbound link); falls back to a median-per-bucket aggregate built from PriceCharting / TCGplayer / eBay-graded history rows when no live eBay data is present.

Query

window_daysintdefault 301–3650. Use 30 / 90 / All for the UI toggle.
limitintdefault 501–500.
grading_companystringoptionalFilter to one slabbing house (PSA/BGS/CGC/SGC). Case-insensitive.
gradestringoptionalFilter to one grade value ("10", "9.5", …).
languagestringoptionalRestrict to a card language (en / jp / de / …). Cross-checks the target card's language and applies a CJK / "Japanese" / "JP" heuristic to eBay titles. Added 2026-05-27.
variantstringoptionalRestrict to a printing variant (1st_edition / unlimited / holofoil / …). Added 2026-05-27.

Response

{ "card_id": 891,
  "sales": [
    { "date": "2026-05-26T00:00:00+00:00",
      "source": "ebay",
      "condition": "Near Mint",
      "variant": "normal",
      "grade": "PSA 10",
      "grading_company": "PSA",
      "grade_value": "10",
      "price": 1295.0,
      "sample": 1,
      "currency": "USD",
      "link": "https://www.ebay.com/itm/...",
      "title": "Charizard PSA 10 Base Set 1999",
      "image_url": "...",
      "language": "en",
      "price_type": "recent_sale" }
  ],
  "count": 1,
  "window_days": 30,
  "source": "ebay_sales",
  "filter": { "language": null, "variant": null } }
GET/{tcg_slug}/cards/{card_id}/pricesList all price records for a cardfree

Query

limitintdefault 50max 200
sourcestringoptional
variantstringoptional
GET/{tcg_slug}/cards/{card_id}/prices/historyTime-series for a card by date rangefree

Query

start_datedateoptional
end_datedateoptional
sourcestringoptional
GET/{tcg_slug}/cards/{card_id}/prices/{price_id}Single price recordfree
GET/{tcg_slug}/cards/{card_id}/trend7d / 30d / 90d / 1y trendfree

Query

periodstringdefault 30d7d | 30d | 90d | 1y

Market intelligence

Pre-aggregated dashboard data: top gainers, losers, most volatile, biggest cross-source spreads, market totals. All endpoints are warmed in Redis and respond in <10 ms.

GET/api/market/moversTop gainers, losers, most volatilefree

Query

tcgstringdefault allA TCG slug or all
periodenumdefault 7d7d | 30d | 90d
limitintdefault 20max 50

Response

{ "gainers":       [{ "card_id": 361, "name": "Fire Energy", "set_name": "Base",
                       "tcg_slug": "pokemon", "current_price": 7.09,
                       "change_pct": 500.0, "change_abs": 35.45 }],
  "losers":        [...],
  "most_volatile": [...],
  "period": "7d", "tcg": "all" }
curl "https://collectorstashmarket.com/api/market/movers?tcg=pokemon&period=30d&limit=10"
const m = await (await fetch(
  "https://collectorstashmarket.com/api/market/movers?tcg=pokemon&period=30d&limit=10"
)).json();
m.gainers.forEach(c => console.log(c.name, c.change_pct + "%"));
m = requests.get(
    "https://collectorstashmarket.com/api/market/movers",
    params={"tcg": "pokemon", "period": "30d", "limit": 10},
).json()
for c in m["gainers"]:
    print(f'{c["name"]:30s} {c["change_pct"]:+.1f}%')
GET/api/market-moversLegacy mover endpointfree

Earlier version of the movers endpoint. Returns a flat list of cards (gainers OR losers, not both) with price-change deltas based on monthly snapshots.

Query

directionenumdefault upup | down
limitintdefault 12max 50
tcg_slugstringoptional
Prefer /api/market/movers for new code — same data, three categories in one call.
GET/api/market/best-dealsCards where source prices diverge mostfree

Query

limitintdefault 20max 100
tcg_slugstringoptional
min_spread_pctfloatdefault 20.0Minimum % gap between cheapest and most-expensive source
GET/api/market/statsMarket-wide totalsfree

Response

{ "total_cards": 562165, "total_prices": 8875351, "total_sets": 7777,
  "tcgs_covered": 12, "sources": ["tcgplayer","cardmarket","cardtrader","ebay_graded","pricecharting"],
  "last_update": "..." }
GET/api/market/overviewMarket overview: avg change + biggest moverfree
GET/api/market/radar-statsStats strip for the Market Radar dashboardfree
GET/api/market/active-sellersRecently-active sellersfree
GET/api/trendingTrending cards from external sourcesfree

Query

limitintdefault 12
tcg_slugstringoptional

Predictions

Linear regression on PriceCharting daily history. Confidence is bucketed by data-point count: low < 10, medium 10-20, high ≥ 20.

GET/api/cards/{card_id}/predictForecast a card's price 30d aheadfree

Query

days_aheadintdefault 307-365

Response

{ "card_id": 891, "card_name": "Charizard",
  "current_price": 894.61, "predicted_price": 921.04,
  "predicted_change_pct": 3.0, "confidence": "medium",
  "data_points": 12, "trend": "rising", "slope_per_day": 0.88,
  "future_points": [
    { "date": "2026-05-13", "price": 901.64 }, ...
  ] }
curl "https://collectorstashmarket.com/api/cards/891/predict?days_ahead=60"
const p = await (await fetch(
  "https://collectorstashmarket.com/api/cards/891/predict?days_ahead=60"
)).json();
p = requests.get(
    "https://collectorstashmarket.com/api/cards/891/predict",
    params={"days_ahead": 60},
).json()
GET/api/market/trending-predictionsTop rising-trend cards across the marketfree

Query

limitintdefault 10max 50
Cards with too few datapoints (< 3) are skipped, as are non-rising trends.

Price intelligence

Cross-source correlation. Combines Cardmarket, CardTrader, TCGPlayer, eBay sold, and other source platforms into a single confidence-weighted price per (card, condition, language, foil).

GET/api/price-intelligence/card/{card_id}Correlated price for a card variantstarter+

Query

foil_statusenumdefault nonfoilnonfoil | foil | etched
conditionenumdefault NMNM | LP | MP | HP | DMG
languageenumdefault enen | de | fr | jp | …
printingenumdefault unlimitedunlimited | 1st_edition

Response

{ "card_id": 891, "price_summary": { "market_price_eur": 712.50,
    "low_eur": 670.00, "high_eur": 749.99, "confidence": 0.92 },
  "source_breakdown": [
    { "source": "cardmarket",  "median_eur": 710.00, "n_listings": 28 },
    { "source": "cardtrader",  "median_eur": 732.40, "n_listings": 11 }
  ],
  "correlation_notes": []
}
GET/api/price-intelligence/card/{card_id}/sourcesRaw source listingsstarter+

Query

sourcestringoptionalcardmarket | cardtrader | tcgplayer | ebay
foil_statusenumoptional
conditionenumoptional
limitintdefault 50max 200
offsetintdefault 0
GET/api/price-intelligence/card/{card_id}/historyHistorical correlated recordstarter+
GET/api/price-intelligence/compare/{card_id}Side-by-side source comparisonstarter+
GET/api/price-intelligence/sourcesList active source platformsfree
curl https://collectorstashmarket.com/api/price-intelligence/sources
const sources = await (await fetch(
  "https://collectorstashmarket.com/api/price-intelligence/sources"
)).json();
sources = requests.get(
    "https://collectorstashmarket.com/api/price-intelligence/sources"
).json()
GET/api/price-intelligence/statsOverall PI statsfree
GET/api/price-intelligence/searchSearch across raw listingsstarter+
GET/api/price-intelligence/fx/rateFX ratefree

Query

from_currencystringdefault USDISO 4217
to_currencystringdefault EUR

Stash Score

The Stash Score (0-100) is our composite "should I keep this card?" signal — a blend of recent price momentum, rarity adjustments, market liquidity, and source agreement.

GET/api/cards/{card_id}/stash-score0-100 composite collectability scorefree

Response

{ "card_id": 891, "score": 72,
  "components": {
    "price_momentum": 18, "rarity": 22,
    "liquidity": 12, "source_agreement": 20
  },
  "label": "Strong hold" }

Auth — login & register

Account creation and session management. All endpoints under /auth/ set or rely on the tcg_session cookie. The cookie is signed (HS256), HttpOnly, and lasts 30 days.

POST/auth/registerCreate a new user accountfree

Body

usernamestringrequired3-30 chars, lowercased
emailstringrequired
passwordstringrequired≥ 8 chars

Response

{ "ok": true, "user_id": 1, "username": "demo-1" }
// + Set-Cookie: tcg_session=...
curl -c cookies.txt -X POST https://collectorstashmarket.com/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"username":"demo-1","email":"demo-1@example.com","password":"hunter2hunter2"}'
const r = await fetch("https://collectorstashmarket.com/auth/register", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ username: "demo-1", email: "demo-1@example.com", password: "hunter2hunter2" }),
});
const data = await r.json();
s = requests.Session()
s.post("https://collectorstashmarket.com/auth/register",
       json={"username":"demo-1","email":"demo-1@example.com","password":"hunter2hunter2"})

Common errors

// 400 — username taken or invalid email
{ "detail": "username already taken" }
// 400 — missing field
{ "detail": "username, email, and password required" }
POST/auth/loginLog infree

Body

usernamestringrequiredOr email
passwordstringrequired
curl -c cookies.txt -X POST https://collectorstashmarket.com/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"demo-1","password":"hunter2hunter2"}'
await fetch("https://collectorstashmarket.com/auth/login", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ username: "demo-1", password: "hunter2hunter2" }),
});
s.post("https://collectorstashmarket.com/auth/login",
       json={"username":"demo-1","password":"hunter2hunter2"})
POST/auth/logoutLog out — clears the cookieauth
GET/auth/meCurrent user infoauth

Response

{ "id": 1, "username": "demo-1", "email": "demo-1@example.com",
  "is_admin": true, "is_seller": true, "created_at": "2024-01-01T12:00:00Z" }
POST/auth/register-sellerRegister and immediately become a sellerfree

Body

username, email, passwordstringrequiredAs /auth/register
display_namestringoptional
countrystringoptionalISO-2

Account & password

POST/auth/change-passwordChange passwordauth

Body

current_passwordstringrequired
new_passwordstringrequired≥ 8 chars
POST/auth/account/delete-requestRequest soft account deletion (24h cooldown)auth

Body

confirmstringrequiredMust equal "DELETE <username>"

The account is flagged for deletion; you have 24h to cancel.

POST/auth/account/delete-cancelCancel a pending soft deletionauth
DELETE/auth/accountPermanently delete the accountauth

Body

confirmstringrequiredMust equal "DELETE <username>"
Hard delete; removes the user and all owned rows. Skipped by the audit.

API keys

Programmatic key management. The dashboard at developer.collectorstashmarket.com/dashboard wraps these endpoints with a UI.

GET/api/keys/List my API keysauth

Response

{ "items": [
  { "id": 17, "name": "production",
    "prefix": "csm_prod_a4b3", "tier": "pro",
    "is_active": true, "daily_limit": 25000, "rate_limit_per_minute": 300,
    "calls_today": 1247, "last_used_at": "2026-05-06T15:42:11Z",
    "created_at": "2026-04-12T...", "expires_at": null }
]}
POST/api/keys/Create a new API keyauth

Body

namestringrequiredFriendly label
tierenumoptionalDefaults to your subscription tier
expires_atdate-timeoptional

Response

{ "id": 18, "name": "staging",
  "key": "csm_stg_e9c2b…f102", "tier": "free", ... }
The full key field is only returned once. Store it immediately.
PATCH/api/keys/{key_id}Rename a keyauth

Body

namestringrequired
DELETE/api/keys/{key_id}Revoke a keyauth
GET/api/developer/infoDeveloper profile + tierauth
POST/api/developer/keyGenerate a key (developer-portal flow)auth

Collection

The signed-in user's owned cards. Each entry has a quantity, optional condition, optional price_paid, and grading metadata. Bulk import / export via CSV.

GET/collectionMy collectionauth

Query

limitintdefault 50max 200
offsetintdefault 0
tcg_slugstringoptional

Response

{ "items": [
  { "id": 132, "card_id": 891, "card_name": "Charizard",
    "tcg_slug": "pokemon", "set_name": "Base Set",
    "quantity": 2, "condition": "NM", "variant": "1st_edition_holo",
    "price_paid": 350.00, "graded": false, "grade": null,
    "current_value": 894.61, "image_url": "/static/images/pokemon/891.webp",
    "added_at": "2025-12-04T..." }
], "total": 47 }
POST/collection/addAdd a card to my collectionauth

Body

card_idintrequired
quantityintdefault 1
conditionenumdefault NMNM, LP, MP, HP, DMG
variantstringoptionale.g. holo, 1st_edition_holo
price_paidfloatoptionalPaid price (USD)
gradedbooldefault false
grading_companystringoptionalPSA, BGS, CGC
gradenumberoptionale.g. 9.5
curl -b cookies.txt -X POST https://collectorstashmarket.com/collection/add \
  -H 'Content-Type: application/json' \
  -d '{"card_id":891,"quantity":1,"condition":"NM","price_paid":300}'
await fetch("https://collectorstashmarket.com/collection/add", {
  method: "POST", credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ card_id: 891, quantity: 1, condition: "NM", price_paid: 300 }),
});
s.post("https://collectorstashmarket.com/collection/add",
       json={"card_id": 891, "quantity": 1, "condition": "NM", "price_paid": 300})
PATCH/collection/{entry_id}Update a collection entryauth

Body: any of quantity, condition, variant, price_paid, graded, grade.

DELETE/collection/{entry_id}Remove a cardauth
GET/collection/check/{card_id}Check if a card is ownedauth
GET/collection/statsCollection summary statsauth
GET/api/collection/analyticsPortfolio analytics dashboardauth
GET/api/collection/statsExtended portfolio statsauth

Response

{ "total_value": 12483.41, "card_count": 47,
  "change_24h": 184.20, "change_24h_pct": 1.5,
  "change_7d": -342.10, "change_7d_pct": -2.7,
  "by_tcg": {"pokemon": 9234.50, "mtg": 1842.91, "yugioh": 1406.0},
  "top_card": { "card_id": 891, "name": "Charizard", "value": 894.61 } }
GET/api/collection/top1010 most valuable owned cardsauth
GET/api/collection/set-completion% set completion across owned setsauth
GET/api/collection/suggested-actionsBuy / sell suggestionsauth
GET/collection/value-historyPortfolio value over timeauth
GET/collection/gainsPer-card P/L vs price-paidauth
GET/collection/completion/{set_id}Set completion for one setauth
GET/collection/exportExport as CSVauth

Returns text/csv with columns: card_id, name, set, tcg, quantity, condition, variant, price_paid, current_value.

POST/collection/import/csvBulk import from CSVauth

multipart/form-data with a file field. Header row required: card_id, quantity, condition, price_paid.

GET/users/{username}/profilePublic profilefree

Wishlist

GET/wishlistMy wishlistauth
POST/wishlist/addAdd a cardauth

Body

card_idintrequired
max_pricefloatoptionalNotify only under this price
curl -b cookies.txt -X POST https://collectorstashmarket.com/wishlist/add \
  -H 'Content-Type: application/json' -d '{"card_id":891,"max_price":750}'
await fetch("https://collectorstashmarket.com/wishlist/add", {
  method:"POST", credentials:"include",
  headers:{"Content-Type":"application/json"},
  body: JSON.stringify({ card_id: 891, max_price: 750 })
});
s.post("https://collectorstashmarket.com/wishlist/add",
       json={"card_id": 891, "max_price": 750})
DELETE/wishlist/{entry_id}Remove from wishlistauth
GET/wishlist/check/{card_id}Is this card on my wishlist?auth

Price alerts

Subscribe to a target price for a card. When the next scrape produces a price that crosses the threshold (in either direction), the alert is triggered, recorded as a notification, and emailed if the user opted in.

GET/alertsList my alertsauth

Response

{ "items": [
  { "id": 7, "card_id": 891, "card_name": "Charizard",
    "target_price": 800.00, "condition": "below",
    "currency": "EUR", "is_active": true,
    "triggered_at": null, "created_at": "2026-04-15T..." }
]}
POST/alertsCreate an alertauth

Body

card_idintrequired
target_pricefloatrequired
conditionenumdefault belowbelow | above
currencystringdefault EUR
DELETE/alerts/{alert_id}Delete an alertauth
POST/alerts/digest-nowSend the digest email immediatelyauth
GET/api/alerts/unread-countUnread triggered alerts badge countauth

Decks

Build, share and value decks. MTG decks support format-legality checks; other TCGs return a generic legality stub.

GET/decksMy decksauth
POST/decksCreate a deckauth

Body

namestringrequired
tcg_slugstringrequired
formatstringoptionale.g. standard, commander, standard-2024
is_publicbooldefault false
GET/decks/formatsFormat whitelist per TCGfree
GET/decks/publicBrowse all public decksfree
GET/decks/{deck_id}Deck detail with card listfree
PUT/decks/{deck_id}Update deck metadataauth
DELETE/decks/{deck_id}Delete a deckauth
POST/decks/{deck_id}/cardsAdd a cardauth

Body

card_idintrequired
quantityintdefault 1
sectionenumdefault mainmain | sideboard | commander
DELETE/decks/{deck_id}/cards/{card_id}Remove a cardauth
GET/decks/{deck_id}/valueTotal deck market valueauth
GET/decks/{deck_id}/legalityFormat-legality check (MTG)auth
POST/decks/{deck_id}/cloneClone a deckauth
POST/decks/import-textParse a text decklist into card IDsauth

Body

tcg_slugstringrequired
textstringrequiredOne card per line, format 4 Lightning Bolt

Profile

POST/api/profile/photoUpload or update profile photoauth

multipart/form-data with a file field. JPEG / PNG / WebP, ≤ 5 MB. Auto-cropped to 256×256 WebP.

GET/api/profile/photo/{username}Get a user's profile photo URLfree

Privacy

Toggles the visibility of personal data on the public profile. When is_collection_public is false (default), visitors only see member-since info and seller listings — the showcase, top cards, recently added grid, portfolio value, and TCG breakdown stay hidden.

POST/me/privacyToggle public-collection visibilityauth

Body

is_collection_publicboolrequiredWhen true, exposes the user's collection grid and portfolio value on /users/{username}.

Response

{ "ok": true, "is_collection_public": true }
curl -b cookies.txt -X POST https://collectorstashmarket.com/me/privacy \
  -H 'Content-Type: application/json' \
  -d '{"is_collection_public": true}'
await fetch("https://collectorstashmarket.com/me/privacy", {
  method: "POST", credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ is_collection_public: true })
});
s.post("https://collectorstashmarket.com/me/privacy",
       json={"is_collection_public": True})

Marketplace listings

Sellers can list cards for sale; buyers can browse, bid, and buy. The full lifecycle (listing → bid → accept → ship → rate) is captured in Transactions.

GET/seller/listings/allAll public active listingsfree

Query

tcg_slugstringoptional
min_pricefloatoptionalEUR
max_pricefloatoptional
conditionenumoptional
limitintdefault 50max 200
offsetintdefault 0
GET/seller/listings/{listing_id}/detailListing detail with seller + cardfree
GET/seller/listings/{listing_id}/similarSimilar listingsfree
GET/seller/listings/public/{username}Public seller storefrontfree
GET/seller/listings/card/{card_id}All listings for a specific cardfree
GET/seller/listingsMy active listings (seller)auth
POST/seller/listingsCreate a listingauth

Body

card_idintrequired
pricefloatrequiredEUR
conditionenumrequiredNM, LP, MP, HP, DMG
quantityintdefault 1
variantstringoptional
gradedbooldefault false
grading_companystringoptional
gradenumberoptional
descriptionstringoptional≤ 2000 chars
accepts_offersbooldefault falseEnable bidding
image_urlsarray<string>optionalFrom /api/upload/listing-image
curl -b cookies.txt -X POST https://collectorstashmarket.com/seller/listings \
  -H 'Content-Type: application/json' \
  -d '{"card_id":891,"price":650,"condition":"NM","quantity":1,"accepts_offers":true}'
await fetch("https://collectorstashmarket.com/seller/listings", {
  method:"POST", credentials:"include",
  headers:{"Content-Type":"application/json"},
  body: JSON.stringify({ card_id:891, price:650, condition:"NM", quantity:1, accepts_offers:true })
});
s.post("https://collectorstashmarket.com/seller/listings",
       json={"card_id":891,"price":650,"condition":"NM","quantity":1,"accepts_offers":True})
PATCH/seller/listings/{listing_id}Update a listingauth
DELETE/seller/listings/{listing_id}Delete a listingauth

Buy now

Instant purchase of a listing without going through the bid flow. The listing must have buy_now_enabled=true. Creates a transaction in awaiting_payment, marks the listing sold, and returns a redirect URL to the checkout page.

POST/api/listings/{listing_id}/buy-nowInstantly buy a listingauth

No body required. An optional variant field disambiguates when the listing has multiple variants.

Body (optional)

variantstringoptionale.g. holo, reverse-holo

Response

{ "ok": true,
  "transaction_id": 4912,
  "redirect_url": "/checkout/4912",
  "amount": 650.00,
  "currency": "EUR",
  "buyer_total": 657.50,
  "handling_fee": 7.50 }

Errors

400errorbuy_now_disabled — listing has buy-now off
400errorself_buy — buyer is the listing owner
400errorlisting_unavailable — sold or expired
401errorAuthentication required
404errorListing not found
curl -b cookies.txt -X POST https://collectorstashmarket.com/api/listings/123/buy-now \
  -H 'Content-Type: application/json' -d '{}'
const r = await fetch("https://collectorstashmarket.com/api/listings/123/buy-now", {
  method: "POST", credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: "{}"
});
const { redirect_url } = await r.json();
location.assign(redirect_url);
r = s.post("https://collectorstashmarket.com/api/listings/123/buy-now", json={})
print(r.json()["redirect_url"])

Listing renewal

Listings expire after 60 days by default. Sellers can extend the expiry by 30 days from the dashboard or from a one-click email link (token-based, no login required). The token is single-use and tied to the listing.

POST/api/listings/{listing_id}/renewExtend listing expiry by 30 daysauth

Owner-only. Idempotent within a 60-second window. Increments renewal_count on the listing for analytics.

Response

{ "ok": true,
  "expires_at": "2026-06-06T10:00:00Z",
  "renewal_count": 3 }
GET/listing-renewal/{token}Token-based renewal landingfree

Public HTML page hit from the renewal-reminder email. The token is signed with SESSION_SECRET and contains the listing id + a 7-day TTL. Renews the listing on GET, then renders a confirmation page. No authentication needed — possession of the token is proof.

Single-use. A second hit returns a "already renewed" page rather than re-renewing.

Fee preview

Public preview of the marketplace + image-protection fees a seller will pay on a hypothetical sale price. Used by the listing-create form to show "you'll receive €X" in real time before the listing is created.

GET/api/listings/fee-previewFee + payout preview for a hypothetical pricefree

Query

pricefloatrequiredEUR sale price
image_protectionbooldefault falseWhen true, adds the watermark+forensic-hash protection fee

Response

{ "marketplace_rate": 0.05,
  "marketplace_amt": 32.50,
  "image_protection_rate": 0.01,
  "image_protection_amt": 6.50,
  "total_amt": 39.00,
  "seller_payout": 611.00 }
curl 'https://collectorstashmarket.com/api/listings/fee-preview?price=650&image_protection=true'
const fees = await (await fetch(
  "https://collectorstashmarket.com/api/listings/fee-preview?price=650&image_protection=true"
)).json();
fees = requests.get("https://collectorstashmarket.com/api/listings/fee-preview",
                    params={"price": 650, "image_protection": True}).json()

Bids

Listings with accepts_offers=true can receive bids. Sellers see their own bidder list with usernames; the public-facing list anonymises bidders.

POST/seller/listings/{listing_id}/bidPlace a bidauth

Body

amountfloatrequiredEUR
messagestringoptional≤ 500 chars
POST/seller/listings/{listing_id}/bids/{bid_id}/acceptAccept a bid (seller)auth

Creates a transaction in awaiting_payment state, marks the listing as sold.

GET/seller/listings/{listing_id}/bidsPublic bid history (anonymised)free
GET/seller/my-placed-bidsBids I placed as a buyerauth
GET/seller/my-bidsBids on my listings (seller)auth

Seller account

POST/seller/becomePromote to sellerauth

Body

display_namestringrequired
countrystringrequiredISO-2
PUT/seller/profileUpdate seller profileauth
PUT/seller/profile/extendedUpdate extended profile (address, shipping)auth
POST/seller/import/cardmarketImport Cardmarket singles by usernameauth

Body

usernamestringrequiredYour Cardmarket username
POST/seller/import/cardmarket-csvImport Cardmarket CSV exportauth

multipart/form-data with a file field; expects the CSV format Cardmarket emits in "My account → Stock → Export".

POST/seller/import/tcgplayer-csvImport TCGPlayer CSV exportauth

Transactions

Every accepted bid (or buy-now in the future) creates a transaction. Status flow: awaiting_paymentpaidshippeddeliveredcompleted. cancelled and disputed are terminal.

GET/api/transactions/buysMy purchasesauth
GET/api/transactions/salesMy sales (sellers only)auth
GET/api/transactions/allAll my transactions (buyer + seller)auth
GET/api/transactions/{tx_id}Transaction detailauth
POST/api/transactions/{tx_id}/statusUpdate transaction statusauth

Body

statusenumrequiredawaiting_payment | paid | shipped | delivered | completed | cancelled | disputed
POST/api/transactions/{tx_id}/trackingSet tracking carrier + number (seller)auth

Body

carrierstringrequirede.g. postnl, dhl, ups
tracking_numberstringrequired
POST/api/transactions/{tx_id}/shippingSet shipping address (buyer)auth
POST/api/transactions/{tx_id}/ratingRate the other partyauth

Body

ratingintrequired1-5
reviewstringoptional≤ 1000 chars

Each party can rate exactly once per transaction; second submit returns 409.

GET/api/users/{username}/reputationAggregate reputation for a sellerfree

Reputation

Cleaner public-API alias for the seller reputation endpoint. Same payload, shorter path. Returns the average rating, total review count, and the most recent reviews.

GET/api/reputation/{username}Public reputation lookupfree

Response

{ "avg": 4.85,
  "count": 42,
  "recent_reviews": [
    { "rating": 5, "review": "Example review body.",
      "buyer": "demo-2", "ts": "2024-01-01T12:00:00Z" },
    { "rating": 5, "review": "Example review body.",
      "buyer": "demo-3", "ts": "2024-01-01T12:00:00Z" }
  ] }
curl https://collectorstashmarket.com/api/reputation/demo-seller
const rep = await (await fetch("https://collectorstashmarket.com/api/reputation/demo-seller")).json();
rep = requests.get("https://collectorstashmarket.com/api/reputation/demo-seller").json()

Image upload

POST/api/upload/listing-imageUpload an image for a listingauth

multipart/form-data with a file field. JPEG/PNG/WebP, ≤ 8 MB. Returns a permanent CDN-style URL to use in image_urls when creating a listing.

Response

{ "url": "/static/listing_images/2026/05/abc123.webp", "size": 184320 }

Card verification

Trust-system Part 1. Each protected listing carries a 6–8 char confirmation code printed on the watermarked photo. Buyers (or anyone holding the card later) can verify the code is real, what listing it belongs to, and whether the protection is still active. The lookup is fully public — no auth — so it can be embedded in third-party tools.

Where to find the code. The watermark overlay on every protected listing photo prints the code in the bottom-right corner. The same code is included in the post-purchase email and the buyer's transaction page.
GET/api/verify/{code}Public verification lookup (JSON)free

Case-insensitive. Codes are 6–8 chars, alphanumeric (no 0/O/1/I).

Response — valid code

{ "valid": true,
  "code": "K7H2QP",
  "status": "active",
  "card_name": "Charizard",
  "set_name": "Base Set",
  "condition": "NM",
  "seller_username": "demo-seller",
  "watermark_enabled": true,
  "created_at": "2024-01-01T12:00:00Z",
  "listing_url": "/listings/4912" }

Response — unknown / expired

{ "valid": false, "code": "K7H2QP" }
curl https://collectorstashmarket.com/api/verify/K7H2QP
const v = await (await fetch("https://collectorstashmarket.com/api/verify/K7H2QP")).json();
if (v.valid) console.log(v.card_name, v.seller_username);
v = requests.get("https://collectorstashmarket.com/api/verify/K7H2QP").json()
print(v.get("card_name"), v.get("seller_username"))
GET/verifyPublic HTML lookup pagefree

Plain HTML page with a search box. Enter a code → 302 to /verify/{code}.

GET/verify/{code}Public HTML result pagefree

Server-rendered result page. Renders the same data as /api/verify/{code} but in a styled card with the listing thumbnail and a "view listing" link.

GET/help/verify-photoHelp article — what is the verification code?free

Static help page explaining the watermark + confirmation code, where to find them, and what an "expired" status means.

Private messages

Buyer ↔ seller direct messaging. Optionally tied to a listing for context. WhatsApp-style threading via /api/messages/threads.

GET/api/messagesList my messages (inbox)auth

Query

folderenumdefault inboxinbox | sent | all
listing_idintoptional
limitintdefault 50max 100

Response

{ "items": [
  { "id": 14, "sender_id": 1, "recipient_id": 2,
    "from_username": "demo-1", "to_username": "demo-2",
    "listing_id": null, "subject": "Re: Example subject",
    "body": "Example message body", "is_read": false, "is_system": false,
    "parent_id": null, "created_at": "2024-01-01T12:00:00Z",
    "direction": "sent" }
], "messages": [...], "unread_count": 0, "folder": "inbox" }
items / messages alias. The mobile app reads items; older web clients read messages. Both contain the same array.
POST/api/messagesSend a messageauth

Body

to_usernamestringrequiredAliases: to, recipient_username
bodystringrequired≤ 5000 chars
subjectstringoptional≤ 200 chars
listing_idintoptional
parent_idintoptionalThreading
curl -b cookies.txt -X POST https://collectorstashmarket.com/api/messages \
  -H 'Content-Type: application/json' \
  -d '{"to_username":"demo-2","body":"Example message body","listing_id":42}'
await fetch("https://collectorstashmarket.com/api/messages", {
  method:"POST", credentials:"include",
  headers:{"Content-Type":"application/json"},
  body: JSON.stringify({ to_username:"demo-2", body:"Example message body", listing_id:42 })
});
s.post("https://collectorstashmarket.com/api/messages",
       json={"to_username":"demo-2", "body":"Example message body", "listing_id":42})
GET/api/messages/threadsConversation list (WhatsApp-style)auth
GET/api/messages/thread/{other_user_id}Full conversation with one userauth

Query

listing_idintoptionalRestrict to one listing

Marks all received messages as read on each call.

GET/api/messages/unread-countUnread message badge countauth
POST/api/messages/{msg_id}/readMark a message as readauth
DELETE/api/messages/{msg_id}Delete a messageauth

Forum

GET/api/forum/Forum categoriesfree
GET/api/forum/announcementsGlobal pinned announcementsfree
GET/api/forum/{forum_slug}/Threads in a sub-forumfree
POST/api/forum/{forum_slug}/newCreate a threadauth

Body

titlestringrequired≤ 200 chars
bodystringrequiredMarkdown, ≤ 20 000 chars
GET/api/forum/thread/{thread_id}Thread detail with repliesfree
POST/api/forum/thread/{thread_id}/replyReply to a threadauth
DELETE/api/forum/post/{post_id}Delete a post (author or admin)auth

Notifications

Alert triggers, bid events, message notifications, and listing-status changes are aggregated into one feed plus per-type unread counters.

GET/api/notifications/feedUnified notification feedauth

Response

{ "items": [
  { "id": 1234, "kind": "alert_triggered",
    "title": "Charizard hit your target", "body": "Now €712.50 (target: €750)",
    "data": {"card_id": 891, "alert_id": 7},
    "is_read": false, "created_at": "..." },
  { "id": 1233, "kind": "new_message",
    "title": "Bericht van demo-2", "body": "Example message body",
    "data": {"message_id": 14, "from_username": "demo-2"},
    "is_read": false, "created_at": "2024-01-01T12:00:00Z" }
]}
GET/api/notifications/feed/unread-countUnified badge countauth
POST/api/notifications/feed/{item_id}/readMark one item readauth
POST/api/notifications/feed/read-allMark all readauth
GET/api/notificationsTriggered alerts onlyauth
POST/api/notifications/{alert_id}/readMark a triggered alert readauth
POST/api/notifications/read-allMark all triggered alerts readauth

Reports & blocks

POST/api/reportsReport content or a userauth

Body

target_typeenumrequiredforum_post | listing | message | user
target_idintrequired
reasonstringrequiredFree-text, ≤ 500 chars
categoryenumoptionalspam | fraud | abuse | illegal | other
GET/api/reports/meReports I have filedauth
POST/api/blocks/{user_id}Block another userauth
DELETE/api/blocks/{user_id}Unblockauth
GET/api/blocksCurrently blocked user idsauth

Recognize a card

End-to-end card recognition pipeline: edge-detect & perspective-correct → 1,728-dim embedding lookup over 80k+ indexed cards → Tesseract OCR → weighted scoring. Confidence is bucketed: high (> 0.75), medium (0.50–0.75), low (0.30–0.50), conflict (visual / OCR disagree).

Session-cookie users get unlimited scans; API-key callers need Starter+ tier and consume a daily quota (Starter 25, Pro 250, Power Collector 1,000, Shop Basic 1,000, Shop Pro 2,500, Enterprise 10,000, Unlimited ∞ — mirrors app/tiers.py).

POST/api/cards/recognizeRecognize a card from a photostarter+

multipart/form-data with one image field. JPEG / PNG / WebP, ≤ 10 MB.

Form fields

imagefilerequired
tcg_slugstringoptionalRestrict to one TCG (improves accuracy)
debugbooldefault falseInclude intermediate-image URLs for diagnosis

Response

{ "scan_id": "20260506_215125",
  "candidates": [
    { "card_id": 891, "name": "Charizard", "set_name": "Base Set",
      "tcg_slug": "pokemon", "score": 0.91, "confidence": "high",
      "components": { "embedding":0.88, "collector_number":1.0,
                      "name":0.95, "feature_match":0.7, "set_context":1.0 } }
  ],
  "capture_quality": {
    "overall_score": 0.78, "color_reliability": "high",
    "glare_severity": "none", "verdict": "likely_real", "fake_risk": 0.04
  } }
curl -b cookies.txt -X POST https://collectorstashmarket.com/api/cards/recognize \
  -F image=@charizard.jpg -F tcg_slug=pokemon
const fd = new FormData();
fd.append("image", fileInput.files[0]);
fd.append("tcg_slug", "pokemon");
const r = await fetch("https://collectorstashmarket.com/api/cards/recognize", {
  method: "POST", credentials: "include", body: fd,
});
const data = await r.json();
with open("charizard.jpg", "rb") as f:
    r = s.post("https://collectorstashmarket.com/api/cards/recognize",
               files={"image": f}, data={"tcg_slug": "pokemon"})
print(r.json()["candidates"][0])
POST/api/cards/recognize/confirmConfirm a scan (improves the index)starter+

Tells the recogniser the user actually owned card card_id from scan scan_id. The image is appended to static/confirmed_scans/{tcg}/{card_id}/ for self-learning. Does not consume quota.

Body

scan_idstringrequiredReturned by /api/cards/recognize
card_idintrequired
POST/api/cards/detect-edgesEdge-only pre-flight (helps mobile UX)starter+

Same form-data shape as /recognize; returns just the contour overlay so the camera UI can prompt "move closer" before consuming a recognise quota slot.

Rotation-fallback (2026-05-07). The recogniser now retries with the image rotated -90° / 180° / +90° if the first pass returns low or conflict confidence. The candidates[0] entry includes rotation_used so the client can mirror that rotation when displaying the cropped thumbnail.

Scan PWA

Self-hosted progressive web app for the live in-browser scan flow. Uses getUserMedia() for camera capture and posts each frame to /api/cards/recognize. Works offline (cached), installable on Android Chrome and iOS Safari.

GET/scan-pwaPWA scan pageauth

Returns the camera + recognise UI. Unauthenticated requests 302 to /login?next=/scan-pwa. Has its own manifest.json + service worker so users can "Add to Home Screen".

GET/scan-pwa/reviewBulk-review of scanned cardsauth

Review screen after a scan session: list of recognised cards with confidence, edit/discard controls, and a "save to collection / create listings" action.

Scan sessions (cross-device)

Bridges a desktop browser to a phone camera for scanning, without making the user log in on the phone. Flow:

  1. Desktop: POST /api/scan-sessions → returns session_id, claim_token, qr_url, deeplink, and a 6-digit confirmation_code.
  2. Desktop renders the QR (GET /api/scan-sessions/{id}/qr.png) and opens an SSE stream (GET /api/scan-sessions/{id}/sse).
  3. Phone scans the QR → opens the deeplink → POST /api/scan-sessions/{id}/claim with ?token=…&device_name=….
  4. Phone repeatedly: POST .../upload-photo (multipart) → POST .../results (JSON with the recognition result) — once per card.
  5. Phone calls POST .../complete when done. Desktop closes the SSE stream and shows the review screen.

Sessions auto-expire after 30 minutes (or 5 minutes idle). The cardvault-expire-scan-sessions timer purges expired sessions hourly.

POST/api/scan-sessionsCreate a cross-device scan sessionauth

Owner endpoint. Creates a session bound to the calling user. The returned claim_token is the only credential the phone needs — keep it on the desktop and never log it.

Response

{ "session_id": "01HW6Q8...",
  "claim_token": "ct_8e2a4c91f3...",
  "confirmation_code": "428193",
  "qr_url":   "/api/scan-sessions/01HW6Q8.../qr.png",
  "deeplink": "https://collectorstashmarket.com/scan-pwa?session=01HW6Q8...&t=ct_8e2a4c91f3...",
  "expires_at": "2026-05-07T13:32:11Z" }
curl -b cookies.txt -X POST https://collectorstashmarket.com/api/scan-sessions
const r = await fetch("https://collectorstashmarket.com/api/scan-sessions", {
  method: "POST", credentials: "include"
});
const session = await r.json();
session = s.post("https://collectorstashmarket.com/api/scan-sessions").json()
GET/api/scan-sessions/{session_id}Read session stateauth

Accepts either the owner's session cookie or a ?token=ct_… query string. Used by both sides to poll if SSE isn't an option.

Response

{ "session_id": "01HW6Q8...",
  "status": "claimed",                  // pending | claimed | active | completed | expired
  "device_name": "Pixel 8",
  "results": [ /* card objects pushed by the phone */ ],
  "expires_at": "2026-05-07T13:32:11Z" }
GET/api/scan-sessions/{session_id}/sseServer-Sent Events (owner)auth

Streams every state change on the session. Owner-only (session cookie). Use EventSource in browsers. Events:

event: claimed   data: { "device_name": "Pixel 8" }
event: photo     data: { "card_index": 0, "image_url": "/static/scans/.../0.webp" }
event: result    data: { "card_index": 0, "recognition": { ... } }
event: completed data: { "count": 12 }
POST/api/scan-sessions/{session_id}/claimPhone claims the sessionfree

Authenticated by the ?token=ct_… query parameter (or X-Scan-Token header). Marks the session as claimed and pushes a claimed event on the desktop SSE stream.

Body

device_namestringrequiredFree text — shown on the desktop ("Connected: Pixel 8")
POST/api/scan-sessions/{session_id}/upload-photoPhone uploads a card photofree

multipart/form-data with a single file field. The server applies a +90° clockwise rotation (matches the typical hand-held landscape capture) and strips EXIF before saving. Token-authenticated.

Response

{ "ok": true,
  "image_url": "/static/scans/01HW6Q8.../0.webp",
  "size": 184320 }
POST/api/scan-sessions/{session_id}/resultsPhone pushes recognition resultsfree

Token-authenticated. Body batches one or more cards. Each entry can include a pre-computed recognition object (from a phone-side /api/cards/recognize call) — saves an extra round-trip.

Body

{ "cards": [
  { "card_index": 0,
    "captured_at": "2026-05-07T13:14:01Z",
    "image_url": "/static/scans/.../0.webp",
    "recognition": { /* /api/cards/recognize response */ } }
]}
POST/api/scan-sessions/{session_id}/completePhone finalises the sessionfree

Marks the session completed and pushes a completed event on the desktop SSE stream. Token-authenticated. After this, no further uploads are accepted.

GET/api/scan-sessions/{session_id}/qr.pngQR code image (PNG)free

Returns a 256×256 PNG of the QR code that encodes the deeplink. Public — no auth — because the URL itself contains the (single-use, time-bound) claim_token.

GET /api/scan-sessions/01HW6Q8.../qr.png  →  Content-Type: image/png

Real-time / WebSocket API streaming

The streaming API pushes events to you over a persistent WebSocket instead of you polling endpoints. Subscribe to the cards / sets / TCGs you care about and receive a compact price_update frame whenever fresh prices land, plus a live market-movers ticker.

Endpoints

URLAuthTierPurpose
wss://collectorstashmarket.com/api/v1/streamAPI key (required)Pro or higherDeveloper streaming — live price updates & movers ticker
wss://collectorstashmarket.com/ws/v1Session cookie or API key or anonymousanyGeneral real-time channel used by the web app (anonymous = public channels only)

Authentication

Pass your key as a query parameter at connect time:

wss://collectorstashmarket.com/api/v1/stream?api_key=csm_live_xxxxxxxxxxxx

Alternatively send {"type":"auth","token":"csm_..."} as your first message (note: to subscribe to your private user:<id> channel you must pass the key in the query string at connect time). Browser clients on collectorstashmarket.com are authenticated automatically via the tcg_session cookie. One WebSocket connection counts as one request against your daily API quota.

Limits

  • Max 50 topic subscriptions per connection.
  • Max 20 concurrent connections per API key (or per IP for anonymous).
  • Max 30 client → server messages per 10 s (excess → an error frame, the connection stays open).
  • Server sends a {"type":"ping"} every ~25 s; reply {"type":"pong"} (or just keep the socket alive). Dead sockets are dropped automatically.

Message protocol

All frames are JSON text. Client → server:

{"type":"subscribe","topic":"card:267"}          // or {"type":"subscribe","topics":["card:267","set:42"]}
{"type":"unsubscribe","topic":"card:267"}
{"type":"ping"}                                   // → {"type":"pong"}
{"type":"auth","token":"csm_..."}                 // optional late auth

Server → client:

{"type":"welcome","conn_id":"…","authenticated":true,"user_id":42,"tier":"pro","heartbeat_secs":25,"public_topics":["card:","set:","tcg:","market:","marketplace:"]}
{"type":"subscribed","topic":"card:267"}
{"type":"unsubscribed","topic":"card:267"}
{"type":"price_update","topic":"card:267","ts":1715520000.0,"data":{"card_id":267,"source":"tcgplayer","variant":"normal","market_price":12.34,"currency":"USD"}}
{"type":"movers","topic":"market:movers","data":{"tcg":"all","period":"7d","items":[…]}}
{"type":"listing_new","topic":"marketplace:new","data":{"listing_id":901,"card_id":267,"price":11.00,"currency":"EUR"}}
{"type":"notification","topic":"user:42","data":{"kind":"price_alert","title":"…","body":"…","url":"…"}}
{"type":"prices_refreshed","data":{"snapshots":12345,"kind":"daily_snapshot"}}
{"type":"ping"}    {"type":"pong"}
{"type":"error","error":"…","topic":"…"}

Reconnection

WebSockets drop — network changes, proxy idle-timeouts, deploys. On reconnect re-send all your subscribe frames (the welcome frame reminds you). Use exponential backoff with jitter (start ~1 s, cap ~30 s). The conn_id in welcome changes per connection — it's only for log correlation, there is no resume token.

Real-time — channels & events

Channels (topics) you can subscribe to

TopicAccessYou receive
card:<card_id>publicprice_update, listing_new
set:<set_id>publicprice_update (per-set summary when a price scrape commits)
tcg:<slug>publicprice_update, listing_new for any card in that TCG
market:moverspublicmovers — refreshed gainers/losers ticker
marketplace:newpubliclisting_new — every new seller listing platform-wide
user:<your_id>your account only (cookie/api-key)notification — price alerts, new DMs, offers, order updates, …
scan:<session_id>authenticatedscan_result — cross-device bulk-scan handoff push

Event-frame catalogue

  • welcome — first frame after connect; tells you your conn_id, auth state, tier, heartbeat interval.
  • price_update{card_id, source, variant, market_price, currency} (set/tcg variants also carry set_id / tcg).
  • movers{tcg, period, items:[…]} top gainers + losers.
  • listing_new{listing_id, card_id, price, currency}.
  • notification{kind, title, body, url} (delivered on user:<id>). Note: existing notification events (notification.created, price_alert.triggered, offer.received, order.updated, …) from the SSE event-bus are also relayed to this channel — see the SSE docs for that catalogue.
  • prices_refreshed — broadcast after the daily price-snapshot job ({snapshots, kind}).
  • scan_result — relayed bulk-scan session events.
  • subscribed / unsubscribed — acks. ping / pong — heartbeat. error{error, topic?}, never closes the socket unless it's a fatal auth/tier error (close codes 4401 = api_key required, 4403 = tier too low, 1013 = too many connections).

Real-time — example clients

JavaScript (browser, vanilla)

const url = "wss://collectorstashmarket.com/api/v1/stream?api_key=" + encodeURIComponent(API_KEY);
let ws, backoff = 1000;
const topics = ["card:267", "market:movers"];

function connect() {
  ws = new WebSocket(url);
  ws.onopen = () => { backoff = 1000; ws.send(JSON.stringify({ type: "subscribe", topics })); };
  ws.onmessage = (ev) => {
    const f = JSON.parse(ev.data);
    if (f.type === "ping") return ws.send(JSON.stringify({ type: "pong" }));
    if (f.type === "price_update") console.log("price", f.data.card_id, f.data.market_price, f.data.currency);
    if (f.type === "movers") console.log("movers", f.data.items.length);
  };
  ws.onclose = () => { setTimeout(connect, backoff = Math.min(backoff * 2, 30000)); };
}
connect();

A ready-made helper with auto-reconnect ships at https://collectorstashmarket.com/static/js/csm-realtime.js:

const rt = CSMRealtime.connect({ apiKey: "csm_..." });   // omit apiKey on collectorstashmarket.com (cookie auth)
rt.subscribe("card:267", (frame) => updateTicker(frame.data));
rt.on("notification", (frame) => showBell(frame.data));

Python (websocket-client)

import json, websocket

API_KEY = "csm_..."
URL = f"wss://collectorstashmarket.com/api/v1/stream?api_key={API_KEY}"

def on_open(ws):
    ws.send(json.dumps({"type": "subscribe", "topics": ["card:267", "market:movers"]}))

def on_message(ws, raw):
    f = json.loads(raw)
    if f["type"] == "ping":
        ws.send(json.dumps({"type": "pong"})); return
    if f["type"] == "price_update":
        print("price", f["data"]["card_id"], f["data"]["market_price"], f["data"]["currency"])

ws = websocket.WebSocketApp(URL, on_open=on_open, on_message=on_message)
ws.run_forever(ping_interval=20)   # also re-connect in a loop with backoff in production

Python (websockets, asyncio)

import asyncio, json, websockets

async def main():
    url = "wss://collectorstashmarket.com/api/v1/stream?api_key=csm_..."
    async for ws in websockets.connect(url):     # auto-reconnects
        try:
            await ws.send(json.dumps({"type": "subscribe", "topic": "card:267"}))
            async for raw in ws:
                f = json.loads(raw)
                if f["type"] == "ping":
                    await ws.send(json.dumps({"type": "pong"}))
                elif f["type"] == "price_update":
                    print(f["data"])
        except websockets.ConnectionClosed:
            continue

asyncio.run(main())

Health & stats

GET/healthLightweight liveness probefree

Response

{ "status": "ok" }
GET/healthzDB-aware health check (monitoring-friendly)free

Response

{ "status": "ok", "db": true }   // 200
{ "status": "degraded", "db": false }   // 503
GET/api/healthDetailed health + DB countsfree

Response

{ "status": "ok", "cards": 562165, "prices": 8875351,
  "users": 1234, "sellers": 87, "active_alerts": 412,
  "db_size_mb": 4520.66, "version": "1.0.0" }
curl https://collectorstashmarket.com/api/health
const h = await (await fetch("https://collectorstashmarket.com/api/health")).json();
h = requests.get("https://collectorstashmarket.com/api/health").json()
GET/api/statsPlatform statisticsfree
GET/api/stats/detailedDetailed stats (per TCG)free
GET/statsPlatform statistics (HTML page redirect)free

Redirects to the localised stats page; for JSON use /api/stats.

Status page

GET/api/status/checksPer-target status summaryfree

Response

{ "items": [
  { "target": "api", "status": "operational", "last_check": "...", "latency_ms": 41 },
  { "target": "redis", "status": "operational", "last_check": "...", "latency_ms": 1 },
  { "target": "scrapers", "status": "operational", "last_check": "...", "latency_ms": null }
]}
GET/api/status/incidentsRecent down/degraded incidentsfree

App meta & devices

Mobile-app version manifest and FCM device registration for push notifications.

GET/api/app/latestLatest published mobile versionfree

Query

platformenumoptionalandroid | ios

Response

{ "android": { "version_name": "1.10.0", "version_code": 21, "min_supported": "1.8.0", "release_notes": "..." },
  "ios":     { "version_name": "1.10.0", "version_code": 21, "min_supported": "1.8.0", "release_notes": "..." } }
POST/api/devices/registerRegister / refresh push tokenauth

Body

platformenumrequiredandroid | ios
tokenstringrequiredFCM token (Android) / APNs (iOS)
app_versionstringoptional
DELETE/api/devices/registerUnregister push token (on logout)auth

Body

tokenstringrequired

Platform settings

Read-only public settings (e.g. seller-fee percentages, payout caps) plus a full admin CRUD for the underlying platform_settings table.

GET/api/platform/settingsPublic settings (fees, commission, …)free

Response

{ "seller_fee_pct": 5.0, "buyer_protection_pct": 0.0,
  "min_payout_eur": 25.0, "site_announcement": null }

Need help?

Open a thread on the community forum, file an issue at /contact, or email support@collectorstashmarket.com. The full changelog lives at /dev/changelog.