Building a Pokémon Price Discord Bot
Build a /price slash command bot in ~60 lines of Python: two PkmnPrices API calls return live Pokémon card market prices, with discord.py v2 and aiohttp.
Your trading Discord asks the same question all day: "what's this card worth?"
This tutorial builds a bot that answers it. Type /price charizard, get the live
market price back as a clean embed, pulled from the PkmnPrices API in
two requests. It's about 70 lines of Python, and you'll have it running locally by
the end.
What you'll learn
- The search-then-fetch pattern the API uses (list results carry no prices)
- How to register a slash command and reply with an embed in discord.py v2
- How to stay inside the free tier's 100 daily credits and 60 requests/minute
What you'll build
A single slash command. A user types /price <card name>, and the bot replies
with the card's set, condition, and current USD market price. No database, no
scraping, just two calls to the API and a Discord embed.
Under the hood it's two steps, because the API splits search from pricing.
GET /v1/cards finds the card and returns its ID. GET /v1/cards/:id returns that
card with its prices attached. Worth saying plainly, because it trips people up:
the list endpoint never returns price data. You always fetch the single card
to get a number.
Before you start
You'll need three things. First, Python 3.10 or newer. Second, a Discord application with a bot token from the Discord Developer Portal. Third, a PkmnPrices API key, which you can grab free from the dashboard.
Keep the two secrets out of your code. They go in a .env file:
DISCORD_TOKEN=your-bot-tokenPKMNPRICES_API_KEY=your-api-key
Set up the project
Create a folder, make a virtual environment, and install the dependencies.
discord.py is the library; python-dotenv loads that .env file. aiohttp
ships with discord.py, so you already have it for HTTP.
mkdir price-bot && cd price-botpython -m venv .venv && source .venv/bin/activatepip install discord.py python-dotenv
Talk to the PkmnPrices API
Start with a thin client so the bot logic stays readable. Two coroutines: one to
search by name, one to fetch a card's prices by ID. Both run on aiohttp so they
never block the event loop. Every request carries your key in the X-API-Key
header, which is the only auth the API needs
(authentication docs).
# pkmnprices.pyimport osBASE = "https://api.pkmnprices.com/v1"HEADERS = {"X-API-Key": os.environ["PKMNPRICES_API_KEY"]}class ApiError(Exception):def __init__(self, status):super().__init__(f"PkmnPrices request failed: {status}")self.status = statusasync def search_card(session, name, limit=10):# A name like "charizard" matches many printings; limit caps how many we# fetch. List endpoints cost 1 credit per item returned, so keep it tight.params = {"name": name, "per_page": limit}async with session.get(f"{BASE}/cards", params=params, headers=HEADERS) as res:if res.status != 200:raise ApiError(res.status)body = await res.json()return body["data"]async def get_card(session, card_id):params = {"currency": "usd"}async with session.get(f"{BASE}/cards/{card_id}", params=params, headers=HEADERS) as res:if res.status != 200:raise ApiError(res.status)return await res.json()
The search response is a data list of cards, each with an id, name, and
set, but no prices. The single-card response adds a prices list, where each
entry has a condition, variant, and market_price. That split is the whole
reason we make two calls. And since a name like charizard matches dozens of
printings across sets and variants, we'll let the user pick the exact one before
fetching its price.
Build the bot
We'll build bot.py in four small pieces: the skeleton, startup, an embed
helper, and the command itself. Start with the skeleton: the imports, a bot with
default intents, and nothing else yet.
# bot.pyimport osimport aiohttpimport discordfrom discord import app_commandsfrom discord.ext import commandsfrom dotenv import load_dotenvfrom pkmnprices import search_card, get_card, ApiErrorload_dotenv()intents = discord.Intents.default()bot = commands.Bot(command_prefix="!", intents=intents)
Slash commands don't need the message-content intent, so the defaults are enough.
The command_prefix is required by commands.Bot but unused here; we're going
all-in on slash commands.
Register the command on startup
Two things have to happen once, when the bot boots: open an HTTP session it can
reuse for every request, and tell Discord the command exists. Both go in
setup_hook, which discord.py runs before the bot connects.
@bot.eventasync def setup_hook():bot.session = aiohttp.ClientSession()await bot.tree.sync()@bot.eventasync def on_ready():print(f"Logged in as {bot.user}")
bot.tree.sync() is what makes /price show up. Global syncs propagate in a few
minutes, so while developing, sync to one server for instant updates with
bot.tree.sync(guild=discord.Object(id=YOUR_GUILD_ID)).
Turn a card into an embed
Keep the presentation out of the command handler. This helper takes a single-card response and returns a Discord embed: set, condition, and price, with the brand green down the side. It prefers the Near Mint price and falls back to whatever's first.
def build_embed(card):prices = card.get("prices", [])entry = next((p for p in prices if p["condition"] == "Near Mint"),prices[0] if prices else None,)embed = discord.Embed(title=card["name"], color=0x00E87B)if card.get("image_url"):embed.set_thumbnail(url=card["image_url"])embed.add_field(name="Set", value=card.get("set", {}).get("name", "Unknown"), inline=True)embed.add_field(name="Number",value=f"{card.get('number', '?')}/{card.get('total_set_number', '?')}",inline=True,)embed.add_field(name="Condition", value=entry["condition"] if entry else "—", inline=True)embed.add_field(name="Market price",value=f"${entry['market_price']:.2f}" if entry else "No data",inline=True,)embed.set_footer(text="Data from PkmnPrices")return embed
Define the /price command
Here's the catch with a name search: charizard matches dozens of cards, one per
set, reprint, and variant. Grabbing results[0] would return an arbitrary one.
The fix is Discord autocomplete (next sub-step): the user picks a real match as
they type, and the value behind their choice is the card's ID. So the command
receives an ID and just fetches it, with a fallback for free text typed without
picking.
@bot.tree.command(name="price", description="Look up the market price of a Pokémon card")@app_commands.describe(card="Start typing a card name, then pick one from the list")async def price(interaction: discord.Interaction, card: str):await interaction.response.defer()try:# `card` is an ID when the user picked a suggestion. If they typed free# text and skipped the list, fall back to the top search match.if card.isdigit():data = await get_card(bot.session, int(card))else:results = await search_card(bot.session, card, limit=1)if not results:await interaction.followup.send(f"No card found for **{card}**.")returndata = await get_card(bot.session, results[0]["id"])await interaction.followup.send(embed=build_embed(data))except Exception as err:await handle_error(interaction, err)
await interaction.response.defer() still runs first: two round-trips can outrun
Discord's three-second reply window, and deferring buys up to fifteen minutes.
(handle_error comes in the next section.)
Let the user pick the right card
The autocomplete callback fires as the user types and returns up to 25 choices, Discord's cap. Pairing each card name with its set turns a vague "Charizard" into a precise list: "Charizard · Base Set", "Charizard · Base Set 2", and so on. The value behind each choice is the ID the command receives.
@price.autocomplete("card")async def card_autocomplete(interaction: discord.Interaction, current: str):if len(current) < 3:return []results = await search_card(bot.session, current)return [app_commands.Choice(name=f"{c['name']} · {c['set']['name']}"[:100], value=str(c["id"]))for c in results[:25]]
The len(current) < 3 gate skips a search until the query is worth running, which
matters because this fires on keystrokes. Lean on the cache from the next section
so repeated prefixes don't each hit the API.
Finally, the line that starts it all. Put this at the bottom of the file:
bot.run(os.environ["DISCORD_TOKEN"])
Handle errors and rate limits
A bot that breaks on the first bad input won't last a day in a busy server. Three cases are worth handling explicitly, and they map straight onto the API's status codes:
async def handle_error(interaction, err):if isinstance(err, ApiError) and err.status == 401:await interaction.followup.send("API key is missing or invalid.")returnif isinstance(err, ApiError) and err.status == 429:await interaction.followup.send("Rate limited — give it a few seconds.")returnprint(err)await interaction.followup.send("Something went wrong. Try again in a moment.")
That 429 matters more than it looks. The free tier allows 60 requests per minute
and 100 credits per day, and list endpoints charge 1 credit per item returned
(rate limiting docs). Autocomplete is where the cost hides:
each suggestion search returns up to ten cards, and it fires repeatedly as the user
types, so a single lookup can spend more on suggestions than on the price itself.
Two guards keep it affordable. The len(current) < 3 gate already trims the
shortest queries; the other is a cache, so repeated prefixes reuse one response
instead of paying for it again. Prices update daily, so a short in-memory cache
costs you nothing in accuracy:
import time_cache = {}TTL = 10 * 60 # secondsasync def search_card_cached(session, name):key = name.lower()hit = _cache.get(key)if hit and time.monotonic() - hit["at"] < TTL:return hit["data"]data = await search_card(session, name)_cache[key] = {"data": data, "at": time.monotonic()}return data
Call search_card_cached from the autocomplete callback instead of search_card,
and a user scrubbing through "char", "chari", "chariz" pays for one search, not
three.
Deploy it
Locally, you're one command away: python bot.py. Invite the bot to a server from
the Developer Portal's OAuth2 URL generator with the applications.commands scope,
and /price shows up in the command list.
For always-on hosting, anywhere that runs Python works: a small VPS, a container platform, or a hobby host. The only hard rule is the one from the start: your token and API key live in environment variables, never in the repo. Rotate the API key from the dashboard if it ever leaks.
Frequently asked questions
- Does this work on the free tier?
- Yes, with two limits to know. The free tier serves English cards only and allows 100 credits per day at 60 requests per minute. A cached /price command fits comfortably inside that for a small-to-mid server; heavy traffic is the cue to upgrade a plan.
- How many credits does one /price cost?
- The lookup is cheap: 1 credit when the user picks a suggestion (just the single-card fetch), or 2 on the free-text fallback. Autocomplete costs more, since each search returns up to 10 cards at 1 credit each. The 3-character gate and caching keep that in check.
- Why two API calls instead of one?
- The list endpoint (GET /v1/cards) is built for search and filtering, so it returns card metadata without prices. Prices live on the single-card endpoint (GET /v1/cards/:id). Search to resolve a name to an ID, then fetch that ID for the price.
- Can the bot show price history or charts?
- It can. The price history endpoint (GET /v1/cards/:id/prices/history) returns daily points over 7d, 30d, 90d, or 365d. Render them into an image and attach it to the embed for a /history command, a natural next feature once /price works.
For the history endpoint and every other route, see the full API reference.
Wrap up
That's a working price bot: a thin async API client, one synced command, and an embed reply, with error handling and a cache to respect the limits. The same two-step pattern, search for an ID then fetch for data, powers sealed products, eBay sold listings, and Cardmarket offers too.
The full API reference covers every endpoint, and a free key from the dashboard is all you need to start building.

Written by
PkmnPrices
PkmnPrices is a developer API for Pokémon TCG data: daily TCGPlayer pricing across 54,000+ cards, 650+ sets, and sealed products.