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.
The bot and its web server run on Node.js.
An account with products, a web shop, and API access.
An application created in the Discord Developer Portal, with a bot added to it.
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 .envCreate 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/callbackunder Redirects. This must match exactly, or Discord shows "Invalid OAuth2 redirect_uri".
2.4 Invite the bot with both thebotandapplications.commandsscopes. Thebotscope 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.commands2.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_USERNAMEand the password asFS_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_PATHto<account>/<storefront>(for examplemystore.test/embedded-store). This is used forPOST /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_URLto your public web shop. - It's used only for the "Browse the full web shop" button and
/announceproduct links.
3.4 Link signing secret
- Set
LINK_TOKEN_SECRETto 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(includehttps://and the/webhookpath). - HMAC SHA256 secret: save it as
FS_WEBHOOK_SECRET. - Subscribe to
order.completed.
Never commit your
.envfile. 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 startRun /store in your server.
Environment variables
This table lists every variable the bot reads from .env and where each value comes from.
| Variable | Description |
|---|---|
DISCORD_BOT_TOKEN | Bot token from the Developer Portal |
DISCORD_CLIENT_ID | Application (client) ID |
DISCORD_GUILD_ID | Server ID to register commands to |
DISCORD_CLIENT_SECRET | OAuth2 client secret (one-time account connect) |
FS_API_USERNAME / FS_API_PASSWORD | FastSpring API credentials |
FS_WEBHOOK_SECRET | HMAC secret matching the FastSpring webhook config |
FS_STOREFRONT_PATH | <account>/<storefront> for the Sessions API (embedded checkout) |
FS_SBL_STOREFRONT | Optional SBL data-storefront; derived from the path if blank |
LINK_TOKEN_SECRET | Signs buy/connect link tokens and the OAuth state |
WEBSHOP_URL | Public web shop for the browse button and /announce links |
SERVER_URL | Public URL forwarding to your server (webhook, OAuth callback, /checkout) |
PORT | Local server port (default 3000) |
DISCORD_VIP_ROLE_ID | Role that grants VIP (in addition to active subscriptions) |
DISCORD_CM_ROLE_ID | Role allowed to run /announce |
Never commit
.env. All secrets belong in.envonly;.env.exampledocuments the keys.
Commands
This section documents what each slash command does, along with the code behind it.
/store
/storeEphemeral (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
/announceCommunity-manager-only. Posts a public web shop message.
| Option | Purpose |
|---|---|
message | Announcement text |
title | Headline |
image | Custom banner (uploaded file); falls back to the default hero |
product1 through product5 | Products to feature, autocompleted live from FastSpring |
ping | Ping @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 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.completedwebhook (the session'sdiscordUserIdorder 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.orderis the order ID string. The real fields (tags,account,customer) sit at the top level ofdata. Readdata.tags,data.account, anddata.customer.emaildirectly.
Privacy and data
This section covers what player data the bot stores and how it's kept out of version control.
data/links.jsonstores 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.
| Symptom | Fix |
|---|---|
| No DM after a purchase | Check 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 undefined | Read 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 appear | Confirm 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.
Updated about 15 hours ago