Home/Blog/Creating a Stripe Subscription System with Webhooks: Complete Tutorial

Creating a Stripe Subscription System with Webhooks: Complete Tutorial

Building a Robust Subscription System: What We’re Creating

In this comprehensive tutorial, we’ll build a production-ready subscription system using Stripe’s powerful API and webhook infrastructure. By the end of this guide, you’ll have a complete subscription platform that can handle recurring payments, manage customer lifecycles, and process webhook events in real-time.

Our system will include user authentication, subscription plan selection, payment processing, webhook handling for subscription events, and a customer dashboard. This isn’t a toy project—we’re building something you could deploy for a real SaaS business today.

Industry Insight: According to Stripe’s 2023 data, businesses using webhooks see 23% fewer failed payments and 18% higher customer retention rates compared to those relying solely on polling-based systems.

The subscription economy has exploded, with recurring revenue models now representing over $435 billion globally. Whether you’re building a content platform like Beehiiv or a productivity tool, understanding how to implement robust subscription systems is crucial for modern SaaS success.

Prerequisites and Tech Stack

Before diving into implementation, ensure you have the following setup:

Required Tools and Accounts

  • Stripe Account: Test and live API keys
  • Node.js: Version 16+ installed locally
  • Database: PostgreSQL or MongoDB (we’ll use PostgreSQL)
  • Development Environment: VS Code or similar
  • Webhook Testing: ngrok for local development

Tech Stack Overview

Component Technology Purpose
Backend Framework Node.js + Express API and webhook handling
Database PostgreSQL + Prisma ORM User and subscription data
Payment Processing Stripe API v2023-10-16 Subscription management
Authentication JWT + bcrypt User sessions
Frontend React + Stripe Elements User interface

Environment Setup

Create a new project directory and initialize your Node.js application:

mkdir stripe-subscription-system
cd stripe-subscription-system
npm init -y
npm install express stripe prisma @prisma/client bcryptjs jsonwebtoken cors dotenv
npm install -D nodemon @types/node

Step-by-Step Implementation

Step 1: Database Schema Design

First, let’s design our database schema using Prisma. Create a schema.prisma file:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  password  String
  stripeCustomerId String? @unique
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  
  subscriptions Subscription[]
}

