The Problem
Monetizing Discord communities through paid memberships is lucrative, but manually assigning roles after payment is a nightmare. You need to verify Stripe payments, link them to Discord users, automatically grant premium roles, handle subscription cancellations, manage failed payments, and prevent users from gaming the system by sharing credentials. Do it manually, and you’ll spend hours copy-pasting Discord IDs; automate it poorly, and users either don’t get access they paid for (chargebacks and support tickets) or get access without paying (lost revenue). The technical challenge is bridging Stripe’s payment webhooks with Discord’s API while handling edge cases: what if a user isn’t in your server yet? What if they cancel and re-subscribe? This integration solves it all—when Stripe confirms payment, your system instantly grants the correct Discord role, removes it on cancellation, and handles upgrades/downgrades automatically.
Tech Stack & Prerequisites
- Node.js v20+ and npm/pnpm
- TypeScript 5+ (recommended)
- Stripe Account with API keys (test mode)
- Discord Bot with proper permissions
- Discord Server with admin access
- stripe npm package v14+
- discord.js v14+
- Express.js v4.18+ for webhook handling
- dotenv for environment variables
- PostgreSQL or MongoDB (for user-payment mapping)
- Prisma v5+ (optional but recommended for database)
Step-by-Step Implementation
Step 1: Setup
Initialize your Node.js project:
mkdir discord-stripe-roles
cd discord-stripe-roles
npm init -y
npm install stripe discord.js express dotenv @prisma/client
npm install -D typescript @types/node @types/express tsx nodemon prisma
Initialize TypeScript:
npx tsc --init
Update tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Initialize Prisma:
npx prisma init
Create prisma/schema.prisma:
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
discordId String @unique
discordUsername String?
email String?
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
subscriptionStatus String? // active, canceled, past_due, etc.
subscriptionTier String? // basic, premium, elite
roleId String? // Discord role ID
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([discordId])
@@index([stripeCustomerId])
}
Run migrations:
npx prisma migrate dev --name init
npx prisma generate
Create project structure:
mkdir src
touch src/index.ts src/config.ts src/stripe-client.ts src/discord-client.ts src/webhook.ts src/database.ts
Update package.json scripts:
{
"scripts": {
"dev": "nodemon --exec tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"prisma:studio": "npx prisma studio"
}
}
Step 2: Configuration
2.1: Create Discord Bot
- Go to Discord Developer Portal
- Click “New Application”
- Go to “Bot” tab → “Add Bot”
- Enable “Server Members Intent” and “Presence Intent”
- Copy the bot token
- Go to OAuth2 → URL Generator:
- Select scopes:
bot,applications.commands - Select permissions:
Manage Roles,Manage Members
- Select scopes:
- Copy generated URL and invite bot to your server
2.2: Create Discord Roles
In your Discord server:
- Server Settings → Roles
- Create roles:
Basic Member,Premium Member,Elite Member - Copy role IDs (enable Developer Mode in Discord settings, right-click role → Copy ID)
2.3: Set Up Stripe Products
In Stripe Dashboard:
- Products → Add Product
- Create three products:
- Basic – $9.99/month
- Premium – $19.99/month
- Elite – $49.99/month
- Copy price IDs
2.4: Configure Environment Variables
Create .env file:
# .env
PORT=3000
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/discord_stripe
# Stripe
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
# Stripe Price IDs (get from Stripe Dashboard)
STRIPE_PRICE_BASIC=price_basic_price_id
STRIPE_PRICE_PREMIUM=price_premium_price_id
STRIPE_PRICE_ELITE=price_elite_price_id
# Discord
DISCORD_BOT_TOKEN=your_discord_bot_token_here
DISCORD_GUILD_ID=your_discord_server_id_here
# Discord Role IDs (get from Discord Server Settings)
DISCORD_ROLE_BASIC=basic_role_id_here
DISCORD_ROLE_PREMIUM=premium_role_id_here
DISCORD_ROLE_ELITE=elite_role_id_here
# App URL (for Stripe redirects)
APP_URL=http://localhost:3000
SUCCESS_URL=http://localhost:3000/success
CANCEL_URL=http://localhost:3000/cancel
Add to .gitignore:
echo ".env" >> .gitignore
echo "node_modules/" >> .gitignore
echo "dist/" >> .gitignore
Create src/config.ts:
// src/config.ts
import dotenv from 'dotenv';
dotenv.config();
export const config = {
port: process.env.PORT || 3000,
database: {
url: process.env.DATABASE_URL || '',
},
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY || '',
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
prices: {
basic: process.env.STRIPE_PRICE_BASIC || '',
premium: process.env.STRIPE_PRICE_PREMIUM || '',
elite: process.env.STRIPE_PRICE_ELITE || '',
},
},
discord: {
token: process.env.DISCORD_BOT_TOKEN || '',
guildId: process.env.DISCORD_GUILD_ID || '',
roles: {
basic: process.env.DISCORD_ROLE_BASIC || '',
premium: process.env.DISCORD_ROLE_PREMIUM || '',
elite: process.env.DISCORD_ROLE_ELITE || '',
},
},
app: {
url: process.env.APP_URL || 'http://localhost:3000',
successUrl: process.env.SUCCESS_URL || 'http://localhost:3000/success',
cancelUrl: process.env.CANCEL_URL || 'http://localhost:3000/cancel',
},
} as const;
// Validate required environment variables
const requiredEnvVars = [
'DATABASE_URL',
'STRIPE_SECRET_KEY',
'STRIPE_WEBHOOK_SECRET',
'DISCORD_BOT_TOKEN',
'DISCORD_GUILD_ID',
];
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
throw new Error(`Missing required environment variable: ${envVar}`);
}
}
Step 3: Core Logic
3.1: Database Client
Create src/database.ts:
// src/database.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
/**
* Get or create user by Discord ID
*/
export async function getOrCreateUser(discordId: string, username?: string) {
let user = await prisma.user.findUnique({
where: { discordId },
});
if (!user) {
user = await prisma.user.create({
data: {
discordId,
discordUsername: username,
},
});
}
return user;
}
/**
* Update user subscription info
*/
export async function updateUserSubscription(
discordId: string,
data: {
stripeCustomerId?: string;
stripeSubscriptionId?: string;
subscriptionStatus?: string;
subscriptionTier?: string;
roleId?: string;
email?: string;
}
) {
return prisma.user.update({
where: { discordId },
data: {
...data,
updatedAt: new Date(),
},
});
}
/**
* Get user by Stripe customer ID
*/
export async function getUserByStripeCustomer(customerId: string) {
return prisma.user.findUnique({
where: { stripeCustomerId: customerId },
});
}
/**
* Get user by Stripe subscription ID
*/
export async function getUserBySubscription(subscriptionId: string) {
return prisma.user.findUnique({
where: { stripeSubscriptionId: subscriptionId },
});
}
3.2: Stripe Client
Create src/stripe-client.ts:
// src/stripe-client.ts
import Stripe from 'stripe';
import { config } from './config';
export const stripe = new Stripe(config.stripe.secretKey, {
apiVersion: '2024-11-20.acacia',
typescript: true,
});
/**
* Map Stripe price ID to subscription tier
*/
export function getTierFromPriceId(priceId: string): string {
if (priceId === config.stripe.prices.basic) return 'basic';
if (priceId === config.stripe.prices.premium) return 'premium';
if (priceId === config.stripe.prices.elite) return 'elite';
return 'unknown';
}
/**
* Create checkout session for subscription
*/
export async function createCheckoutSession(
discordId: string,
priceId: string,
email?: string
) {
try {
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: config.app.successUrl + '?session_id={CHECKOUT_SESSION_ID}',
cancel_url: config.app.cancelUrl,
customer_email: email,
metadata: {
discordId,
},
subscription_data: {
metadata: {
discordId,
},
},
});
return session;
} catch (error) {
console.error('Error creating checkout session:', error);
throw error;
}
}
/**
* Create customer portal session
*/
export async function createPortalSession(customerId: string) {
try {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: config.app.url,
});
return session;
} catch (error) {
console.error('Error creating portal session:', error);
throw error;
}
}
/**
* Get subscription details
*/
export async function getSubscription(subscriptionId: string) {
try {
return await stripe.subscriptions.retrieve(subscriptionId);
} catch (error) {
console.error('Error retrieving subscription:', error);
throw error;
}
}
3.3: Discord Client
Create src/discord-client.ts:
// src/discord-client.ts
import { Client, GatewayIntentBits, Guild, GuildMember } from 'discord.js';
import { config } from './config';
export const discordClient = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
],
});
let isReady = false;
/**
* Initialize Discord bot
*/
export async function initializeDiscord(): Promise<void> {
return new Promise((resolve, reject) => {
discordClient.once('ready', () => {
console.log(`Discord bot logged in as ${discordClient.user?.tag}`);
isReady = true;
resolve();
});
discordClient.on('error', (error) => {
console.error('Discord client error:', error);
});
discordClient.login(config.discord.token).catch(reject);
});
}
/**
* Get guild (server)
*/
async function getGuild(): Promise<Guild> {
if (!isReady) {
throw new Error('Discord client not ready');
}
const guild = await discordClient.guilds.fetch(config.discord.guildId);
if (!guild) {
throw new Error('Guild not found');
}
return guild;
}
/**
* Get Discord role ID from tier
*/
export function getRoleIdFromTier(tier: string): string {
switch (tier) {
case 'basic':
return config.discord.roles.basic;
case 'premium':
return config.discord.roles.premium;
case 'elite':
return config.discord.roles.elite;
default:
throw new Error(`Unknown tier: ${tier}`);
}
}
/**
* Assign role to Discord user
*/
export async function assignRole(
discordId: string,
tier: string
): Promise<void> {
try {
const guild = await getGuild();
const member = await guild.members.fetch(discordId);
if (!member) {
throw new Error(`Member ${discordId} not found in guild`);
}
const roleId = getRoleIdFromTier(tier);
// Remove all subscription roles first
const allSubscriptionRoles = [
config.discord.roles.basic,
config.discord.roles.premium,
config.discord.roles.elite,
];
for (const role of allSubscriptionRoles) {
if (member.roles.cache.has(role)) {
await member.roles.remove(role);
}
}
// Add new role
await member.roles.add(roleId);
console.log(`Assigned ${tier} role to ${discordId}`);
} catch (error) {
console.error(`Error assigning role to ${discordId}:`, error);
throw error;
}
}
/**
* Remove all subscription roles from user
*/
export async function removeAllRoles(discordId: string): Promise<void> {
try {
const guild = await getGuild();
const member = await guild.members.fetch(discordId);
if (!member) {
console.log(`Member ${discordId} not found in guild - already left?`);
return;
}
const allSubscriptionRoles = [
config.discord.roles.basic,
config.discord.roles.premium,
config.discord.roles.elite,
];
for (const role of allSubscriptionRoles) {
if (member.roles.cache.has(role)) {
await member.roles.remove(role);
}
}
console.log(`Removed all subscription roles from ${discordId}`);
} catch (error) {
console.error(`Error removing roles from ${discordId}:`, error);
throw error;
}
}
/**
* Send DM to user
*/
export async function sendDM(
discordId: string,
message: string
): Promise<void> {
try {
const user = await discordClient.users.fetch(discordId);
await user.send(message);
console.log(`Sent DM to ${discordId}`);
} catch (error) {
console.error(`Error sending DM to ${discordId}:`, error);
// Don't throw - user might have DMs disabled
}
}
/**
* Check if user is in guild
*/
export async function isUserInGuild(discordId: string): Promise<boolean> {
try {
const guild = await getGuild();
const member = await guild.members.fetch(discordId);
return !!member;
} catch (error) {
return false;
}
}
3.4: Webhook Handler
Create src/webhook.ts:
// src/webhook.ts
import { Request, Response } from 'express';
import Stripe from 'stripe';
import { stripe, getTierFromPriceId } from './stripe-client';
import { assignRole, removeAllRoles, sendDM, isUserInGuild } from './discord-client';
import {
getUserByStripeCustomer,
getUserBySubscription,
updateUserSubscription,
getOrCreateUser,
} from './database';
import { config } from './config';
/**
* Handle successful checkout
*/
async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
try {
console.log(`Checkout completed: ${session.id}`);
const discordId = session.metadata?.discordId;
if (!discordId) {
console.error('No Discord ID in checkout session metadata');
return;
}
// Get or create user
const user = await getOrCreateUser(discordId);
// Get subscription details
const subscriptionId = session.subscription as string;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const priceId = subscription.items.data[0].price.id;
const tier = getTierFromPriceId(priceId);
// Update user in database
await updateUserSubscription(discordId, {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: subscriptionId,
subscriptionStatus: subscription.status,
subscriptionTier: tier,
email: session.customer_email || undefined,
});
// Check if user is in Discord server
const inGuild = await isUserInGuild(discordId);
if (!inGuild) {
console.log(`User ${discordId} not in guild yet - role will be assigned when they join`);
await sendDM(
discordId,
`Thank you for subscribing! Please join our Discord server to access your ${tier} benefits: [Your Server Invite Link]`
);
return;
}
// Assign Discord role
await assignRole(discordId, tier);
// Send welcome DM
await sendDM(
discordId,
`Welcome to ${tier.toUpperCase()} membership! Your role has been assigned. Enjoy your benefits!`
);
console.log(`Successfully processed subscription for ${discordId}`);
} catch (error) {
console.error('Error handling checkout complete:', error);
throw error;
}
}
/**
* Handle subscription update
*/
async function handleSubscriptionUpdate(subscription: Stripe.Subscription) {
try {
console.log(`Subscription updated: ${subscription.id}`);
const user = await getUserBySubscription(subscription.id);
if (!user) {
console.error('User not found for subscription:', subscription.id);
return;
}
const priceId = subscription.items.data[0].price.id;
const tier = getTierFromPriceId(priceId);
const status = subscription.status;
// Update database
await updateUserSubscription(user.discordId, {
subscriptionStatus: status,
subscriptionTier: tier,
});
// Handle different statuses
if (status === 'active') {
// Assign new role (handles upgrades/downgrades)
await assignRole(user.discordId, tier);
await sendDM(
user.discordId,
`Your subscription has been updated to ${tier.toUpperCase()}!`
);
} else if (status === 'past_due') {
await sendDM(
user.discordId,
'⚠️ Your payment failed. Please update your payment method to avoid losing access.'
);
} else if (status === 'canceled' || status === 'unpaid') {
await removeAllRoles(user.discordId);
await sendDM(
user.discordId,
'Your subscription has been canceled. Thank you for being a member!'
);
}
console.log(`Subscription ${subscription.id} updated to ${status}`);
} catch (error) {
console.error('Error handling subscription update:', error);
throw error;
}
}
/**
* Handle subscription deletion
*/
async function handleSubscriptionDelete(subscription: Stripe.Subscription) {
try {
console.log(`Subscription deleted: ${subscription.id}`);
const user = await getUserBySubscription(subscription.id);
if (!user) {
console.error('User not found for subscription:', subscription.id);
return;
}
// Update database
await updateUserSubscription(user.discordId, {
subscriptionStatus: 'canceled',
stripeSubscriptionId: null,
});
// Remove Discord roles
await removeAllRoles(user.discordId);
await sendDM(
user.discordId,
'Your subscription has ended. We hope to see you again soon!'
);
console.log(`Subscription deleted for ${user.discordId}`);
} catch (error) {
console.error('Error handling subscription delete:', error);
throw error;
}
}
/**
* Handle invoice payment failed
*/
async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
try {
console.log(`Invoice payment failed: ${invoice.id}`);
const customerId = invoice.customer as string;
const user = await getUserByStripeCustomer(customerId);
if (!user) {
console.error('User not found for customer:', customerId);
return;
}
await sendDM(
user.discordId,
'❌ Your payment failed. Please update your payment method at: ' +
config.app.url +
'/portal'
);
} catch (error) {
console.error('Error handling invoice payment failed:', error);
throw error;
}
}
/**
* Main webhook handler
*/
export async function handleWebhook(req: Request, res: Response) {
const sig = req.headers['stripe-signature'];
if (!sig) {
return res.status(400).send('Missing stripe-signature header');
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
config.stripe.webhookSecret
);
} catch (err: any) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
try {
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object as Stripe.Checkout.Session);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDelete(event.data.object as Stripe.Subscription);
break;
case 'invoice.payment_failed':
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
res.json({ received: true });
} catch (error) {
console.error('Error handling webhook:', error);
res.status(500).json({ error: 'Webhook handler failed' });
}
}
3.5: Main Application
Create src/index.ts:
// src/index.ts
import express from 'express';
import { config } from './config';
import { handleWebhook } from './webhook';
import { initializeDiscord, assignRole } from './discord-client';
import { createCheckoutSession, createPortalSession, getTierFromPriceId } from './stripe-client';
import { getOrCreateUser, prisma } from './database';
const app = express();
// Webhook endpoint - MUST use raw body
app.post('/webhook', express.raw({ type: 'application/json' }), handleWebhook);
// Other routes use JSON parser
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Home page
app.get('/', (req, res) => {
res.send(`
<html>
<head><title>Discord Premium Access</title></head>
<body style="font-family: Arial; max-width: 800px; margin: 50px auto; padding: 20px;">
<h1>Premium Discord Membership</h1>
<p>Choose your tier and get instant access to exclusive roles!</p>
<div style="margin: 30px 0;">
<h3>Basic - $9.99/month</h3>
<ul>
<li>Access to premium channels</li>
<li>Custom role color</li>
<li>Priority support</li>
</ul>
<form action="/checkout" method="POST">
<input type="hidden" name="tier" value="basic">
<input type="text" name="discordId" placeholder="Your Discord ID" required style="padding: 10px; margin-right: 10px;">
<input type="email" name="email" placeholder="Your Email" required style="padding: 10px; margin-right: 10px;">
<button type="submit" style="padding: 10px 20px; background: #5865F2; color: white; border: none; cursor: pointer;">Subscribe</button>
</form>
</div>
<div style="margin: 30px 0;">
<h3>Premium - $19.99/month</h3>
<ul>
<li>Everything in Basic</li>
<li>Exclusive voice channels</li>
<li>Early access to announcements</li>
</ul>
<form action="/checkout" method="POST">
<input type="hidden" name="tier" value="premium">
<input type="text" name="discordId" placeholder="Your Discord ID" required style="padding: 10px; margin-right: 10px;">
<input type="email" name="email" placeholder="Your Email" required style="padding: 10px; margin-right: 10px;">
<button type="submit" style="padding: 10px 20px; background: #5865F2; color: white; border: none; cursor: pointer;">Subscribe</button>
</form>
</div>
<div style="margin: 30px 0;">
<h3>Elite - $49.99/month</h3>
<ul>
<li>Everything in Premium</li>
<li>1-on-1 sessions</li>
<li>Custom bot commands</li>
</ul>
<form action="/checkout" method="POST">
<input type="hidden" name="tier" value="elite">
<input type="text" name="discordId" placeholder="Your Discord ID" required style="padding: 10px; margin-right: 10px;">
<input type="email" name="email" placeholder="Your Email" required style="padding: 10px; margin-right: 10px;">
<button type="submit" style="padding: 10px 20px; background: #5865F2; color: white; border: none; cursor: pointer;">Subscribe</button>
</form>
</div>
<p style="margin-top: 50px; color: #666;">
Already a member? <a href="/portal">Manage subscription</a>
</p>
</body>
</html>
`);
});
// Create checkout session
app.post('/checkout', async (req, res) => {
try {
const { tier, discordId, email } = req.body;
if (!tier || !discordId || !email) {
return res.status(400).json({ error: 'Missing required fields' });
}
// Get price ID from tier
let priceId: string;
if (tier === 'basic') priceId = config.stripe.prices.basic;
else if (tier === 'premium') priceId = config.stripe.prices.premium;
else if (tier === 'elite') priceId = config.stripe.prices.elite;
else return res.status(400).json({ error: 'Invalid tier' });
// Create checkout session
const session = await createCheckoutSession(discordId, priceId, email);
res.redirect(303, session.url!);
} catch (error) {
console.error('Checkout error:', error);
res.status(500).json({ error: 'Failed to create checkout session' });
}
});
// Success page
app.get('/success', (req, res) => {
res.send(`
<html>
<head><title>Success!</title></head>
<body style="font-family: Arial; max-width: 800px; margin: 50px auto; padding: 20px; text-align: center;">
<h1 style="color: #27ae60;">✓ Payment Successful!</h1>
<p>Your Discord role will be assigned within a few seconds.</p>
<p>Check your Discord DMs for confirmation!</p>
<a href="/" style="display: inline-block; margin-top: 20px; padding: 10px 20px; background: #5865F2; color: white; text-decoration: none;">Back to Home</a>
</body>
</html>
`);
});
// Cancel page
app.get('/cancel', (req, res) => {
res.send(`
<html>
<head><title>Cancelled</title></head>
<body style="font-family: Arial; max-width: 800px; margin: 50px auto; padding: 20px; text-align: center;">
<h1>Payment Cancelled</h1>
<p>No charges were made.</p>
<a href="/" style="display: inline-block; margin-top: 20px; padding: 10px 20px; background: #5865F2; color: white; text-decoration: none;">Try Again</a>
</body>
</html>
`);
});
// Customer portal (for managing subscriptions)
app.get('/portal', async (req, res) => {
res.send(`
<html>
<head><title>Manage Subscription</title></head>
<body style="font-family: Arial; max-width: 800px; margin: 50px auto; padding: 20px;">
<h1>Manage Your Subscription</h1>
<form action="/portal" method="POST">
<input type="text" name="discordId" placeholder="Your Discord ID" required style="padding: 10px; margin-right: 10px; width: 300px;">
<button type="submit" style="padding: 10px 20px; background: #5865F2; color: white; border: none; cursor: pointer;">Access Portal</button>
</form>
</body>
</html>
`);
});
app.post('/portal', async (req, res) => {
try {
const { discordId } = req.body;
const user = await prisma.user.findUnique({
where: { discordId },
});
if (!user || !user.stripeCustomerId) {
return res.status(404).send('No active subscription found for this Discord ID');
}
const session = await createPortalSession(user.stripeCustomerId);
res.redirect(303, session.url);
} catch (error) {
console.error('Portal error:', error);
res.status(500).json({ error: 'Failed to create portal session' });
}
});
// Manual role assignment (admin endpoint)
app.post('/admin/assign-role', async (req, res) => {
try {
const { discordId, tier } = req.body;
if (!discordId || !tier) {
return res.status(400).json({ error: 'Missing discordId or tier' });
}
await assignRole(discordId, tier);
res.json({ success: true, message: `Assigned ${tier} role to ${discordId}` });
} catch (error) {
console.error('Role assignment error:', error);
res.status(500).json({ error: 'Failed to assign role' });
}
});
// Start server
async function start() {
try {
// Initialize Discord bot
await initializeDiscord();
// Start Express server
app.listen(config.port, () => {
console.log(`Server running on ${config.app.url}`);
console.log(`Webhook endpoint: ${config.app.url}/webhook`);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
}
start();
Step 4: Testing
4.1: Start the Server
Run database migrations:
npx prisma migrate dev
Start the development server:
npm run dev
```
Expected output:
```
Discord bot logged in as YourBot#1234
Server running on http://localhost:3000
Webhook endpoint: http://localhost:3000/webhook
4.2: Set Up Stripe Webhook
Install Stripe CLI:
brew install stripe/stripe-cli/stripe
Login and forward webhooks:
stripe login
stripe listen --forward-to localhost:3000/webhook
Copy the webhook signing secret to your .env file.
4.3: Test End-to-End Flow
Step 1: Get Your Discord ID
- Enable Developer Mode in Discord (User Settings → Advanced)
- Right-click your username → Copy ID
Step 2: Subscribe
- Open
http://localhost:3000 - Enter your Discord ID and email
- Click “Subscribe” for any tier
- Complete payment with test card:
4242 4242 4242 4242
Step 3: Verify
- Check webhook logs for “Checkout completed”
- Check Discord – you should have the role
- Check your DMs for welcome message
Step 4: Test Cancellation
- Go to
http://localhost:3000/portal - Enter your Discord ID
- Cancel subscription
- Verify role is removed
4.4: Test with Stripe CLI
Trigger test events:
# Test successful subscription
stripe trigger customer.subscription.created
# Test subscription update
stripe trigger customer.subscription.updated
# Test failed payment
stripe trigger invoice.payment_failed
4.5: Database Verification
View database:
npm run prisma:studio
```
Check that users are being created and updated correctly.
## Common Errors & Troubleshooting
### 1. **Error: "Missing Access" or "Unknown Member" when assigning roles**
**Cause:** Discord bot doesn't have proper permissions, or the bot's role is positioned below the roles it's trying to assign.
**Fix:** Ensure bot has correct permissions and role hierarchy:
1. In Discord Server Settings → Roles:
- Bot role must be **above** all subscription roles
- Bot must have "Manage Roles" permission
2. Verify bot permissions when inviting:
```
https://discord.com/oauth2/authorize?client_id=YOUR_BOT_ID&permissions=268435456&scope=bot
- Check role assignment code handles errors:
try {
await member.roles.add(roleId);
} catch (error: any) {
if (error.code === 50013) {
console.error('Bot lacks permission to manage roles');
}
throw error;
}
2. Error: “User not found in guild” despite successful payment
Cause: User hasn’t joined the Discord server yet, or bot can’t fetch members due to missing intents.
Fix: Enable required intents and handle delayed joins:
// In discord-client.ts - ensure intents are enabled
export const discordClient = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers, // Required for fetching members
],
});
Handle users who pay before joining:
if (!inGuild) {
// Store pending role assignment
await updateUserSubscription(discordId, {
subscriptionStatus: 'pending_join',
});
// Send invite link via DM
await sendDM(discordId, `Join our server to activate: ${INVITE_LINK}`);
// Set up guildMemberAdd event to assign role when they join
}
Add member join handler in src/discord-client.ts:
discordClient.on('guildMemberAdd', async (member) => {
const user = await prisma.user.findUnique({
where: { discordId: member.id },
});
if (user && user.subscriptionStatus === 'active' && user.subscriptionTier) {
await assignRole(member.id, user.subscriptionTier);
}
});
3. Error: Webhook receives events but roles aren’t assigned
Cause: Race condition between webhook processing and Discord API, or database isn’t updating.
Fix: Implement retry logic and proper error handling:
async function assignRoleWithRetry(
discordId: string,
tier: string,
maxRetries = 3
): Promise<void> {
for (let i = 0; i < maxRetries; i++) {
try {
await assignRole(discordId, tier);
return;
} catch (error: any) {
if (i === maxRetries - 1) throw error;
console.log(`Retry ${i + 1}/${maxRetries} for ${discordId}`);
await new Promise(resolve => setTimeout(resolve, 2000 * (i + 1)));
}
}
}
Add transaction to ensure database consistency:
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { discordId },
data: { subscriptionStatus: 'active', subscriptionTier: tier },
});
await assignRole(discordId, tier);
});
Security Checklist
- Verify webhook signatures using
stripe.webhooks.constructEvent()to prevent spoofed events - Never commit
.envfiles, tokens, or API keys to version control - Use HTTPS in production to protect webhook payloads and user data
- Implement rate limiting on checkout endpoints to prevent abuse
- Validate Discord IDs before processing payments (check format, existence)
- Store minimal user data – only Discord ID and Stripe customer ID are required
- Use database transactions to ensure consistency between Stripe and Discord state
- Implement role hierarchy properly – bot role must be positioned correctly
- Validate subscription status before granting access to prevent fraud
- Handle edge cases – users leaving server, changing Discord IDs, multiple subscriptions
- Log all role assignments for audit trails and debugging
- Implement idempotency for webhook processing to handle duplicate events
- Secure admin endpoints with authentication (don’t expose
/admin/assign-rolepublicly) - Rotate bot tokens regularly and monitor for unauthorized access
- Use environment-specific Stripe keys (test vs production)
- Validate metadata from Stripe webhooks before trusting Discord IDs
- Implement Discord OAuth for production instead of manual ID entry
- Monitor failed role assignments and implement alerting
- Set up RBAC in database to prevent privilege escalation
Related Resources
Building a complete membership system requires multiple integrations. Check out our guide on integrating Stripe usage-based billing with Next.js to handle metered pricing alongside subscription tiers.
For teams managing Google Calendar integrations to schedule member-only events, see our tutorial on integrating Google Calendar API with Node.js.
When implementing notification systems for payment confirmations or role assignments, explore our Twilio and Node.js integration guide for SMS alerts.
For production APIs that handle member authentication and role verification, read our comprehensive guide on securing APIs with OAuth 2.0 and JWT.

Finly Insights Team is a group of software developers, cloud engineers, and technical writers with real hands-on experience in the tech industry. We specialize in cloud computing, cybersecurity, SaaS tools, AI automation, and API development. Every article we publish is thoroughly researched, written, and reviewed by people who have actually worked in these fields.




