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
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.
| Metric | Value |
|---|---|
| Cards indexed | 500k+ |
| Price data points | 220M+ historical (v2) |
| Sets / expansions | 7,500+ |
| TCGs covered | 15 |
| Price sources | Multiple — see Prices section & v2 price sources |
| Refresh cadence | Hot cards refreshed frequently; full catalogue updated multiple times daily |
Supported TCGs
| Slug | Game |
|---|---|
| pokemon | Pokémon TCG |
| mtg | Magic: The Gathering |
| yugioh | Yu-Gi-Oh! |
| fab | Flesh and Blood |
| onepiece | One Piece Card Game |
| dragonball | Dragon Ball Super CCG |
| lorcana | Disney Lorcana |
| starwars | Star Wars Unlimited |
| digimon | Digimon Card Game |
| keyforge | KeyForge |
| grandarc | Grand Archive |
| sorcery | Sorcery: Contested Realm |
| unionarena | Union Arena |
| gundam | Gundam Card Game |
| riftbound | Riftbound |
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
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.
| Tier | Segment | Req / day | Req / min | Keys | Recognition / day | Price (monthly) |
|---|---|---|---|---|---|---|
| Free | Collectors | 500 | 30 | 1 | — | €0 |
| Starter | Collectors | 5,000 | 60 | 3 | 25 | €9 / mo |
| Pro | Collectors | 25,000 | 120 | 5 | 250 | €29 / mo |
| Power Collector | Collectors | 100,000 | 300 | 10 | 1,000 | €59 / mo |
| Shop Basic | Business | 250,000 | 500 | 10 | 1,000 | €99 / mo |
| Shop Pro | Business | 500,000 | 1,000 | 15 | 2,500 | €249 / mo |
| Enterprise | Business | 1,000,000 | 3,000 | 25 | 10,000 | €499 / mo |
| Unlimited | Business | ∞ | ∞ | 50 | ∞ | €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.
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"
}
]
}
| Status | Meaning | Typical cause |
|---|---|---|
| 200 | OK | Success |
| 201 | Created | POST created a resource |
| 204 | No Content | DELETE succeeded |
| 301 | Moved Permanently | Canonical-URL redirect (e.g. /dev → developer portal) |
| 400 | Bad Request | Invalid or missing JSON body field |
| 401 | Unauthorized | Missing or invalid key / session |
| 403 | Forbidden | Tier too low, not the resource owner, or not admin |
| 404 | Not Found | Card / set / TCG / row id does not exist |
| 409 | Conflict | Duplicate (alert exists, listing already sold, …) |
| 422 | Unprocessable | Field validation (range, regex, type) |
| 429 | Too Many Requests | Rate limit exceeded — see Retry-After |
| 500 | Server Error | Bug on our side — please report |
Pagination & filtering
List endpoints are limit/offset paginated and almost always cap at limit=200. The response shape is:
{
"items": [...],
"total": 20543,
"limit": 50,
"offset": 0
}
Walk a list with offset += limit until offset >= total. Some legacy endpoints use page + per_page — these are flagged in the relevant section.
Search endpoints accept q for free-text and one or more typed filters (tcg_slug, min_price, max_price, foil_status, condition, language, …). All filters AND together.
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=trueon 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.
/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/statsrather 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
| Array | Region / currency | Sources | Meaning |
|---|---|---|---|
| eu | EU · EUR (€) | Cardmarket | European raw (ungraded) prices. price_type ∈ avg, avg1, avg7, avg30, trend, low. |
| us | US · USD ($) | TCGplayer (via tcgcsv), PriceCharting, CardTrader, JustTCG | North-American raw prices. price_type ∈ market, low, mid, high, direct_low, raw_us, last_sold. |
| graded | US · USD ($) | PriceCharting | Graded-slab prices keyed by grading_company + grade. |
Graded semantics — read carefully
graded array mixes PriceCharting's
aggregate grade buckets with explicit grading-company prices. Always
branch on grading_company:| grading_company | grade | What it actually is |
|---|---|---|
| psa | 10, 9, … | Explicit PSA-graded price. |
| bgs | 10, 9.5, … | Explicit BGS / Beckett-graded price. |
| cgc / sgc / ace | varies | Explicit price for that grading company (when available). |
| other | 7 / 8 / 9 / 9.5 | PriceCharting cross-company aggregate for that numeric grade — NOT PSA. Treat as "graded ~grade N, company-agnostic". |
| other | pc17 / pc18 | PriceCharting 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. |
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.
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/statsconst stats = await (await fetch("https://collectorstashmarket.com/api/v2/stats")).json();import requests
stats = requests.get("https://collectorstashmarket.com/api/v2/stats").json()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/gamesimport requests
games = requests.get("https://collectorstashmarket.com/api/v2/games").json()["items"]Path / query
| slug | string | required | e.g. pokemon |
| page | int | default 1 | 1-based |
| size | int | default 50 | 1–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"Path / query
| set_id | int | required | global set id (e.g. 1043) |
| page | int | default 1 | |
| size | int | default 60 | 1–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"Lightweight resolver — pass at least one of the filters. Use it to find a card id before fetching the full detail (ids can change on importer re-runs, so resolve rather than hard-code).
Query
| game | string | optional | game slug, e.g. pokemon |
| name | string | optional | substring match (case-insensitive) |
| number | string | optional | exact collector number, e.g. 4/102 |
| limit | int | default 25 | 1–100 |
game/name/number
is required, else 400.Response
{
"items": [
{ "id": 119778, "name": "Charizard", "collector_number": "4/102",
"rarity": null, "card_type": null, "set_id": 1188,
"thumbnail_url": null },
{ "id": 441076, "name": "Charizard", "collector_number": "4/102",
"rarity": null, "card_type": null, "set_id": 1043,
"thumbnail_url": "/static/images/csm/pokemon/435825.avif" },
{ "id": 442711, "name": "Charizard 4 102 CoroCoro Promo",
"collector_number": "4/102", "rarity": null, "card_type": null,
"set_id": 6357, "thumbnail_url": "/static/images/csm/pokemon/442711.avif" }
// … resolves to the right id; fetch /api/v2/cards/{id} for the full detail
]
}
curl "https://collectorstashmarket.com/api/v2/cards?game=pokemon&name=Charizard&number=4/102"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_id | int | required | canonical 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
]
}
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/441076import 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)Path / query
| card_id | int | required | |
| variant_id | int | optional | defaults to the card's first variant |
| region | string | default eu | one of eu, us, jp, global |
| days | int | default 90 | 1–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).
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/tcgsconst r = await fetch("https://collectorstashmarket.com/tcgs");
const { items } = await r.json();import requests
items = requests.get("https://collectorstashmarket.com/tcgs").json()["items"]Path parameters
| slug | string | required | e.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/pokemonconst 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.
Path / query
| tcg_slug | string | required | e.g. pokemon |
| limit | int | default 50 | max 200 |
| offset | int | default 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"]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/1const set = await (await fetch("https://collectorstashmarket.com/pokemon/sets/1")).json();set_obj = requests.get("https://collectorstashmarket.com/pokemon/sets/1").json()Response
{ "set_id": 1, "card_count": 102, "min_price": 0.05, "max_price": 5800.00,
"avg_price": 32.41, "total_value": 3308 }
Query
| limit | int | default 10 | max 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.
Query
| limit | int | default 50 | max 200 |
| offset | int | default 0 | |
| set_id | int | optional | Filter by set |
| rarity | string | optional | |
| name | string | optional | Substring (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"]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" }
]
}
Query
| limit | int | default 20 | max 100 |
| tcg_slug | string | optional |
Query
| since | date | optional | YYYY-MM-DD; default 30 days ago |
| limit | int | default 50 | max 200 |
| tcg_slug | string | optional |
Query
| ids | comma-separated ints | required | e.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()Query
| limit | int | default 25 | max 100 |
| tcg_slug | string | optional |
Query
| variant | string | default normal | normal | 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" }
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.
Query
| limit | int | default 50 | |
| offset | int | default 0 | |
| set_id | int | optional |
curl https://collectorstashmarket.com/pokemon/sealed/1const p = await (await fetch("https://collectorstashmarket.com/pokemon/sealed/1")).json();p = requests.get("https://collectorstashmarket.com/pokemon/sealed/1").json()Search
Cross-TCG card search with optional typed filters.
Query
| q | string | required | Free-text card name, number, or set |
| tcg_slug | string | optional | Restrict to one TCG |
| set_id | int | optional | |
| rarity | string | optional | |
| min_price | float | optional | USD market price |
| max_price | float | optional | |
| limit | int | default 50 | max 200 |
| offset | int | default 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.
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/pricesconst 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.
Query
| days | int | default 90 | 7, 30, 90, 180, 365 |
| source | string | optional | Limit to one source |
| variant | string | optional |
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"
}
}
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
}
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
| source | string | default pokemon_price_tracker | Any source we write into price_snapshots (e.g. pokemon_price_tracker_ebay). |
| variant | string | optional | Restrict to one variant (normal / holofoil / reverse_holo / …). |
| days | int | default 180 | Window 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 }
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
| days | int | default 90 | Window length, max 365. |
| grading_company | string | optional | PSA / CGC / BGS / SGC / ... |
| grade | string | optional | "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 }
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
| variant | string | default normal | Variant slug. Pass auto to fall back to the best-covered ungraded variant. |
| grading_company | string | optional | Empty = ungraded scope. PSA / CGC / BGS / SGC. |
| grade | string | optional | Requires 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" }
}
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).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_days | int | default 30 | 1–3650. Use 30 / 90 / All for the UI toggle. |
| limit | int | default 50 | 1–500. |
| grading_company | string | optional | Filter to one slabbing house (PSA/BGS/CGC/SGC). Case-insensitive. |
| grade | string | optional | Filter to one grade value ("10", "9.5", …). |
| language | string | optional | Restrict 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. |
| variant | string | optional | Restrict 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 } }
Query
| limit | int | default 50 | max 200 |
| source | string | optional | |
| variant | string | optional |
Query
| start_date | date | optional | |
| end_date | date | optional | |
| source | string | optional |
Query
| period | string | default 30d | 7d | 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.
Query
| tcg | string | default all | A TCG slug or all |
| period | enum | default 7d | 7d | 30d | 90d |
| limit | int | default 20 | max 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}%')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
| direction | enum | default up | up | down |
| limit | int | default 12 | max 50 |
| tcg_slug | string | optional |
/api/market/movers for new code — same data, three categories in one call.Query
| limit | int | default 20 | max 100 |
| tcg_slug | string | optional | |
| min_spread_pct | float | default 20.0 | Minimum % gap between cheapest and most-expensive source |
Response
{ "total_cards": 562165, "total_prices": 8875351, "total_sets": 7777,
"tcgs_covered": 12, "sources": ["tcgplayer","cardmarket","cardtrader","ebay_graded","pricecharting"],
"last_update": "..." }
Query
| limit | int | default 12 | |
| tcg_slug | string | optional |
Predictions
Linear regression on PriceCharting daily history. Confidence is bucketed by data-point count: low < 10, medium 10-20, high ≥ 20.
Query
| days_ahead | int | default 30 | 7-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()Query
| limit | int | default 10 | max 50 |
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).
Query
| foil_status | enum | default nonfoil | nonfoil | foil | etched |
| condition | enum | default NM | NM | LP | MP | HP | DMG |
| language | enum | default en | en | de | fr | jp | … |
| printing | enum | default unlimited | unlimited | 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": []
}
Query
| source | string | optional | cardmarket | cardtrader | tcgplayer | ebay |
| foil_status | enum | optional | |
| condition | enum | optional | |
| limit | int | default 50 | max 200 |
| offset | int | default 0 |
curl https://collectorstashmarket.com/api/price-intelligence/sourcesconst sources = await (await fetch(
"https://collectorstashmarket.com/api/price-intelligence/sources"
)).json();sources = requests.get(
"https://collectorstashmarket.com/api/price-intelligence/sources"
).json()Query
| from_currency | string | default USD | ISO 4217 |
| to_currency | string | default 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.
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.
Body
| username | string | required | 3-30 chars, lowercased |
| string | required | ||
| password | string | required | ≥ 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" }
Body
| username | string | required | Or email |
| password | string | required |
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"})Response
{ "id": 1, "username": "demo-1", "email": "demo-1@example.com",
"is_admin": true, "is_seller": true, "created_at": "2024-01-01T12:00:00Z" }
Body
| username, email, password | string | required | As /auth/register |
| display_name | string | optional | |
| country | string | optional | ISO-2 |
Account & password
Body
| current_password | string | required | |
| new_password | string | required | ≥ 8 chars |
Body
| confirm | string | required | Must equal "DELETE <username>" |
The account is flagged for deletion; you have 24h to cancel.
Body
| confirm | string | required | Must equal "DELETE <username>" |
API keys
Programmatic key management. The dashboard at developer.collectorstashmarket.com/dashboard wraps these endpoints with a UI.
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 }
]}
Body
| name | string | required | Friendly label |
| tier | enum | optional | Defaults to your subscription tier |
| expires_at | date-time | optional |
Response
{ "id": 18, "name": "staging",
"key": "csm_stg_e9c2b…f102", "tier": "free", ... }
key field is only returned once. Store it immediately.Body
| name | string | required |
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.
Query
| limit | int | default 50 | max 200 |
| offset | int | default 0 | |
| tcg_slug | string | optional |
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 }
Body
| card_id | int | required | |
| quantity | int | default 1 | |
| condition | enum | default NM | NM, LP, MP, HP, DMG |
| variant | string | optional | e.g. holo, 1st_edition_holo |
| price_paid | float | optional | Paid price (USD) |
| graded | bool | default false | |
| grading_company | string | optional | PSA, BGS, CGC |
| grade | number | optional | e.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})Body: any of quantity, condition, variant, price_paid, graded, grade.
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 } }
Returns text/csv with columns: card_id, name, set, tcg, quantity, condition, variant, price_paid, current_value.
multipart/form-data with a file field. Header row required: card_id, quantity, condition, price_paid.
Wishlist
Body
| card_id | int | required | |
| max_price | float | optional | Notify 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})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.
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..." }
]}
Body
| card_id | int | required | |
| target_price | float | required | |
| condition | enum | default below | below | above |
| currency | string | default EUR |
Decks
Build, share and value decks. MTG decks support format-legality checks; other TCGs return a generic legality stub.
Body
| name | string | required | |
| tcg_slug | string | required | |
| format | string | optional | e.g. standard, commander, standard-2024 |
| is_public | bool | default false |
Body
| card_id | int | required | |
| quantity | int | default 1 | |
| section | enum | default main | main | sideboard | commander |
Body
| tcg_slug | string | required | |
| text | string | required | One card per line, format 4 Lightning Bolt |
Profile
multipart/form-data with a file field. JPEG / PNG / WebP, ≤ 5 MB. Auto-cropped to 256×256 WebP.
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.
Body
| is_collection_public | bool | required | When 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.
Query
| tcg_slug | string | optional | |
| min_price | float | optional | EUR |
| max_price | float | optional | |
| condition | enum | optional | |
| limit | int | default 50 | max 200 |
| offset | int | default 0 |
Body
| card_id | int | required | |
| price | float | required | EUR |
| condition | enum | required | NM, LP, MP, HP, DMG |
| quantity | int | default 1 | |
| variant | string | optional | |
| graded | bool | default false | |
| grading_company | string | optional | |
| grade | number | optional | |
| description | string | optional | ≤ 2000 chars |
| accepts_offers | bool | default false | Enable bidding |
| image_urls | array<string> | optional | From /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})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.
No body required. An optional variant field disambiguates when the listing has multiple variants.
Body (optional)
| variant | string | optional | e.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
| 400 | error | buy_now_disabled — listing has buy-now off |
| 400 | error | self_buy — buyer is the listing owner |
| 400 | error | listing_unavailable — sold or expired |
| 401 | error | Authentication required |
| 404 | error | Listing 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.
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 }
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.
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.
Query
| price | float | required | EUR sale price |
| image_protection | bool | default false | When 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.
Body
| amount | float | required | EUR |
| message | string | optional | ≤ 500 chars |
Creates a transaction in awaiting_payment state, marks the listing as sold.
Seller account
Body
| display_name | string | required | |
| country | string | required | ISO-2 |
Body
| username | string | required | Your Cardmarket username |
multipart/form-data with a file field; expects the CSV format Cardmarket emits in "My account → Stock → Export".
Transactions
Every accepted bid (or buy-now in the future) creates a transaction. Status flow: awaiting_payment → paid → shipped → delivered → completed. cancelled and disputed are terminal.
Body
| status | enum | required | awaiting_payment | paid | shipped | delivered | completed | cancelled | disputed |
Body
| carrier | string | required | e.g. postnl, dhl, ups |
| tracking_number | string | required |
Body
| rating | int | required | 1-5 |
| review | string | optional | ≤ 1000 chars |
Each party can rate exactly once per transaction; second submit returns 409.
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.
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-sellerconst 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
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.
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/K7H2QPconst 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"))Plain HTML page with a search box. Enter a code → 302 to /verify/{code}.
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.
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.
Query
| folder | enum | default inbox | inbox | sent | all |
| listing_id | int | optional | |
| limit | int | default 50 | max 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; older web clients read messages. Both contain the same array.Body
| to_username | string | required | Aliases: to, recipient_username |
| body | string | required | ≤ 5000 chars |
| subject | string | optional | ≤ 200 chars |
| listing_id | int | optional | |
| parent_id | int | optional | Threading |
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})Query
| listing_id | int | optional | Restrict to one listing |
Marks all received messages as read on each call.
Forum
Body
| title | string | required | ≤ 200 chars |
| body | string | required | Markdown, ≤ 20 000 chars |
Notifications
Alert triggers, bid events, message notifications, and listing-status changes are aggregated into one feed plus per-type unread counters.
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" }
]}
Reports & blocks
Body
| target_type | enum | required | forum_post | listing | message | user |
| target_id | int | required | |
| reason | string | required | Free-text, ≤ 500 chars |
| category | enum | optional | spam | fraud | abuse | illegal | other |
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).
multipart/form-data with one image field. JPEG / PNG / WebP, ≤ 10 MB.
Form fields
| image | file | required | |
| tcg_slug | string | optional | Restrict to one TCG (improves accuracy) |
| debug | bool | default false | Include 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=pokemonconst 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])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_id | string | required | Returned by /api/cards/recognize |
| card_id | int | required |
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.
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.
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".
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:
- Desktop:
POST /api/scan-sessions→ returnssession_id,claim_token,qr_url,deeplink, and a 6-digitconfirmation_code. - Desktop renders the QR (
GET /api/scan-sessions/{id}/qr.png) and opens an SSE stream (GET /api/scan-sessions/{id}/sse). - Phone scans the QR → opens the deeplink →
POST /api/scan-sessions/{id}/claimwith?token=…&device_name=…. - Phone repeatedly:
POST .../upload-photo(multipart) →POST .../results(JSON with therecognitionresult) — once per card. - Phone calls
POST .../completewhen 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.
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-sessionsconst 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()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" }
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 }
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_name | string | required | Free text — shown on the desktop ("Connected: Pixel 8") |
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 }
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 */ } }
]}
Marks the session completed and pushes a completed event on the desktop SSE stream. Token-authenticated. After this, no further uploads are accepted.
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
| URL | Auth | Tier | Purpose |
|---|---|---|---|
wss://collectorstashmarket.com/api/v1/stream | API key (required) | Pro or higher | Developer streaming — live price updates & movers ticker |
wss://collectorstashmarket.com/ws/v1 | Session cookie or API key or anonymous | any | General 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
errorframe, 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
| Topic | Access | You receive |
|---|---|---|
card:<card_id> | public | price_update, listing_new |
set:<set_id> | public | price_update (per-set summary when a price scrape commits) |
tcg:<slug> | public | price_update, listing_new for any card in that TCG |
market:movers | public | movers — refreshed gainers/losers ticker |
marketplace:new | public | listing_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> | authenticated | scan_result — cross-device bulk-scan handoff push |
Event-frame catalogue
welcome— first frame after connect; tells you yourconn_id, auth state, tier, heartbeat interval.price_update—{card_id, source, variant, market_price, currency}(set/tcg variants also carryset_id/tcg).movers—{tcg, period, items:[…]}top gainers + losers.listing_new—{listing_id, card_id, price, currency}.notification—{kind, title, body, url}(delivered onuser:<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 codes4401= 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
Response
{ "status": "ok" }
Response
{ "status": "ok", "db": true } // 200
{ "status": "degraded", "db": false } // 503
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/healthconst h = await (await fetch("https://collectorstashmarket.com/api/health")).json();h = requests.get("https://collectorstashmarket.com/api/health").json()Redirects to the localised stats page; for JSON use /api/stats.
Status page
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 }
]}
App meta & devices
Mobile-app version manifest and FCM device registration for push notifications.
Query
| platform | enum | optional | android | 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": "..." } }
Body
| platform | enum | required | android | ios |
| token | string | required | FCM token (Android) / APNs (iOS) |
| app_version | string | optional |
Body
| token | string | required |
Platform settings
Read-only public settings (e.g. seller-fee percentages, payout caps) plus a full admin CRUD for the underlying platform_settings table.
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.