model Subscription {
  id                 String            @id @default(cuid())
  stripeSubscriptionId String         @unique
  userId             String
  status             SubscriptionStatus
  priceId            String
  currentPeriodStart DateTime
  currentPeriodEnd   DateTime
  cancelAtPeriodEnd  Boolean          @default(false)
  createdAt          DateTime         @default(now())
  updatedAt          DateTime         @updatedAt
  
  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

enum SubscriptionStatus {
  INCOMPLETE
  INCOMPLETE_EXPIRED
  TRIALING
  ACTIVE
  PAST_DUE
  CANCELED
  UNPAID
}

Step 2: Stripe Configuration and Products

Set up your environment variables in a .env file:

DATABASE_URL="postgresql://username:password@localhost:5432/subscription_db"
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_PUBLISHABLE_KEY="pk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
JWT_SECRET="your-super-secret-jwt-key"
PORT=3001

Create your subscription products in Stripe. For this tutorial, we’ll assume you have three pricing tiers:

  • Starter: $9/month (price_1234starter)
  • Professional: $29/month (price_1234professional)
  • Enterprise: $99/month (price_1234enterprise)

Step 3: Core Server Setup

Create the main server file server.js:

const express = require('express');
const cors = require('cors');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const { PrismaClient } = require('@prisma/client');
require('dotenv').config();

const app = express();
const prisma = new PrismaClient();

// Middleware
app.use(cors());
app.use('/webhook', express.raw({ type: 'application/json' }));
app.use(express.json());

// Routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/subscriptions', require('./routes/subscriptions'));
app.use('/api/webhooks', require('./routes/webhooks'));

const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Step 4: Authentication System

Create routes/auth.js for user registration and login:

const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { PrismaClient } = require('@prisma/client');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

const router = express.Router();
const prisma = new PrismaClient();

// Register
router.post('/register', async (req, res) => {
  try {
    const { email, password } = req.body;
    
    // Check if user exists
    const existingUser = await prisma.user.findUnique({
      where: { email }
    });
    
    if (existingUser) {
      return res.status(400).json({ error: 'User already exists' });
    }
    
    // Hash password
    const hashedPassword = await bcrypt.hash(password, 12);
    
    // Create Stripe customer
    const stripeCustomer = await stripe.customers.create({
      email: email,
      metadata: {
        source: 'subscription_app'
      }
    });
    
    // Create user
    const user = await prisma.user.create({
      data: {
        email,
        password: hashedPassword,
        stripeCustomerId: stripeCustomer.id
      }
    });
    
    // Generate JWT
    const token = jwt.sign(
      { userId: user.id, email: user.email },
      process.env.JWT_SECRET,
      { expiresIn: '7d' }
    );
    
    res.status(201).json({
      token,
      user: {
        id: user.id,
        email: user.email,
        stripeCustomerId: user.stripeCustomerId
      }
    });
  } catch (error) {
    console.error('Registration error:', error);
    res.status(500).json({ error: 'Registration failed' });
  }
});

module.exports = router;

Step 5: Subscription Management

Create routes/subscriptions.js to handle subscription creation and management:

const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const { PrismaClient } = require('@prisma/client');
const authMiddleware = require('../middleware/auth');

const router = express.Router();
const prisma = new PrismaClient();

// Create subscription
router.post('/create', authMiddleware, async (req, res) => {
  try {
    const { priceId, paymentMethodId } = req.body;
    const userId = req.user.userId;
    
    const user = await prisma.user.findUnique({
      where: { id: userId }
    });
    
    if (!user.stripeCustomerId) {
      return res.status(400).json({ error: 'No Stripe customer found' });
    }
    
    // Attach payment method to customer
    await stripe.paymentMethods.attach(paymentMethodId, {
      customer: user.stripeCustomerId,
    });
    
    // Set as default payment method
    await stripe.customers.update(user.stripeCustomerId, {
      invoice_settings: {
        default_payment_method: paymentMethodId,
      },
    });
    
    // Create subscription
    const subscription = await stripe.subscriptions.create({
      customer: user.stripeCustomerId,
      items: [{ price: priceId }],
      payment_behavior: 'default_incomplete',
      payment_settings: { save_default_payment_method: 'on_subscription' },
      expand: ['latest_invoice.payment_intent'],
      metadata: {
        userId: userId
      }
    });
    
    res.json({
      subscriptionId: subscription.id,
      clientSecret: subscription.latest_invoice.payment_intent.client_secret
    });
  } catch (error) {
    console.error('Subscription creation error:', error);
    res.status(500).json({ error: error.message });
  }
});

// Get user subscriptions
router.get('/mine', authMiddleware, async (req, res) => {
  try {
    const userId = req.user.userId;
    
    const subscriptions = await prisma.subscription.findMany({
      where: { userId },
      orderBy: { createdAt: 'desc' }
    });
    
    res.json(subscriptions);
  } catch (error) {
    console.error('Fetch subscriptions error:', error);
    res.status(500).json({ error: 'Failed to fetch subscriptions' });
  }
});

module.exports = router;

Step 6: Webhook Implementation

The webhook system is the heart of our subscription management. Create routes/webhooks.js:

const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const { PrismaClient } = require('@prisma/client');

const router = express.Router();
const prisma = new PrismaClient();

router.post('/stripe', async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;
  
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    console.error(`Webhook signature verification failed:`, err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }
  
  try {
    switch (event.type) {
      case 'customer.subscription.created':
        await handleSubscriptionCreated(event.data.object);
        break;
      case 'customer.subscription.updated':
        await handleSubscriptionUpdated(event.data.object);
        break;
      case 'customer.subscription.deleted':
        await handleSubscriptionDeleted(event.data.object);
        break;
      case 'invoice.payment_succeeded':
        await handlePaymentSucceeded(event.data.object);
        break;
      case 'invoice.payment_failed':
        await handlePaymentFailed(event.data.object);
        break;
      default:
        console.log(`Unhandled event type: ${event.type}`);
    }
    
    res.json({ received: true });
  } catch (error) {
    console.error('Webhook processing error:', error);
    res.status(500).json({ error: 'Webhook processing failed' });
  }
});

async function handleSubscriptionCreated(subscription) {
  const userId = subscription.metadata.userId;
  
  if (!userId) {
    console.error('No userId in subscription metadata');
    return;
  }
  
  await prisma.subscription.create({
    data: {
      stripeSubscriptionId: subscription.id,
      userId: userId,
      status: subscription.status.toUpperCase(),
      priceId: subscription.items.data[0].price.id,
      currentPeriodStart: new Date(subscription.current_period_start * 1000),
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end
    }
  });
}

async function handleSubscriptionUpdated(subscription) {
  await prisma.subscription.update({
    where: { stripeSubscriptionId: subscription.id },
    data: {
      status: subscription.status.toUpperCase(),
      currentPeriodStart: new Date(subscription.current_period_start * 1000),
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end
    }
  });
}

module.exports = router;

Step 7: Frontend Integration

Create a React component for subscription management. This example shows the payment form integration:

import React, { useState } from 'react';
import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js';

