Discord bot and FastSpring embedded checkout integration

Run a Discord bot that pulls live products from FastSpring, sells through an embedded checkout, and credits VIP members automatically.

A Discord bot that brings your FastSpring web shop into your game's Discord server. Players run /store to see featured products (pulled live from FastSpring) and buy through an embedded FastSpring checkout; community managers run /announce to promote season updates and products to the whole server; and VIP members see exclusive items no one else does.

This page covers a working reference implementation from the FSBuilds GitHub repository, found in the fsbuilds-discord-store-bot folder of the FastSpring fsBuilds examples repo.

ℹ️

This is a reference example, not an actively maintained SDK or library. It reflects the FastSpring and Discord APIs as of its publish date and may not account for later changes to either platform. Check the linked documentation on this page for current behavior before building on it.

What it does

This section breaks down what the bot does for players and community managers, and where FastSpring stays the source of truth.

  • /store: a single, branded message with featured product cards (image, description, price) and a buy button on each, plus a VIP Exclusives section for members and an Unlock VIP upsell for everyone else.
  • /announce: community-manager-only. Posts a public web shop announcement with an optional custom banner and product cards chosen live from the catalog.
  • VIP logic: a player is VIP by a Discord role or an active FastSpring subscription.
  • Server-side checkout sessions: each Buy button creates a FastSpring Sessions API checkout on the server (product, buyer, and order tags), then shows it as an embedded checkout. Nothing editable rides in the URL.
  • One-time account connect: players authorize once with Discord OAuth so the bot can give FastSpring their verified email (the Sessions API requires a buyer).
  • Purchase confirmation and identity linking: a FastSpring webhook confirms purchases, DMs the buyer, and maps the Discord user to their FastSpring account.

Everything runs from one bot. Product data (names, prices, art, descriptions) lives in FastSpring and is fetched live, so updating the shop needs no code change.

How it works

This section gives a high-level view of how the player's Discord client, your server, and FastSpring coordinate during a purchase.

sequenceDiagram
    participant P as Player
    participant D as Discord Bot
    participant S as Your Server
    participant FS as FastSpring

    rect rgb(240, 253, 244)
        note over P,FS: 1. Browse
        P->>D: /store
        D->>FS: GET /products/{path}
        FS-->>D: Name, price, image, description
        D-->>P: Product cards and Buy button
    end

    rect rgb(255, 251, 235)
        note over P,S: 2. One-time connect (first purchase only)
        P->>S: Connect to buy
        S->>P: Discord OAuth prompt
        P->>S: Authorizes
        S-->>S: Save verified email
    end

    rect rgb(245, 243, 255)
        note over P,FS: 3. Checkout
        P->>S: Clicks Buy (signed token)
        S->>FS: POST /v2/checkouts/{path}/sessions
        FS-->>S: Session ID
        S-->>P: Embedded checkout (session ID)
        P->>FS: Completes purchase
    end

    rect rgb(254, 242, 242)
        note over FS,S: 4. Confirmation
        FS->>S: Webhook: order.completed (HMAC signed)
        S-->>S: Verify signature, link Discord and FastSpring account
        S-->>P: DM confirmation
    end

The player's client in Discord talks to your backend (Node and Express), which talks to FastSpring. The bot never handles payment; FastSpring does. The bot surfaces products, hands off to a FastSpring checkout, and listens for the result.

Prerequisites

Before you start building, make sure you have accounts and access set up on both the Discord and FastSpring side.

Node.js v18+

The bot and its web server run on Node.js.

FastSpring account

An account with products, a web shop, and API access.

Discord application and bot

An application created in the Discord Developer Portal, with a bot added to it.

Public HTTPS tunnel

A URL that forwards to your local server, so FastSpring can reach the /webhook endpoint during development.

Project structure

This section maps out where each piece of the bot lives, so you know where to look when you want to customize something.

src/
├── index.js                 Entry point, starts the web server, then the bot
├── discord-client.js        The shared discord.js client
├── discord-oauth.js         One-time Discord OAuth (identify + email)
├── tokens.js                HMAC-signed, expiring tokens for buy/connect links
├── bot.js                   Registers commands, routes interactions
├── presentation.js          Branding, featured list, VIP list (edit this to reshape the shop)
├── roles.js                 hasRole() helper
├── vip.js                   checkVip(), role OR active subscription
├── commands/
│   ├── store.js             /store message (cards, buy buttons, VIP section)
│   └── announce.js          /announce (CM-only, product autocomplete, custom banner)
├── fastspring/
│   ├── api.js               FastSpring API client (Basic auth)
│   ├── catalog.js           Fetch products from FastSpring
│   ├── session.js           Build buy / connect / web-shop links
│   ├── sessions.js          createCheckout(), FastSpring Sessions API
│   └── accounts.js          Subscription lookup (VIP)
├── store/
│   └── repository.js        Discord to FastSpring link store (JSON file)
└── web/
    ├── server.js             Express server (captures raw body for HMAC)
    └── routes/
        ├── webhook.js        POST /webhook, verify, link, confirm
        ├── auth.js           GET /auth/discord (+ /callback), save email
        └── checkout.js       GET /checkout/:token, session, embedded checkout
