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

CollectorsStashMarket API v1.0

Programmatic access to real-time TCG card prices, market intelligence, marketplace listings, user collections, decks, and AI-powered card recognition across 12 trading card games.

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.

Supported TCGs

SlugGameCardsPrimary sources
pokemonPokémon TCG~20,500TCGPlayer, Cardmarket, eBay graded, PriceCharting
mtgMagic: The Gathering~30,000Scryfall, TCGPlayer, Cardmarket, eBay graded
yugiohYu-Gi-Oh!~15,000YGOPRODeck, TCGPlayer, eBay graded
onepieceOne Piece Card Game~3,000TCGPlayer
lorcanaDisney Lorcana~600TCGPlayer
fabFlesh and Blood~14,000TCGPlayer, PriceCharting
digimonDigimon Card Game~1,900TCGPlayer
starwarsStar Wars Unlimited~700TCGPlayer
grandarcGrand Archive~4,400GATCG.com
dragonballDragon Ball Super CCG~4,500TCGPlayer
riftboundRiftbound~100Limited
keyforgeKeyForge~36Demo

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, /admin/, /auth/, /seller/, /dl/ never redirect.

Tiers & rate limits

TierReq / dayReq / minKeysRecognition / dayPrice
Free500601€0
Starter5,000120210€9 / mo
Pro25,0003005100€29 / mo
Business200,0001,00010500€79 / mo
Unlimited25€199 / mo

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.

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).

TCGs

The catalogue is rooted at TCGs — twelve trading card games, each identified by a slug. Every card and set belongs to exactly one TCG.

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 analysisfree

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/{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": 133317, "total_prices": 2021340, "total_users": 7,
  "total_sellers": 6, "tcgs_covered": 12, "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": 42, "username": "alice" }
// + Set-Cookie: tcg_session=...
curl -c cookies.txt -X POST https://collectorstashmarket.com/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"username":"alice","email":"a@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: "alice", email: "a@example.com", password: "hunter2hunter2" }),
});
const data = await r.json();
s = requests.Session()
s.post("https://collectorstashmarket.com/auth/register",
       json={"username":"alice","email":"a@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":"alice","password":"hunter2hunter2"}'
await fetch("https://collectorstashmarket.com/auth/login", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ username: "alice", password: "hunter2hunter2" }),
});
s.post("https://collectorstashmarket.com/auth/login",
       json={"username":"alice","password":"hunter2hunter2"})
POST/auth/logoutLog out — clears the cookieauth
GET/auth/meCurrent user infoauth

Response

{ "id": 2, "username": "jeffreyplv", "email": "jeff@example.com",
  "is_admin": true, "is_seller": true, "created_at": "2025-09-12T..." }
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": "Lightning-fast shipping, mint card.",
      "buyer": "ash_collector", "ts": "2026-05-04T18:22:11Z" },
    { "rating": 5, "review": "As described, would buy again.",
      "buyer": "misty99",      "ts": "2026-05-02T09:11:03Z" }
  ] }
curl https://collectorstashmarket.com/api/reputation/jeffreyplv
const rep = await (await fetch("https://collectorstashmarket.com/api/reputation/jeffreyplv")).json();
rep = requests.get("https://collectorstashmarket.com/api/reputation/jeffreyplv").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": "jeffreyplv",
  "watermark_enabled": true,
  "created_at": "2026-05-01T10:14:08Z",
  "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": 2, "recipient_id": 5,
    "from_username": "jeffreyplv", "to_username": "cardchef",
    "listing_id": null, "subject": "Re: 🏆 Bod geaccepteerd",
    "body": "Thnx!", "is_read": false, "is_system": false,
    "parent_id": null, "created_at": "2026-04-15T...",
    "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":"cardchef","body":"Is this card still available?","listing_id":42}'
await fetch("https://collectorstashmarket.com/api/messages", {
  method:"POST", credentials:"include",
  headers:{"Content-Type":"application/json"},
  body: JSON.stringify({ to_username:"cardchef", body:"Is this card still available?", listing_id:42 })
});
s.post("https://collectorstashmarket.com/api/messages",
       json={"to_username":"cardchef", "body":"Is this card still available?", "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 cardchef", "body": "Is this card still available?",
    "data": {"message_id": 14, "from_username": "cardchef"},
    "is_read": false, "created_at": "..." }
]}
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 (10 / 100 / 500 / ∞ for Starter / Pro / Business / Unlimited).

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

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": 133317, "prices": 2021340,
  "users": 7, "sellers": 6, "active_alerts": 0,
  "db_size_mb": 1250.13, "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.