Back to Blog
Node.js20 min read

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 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.