assets/                       Branding images (hero banner + logo)
data/                         Link store (gitignored, contains email PII)

Set up the bot

Follow these steps in order to get the bot installed, connected to Discord and FastSpring, and running in your server.

This project lives inside FastSpring's shared fastspring-fsBuilds-examples repository, alongside other FSBuilds projects. Download the fsbuilds-discord-store-bot folder from GitHub, then open a terminal inside it for the steps below.

Install

npm install
cp .env.example .env

Create the Discord bot

2.1 In the Discord Developer Portal, create an application.
2.2 Under Bot:

  • Copy the token and save it as DISCORD_BOT_TOKEN.
  • Copy the Application ID and save it as DISCORD_CLIENT_ID.
    2.3 Under OAuth2:
  • Reset and copy the Client Secret, and save it as DISCORD_CLIENT_SECRET.
  • Add <SERVER_URL>/auth/discord/callback under Redirects. This must match exactly, or Discord shows "Invalid OAuth2 redirect_uri".
    2.4 Invite the bot with both the bot and applications.commands scopes. The bot scope is required; without it the bot can run commands but can't DM:
https://discord.com/api/oauth2/authorize?client_id=<CLIENT_ID>&permissions=2147567616&scope=bot%20applications.commands

2.5 Enable Developer Mode in Discord, right-click your server, and select Copy Server ID. Save it as DISCORD_GUILD_ID. Registering commands to a single server makes them appear instantly, which is ideal for development.

Connect FastSpring

3.1 API credentials

  • FastSpring dashboard > Integrations > API Credentials.
  • Save the username as FS_API_USERNAME and the password as FS_API_PASSWORD. These authenticate the product, subscription, and Sessions API calls:
// src/fastspring/api.js
const fsApi = axios.create({
  baseURL: 'https://api.fastspring.com',
  auth: { username: process.env.FS_API_USERNAME, password: process.env.FS_API_PASSWORD },
  headers: { 'Content-Type': 'application/json' },
});

3.2 Embedded checkout

  • Set FS_STOREFRONT_PATH to <account>/<storefront> (for example mystore.test/embedded-store). This is used for POST /v2/checkouts/{FS_STOREFRONT_PATH}/sessions.
  • It must be an embedded-type checkout whose allowed domains include your SERVER_URL.
  • FS_SBL_STOREFRONT (<account>.onfastspring.com/<storefront>) is optional; it's derived from the path if left blank.

3.3 Web shop URL

  • Set WEBSHOP_URL to your public web shop.
  • It's used only for the "Browse the full web shop" button and /announce product links.

3.4 Link signing secret

  • Set LINK_TOKEN_SECRET to a long random string:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

3.5 Webhook

  • In the FastSpring dashboard, go to Integrations > Webhooks and add a URL endpoint.
  • URL: https://<your-public-url>/webhook (include https:// and the /webhook path).
  • HMAC SHA256 secret: save it as FS_WEBHOOK_SECRET.
  • Subscribe to order.completed.
🔒

Never commit your .env file. Your FastSpring API credentials, webhook secret, and Discord client secret should never be made public under any circumstances.

Expose your server

Start a public HTTPS tunnel to your local port (PORT, default 3000), and put that URL in .env as SERVER_URL. Use it for the webhook endpoint (previous step) and the OAuth redirect (previous step). A reserved or stable domain is worth it so the webhook URL doesn't change on restart.

Set up roles

Create three roles in your server: VIP, Community Manager, and Player. Copy the VIP and Community Manager role IDs (Developer Mode, right-click role, Copy Role ID) into .env:

DISCORD_VIP_ROLE_ID=<vip role id>
DISCORD_CM_ROLE_ID=<community manager role id>
  • VIP sees exclusive items in /store.
  • Community Manager is allowed to run /announce.
  • Player is the default, standard experience.

Hide /announce from non-managers: the command is registered so it's hidden from members without Manage Server by default. To show it to your Community Manager role specifically, go to Server Settings > Integrations > (your bot) > Command Permissions > /announce and allow that role. The bot also re-checks the role at runtime:

