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.
📖 Want the complete guide with more examples and advanced patterns? Check out the full article on my blog for an in-depth tutorial with additional code examples, troubleshooting tips, and real-world use cases.
What is Stripe Subscriptions?
Stripe Subscriptions is a payment solution that handles:
- Recurring billing - Automatic monthly/yearly charges
- Subscription management - Upgrade, downgrade, cancel subscriptions
- Customer portal - Self-service billing management
- Webhooks - Real-time subscription lifecycle events
- Proration - Automatic billing adjustments for plan changes
- Multiple payment methods - Credit cards, ACH, and more
Installation and Setup
First, let's install Stripe:
npm install stripe
Configure 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:
// 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 });
});
Key Webhook Events
-
checkout.session.completed- New subscription created -
customer.subscription.updated- Plan changes, status updates -
customer.subscription.deleted- Subscription canceled -
invoice.payment_succeeded- Successful payment -
invoice.payment_failed- Failed payment
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' });
});
Database 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;
}
Environment Variables
Configure your .env file:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
FRONTEND_URL=http://localhost:3000
Important:
- Get your API keys from Stripe Dashboard
- Use test keys for development, live keys for production
- Get webhook secret from Stripe Dashboard → Webhooks → Add endpoint
Subscription Lifecycle
Understanding the subscription lifecycle is crucial:
- User selects plan → Create checkout session
-
User completes payment →
checkout.session.completedwebhook -
Subscription active →
customer.subscription.updated(status: active) -
Monthly/yearly billing →
invoice.payment_succeededwebhook -
Plan change →
customer.subscription.updatedwebhook -
Cancellation →
cancel_at_period_end: true -
Period ends →
customer.subscription.deletedwebhook
Best Practices
- Always verify webhook signatures - Prevent unauthorized requests
- Use metadata - Link Stripe objects to your database records
- Handle all subscription events - Keep database in sync
- Store Stripe IDs - For easy lookup and updates
-
Use
cancel_at_period_end- Better user experience - Implement proration - For plan changes
- Keep database synchronized - Via webhooks
- Use environment variables - Never hardcode API keys
- Test with Stripe CLI - Before deploying to production
- Handle payment failures - Notify users and retry logic
Webhook Signature Verification
Always verify webhook signatures to ensure requests are from Stripe:
const sig = req.headers['stripe-signature'];
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
try {
event = stripe.webhooks.constructEvent(req.body, sig!, webhookSecret!);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
Using Metadata
Metadata helps link Stripe objects to your database:
// When creating checkout session
metadata: { userId, planId, billingInterval }
// When creating customer
metadata: { userId }
// Access in webhooks
const userId = event.data.object.metadata.userId;
Testing with Stripe CLI
Test webhooks locally using Stripe CLI:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/stripe/webhook
Common Patterns
Handling Payment Failures
case 'invoice.payment_failed':
const failedInvoice = event.data.object;
const customer = await getCustomerByStripeId(failedInvoice.customer);
// Notify user
await sendEmail(customer.email, {
subject: 'Payment Failed',
body: 'Your payment failed. Please update your payment method.',
});
// Update subscription status
await updateSubscriptionByStripeId(failedInvoice.subscription, {
status: 'past_due',
});
break;
Plan Upgrade/Downgrade
// Upgrade with proration
await stripe.subscriptions.update(subscriptionId, {
items: [{ id: itemId, price: newPriceId }],
proration_behavior: 'create_prorations', // Charge difference immediately
});
// Downgrade (cancel at period end, then change)
await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
// On period end webhook, create new subscription with lower plan
Resources and Further Reading
- 📚 Full Stripe Subscription Guide - Complete tutorial with advanced examples, troubleshooting, and best practices
- JWT Authentication Guide - Add authentication to your subscription system
- Express.js REST API Setup - Set up your backend API
- Stripe Documentation - Official Stripe subscriptions docs
- Stripe API Reference - Complete API reference
- Stripe Webhooks Guide - Webhook best practices
- Stripe Customer Portal - Customer portal setup
- Sequelize ORM Setup - Database setup for storing subscriptions
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.
Key Takeaways:
- Stripe subscriptions handle recurring billing automatically
- Checkout sessions provide hosted payment pages
- Webhooks keep your database synchronized with Stripe
- Customer portal enables self-service billing management
- Proration handles plan changes automatically
- Metadata links Stripe objects to your database
- Webhook signature verification ensures security
- Cancel at period end provides better UX
Whether you're building a simple SaaS app or a complex multi-tier subscription system, this guide provides the foundation you need. Stripe handles the complexity of payments, while you focus on building great features.
What's your experience with Stripe subscriptions? Share your tips and tricks in the comments below! 🚀
💡 Looking for more details? This is a condensed version of my comprehensive guide. Read the full article on my blog for additional examples, advanced patterns, troubleshooting tips, and more in-depth explanations.
If you found this guide helpful, consider checking out my other articles on Node.js development and payment integration best practices.
Top comments (0)