# A Discord price bot in 57 lines with the TypeScript SDK

> A /price slash command with autocomplete that answers with dated Cardmarket EUR and TCGplayer USD quotes and the card art. Full source, no framework.

- **Published**: 2026-09-25
- **Updated**: 2026-09-25
- **Tags**: sdk

Source: https://pokemontcgapi.com/blog/discord-price-bot-typescript-sdk

Every trading server ends up with the same question in chat: "what is this worth?" A bot that answers it in under a second, with the European and the American figure side by side and the date of each, saves everyone the alt-tab. This is that bot, complete, using the TypeScript SDK and discord.js. It fits in one file, and most of the file is Discord plumbing rather than API calls.

What you need: Node 20 or newer, a Discord application with a bot token, and an API key. A [trial key](https://pokemontcgapi.com/free-api-key) needs no card and comes with 800 credits, which is a lot of price lookups. The two packages: `npm install discord.js @pokemontcgapi/sdk`.

## The whole bot

Read it top to bottom once, then the sections below explain the three decisions in it. The four environment variables are `DISCORD_TOKEN`, `DISCORD_APP_ID`, `DISCORD_GUILD_ID` and `PTCG_API_KEY`.

```ts
import { Client, EmbedBuilder, GatewayIntentBits, REST, Routes, SlashCommandBuilder } from 'discord.js';
import { PokemonTcgApi, RateLimitedError, type Price } from '@pokemontcgapi/sdk';

const api = new PokemonTcgApi({ apiKey: process.env.PTCG_API_KEY });
const bot = new Client({ intents: [GatewayIntentBits.Guilds] });

// One command, one option, with autocomplete: the user picks a printing before
// the command runs, so the handler receives a card id and never has to guess.
const price = new SlashCommandBuilder()
  .setName('price')
  .setDescription('EUR and USD quotes for a card')
  .addStringOption((o) => o.setName('card').setDescription('Card name').setRequired(true).setAutocomplete(true));

await new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN!).put(
  Routes.applicationGuildCommands(process.env.DISCORD_APP_ID!, process.env.DISCORD_GUILD_ID!),
  { body: [price.toJSON()] },
);

const line = (q: Price | undefined): string =>
  q ? `${q.amount.toFixed(2)} ${q.currency} · ${q.basis.toLowerCase()} · ${q.as_of} · ${q.provenance}` : 'no quote today';

bot.on('interactionCreate', async (interaction) => {
  if (interaction.isAutocomplete()) {
    const typed = interaction.options.getFocused();
    if (typed.length < 2) return interaction.respond([]);
    const page = await api.cards.search({
      q: `name:"${typed}*"`, orderBy: '-release_date', limit: 25, select: ['id', 'name', 'number', 'set_name'],
    });
    return interaction.respond(
      page.data.map((c) => ({ name: `${c.name} · ${c.set_name} #${c.number}`.slice(0, 100), value: c.id })),
    );
  }
  if (!interaction.isChatInputCommand() || interaction.commandName !== 'price') return;

  await interaction.deferReply();
  const id = interaction.options.getString('card', true);
  try {
    const [card, prices] = await Promise.all([api.cards.get(id, { include: ['images'] }), api.prices.card(id)]);
    // Raw copies only: a graded row is a different market, and it has its own command in a bigger bot.
    const raw = prices.data.quotes.filter((q) => q.grading === null);
    const embed = new EmbedBuilder()
      .setTitle(`${card.name} · ${card.set_name} #${card.number}`)
      .addFields(
        { name: 'Europe', value: line(raw.find((q) => q.source === 'CARDMARKET')) },
        { name: 'United States', value: line(raw.find((q) => q.source === 'TCGPLAYER')) },
      )
      .setFooter({ text: prices.data.index ? `Index ${prices.data.index.eur.toFixed(2)} EUR · ${prices.data.index.as_of}` : 'No index for this card' });
    const art = card.images?.find((i) => i.size === 'LARGE') ?? card.images?.[0];
    if (art) embed.setThumbnail(art.url);
    await interaction.editReply({ embeds: [embed] });
  } catch (err) {
    const retry = err instanceof RateLimitedError ? ` Try again in ${err.retryAfter ?? 1} s.` : '';
    await interaction.editReply(`Could not price ${id}.${retry}`);
  }
});

