Back to Blog
Node.js20 min read

Stripe Subscription Payment – Complete Guide

Learn how to implement Stripe subscription payments in Node.js and React. Complete guide with 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 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 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:3000

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; }

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.