const SubscriptionForm = ({ priceId, onSuccess }) => {
  const stripe = useStripe();
  const elements = useElements();
  const [loading, setLoading] = useState(false);
  
  const handleSubmit = async (event) => {
    event.preventDefault();
    setLoading(true);
    
    if (!stripe || !elements) return;
    
    const cardElement = elements.getElement(CardElement);
    
    // Create payment method
    const { error, paymentMethod } = await stripe.createPaymentMethod({
      type: 'card',
      card: cardElement,
    });
    
    if (error) {
      console.error('Payment method creation failed:', error);
      setLoading(false);
      return;
    }
    
    // Create subscription
    const response = await fetch('/api/subscriptions/create', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${localStorage.getItem('token')}`
      },
      body: JSON.stringify({
        priceId,
        paymentMethodId: paymentMethod.id
      })
    });
    
    const { clientSecret } = await response.json();
    
    // Confirm payment
    const { error: confirmError } = await stripe.confirmCardPayment(clientSecret);
    
    if (confirmError) {
      console.error('Payment confirmation failed:', confirmError);
    } else {
      onSuccess();
    }
    
    setLoading(false);
  };
  
  return (
    
      
      
    
  );
};

Testing and Validation

Local Development Testing

Use ngrok to expose your local server for webhook testing:

ngrok http 3001

Configure your Stripe webhook endpoint to point to your ngrok URL: https://your-ngrok-url.ngrok.io/api/webhooks/stripe

Test Scenarios

Test these critical subscription flows:

  1. Successful Subscription Creation: Use test card 4242 4242 4242 4242
  2. Failed Payment: Use test card 4000 0000 0000 0002
  3. Subscription Cancellation: Test immediate and end-of-period cancellation
  4. Plan Upgrades/Downgrades: Verify prorations work correctly
  5. Webhook Reliability: Ensure idempotency and proper error handling

Testing Tip: Stripe’s webhook testing tool in the dashboard is invaluable for debugging webhook events. Use it to replay events and verify your handlers work correctly.

Monitoring and Analytics

Integrate with analytics tools like Google Analytics to track subscription metrics. Key events to monitor include:

  • Subscription starts and cancellations
  • Payment failures and recoveries
  • Plan upgrade/downgrade patterns
  • Customer lifetime value trends

Deployment Considerations

Production Environment Setup

When deploying to production, ensure you:

  • Use Live Stripe Keys: Switch from test to live API keys
  • Secure Environment Variables: Use proper secret management
  • Database Migrations: Run Prisma migrations safely
  • SSL/HTTPS: Required for Stripe webhooks in production
  • Error Monitoring: Implement logging and alerting

Scaling Considerations

Component Scaling Strategy Considerations
Webhook Processing Queue-based processing Use Redis/Bull for job queues
Database Read replicas + connection pooling Monitor query performance
API Rate Limits Implement rate limiting Stripe has built-in rate limits
Monitoring APM tools + custom metrics Track MRR, churn, failed payments

Enhancement Ideas and Advanced Features

Customer Communication

Integrate with email marketing platforms like ConvertKit or HubSpot to automate customer communications:

  • Welcome sequences for new subscribers
  • Payment failure notifications
  • Upgrade prompts based on usage
  • Cancellation feedback collection

Advanced Subscription Features

Consider implementing these advanced features:

  • Usage-Based Billing: Metered billing for API calls or storage
  • Proration Handling: Smart upgrade/downgrade logic
  • Multi-Seat Management: Team and organization billing
  • Dunning Management: Automated retry logic for failed payments
  • Revenue Recognition: Proper accounting for subscription revenue

Customer Self-Service Portal

Build a comprehensive customer portal using Stripe’s Customer Portal or create a custom solution with features like:

  • Subscription plan changes
  • Payment method updates
  • Billing history and invoice downloads
  • Usage analytics and limits
  • Cancellation flow with retention offers

Frequently Asked Questions

How do I handle failed payments and dunning?

Stripe automatically retries failed payments based on your dashboard settings. Implement webhook handlers for invoice.payment_failed events to trigger customer notifications and account restrictions. Consider implementing a grace period before downgrading service levels.

What’s the best way to handle subscription upgrades and downgrades?

Use Stripe’s subscription modification API with proration enabled. When upgrading, customers pay the prorated difference immediately. For downgrades, you can either apply credits or wait until the next billing cycle. Always update your local database to reflect the changes.

How can I prevent webhook replay attacks?

Stripe includes a timestamp in webhook headers. Reject webhooks older than 5 minutes to prevent replay attacks. Additionally, implement idempotency keys in your webhook handlers to safely process duplicate events without side effects.

Should I store payment information in my database?

Never store sensitive payment information like card numbers in your database. Store only Stripe IDs (customer ID, subscription ID, payment method ID) and let Stripe handle the sensitive data. This approach maintains PCI compliance and reduces security risks.

Building a robust subscription system requires careful attention to webhook handling, error management, and customer experience. The implementation we’ve covered provides a solid foundation that can scale with your business needs. For businesses looking to accelerate their subscription platform development with expert implementation and ongoing optimization, consider partnering with futia.io’s automation services to ensure your subscription system is built for long-term success and growth.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *