Discord bot + Stripe paid roles: the full architecture
How to grant Discord roles based on Stripe subscriptions — webhook flow, role syncing, churn handling, and the patterns that survive Discord's rate limits.
The standard pattern for a paid Discord community: users subscribe via Stripe Checkout, your bot syncs their subscription state to a Discord role, and the role gates access to channels. It sounds simple. The production version is not. The hard parts are matching a Stripe customer to a Discord user (the OAuth dance), keeping roles in sync when subscriptions change asynchronously (the reconciliation problem), and recovering when Discord's API ratelimits you mid-batch (the recovery problem). This is the architecture we ship for paid Discord communities — and the parts the tutorials skip.
The architecture in one diagram
User clicks "Subscribe" in Discord
│
▼
Bot DMs a Stripe Checkout URL with state={discord_user_id}
│
▼
User completes checkout → Stripe webhook hits your server
│
▼
Server stores (discord_user_id, stripe_customer_id, subscription_status)
│
▼
Server calls Discord REST API to add the "Subscriber" role
│
▼
Periodic reconciliation job:
for each subscription in DB:
expected_role = subscription_status == 'active' ? 'Subscriber' : null
actual_role = discord guild role
if mismatch: fix itFive pieces. Each one has a failure mode.
Step 1 — linking Discord identity to Stripe checkout
The Discord user clicks /subscribe. Your bot generates a Stripe Checkout session and includes the Discord user id in client_reference_id and metadata:
import { SlashCommandBuilder } from "discord.js";
export const subscribeCommand = {
data: new SlashCommandBuilder()
.setName("subscribe")
.setDescription("Subscribe to premium access"),
async execute(interaction) {
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: process.env.STRIPE_PRICE_PREMIUM!, quantity: 1 }],
success_url: `${process.env.PUBLIC_URL}/discord/success?cs={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.PUBLIC_URL}/discord/cancel`,
client_reference_id: interaction.user.id,
subscription_data: {
metadata: {
discord_user_id: interaction.user.id,
discord_guild_id: interaction.guildId!,
},
},
});
await interaction.reply({
ephemeral: true,
content: `Subscribe here (link expires in 24h):\n${session.url}`,
});
},
};The metadata on subscription_data carries the Discord user id through every future webhook event for that subscription. That is what lets your churn handler later look up "whose role should I revoke?"
Step 2 — the webhook handler
Stripe fires customer.subscription.created. The handler resolves the Discord user id from the subscription metadata and updates your local mapping:
case "customer.subscription.created":
case "customer.subscription.updated": {
const sub = event.data.object as Stripe.Subscription;
const discordUserId = sub.metadata.discord_user_id;
const guildId = sub.metadata.discord_guild_id;
if (!discordUserId || !guildId) {
log.warn({ event: "subscription_without_discord_metadata", id: sub.id });
break;
}
await db.subscriptions.upsert({
where: { stripeSubscriptionId: sub.id },
update: { status: sub.status, currentPeriodEnd: new Date(sub.current_period_end * 1000) },
create: {
stripeSubscriptionId: sub.id,
stripeCustomerId: sub.customer as string,
discordUserId,
discordGuildId: guildId,
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
},
});
if (sub.status === "active" || sub.status === "trialing") {
await enqueueRoleSync({ discordUserId, guildId, action: "grant" });
} else {
await enqueueRoleSync({ discordUserId, guildId, action: "revoke" });
}
break;
}Note the enqueueRoleSync — we do not call Discord directly from the webhook handler. Discord rate-limits aggressively and a webhook handler that calls Discord is a webhook handler that occasionally times out. Push it to a queue.
Step 3 — the role sync worker
A worker pulls from the queue, calls Discord's REST API to add or remove the role, and respects Discord's rate-limit headers:
import { REST } from "@discordjs/rest";
import { Routes } from "discord-api-types/v10";
const rest = new REST({ version: "10" }).setToken(process.env.DISCORD_BOT_TOKEN!);
async function syncRole(job: RoleSyncJob) {
const route = Routes.guildMemberRole(
job.guildId,
job.discordUserId,
process.env.DISCORD_SUBSCRIBER_ROLE_ID!,
);
try {
if (job.action === "grant") {
await rest.put(route);
} else {
await rest.delete(route);
}
log.info({ event: "role_sync_ok", ...job });
} catch (e: unknown) {
if (isRateLimited(e)) {
// discord.js REST handles 429 internally — if it bubbles, it's a global limit
throw e; // requeue
}
if (isNotFound(e)) {
// User left the guild. Mark as inactive in our DB.
await db.subscriptions.update({
where: { discordUserId_discordGuildId: { discordUserId: job.discordUserId, discordGuildId: job.guildId } },
data: { membershipLost: true },
});
return;
}
throw e;
}
}Two failure modes worth highlighting.
User left the guild between subscribing and the worker firing. Stripe still considers the subscription active. Discord doesn't know they were ever a member. The role-add returns 404. We log it and mark the row — your next reconciliation pass picks it up if they rejoin.
Discord rate-limited. Returns 429 with a Retry-After header. The discord.js REST client handles per-route limits automatically. Global rate limits surface as exceptions; let the queue retry with backoff.
Step 4 — reconciliation (the part everyone skips)
Reality drifts. A user manually removes a role. A subscription gets refunded outside the webhook flow. The bot was down when a subscription.deleted fired and Stripe stopped retrying. Whatever the cause, your DB and Discord's reality eventually disagree.
The fix is a nightly reconciliation job that walks all your tracked subscriptions and asserts the Discord state matches:
async function reconcileGuild(guildId: string) {
const subs = await db.subscriptions.findMany({
where: { discordGuildId: guildId },
});
for (const sub of subs) {
const shouldHaveRole = sub.status === "active" || sub.status === "trialing";
const member = await fetchGuildMember(guildId, sub.discordUserId).catch(() => null);
if (!member) {
// user left, nothing to do
continue;
}
const hasRole = member.roles.includes(process.env.DISCORD_SUBSCRIBER_ROLE_ID!);
if (shouldHaveRole && !hasRole) {
await enqueueRoleSync({ ...sub, action: "grant" });
} else if (!shouldHaveRole && hasRole) {
await enqueueRoleSync({ ...sub, action: "revoke" });
}
}
}We run this hourly for active guilds. It catches drift caused by manual ops, missed webhooks, and the user-rejoined-after-leaving case.
Step 5 — graceful degradation
If your Stripe webhook is down, you accumulate work. When it recovers, you don't want to flood Discord with thousands of role changes and trip a global rate limit.
Cap the rate-sync worker concurrency. We run with concurrency=5 and a 100ms minimum gap between Discord calls per guild. That's well under Discord's per-route rate limits for guild member role mutations.
Authentication: do you actually need OAuth?
A common over-engineered pattern: the bot DMs the user a link to a custom OAuth page that links their Discord identity to a session before Stripe Checkout. This is rarely necessary.
Discord's slash command interactions already authenticate the user from Discord's side. Stripe's client_reference_id carries the Discord user id forward. You don't need a separate OAuth dance unless you also want to know the user's email before they pay (which is rare — Stripe captures email during checkout).
Add the OAuth dance only if you need to associate the subscription with the user on your own website (cross-platform identity). For a Discord-only paid community, skip it.
Refunds and chargebacks
Stripe charge.refunded fires. The matching subscription either gets customer.subscription.deleted (full refund) or stays active (partial refund). Decision time: do you revoke access on refund, or keep them on until period end?
Most communities choose revoke on refund. Build the handler:
case "charge.refunded": {
const charge = event.data.object as Stripe.Charge;
const subId = (charge as { invoice?: { subscription?: string } }).invoice?.subscription;
if (!subId) break;
const sub = await db.subscriptions.findFirst({ where: { stripeSubscriptionId: subId } });
if (!sub) break;
await db.subscriptions.update({
where: { id: sub.id },
data: { status: "refunded" },
});
await enqueueRoleSync({
discordUserId: sub.discordUserId,
guildId: sub.discordGuildId,
action: "revoke",
});
break;
}The Discord API quirks worth knowing
A short list:
- Roles are per-guild. If your bot is in multiple guilds for the same product, you maintain per-guild role mappings.
- Discord has both "guild member roles" and "user global" — you want guild member roles. Don't confuse them.
- Adding a role the user already has returns 204, not 400. Idempotency for free at that layer.
- The bot needs
Manage Rolespermission AND the bot's highest role must be above the role it's assigning. Otherwise the API returns 403. Most common deploy bug. - A user with admin permission can DM the bot regardless. Use that as your support channel.
Cost notes
Discord hosting your bot: free, you pay only your own infrastructure. A bot like this fits comfortably on a $5/month VM or Cloudflare Workers. Stripe takes its standard fee. Your only ongoing cost is the reconciliation worker and the webhook handler — both cheap.
Building a paid Discord community?
We've shipped Discord bots with Stripe subscriptions, multi-tier roles, reconciliation jobs, and abuse-resistant onboarding.
FAQ
Can I use Discord's built-in monetization instead?
Discord Premium App subscriptions exist but the revenue share is significantly worse than Stripe direct (Discord takes a 12% platform fee + the 2.9% payment fee). For most communities, Stripe + bot integration is cheaper.
What about Patreon integration?
Patreon has a native Discord integration that handles role sync. If you're already on Patreon, use it. If you're starting fresh, Stripe direct is more flexible (subscription plans, usage tiers, marketplace patterns).
How do I handle a user leaving and rejoining the guild?
Reconciliation handles it. When they rejoin, the next reconciliation pass sees the user is now a guild member, the subscription is still active, and the role is missing. It grants the role automatically.
Can I have multiple paid tiers?
Yes — map each Stripe price to a distinct Discord role. Webhook handler reads the price id from the subscription and grants the corresponding role. Same reconciliation pattern works across tiers.
What about Stripe Connect for community owners?
If you're building a platform for community owners (so each one has their own Stripe account), you need Stripe Connect. The architecture extends the same pattern with an extra "which connected account does this subscription belong to" lookup.
How do I handle Discord guild deletes?
A GUILD_DELETE gateway event fires when your bot is removed from a guild. Mark all subscriptions for that guild as inactive and surface a Stripe Customer Portal link so users can cancel themselves.
Can the bot run on Cloudflare Workers?
Yes for the slash command webhook. No for the Gateway (persistent WebSocket connection) — that needs a long-running process. The cheapest pattern: command webhook on Workers, gateway-listening worker on Fly or Railway.
What about Discord rate-limit headers?
Discord returns X-RateLimit-Remaining, X-RateLimit-Reset, and bucket info on every response. The discord.js REST client uses them to throttle automatically. If you're hand-rolling the REST calls, respect those headers — it'll save you a global rate-limit ban.