Stripe Subscription Payment Implementation: Complete Guide
Implementing subscription payments is essential for SaaS applications. In this guide, we'll learn how to integrate Stripe subscriptions with Node.js and React, including checkout sessions, webhooks, customer portal, and subscription management.
Building a SaaS application means you need to handle recurring payments, and Stripe makes this process straightforward. After implementing Stripe subscriptions in multiple production applications, I've learned the patterns that work reliably and scale well.
In this guide, I'll show you how to implement a complete Stripe subscription system with minimal code. We'll cover creating checkout sessions, handling webhooks, managing subscriptions, and integrating the customer portal. The examples are production-ready and follow Stripe's best practices.
Installation and Setup
npm install stripeConfigure Stripe with your API keys:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-02-24.acacia',
});Stripe Service: Core Functions
Create a service class to handle Stripe operations:
import Stripe from 'stripe';
export class StripeService {
// Get or create Stripe customer
static async getOrCreateCustomer(userId: string, email: string) {
// Check if user already has a customer ID
const user = await getUser(userId);
if (user?.stripeCustomerId) {
return user.stripeCustomerId;
}
// Create new customer
const customer = await stripe.customers.create({
email,
metadata: { userId },
});
// Save customer ID to database
await updateUser(userId, { stripeCustomerId: customer.id });
return customer.id;
}
// Create checkout session for new subscription
static async createCheckoutSession(
userId: string,
userEmail: string,
planId: string,
billingInterval: 'monthly' | 'yearly'
) {
const plan = await getPlan(planId);
const priceId = billingInterval === 'monthly'
? plan.stripePriceIdMonthly
: plan.stripePriceIdYearly;
const customerId = await this.getOrCreateCustomer(userId, userEmail);
const session = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
success_url: `${FRONTEND_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${FRONTEND_URL}/pricing`,
metadata: { userId, planId, billingInterval },
});
return session.url!;
}
// Create customer portal session
static async createPortalSession(customerId: string) {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${FRONTEND_URL}/billing`,
});
return session.url;
}
// Update subscription plan
static async updateSubscriptionPlan(
subscriptionId: string,
newPriceId: string
) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const itemId = subscription.items.data[0]?.id;
return await stripe.subscriptions.update(subscriptionId, {
items: [{ id: itemId, price: newPriceId }],
proration_behavior: 'create_prorations',
});
}
}API Routes
Create Checkout Session
router.post('/create-checkout-session', requireAuth, async (req, res) => {
try {
const { planId, billingInterval } = req.body;
const url = await StripeService.createCheckoutSession({
userId: req.user.id,
userEmail: req.user.email,
planId,
billingInterval,
});
res.json({ url });
} catch (error) {
res.status(500).json({ error: 'Failed to create checkout session' });
}
});Customer Portal
router.post('/create-portal-session', requireAuth, async (req, res) => {
try {
const user = await getUser(req.user.id);
if (!user?.stripeCustomerId) {
return res.status(400).json({ error: 'No billing account found' });
}
const url = await StripeService.createPortalSession({
customerId: user.stripeCustomerId,
});
res.json({ url });
} catch (error) {
res.status(500).json({ error: 'Failed to create portal session' });
}
});Change Plan
router.post('/change-plan', requireAuth, async (req, res) => {
try {
const { planId, billingInterval } = req.body;
const plan = await getPlan(planId);
const priceId = billingInterval === 'monthly'
? plan.stripePriceIdMonthly
: plan.stripePriceIdYearly;
const subscription = await getSubscription(req.user.id);
const updated = await StripeService.updateSubscriptionPlan(
subscription.stripeSubscriptionId,
priceId
);
// Update local database
await updateSubscription(subscription.id, {
planId,
billingInterval,
status: updated.status,
});
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to change plan' });
}
});Webhook Handler
Handle Stripe webhooks to keep your database in sync:
// Important: Use raw body for webhook endpoint
app.use('/api/stripe/webhook', express.raw({ type: 'application/json' }));
router.post('/webhook', async (req, res) => {
const sig = req.headers['stripe-signature'];
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig!, webhookSecret!);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object;
// Create subscription in database
await createSubscription({
userId: session.metadata.userId,
stripeSubscriptionId: session.subscription,
planId: session.metadata.planId,
billingInterval: session.metadata.billingInterval,
});
break;
case 'customer.subscription.updated':
const subscription = event.data.object;
// Update subscription status and period
await updateSubscriptionByStripeId(subscription.id, {
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
});
break;
case 'customer.subscription.deleted':
// Mark subscription as canceled
await updateSubscriptionByStripeId(event.data.object.id, {
status: 'canceled',
});
break;
case 'invoice.payment_succeeded':
// Update subscription period after successful payment
const invoice = event.data.object;
if (invoice.subscription) {
await updateSubscriptionPeriod(invoice.subscription);
}
break;
case 'invoice.payment_failed':
// Handle failed payment
await handlePaymentFailure(event.data.object);
break;
}
res.json({ received: true });
});React Frontend Integration
Pricing Page
function PricingPage() {
const [billingInterval, setBillingInterval] = useState<'monthly' | 'yearly'>('monthly');
const handleSelectPlan = async (planId: string) => {
try {
const response = await api.post('/api/stripe/create-checkout-session', {
planId,
billingInterval,
});
// Redirect to Stripe Checkout
window.location.href = response.data.url;
} catch (error) {
console.error('Failed to create checkout session:', error);
}
};
return (
<div>
<button onClick={() => handleSelectPlan(plan.id)}>
Select Plan
</button>
</div>
);
}Billing Page
function BillingPage() {
const handleManageBilling = async () => {
try {
const response = await api.post('/api/stripe/create-portal-session');
window.location.href = response.data.url;
} catch (error) {
console.error('Failed to open portal:', error);
}
};
const handleCancelSubscription = async () => {
try {
await api.post('/api/subscription/cancel');
// Subscription will cancel at period end
} catch (error) {
console.error('Failed to cancel:', error);
}
};
return (
<div>
<button onClick={handleManageBilling}>
Manage Payment Method
</button>
<button onClick={handleCancelSubscription}>
Cancel Subscription
</button>
</div>
);
}Subscription Management
Cancel Subscription
router.post('/subscription/cancel', requireAuth, async (req, res) => {
const subscription = await getSubscription(req.user.id);
// Cancel at period end (user keeps access until then)
await stripe.subscriptions.update(subscription.stripeSubscriptionId, {
cancel_at_period_end: true,
});
await updateSubscription(subscription.id, {
cancelAtPeriodEnd: true,
});
res.json({ message: 'Subscription will cancel at period end' });
});Reactivate Subscription
router.post('/subscription/reactivate', requireAuth, async (req, res) => {
const subscription = await getSubscription(req.user.id);
await stripe.subscriptions.update(subscription.stripeSubscriptionId, {
cancel_at_period_end: false,
});
await updateSubscription(subscription.id, {
cancelAtPeriodEnd: false,
});
res.json({ message: 'Subscription reactivated' });
});Environment Variables
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
FRONTEND_URL=http://localhost:3000Database Schema
Store subscription data in your database:
// Users table
{
id: string;
email: string;
stripeCustomerId: string | null;
}
// Subscriptions table
{
id: string;
userId: string;
stripeSubscriptionId: string;
status: string; // active, canceled, past_due
planId: string;
billingInterval: 'monthly' | 'yearly';
currentPeriodStart: Date;
currentPeriodEnd: Date;
cancelAtPeriodEnd: boolean;
}
// Invoices table
{
id: string;
userId: string;
stripeInvoiceId: string;
amountPaid: number;
status: string;
invoicePdfUrl: string | null;
}Best Practices
- Always verify webhook signatures to prevent unauthorized requests
- Use metadata to link Stripe objects to your database records
- Handle all subscription lifecycle events (created, updated, deleted)
- Store Stripe IDs in your database for easy lookup
- Use cancel_at_period_end for better user experience
- Implement proration for plan changes
- Keep your database in sync with Stripe via webhooks
- Use environment variables for all Stripe keys
Conclusion
Stripe subscriptions provide a robust solution for handling recurring payments in SaaS applications. With checkout sessions, webhooks, and the customer portal, you can build a complete subscription system with minimal code. The key is keeping your database synchronized with Stripe through webhooks and providing a smooth user experience for subscription management.
Related Articles
JWT Authentication in Express.js and Node.js: Complete Guide
Learn how to implement JWT authentication with bcrypt password hashing and protected routes.
Express.js REST API Setup: Complete Guide with Error Handling
Learn how to set up a production-ready Express.js REST API with CORS and error handling.
Sequelize ORM with MySQL Setup: Complete Guide for Node.js
Complete guide with connection pooling, migrations, and best practices for database setup.
Redux Toolkit RTK Query: Complete Guide for React State Management
Learn how to use Redux Toolkit RTK Query for API data fetching and state management.