await bot.login(process.env.DISCORD_TOKEN);
```

Run it with `node --experimental-strip-types bot.ts` on Node 22, or compile it first on Node 20. The slash command is registered on one guild, which is what you want while developing: guild commands appear immediately, global ones take up to an hour to propagate.

## Decision one: autocomplete picks the printing, not the handler

"Charizard" is not a card. It is a few hundred cards across thirty years of sets, and the one the user means is almost never the one a name search returns first. So the command never receives a name. While the user types, the autocomplete handler runs a card search with a prefix query, newest release first, and offers up to twenty-five choices labelled with the set and the collector number. The value the command finally receives is a card id, and everything after that is a lookup, not a guess.

The search uses `select` to ask for four fields only. It does not change the price, a catalogue search is 1 credit whatever you select, but it keeps the payload small, which matters when the handler has to answer Discord within three seconds while someone is still typing.

## Decision two: two markets, two fields, no arithmetic

The reply shows the Cardmarket row in EUR under "Europe" and the TCGplayer row in USD under "United States", and the footer carries the composite index in EUR with its date. Nothing is converted and nothing is averaged. Each line prints the basis (an asking price on Cardmarket, a guide figure on TCGplayer), the day the figure is for and the provenance string, because a number without those three things is an opinion, and a bot that states opinions with two decimals gets argued with in chat. [How the rows are built](https://pokemontcgapi.com/blog/how-we-compute-eur-and-usd-card-prices) is its own article.

Graded rows are filtered out on purpose. A PSA 10 median under a "Europe" heading would be wrong twice: it is a different market, and on this API it is a different plan. A bigger bot gives slabs their own command.

## Decision three: two calls in parallel, and a reply that survives errors

The command needs the card (for the title and the art) and the prices. They are two requests, `cards.get` with `include: ['images']` at 1 credit and `prices.card` at 2 credits, and they do not depend on each other, so they run in a `Promise.all`. Discord gives an interaction three seconds before it expires; `deferReply` buys fifteen minutes, and the two calls in parallel come back well inside the first second on a warm connection.

The catch block does one thing worth copying: it tells a rate-limited user how long to wait, straight from the `retryAfter` the SDK parsed off the 429. Every other failure becomes one plain sentence with the id in it, which is what the person who asked needs in order to try again or report it.

## What it costs to run

| Interaction | Calls | Credits |
| --- | --- | --- |
| One autocomplete keystroke (after two characters) | `cards.search` | 1 |
| One /price answer | `cards.get` + `prices.card` | 3 |
| A quiet server, 50 lookups a day | about 150 searches and 100 calls | about 300 a day |

Autocomplete is the part that adds up, because it fires on every keystroke after the second. Two cheap improvements if the server is busy: debounce on the client side by ignoring focused values shorter than three characters, and keep a small in-process cache keyed by the typed prefix for a minute. The [Discord bot use case](https://pokemontcgapi.com/use-cases/discord-bot) shows the same command written with plain `fetch` and ETags, where a revalidated lookup answers 304 for free.

- Card art costs nothing extra: `include: ['images']` stays inside the 1 credit of the lookup.
- The trial key works for building and for a small server. A server that stays busy wants the Developer plan; the [pricing page](https://pokemontcgapi.com/pricing) has the monthly allowances.
- Keep the API key in the bot's environment, never in a message or an embed. A key that leaks in chat is a key you rotate.

> **Where to go next**
>
> The [SDK page](https://pokemontcgapi.com/sdk) lists every method with the route it wraps, and the [use case](https://pokemontcgapi.com/use-cases/discord-bot) adds disambiguation between reprints and a rules-text field to the embed.
