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
Supported TCGs
| Slug | Game | Cards | Primary sources |
|---|---|---|---|
| pokemon | Pokémon TCG | ~20,500 | TCGPlayer, Cardmarket, eBay graded, PriceCharting |
| mtg | Magic: The Gathering | ~30,000 | Scryfall, TCGPlayer, Cardmarket, eBay graded |
| yugioh | Yu-Gi-Oh! | ~15,000 | YGOPRODeck, TCGPlayer, eBay graded |
| onepiece | One Piece Card Game | ~3,000 | TCGPlayer |
| lorcana | Disney Lorcana | ~600 | TCGPlayer |
| fab | Flesh and Blood | ~14,000 | TCGPlayer, PriceCharting |
| digimon | Digimon Card Game | ~1,900 | TCGPlayer |
| starwars | Star Wars Unlimited | ~700 | TCGPlayer |
| grandarc | Grand Archive | ~4,400 | GATCG.com |
| dragonball | Dragon Ball Super CCG | ~4,500 | TCGPlayer |
| riftbound | Riftbound | ~100 | Limited |
| keyforge | KeyForge | ~36 | Demo |
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, /admin/, /auth/, /seller/, /dl/ never redirect.
Tiers & rate limits
| Tier | Req / day | Req / min | Keys | Recognition / day | Price |
|---|---|---|---|---|---|
| Free | 500 | 60 | 1 | — | €0 |
| Starter | 5,000 | 120 | 2 | 10 | €9 / mo |
| Pro | 25,000 | 300 | 5 | 100 | €29 / mo |
| Business | 200,000 | 1,000 | 10 | 500 | €79 / mo |
| Unlimited | ∞ | ∞ | 25 | ∞ | €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"
}
]
}
| 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).
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.
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"
}
}
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": 133317, "total_prices": 2021340, "total_users": 7,
"total_sellers": 6, "tcgs_covered": 12, "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": 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" }
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":"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"})Response
{ "id": 2, "username": "jeffreyplv", "email": "jeff@example.com",
"is_admin": true, "is_seller": true, "created_at": "2025-09-12T..." }
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": "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/jeffreyplvconst rep = await (await fetch("https://collectorstashmarket.com/api/reputation/jeffreyplv")).json();rep = requests.get("https://collectorstashmarket.com/api/reputation/jeffreyplv").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": "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/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": 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; 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":"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})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 cardchef", "body": "Is this card still available?",
"data": {"message_id": 14, "from_username": "cardchef"},
"is_read": false, "created_at": "..." }
]}
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 (10 / 100 / 500 / ∞ for Starter / Pro / Business / Unlimited).
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
Health & stats
Response
{ "status": "ok" }
Response
{ "status": "ok", "db": true } // 200
{ "status": "degraded", "db": false } // 503
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/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.