Building a Pokémon Price Discord Bot
Build a /price slash command bot in ~50 lines of Python using the official pkmnprices SDK: an async client, typed cards, and live market prices in discord.py v2.
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 through
its official Python SDK. It's about 50 lines of Python, and you'll have it running
locally by the end.
What you'll learn
- The search-then-fetch pattern the SDK uses (list results carry no prices)
- How to register a slash command with autocomplete in discord.py v2
- How the
pkmnpricesSDK gives you an async client, typed models, and retries for free
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, no hand-written HTTP, just the SDK and a Discord embed.
Under the hood it's two steps, because the API splits search from pricing.
client.cards.list() finds the card and returns its ID. client.cards.get()
returns that card with its prices attached. Worth saying plainly, because it trips
people up: the list call never returns price data. You always fetch the single
card to get a number. 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.
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=pk_your-api-key
Set up the project
Create a folder, make a virtual environment, and install the dependencies. The
pkmnprices SDK brings its own HTTP client,
retries, and typed models, so there's no API plumbing left to write.
mkdir price-bot && cd price-botpython -m venv .venv && source .venv/bin/activatepip install pkmnprices discord.py python-dotenv
Meet the SDK
The SDK does the HTTP, retries, and typing for you, so there's no client class to
write. discord.py is async, and the SDK ships an async client built for exactly
this: AsyncPkmnPrices. One call searches, one fetches.
from pkmnprices import AsyncPkmnPricesasync with AsyncPkmnPrices("pk_your-api-key") as client:page = await client.cards.list(name="charizard", per_page=5) # no pricescard = await client.cards.get(page.data[0].id, currency="usd") # with pricesprint(card.prices[0].market_price) # 285.0
list() returns a page with .data and .pagination; each card is a typed object,
so you reach for card.name and card.set.name, not dictionary keys. get() adds
the prices list, where each entry has a condition, variant, and market_price.
That split is the whole reason we make two calls.
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 discordfrom discord import app_commandsfrom discord.ext import commandsfrom dotenv import load_dotenvfrom pkmnprices import AsyncPkmnPrices, UnauthorizedError, RateLimitError, CreditLimitErrorload_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: create an SDK client 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.api = AsyncPkmnPrices(os.environ["PKMNPRICES_API_KEY"])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 card and returns a Discord embed, with the brand green down the side. Because the SDK returns typed models, it's all attribute access. It prefers the Near Mint price and falls back to whatever's first.
def build_embed(card):price = next((p for p in card.prices if p.condition == "Near Mint"),card.prices[0] if card.prices else None,)embed = discord.Embed(title=card.name, color=0x00E87B)if card.image_url:embed.set_thumbnail(url=card.image_url)embed.add_field(name="Set", value=card.set.name, inline=True)embed.add_field(name="Number", value=f"{card.number}/{card.total_set_number}", inline=True)embed.add_field(name="Condition", value=price.condition if price else "—", inline=True)embed.add_field(name="Market price",value=f"${price.market_price:.2f}" if price 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 the first result 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:if card.isdigit():data = await bot.api.cards.get(int(card), currency="usd")else:page = await bot.api.cards.list(name=card, per_page=1)if not page.data:await interaction.followup.send(f"No card found for **{card}**.")returndata = await bot.api.cards.get(page.data[0].id, currency="usd")await interaction.followup.send(embed=build_embed(data))except Exception as err:await handle_error(interaction, err)
await interaction.response.defer() 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 []page = await bot.api.cards.list(name=current, per_page=10)return [app_commands.Choice(name=f"{c.name} · {c.set.name}"[:100], value=str(c.id))for c in page.data]
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. The SDK raises typed exceptions, so you handle exactly the cases you care about. The two worth catching map onto auth and limits:
async def handle_error(interaction, err):if isinstance(err, UnauthorizedError):await interaction.followup.send("API key is missing or invalid.")elif isinstance(err, (RateLimitError, CreditLimitError)):await interaction.followup.send("Rate or credit limit hit. Try again shortly.")else:print(err)await interaction.followup.send("Something went wrong. Try again in a moment.")
The SDK already retries transient failures with backoff (tune it with
AsyncPkmnPrices(key, max_retries=2)), so by the time RateLimitError reaches your
handler, the limit is real. 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 fires 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 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_cached(api, name):key = name.lower()hit = _cache.get(key)if hit and time.monotonic() - hit["at"] < TTL:return hit["data"]page = await api.cards.list(name=name, per_page=10)_cache[key] = {"data": page.data, "at": time.monotonic()}return page.data
Call search_cached(bot.api, current) from the autocomplete callback instead of
bot.api.cards.list, 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
- Why use the SDK instead of calling the API directly?
- The pkmnprices SDK ships an async client (AsyncPkmnPrices) that fits discord.py, plus typed models, built-in retries with backoff, and typed exceptions like UnauthorizedError and RateLimitError. You write bot logic, not HTTP plumbing.
- 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 cards.list returns up to 10 cards at 1 credit each. The 3-character gate and caching keep that in check.
- Can the bot show price history or charts?
- It can. The price history endpoint 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: the official SDK, one synced command with autocomplete, and an embed reply, with typed 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.