PkmnPrices9 min read

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:

bash
DISCORD_TOKEN=your-bot-token
PKMNPRICES_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.

bash
mkdir price-bot && cd price-bot
python -m venv .venv && source .venv/bin/activate
pip 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).

python
# pkmnprices.py
import os
BASE = "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 = status
async 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.

python
# bot.py
import os
import aiohttp
import discord
from discord import app_commands
from discord.ext import commands
from dotenv import load_dotenv
from pkmnprices import search_card, get_card, ApiError
load_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.

python
@bot.event
async def setup_hook():
bot.session = aiohttp.ClientSession()
await bot.tree.sync()
@bot.event
async 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.

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

python
@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}**.")
return
data = 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.

python
@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:

python
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:

python
async def handle_error(interaction, err):
if isinstance(err, ApiError) and err.status == 401:
await interaction.followup.send("API key is missing or invalid.")
return
if isinstance(err, ApiError) and err.status == 429:
await interaction.followup.send("Rate limited — give it a few seconds.")
return
print(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:

python
import time
_cache = {}
TTL = 10 * 60 # seconds
async 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.

PkmnPrices

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.