DEV Community

Cover image for Stripe Subscription Payment Implementation: Complete Guide for SaaS Applications
Md. Maruf Rahman
Md. Maruf Rahman

Posted on • Originally published at marufrahman.live

Stripe Subscription Payment Implementation: Complete Guide for SaaS Applications

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
Enter fullscreen mode Exit fullscreen mode

Configure Stripe with your API keys:

import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2025-02-24.acacia',
});
Enter fullscreen mode Exit fullscreen mode

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',
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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' });
  }
});
Enter fullscreen mode Exit fullscreen mode

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' });
  }
});
Enter fullscreen mode Exit fullscreen mode

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' });
  }
});
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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' });
});
Enter fullscreen mode Exit fullscreen mode

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' });
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Environment Variables

Configure your .env file:

STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
FRONTEND_URL=http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

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:

  1. User selects plan → Create checkout session
  2. User completes paymentcheckout.session.completed webhook
  3. Subscription activecustomer.subscription.updated (status: active)
  4. Monthly/yearly billinginvoice.payment_succeeded webhook
  5. Plan changecustomer.subscription.updated webhook
  6. Cancellationcancel_at_period_end: true
  7. Period endscustomer.subscription.deleted webhook

Best Practices

  1. Always verify webhook signatures - Prevent unauthorized requests
  2. Use metadata - Link Stripe objects to your database records
  3. Handle all subscription events - Keep database in sync
  4. Store Stripe IDs - For easy lookup and updates
  5. Use cancel_at_period_end - Better user experience
  6. Implement proration - For plan changes
  7. Keep database synchronized - Via webhooks
  8. Use environment variables - Never hardcode API keys
  9. Test with Stripe CLI - Before deploying to production
  10. 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}`);
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Resources and Further Reading

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)