PkmnPrices9 min read

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 pkmnprices SDK 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:

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

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

python
from pkmnprices import AsyncPkmnPrices
async with AsyncPkmnPrices("pk_your-api-key") as client:
page = await client.cards.list(name="charizard", per_page=5) # no prices
card = await client.cards.get(page.data[0].id, currency="usd") # with prices
print(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.

python
# bot.py
import os
import discord
from discord import app_commands
from discord.ext import commands
from dotenv import load_dotenv
from pkmnprices import AsyncPkmnPrices, UnauthorizedError, RateLimitError, CreditLimitError
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: 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.

python
@bot.event
async def setup_hook():
bot.api = AsyncPkmnPrices(os.environ["PKMNPRICES_API_KEY"])
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 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.

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

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

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

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. The SDK raises typed exceptions, so you handle exactly the cases you care about. The two worth catching map onto auth and limits:

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

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

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.