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

TCG API for Discord Bots

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.

What you can build in an afternoon

curl — sanity check before you wire the bot

curl -H "Authorization: Bearer $CSM_KEY" \
  "https://collectorstashmarket.com/api/search?q=Charizard&per_page=1"

PHP — slash-command webhook receiver

<?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]]);

Quickstart: /price in discord.py

import 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"])

Same thing in TypeScript (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);

Recognise-from-photo for /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.

Live price-alert DM with WebSockets

// 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}`));
  }
});

Related