// src/commands/announce.js
function isCommunityManager(interaction) {
  const cmRoleId = process.env.DISCORD_CM_ROLE_ID;
  return cmRoleId
    ? hasRole(interaction, cmRoleId)
    : interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild);
}

Choose what the shop shows

Edit src/presentation.js, no other code changes are needed:

// Featured products (everyone). Array order = display order.
const FEATURED_PRODUCTS = ['100-coins', 'battle-pass', 'venomtail-egg', 'lavablast-egg'];

// VIP-only products.
const VIP_PRODUCTS = ['500-coins', 'mega-pack'];

// The subscription a non-VIP is nudged to buy to unlock VIP perks.
const VIP_UPSELL_PRODUCT = 'battle-pass';

Branding (title, tagline, color, and the assets/ hero and logo images) also lives in presentation.js, under STORE_BRANDING.

Run

npm start

Run /store in your server.

Environment variables

This table lists every variable the bot reads from .env and where each value comes from.

VariableDescription
DISCORD_BOT_TOKENBot token from the Developer Portal
DISCORD_CLIENT_IDApplication (client) ID
DISCORD_GUILD_IDServer ID to register commands to
DISCORD_CLIENT_SECRETOAuth2 client secret (one-time account connect)
FS_API_USERNAME / FS_API_PASSWORDFastSpring API credentials
FS_WEBHOOK_SECRETHMAC secret matching the FastSpring webhook config
FS_STOREFRONT_PATH<account>/<storefront> for the Sessions API (embedded checkout)
FS_SBL_STOREFRONTOptional SBL data-storefront; derived from the path if blank
LINK_TOKEN_SECRETSigns buy/connect link tokens and the OAuth state
WEBSHOP_URLPublic web shop for the browse button and /announce links
SERVER_URLPublic URL forwarding to your server (webhook, OAuth callback, /checkout)
PORTLocal server port (default 3000)
DISCORD_VIP_ROLE_IDRole that grants VIP (in addition to active subscriptions)
DISCORD_CM_ROLE_IDRole allowed to run /announce
🔒

Never commit .env. All secrets belong in .env only; .env.example documents the keys.

Commands

This section documents what each slash command does, along with the code behind it.

/store

Ephemeral (only the player sees it). Shows the branded header and browse button, the VIP section (or upsell), and the featured items, each product with its own buy button. Until a player connects (once, via Discord OAuth), buttons read "Connect to buy." After that, each Buy link carries only a signed, expiring token:

// src/fastspring/session.js (per-player buy link)
function buildBuyLink(productPath, discordUserId, discordUsername) {
  const token = tokens.sign({ productPath, discordUserId, discordUsername }, 900);
  return `${serverBase()}/checkout/${token}`;
}

Clicking it creates the FastSpring session server-side and renders it embedded:

// src/web/routes/checkout.js
const { id: sessionId } = await createCheckout({
  productPath: claims.productPath,
  accountId: link.fsAccountId || undefined,          // returning buyer
  contact: link.email ? { email: link.email } : undefined, // first purchase (OAuth email)
  tags: { discordUserId: claims.discordUserId, discordUsername: claims.discordUsername || '' },
});
// serves a page that calls fastspring.builder.checkout(sessionId)

/announce

Community-manager-only. Posts a public web shop message.

OptionPurpose
messageAnnouncement text
titleHeadline
imageCustom banner (uploaded file); falls back to the default hero
product1 through product5Products to feature, autocompleted live from FastSpring
pingPing @everyone

The product options autocomplete straight from your catalog, so a manager curates each announcement with no code:

// src/commands/announce.js (live product autocomplete)
async function autocomplete(interaction) {
  const focused = (interaction.options.getFocused() || '').toLowerCase();
  const paths = await fetchAllProductPaths();          // GET /products
  await interaction.respond(
    paths.filter((p) => p.toLowerCase().includes(focused)).slice(0, 25).map((p) => ({ name: p, value: p }))
  );
}

Public announcements use an identity-free product link, since there's no single buyer to tag:

// src/fastspring/session.js
function featureUrl(productPath) {
  const base = process.env.WEBSHOP_URL || DEFAULT_WEBSHOP_URL;
  const sep = base.includes('?') ? '&' : '?';
  return `${base}${sep}prod=${encodeURIComponent(productPath)}`;
}

How the key pieces work

This section walks through the core logic behind the bot, from fetching live product data to verifying webhooks.

Live product data from FastSpring

The bot fetches only the products it needs and normalizes them for display. FastSpring is the source of truth for name, price, image, and description:

