Every play-group has that one bot for !price, !card and !meta. The Collector Stash Market API gives you the data: 500k+ cards across 13 TCGs, 220M+ historical price points from multiple sources, and a recognise-from-photo endpoint for slash commands that accept attachments.
/card <name> — fuzzy lookup + image + cheapest source./price <name> — live market median + 7d / 30d trend arrows./scan + photo attachment — recogniser returns top-3 candidates./alert <card> <target> — DM the user when price crosses the target./meta <format> — top tournament-played cards last 30 days.curl -H "Authorization: Bearer $CSM_KEY" \
"https://collectorstashmarket.com/api/search?q=Charizard&per_page=1"
<?php
// /discord-webhook.php — register this URL as your Discord interactions endpoint.
$json = file_get_contents("php://input");
$body = json_decode($json, true);
if (($body["type"] ?? 0) !== 2) { http_response_code(200); echo "{}"; exit; }
$name = $body["data"]["options"][0]["value"] ?? "";
$ch = curl_init("https://collectorstashmarket.com/api/search?q=" . urlencode($name) . "&per_page=1");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => 1,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("CSM_KEY")],
]);
$card = (json_decode(curl_exec($ch), true)["results"] ?? [[]])[0];
$reply = $card ? "**{$card["name"]}** — see {$card["image_url"]}" : "No match.";
header("Content-Type: application/json");
echo json_encode(["type" => 4, "data" => ["content" => $reply]]);
/price in discord.pyimport os, discord, httpx
from discord import app_commands
CSM = "https://collectorstashmarket.com"
KEY = os.environ["CSM_KEY"]
HDR = {"Authorization": f"Bearer {KEY}"}
intents = discord.Intents.default()
client = discord.Client(intents=intents)
tree = app_commands.CommandTree(client)
@tree.command(name="price", description="Look up the live market price of a card")
async def price(inter: discord.Interaction, name: str, tcg: str = "pokemon"):
await inter.response.defer()
async with httpx.AsyncClient(headers=HDR, timeout=10) as h:
r = await h.get(f"{CSM}/api/search", params={"q": name, "tcg": tcg, "per_page": 1})
items = r.json().get("results") or r.json().get("items") or []
if not items:
return await inter.followup.send(f"No match for **{name}**.")
card = items[0]
p = await h.get(f"{CSM}/api/cards/{card['id']}/prices")
rows = p.json().get("prices") or []
if not rows:
return await inter.followup.send(f"**{card['name']}** — no live prices yet.")
cheap = min(rows, key=lambda x: x.get("market_price") or 1e9)
await inter.followup.send(
f"**{card['name']}** ({card.get('set_name','?')})\n"
f"Cheapest: **{cheap['market_price']} {cheap['currency']}** via {cheap['source']}\n"
f"{card.get('image_url','')}"
)
client.run(os.environ["DISCORD_TOKEN"])
discord.js)import { Client, GatewayIntentBits, SlashCommandBuilder } from "discord.js";
const CSM = "https://collectorstashmarket.com";
const KEY = process.env.CSM_KEY!;
const HDR = { Authorization: `Bearer ${KEY}` };
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.on("interactionCreate", async (i) => {
if (!i.isChatInputCommand() || i.commandName !== "price") return;
const name = i.options.getString("name", true);
const tcg = i.options.getString("tcg") ?? "pokemon";
await i.deferReply();
const search = await fetch(
`${CSM}/api/search?q=${encodeURIComponent(name)}&tcg=${tcg}&per_page=1`,
{ headers: HDR },
).then(r => r.json());
const card = (search.results ?? search.items ?? [])[0];
if (!card) return i.editReply(`No match for **${name}**.`);
const prices = await fetch(`${CSM}/api/cards/${card.id}/prices`, { headers: HDR })
.then(r => r.json());
const rows = prices.prices ?? [];
if (!rows.length) return i.editReply(`**${card.name}** — no live prices yet.`);
const cheapest = rows.reduce((a: any, b: any) => (a.market_price < b.market_price ? a : b));
await i.editReply(
`**${card.name}** — ${cheapest.market_price} ${cheapest.currency} via ${cheapest.source}`,
);
});
client.login(process.env.DISCORD_TOKEN);
/scan# Python — discord.py message-attachment handler
import httpx
@tree.command(name="scan", description="Identify a card from a photo")
async def scan(inter: discord.Interaction, photo: discord.Attachment):
await inter.response.defer()
img = await photo.read()
async with httpx.AsyncClient(headers=HDR, timeout=30) as h:
r = await h.post(
f"{CSM}/api/cards/recognize",
files={"file": (photo.filename, img, photo.content_type)},
)
if r.status_code == 403:
return await inter.followup.send("Recognition needs a Starter+ API key.")
data = r.json()
top = (data.get("candidates") or [{}])[0]
await inter.followup.send(
f"Most likely: **{top.get('name','?')}** "
f"({data.get('confidence_label')} · {round(top.get('score',0)*100)}% match)"
)
The recognise endpoint is gated on Starter+ (10/day) → Pro (100/day) → Business (500) → Enterprise (2,500) → Unlimited.
// Node — push DM to a user when their watch card crosses a target
import WebSocket from "ws";
const ws = new WebSocket(`wss://collectorstashmarket.com/api/v1/stream?api_key=${process.env.CSM_KEY}`);
ws.on("open", () => {
ws.send(JSON.stringify({ type: "subscribe", topics: ["card:3782", "card:11172"] }));
});
ws.on("message", (raw) => {
const f = JSON.parse(raw.toString());
if (f.type === "ping") return ws.send(JSON.stringify({ type: "pong" }));
if (f.type === "price_update" && f.data.market_price < 600) {
discordClient.users.fetch("USER_ID")
.then((u) => u.send(`Card ${f.data.card_id} dropped to ${f.data.market_price}`));
}
});