// src/fastspring/catalog.js
async function fetchProducts(paths) {
  const list = (paths || []).slice(0, 25);
  const results = await Promise.all(
    list.map((path) =>
      fsApi.get(`/products/${path}`).then((r) => r.data.products?.[0] || null).catch(() => null)
    )
  );
  return results.filter(Boolean).map(normalizeProduct); // { path, displayName, price, imageUrl, description }
}

The single /store message (Components V2)

/store renders one cohesive message: a hero banner, a browse button, then a card and buy button per product, grouped into VIP Exclusives and Featured Items sections with dividers.

⚠️

A Components V2 message can contain at most 40 components. Every section, text block, thumbnail, button, and divider counts, and each product card costs about 5. If you feature many products, expect to trade some off against dividers and sections.

VIP validation

A player is VIP by role or active subscription:

// src/vip.js
async function checkVip(interaction) {
  const viaRole = hasRole(interaction, process.env.DISCORD_VIP_ROLE_ID);
  let viaSubscription = false;
  const link = playerLinks.getByDiscordId(interaction.user.id);   // Discord to FastSpring account
  if (link?.fsAccountId) {
    viaSubscription = await accounts.hasActiveSubscription(link.fsAccountId); // GET /accounts?subscriptions=active
  }
  return { vip: viaRole || viaSubscription, viaSubscription, viaRole };
}

Two things to know about the subscription path, also noted in the code:

  • It requires the Discord to FastSpring account ID, which is saved from the first order.completed webhook (the session's discordUserId order tag). Before a player's first purchase, VIP is role-only.
  • It matches any active subscription. To gate on a specific product, check the subscription's product path.

The webhook: verify, link, confirm

FastSpring signs each webhook with your HMAC secret. The handler verifies it before trusting anything, then links the account and DMs the buyer:

// src/web/routes/webhook.js (signature check)
function verifySignature(rawBody, signature) {
  if (!rawBody || !signature) return false;
  const computed = crypto.createHmac('sha256', process.env.FS_WEBHOOK_SECRET).update(rawBody).digest('base64');
  try {
    return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature));
  } catch {
    return false;
  }
}
// passive identity link: a completed purchase proves the buyer controls both IDs
const fsAccountId = data?.account;
const email = data?.customer?.email;
if (discordUserId && fsAccountId) {
  playerLinks.upsert({ discordUserId, fsAccountId, email, linkSource: 'purchase' });
}
ℹ️

Reading the payload: in order.completed, data.order is the order ID string. The real fields (tags, account, customer) sit at the top level of data. Read data.tags, data.account, and data.customer.email directly.

Privacy and data

This section covers what player data the bot stores and how it's kept out of version control.

  • data/links.json stores customer email addresses, which is personal data. It's gitignored and must never be committed.
  • The link store is a single JSON file (src/store/repository.js) behind a small interface, so you can swap it for a database without touching callers.

Before you go live

Before rolling this out to a live server, address these three gaps in the reference implementation.

Webhook idempotency

FastSpring may deliver the same event more than once. Dedupe on event.id before granting anything. The link upsert is already idempotent.

Real fulfillment

Replace the grant-item step in the webhook handler with a call to your game's grant API. Today it confirms and DMs but doesn't deliver goods.

Global command rollout

Commands register to one guild for fast dev iteration. Switch to application-wide registration for a public rollout.

Troubleshooting

Use this table to diagnose the most common issues when the bot isn't behaving as expected.

SymptomFix
No DM after a purchaseCheck that the FastSpring webhook URL ends in /webhook and that FS_WEBHOOK_SECRET matches. Your tunnel log should show POST /webhook 200.
"Invalid OAuth2 redirect_uri"Add <SERVER_URL>/auth/discord/callback to Discord Developer Portal > OAuth2 > Redirects.
Checkout page stays on "Loading..."The embedded checkout's allowed domains must include your SERVER_URL host. Check the browser console.
DM fails with "no mutual guilds"The bot was invited without the bot scope. Re-invite with both bot and applications.commands.
FastSpring log: "host parameter is null"The webhook URL is missing the https:// scheme. Enter the full URL, including scheme and /webhook path.
Webhook fields read as undefinedRead data.tags, data.account, and data.customer.email at the top level, not data.order.* (that's the order ID).
/store hangs on "thinking"Usually the Components V2 40-component limit. Reduce featured or VIP items, or dividers.
A featured product doesn't appearConfirm the path in presentation.js matches the FastSpring product path exactly, and that the product is published under the configured API credentials.

Related resources

These are the FastSpring and Discord references used throughout this guide.


Did this page help you?