Integrating Stripe Billing with a Node.js Express Backend

Integrating Stripe Billing with a Node.js Express Backend

The Problem

Building a subscription-based SaaS requires more than just accepting one-time payments. You need recurring billing, subscription management, usage-based pricing, proration calculations, invoice generation, payment retry logic, and webhook handling for subscription state changes. Manually building this infrastructure is complex and error-prone—calculating prorations incorrectly costs revenue, missing failed payment events leads to unauthorized access, and poor webhook handling creates billing inconsistencies. Stripe handles the payment infrastructure, but integrating it properly requires understanding webhook signatures, idempotency keys, asynchronous event processing, and secure customer data storage. You need endpoints that create customers, manage subscriptions, handle upgrades/downgrades, process webhooks for payment failures, and sync subscription status with your database. This tutorial provides production-ready code for a complete Stripe billing integration with subscription lifecycle management, webhook event processing, and proper error handling.

Tech Stack & Prerequisites

  • Node.js v18+ with npm
  • Express.js 4.18+ for API server
  • Stripe Node.js SDK 14.0+ (stripe npm package)
  • PostgreSQL 14+ or MongoDB for storing customer/subscription data
  • dotenv for environment variables
  • Stripe Account (free test mode available at stripe.com)
  • Stripe CLI for local webhook testing (optional but recommended)
  • body-parser for parsing webhook raw bodies
  • pg 8.11+ for PostgreSQL connection

Required Stripe Setup:

  • Stripe account with API keys (test mode)
  • Products and Prices created in Stripe Dashboard
  • Webhook endpoint configured (for production)
  • Payment methods enabled (card required minimum)

Required Database Setup:

  • PostgreSQL database with tables for customers and subscriptions
  • Connection credentials with INSERT, UPDATE, SELECT privileges

Step-by-Step Implementation

Step 1: Setup

Initialize the project:

bash
mkdir stripe-billing-integration
cd stripe-billing-integration
npm init -y
npm install express stripe dotenv pg body-parser
npm install --save-dev nodemon

Create project structure:

bash
mkdir src routes controllers webhooks db config
touch src/server.js routes/billing.js controllers/stripeController.js
touch webhooks/stripeWebhooks.js db/database.js config/stripe.js
touch .env .gitignore
```

Your structure should be:
```
stripe-billing-integration/
├── src/
│   └── server.js
├── routes/
│   └── billing.js
├── controllers/
│   └── stripeController.js
├── webhooks/
│   └── stripeWebhooks.js
├── db/
│   ├── database.js
│   └── schema.sql
├── config/
│   └── stripe.js
├── .env
├── .gitignore
└── package.json

db/schema.sql — Create database schema:

sql
-- Customers table
CREATE TABLE IF NOT EXISTS customers (
    id SERIAL PRIMARY KEY,
    user_id INTEGER UNIQUE NOT NULL, -- Your app's user ID
    stripe_customer_id VARCHAR(255) UNIQUE NOT NULL,
    email VARCHAR(255) NOT NULL,
    name VARCHAR(255),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Subscriptions table
CREATE TABLE IF NOT EXISTS subscriptions (
    id SERIAL PRIMARY KEY,
    customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
    stripe_subscription_id VARCHAR(255) UNIQUE NOT NULL,
    stripe_customer_id VARCHAR(255) NOT NULL,
    status VARCHAR(50) NOT NULL, -- active, canceled, past_due, etc.
    plan_id VARCHAR(255), -- Stripe price ID
    plan_name VARCHAR(255),
    current_period_start TIMESTAMP,
    current_period_end TIMESTAMP,
    cancel_at_period_end BOOLEAN DEFAULT false,
    canceled_at TIMESTAMP,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Payment history table
CREATE TABLE IF NOT EXISTS payment_history (
    id SERIAL PRIMARY KEY,
    customer_id INTEGER REFERENCES customers(id),
    stripe_invoice_id VARCHAR(255),
    stripe_payment_intent_id VARCHAR(255),
    amount INTEGER, -- Amount in cents
    currency VARCHAR(10) DEFAULT 'usd',
    status VARCHAR(50), -- paid, failed, pending
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Webhook events log (for idempotency)
CREATE TABLE IF NOT EXISTS webhook_events (
    id SERIAL PRIMARY KEY,
    stripe_event_id VARCHAR(255) UNIQUE NOT NULL,
    event_type VARCHAR(100) NOT NULL,
    processed BOOLEAN DEFAULT false,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Indexes for performance
CREATE INDEX idx_stripe_customer_id ON customers(stripe_customer_id);
CREATE INDEX idx_stripe_subscription_id ON subscriptions(stripe_subscription_id);
CREATE INDEX idx_user_id ON customers(user_id);
CREATE INDEX idx_webhook_event_id ON webhook_events(stripe_event_id);

Run schema:

bash
psql -U your_username -d your_database -f db/schema.sql

package.json — Add scripts:

json
{
  "name": "stripe-billing-integration",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js",
    "stripe:listen": "stripe listen --forward-to localhost:3000/webhooks/stripe"
  },
  "dependencies": {
    "body-parser": "^1.20.2",
    "dotenv": "^16.3.1",
    "express": "^4.18.2",
    "pg": "^8.11.3",
    "stripe": "^14.0.0"
  },
  "devDependencies": {
    "nodemon": "^3.0.1"
  }
}

Update .gitignore:

bash
echo "node_modules/
.env
*.log
.DS_Store" > .gitignore

Step 2: Configuration

.env — Store credentials securely:

env
# Stripe Configuration
STRIPE_SECRET_KEY=sk_test_your_secret_key_here
STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here

# PostgreSQL Configuration
PG_HOST=localhost
PG_PORT=5432
PG_DATABASE=stripe_billing
PG_USER=your_username
PG_PASSWORD=your_password

# Server Configuration
PORT=3000
NODE_ENV=development

# Stripe Price IDs (create these in Stripe Dashboard)
STRIPE_PRICE_BASIC=price_basic_monthly_id
STRIPE_PRICE_PRO=price_pro_monthly_id
STRIPE_PRICE_ENTERPRISE=price_enterprise_monthly_id

How to get Stripe keys:

  1. Go to https://dashboard.stripe.com/test/apikeys
  2. Copy Secret key (starts with sk_test_)
  3. Copy Publishable key (starts with pk_test_)
  4. For webhook secret (local testing):
bash
   stripe login
   stripe listen --forward-to localhost:3000/webhooks/stripe
   # Copy the webhook signing secret (whsec_...)

config/stripe.js — Initialize Stripe client:

javascript
import Stripe from 'stripe';
import dotenv from 'dotenv';

dotenv.config();

// Initialize Stripe with API version pinning
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: '2023-10-16', // Pin API version for consistency
});

// Common Stripe configurations
export const stripeConfig = {
  currency: 'usd',
  successUrl: 'http://localhost:3000/success',
  cancelUrl: 'http://localhost:3000/cancel',
};

// Price IDs for subscription plans
export const pricingPlans = {
  basic: process.env.STRIPE_PRICE_BASIC,
  pro: process.env.STRIPE_PRICE_PRO,
  enterprise: process.env.STRIPE_PRICE_ENTERPRISE,
};

export default stripe;

db/database.js — Database connection and helpers:

javascript
import pg from 'pg';
import dotenv from 'dotenv';

dotenv.config();

const { Pool } = pg;

// Create connection pool
const pool = new Pool({
  host: process.env.PG_HOST,
  port: process.env.PG_PORT,
  database: process.env.PG_DATABASE,
  user: process.env.PG_USER,
  password: process.env.PG_PASSWORD,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

// Test database connection
export async function testConnection() {
  try {
    const client = await pool.connect();
    console.log('✓ PostgreSQL connected successfully');
    client.release();
    return true;
  } catch (error) {
    console.error('✗ PostgreSQL connection failed:', error.message);
    return false;
  }
}

// Customer operations
export async function createCustomer(userId, stripeCustomerId, email, name) {
  const query = `
    INSERT INTO customers (user_id, stripe_customer_id, email, name)
    VALUES ($1, $2, $3, $4)
    ON CONFLICT (user_id) 
    DO UPDATE SET 
      stripe_customer_id = EXCLUDED.stripe_customer_id,
      email = EXCLUDED.email,
      name = EXCLUDED.name,
      updated_at = CURRENT_TIMESTAMP
    RETURNING *
  `;
  
  const result = await pool.query(query, [userId, stripeCustomerId, email, name]);
  return result.rows[0];
}

export async function getCustomerByUserId(userId) {
  const query = 'SELECT * FROM customers WHERE user_id = $1';
  const result = await pool.query(query, [userId]);
  return result.rows[0];
}

export async function getCustomerByStripeId(stripeCustomerId) {
  const query = 'SELECT * FROM customers WHERE stripe_customer_id = $1';
  const result = await pool.query(query, [stripeCustomerId]);
  return result.rows[0];
}

// Subscription operations
export async function createSubscription(customerId, subscriptionData) {
  const query = `
    INSERT INTO subscriptions (
      customer_id, stripe_subscription_id, stripe_customer_id,
      status, plan_id, plan_name, current_period_start, current_period_end
    ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
    ON CONFLICT (stripe_subscription_id)
    DO UPDATE SET
      status = EXCLUDED.status,
      current_period_start = EXCLUDED.current_period_start,
      current_period_end = EXCLUDED.current_period_end,
      updated_at = CURRENT_TIMESTAMP
    RETURNING *
  `;
  
  const values = [
    customerId,
    subscriptionData.stripeSubscriptionId,
    subscriptionData.stripeCustomerId,
    subscriptionData.status,
    subscriptionData.planId,
    subscriptionData.planName,
    subscriptionData.currentPeriodStart,
    subscriptionData.currentPeriodEnd,
  ];
  
  const result = await pool.query(query, values);
  return result.rows[0];
}

export async function updateSubscriptionStatus(stripeSubscriptionId, status, cancelAtPeriodEnd = false) {
  const query = `
    UPDATE subscriptions 
    SET status = $1, 
        cancel_at_period_end = $2,
        updated_at = CURRENT_TIMESTAMP
    WHERE stripe_subscription_id = $3
    RETURNING *
  `;
  
  const result = await pool.query(query, [status, cancelAtPeriodEnd, stripeSubscriptionId]);
  return result.rows[0];
}

export async function getSubscriptionByStripeId(stripeSubscriptionId) {
  const query = 'SELECT * FROM subscriptions WHERE stripe_subscription_id = $1';
  const result = await pool.query(query, [stripeSubscriptionId]);
  return result.rows[0];
}

export async function getActiveSubscriptionByUserId(userId) {
  const query = `
    SELECT s.* FROM subscriptions s
    JOIN customers c ON s.customer_id = c.id
    WHERE c.user_id = $1 AND s.status IN ('active', 'trialing')
    ORDER BY s.created_at DESC
    LIMIT 1
  `;
  
  const result = await pool.query(query, [userId]);
  return result.rows[0];
}

// Payment history
export async function logPayment(customerId, paymentData) {
  const query = `
    INSERT INTO payment_history (
      customer_id, stripe_invoice_id, stripe_payment_intent_id,
      amount, currency, status
    ) VALUES ($1, $2, $3, $4, $5, $6)
    RETURNING *
  `;
  
  const values = [
    customerId,
    paymentData.invoiceId,
    paymentData.paymentIntentId,
    paymentData.amount,
    paymentData.currency || 'usd',
    paymentData.status,
  ];
  
  const result = await pool.query(query, values);
  return result.rows[0];
}

// Webhook event tracking (for idempotency)
export async function isEventProcessed(eventId) {
  const query = 'SELECT processed FROM webhook_events WHERE stripe_event_id = $1';
  const result = await pool.query(query, [eventId]);
  return result.rows[0]?.processed || false;
}

export async function markEventProcessed(eventId, eventType) {
  const query = `
    INSERT INTO webhook_events (stripe_event_id, event_type, processed)
    VALUES ($1, $2, true)
    ON CONFLICT (stripe_event_id) 
    DO UPDATE SET processed = true
  `;
  
  await pool.query(query, [eventId, eventType]);
}

export default pool;

Step 3: Core Logic

controllers/stripeController.js — Subscription management logic:

javascript
import { stripe, pricingPlans } from '../config/stripe.js';
import {
  createCustomer,
  getCustomerByUserId,
  createSubscription,
  updateSubscriptionStatus,
  getActiveSubscriptionByUserId,
} from '../db/database.js';

// Create or retrieve Stripe customer
export async function getOrCreateCustomer(userId, email, name) {
  // Check if customer already exists in database
  let customer = await getCustomerByUserId(userId);
  
  if (customer) {
    return customer.stripe_customer_id;
  }
  
  // Create new Stripe customer
  const stripeCustomer = await stripe.customers.create({
    email,
    name,
    metadata: {
      userId: userId.toString(),
    },
  });
  
  // Save to database
  customer = await createCustomer(userId, stripeCustomer.id, email, name);
  
  return stripeCustomer.id;
}

// Create subscription checkout session
export async function createCheckoutSession(userId, email, name, priceId) {
  try {
    // Get or create customer
    const customerId = await getOrCreateCustomer(userId, email, name);
    
    // Create Checkout Session
    const session = await stripe.checkout.sessions.create({
      customer: customerId,
      payment_method_types: ['card'],
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      mode: 'subscription',
      success_url: `${process.env.FRONTEND_URL || 'http://localhost:3000'}/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.FRONTEND_URL || 'http://localhost:3000'}/cancel`,
      metadata: {
        userId: userId.toString(),
      },
    });
    
    return session;
  } catch (error) {
    console.error('Error creating checkout session:', error);
    throw error;
  }
}

// Create subscription programmatically (without Checkout)
export async function createSubscriptionDirect(userId, email, name, priceId, paymentMethodId) {
  try {
    // Get or create customer
    const customerId = await getOrCreateCustomer(userId, email, name);
    
    // Attach payment method to customer
    await stripe.paymentMethods.attach(paymentMethodId, {
      customer: customerId,
    });
    
    // Set as default payment method
    await stripe.customers.update(customerId, {
      invoice_settings: {
        default_payment_method: paymentMethodId,
      },
    });
    
    // Create subscription
    const subscription = await stripe.subscriptions.create({
      customer: customerId,
      items: [{ price: priceId }],
      expand: ['latest_invoice.payment_intent'],
      metadata: {
        userId: userId.toString(),
      },
    });
    
    // Save to database
    const dbCustomer = await getCustomerByUserId(userId);
    await createSubscription(dbCustomer.id, {
      stripeSubscriptionId: subscription.id,
      stripeCustomerId: customerId,
      status: subscription.status,
      planId: priceId,
      planName: subscription.items.data[0].price.nickname || 'Subscription',
      currentPeriodStart: new Date(subscription.current_period_start * 1000),
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    });
    
    return subscription;
  } catch (error) {
    console.error('Error creating subscription:', error);
    throw error;
  }
}

// Cancel subscription
export async function cancelSubscription(userId, cancelAtPeriodEnd = true) {
  try {
    const subscription = await getActiveSubscriptionByUserId(userId);
    
    if (!subscription) {
      throw new Error('No active subscription found');
    }
    
    // Cancel in Stripe
    const updated = await stripe.subscriptions.update(subscription.stripe_subscription_id, {
      cancel_at_period_end: cancelAtPeriodEnd,
    });
    
    // Update database
    await updateSubscriptionStatus(
      subscription.stripe_subscription_id,
      updated.status,
      cancelAtPeriodEnd
    );
    
    return updated;
  } catch (error) {
    console.error('Error canceling subscription:', error);
    throw error;
  }
}

// Resume canceled subscription
export async function resumeSubscription(userId) {
  try {
    const subscription = await getActiveSubscriptionByUserId(userId);
    
    if (!subscription || !subscription.cancel_at_period_end) {
      throw new Error('No subscription to resume');
    }
    
    // Resume in Stripe
    const updated = await stripe.subscriptions.update(subscription.stripe_subscription_id, {
      cancel_at_period_end: false,
    });
    
    // Update database
    await updateSubscriptionStatus(subscription.stripe_subscription_id, updated.status, false);
    
    return updated;
  } catch (error) {
    console.error('Error resuming subscription:', error);
    throw error;
  }
}

// Upgrade/downgrade subscription
export async function changeSubscriptionPlan(userId, newPriceId) {
  try {
    const subscription = await getActiveSubscriptionByUserId(userId);
    
    if (!subscription) {
      throw new Error('No active subscription found');
    }
    
    // Get current subscription from Stripe
    const stripeSubscription = await stripe.subscriptions.retrieve(
      subscription.stripe_subscription_id
    );
    
    // Update subscription with new price
    const updated = await stripe.subscriptions.update(subscription.stripe_subscription_id, {
      items: [
        {
          id: stripeSubscription.items.data[0].id,
          price: newPriceId,
        },
      ],
      proration_behavior: 'create_prorations', // Prorate charges
    });
    
    // Update database
    await createSubscription(subscription.customer_id, {
      stripeSubscriptionId: updated.id,
      stripeCustomerId: updated.customer,
      status: updated.status,
      planId: newPriceId,
      planName: updated.items.data[0].price.nickname || 'Subscription',
      currentPeriodStart: new Date(updated.current_period_start * 1000),
      currentPeriodEnd: new Date(updated.current_period_end * 1000),
    });
    
    return updated;
  } catch (error) {
    console.error('Error changing plan:', error);
    throw error;
  }
}

// Get subscription details
export async function getSubscriptionDetails(userId) {
  try {
    const subscription = await getActiveSubscriptionByUserId(userId);
    
    if (!subscription) {
      return null;
    }
    
    // Fetch latest from Stripe
    const stripeSubscription = await stripe.subscriptions.retrieve(
      subscription.stripe_subscription_id
    );
    
    return {
      id: stripeSubscription.id,
      status: stripeSubscription.status,
      currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
      cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
      plan: stripeSubscription.items.data[0].price,
    };
  } catch (error) {
    console.error('Error fetching subscription:', error);
    throw error;
  }
}

routes/billing.js — API routes for billing operations:

javascript
import express from 'express';
import {
  createCheckoutSession,
  createSubscriptionDirect,
  cancelSubscription,
  resumeSubscription,
  changeSubscriptionPlan,
  getSubscriptionDetails,
} from '../controllers/stripeController.js';
import { pricingPlans } from '../config/stripe.js';

const router = express.Router();

// Middleware to extract userId from auth (simplified - add real auth)
function authenticateUser(req, res, next) {
  // In production, verify JWT token or session
  const userId = req.headers['x-user-id'] || req.query.userId;
  
  if (!userId) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  
  req.userId = parseInt(userId);
  next();
}

// POST /api/billing/checkout - Create checkout session
router.post('/checkout', authenticateUser, async (req, res) => {
  try {
    const { email, name, plan } = req.body;
    
    if (!email || !plan) {
      return res.status(400).json({ error: 'Email and plan are required' });
    }
    
    const priceId = pricingPlans[plan];
    
    if (!priceId) {
      return res.status(400).json({ error: 'Invalid plan selected' });
    }
    
    const session = await createCheckoutSession(req.userId, email, name, priceId);
    
    res.json({
      success: true,
      sessionId: session.id,
      url: session.url,
    });
  } catch (error) {
    console.error('Checkout error:', error);
    res.status(500).json({ error: error.message });
  }
});

// POST /api/billing/subscribe - Create subscription directly
router.post('/subscribe', authenticateUser, async (req, res) => {
  try {
    const { email, name, plan, paymentMethodId } = req.body;
    
    if (!email || !plan || !paymentMethodId) {
      return res.status(400).json({ error: 'Missing required fields' });
    }
    
    const priceId = pricingPlans[plan];
    
    if (!priceId) {
      return res.status(400).json({ error: 'Invalid plan' });
    }
    
    const subscription = await createSubscriptionDirect(
      req.userId,
      email,
      name,
      priceId,
      paymentMethodId
    );
    
    res.json({
      success: true,
      subscription: {
        id: subscription.id,
        status: subscription.status,
        clientSecret: subscription.latest_invoice.payment_intent.client_secret,
      },
    });
  } catch (error) {
    console.error('Subscription error:', error);
    res.status(500).json({ error: error.message });
  }
});

// POST /api/billing/cancel - Cancel subscription
router.post('/cancel', authenticateUser, async (req, res) => {
  try {
    const { immediate } = req.body;
    
    const subscription = await cancelSubscription(req.userId, !immediate);
    
    res.json({
      success: true,
      subscription: {
        id: subscription.id,
        status: subscription.status,
        cancelAtPeriodEnd: subscription.cancel_at_period_end,
      },
    });
  } catch (error) {
    console.error('Cancel error:', error);
    res.status(500).json({ error: error.message });
  }
});

// POST /api/billing/resume - Resume canceled subscription
router.post('/resume', authenticateUser, async (req, res) => {
  try {
    const subscription = await resumeSubscription(req.userId);
    
    res.json({
      success: true,
      subscription: {
        id: subscription.id,
        status: subscription.status,
      },
    });
  } catch (error) {
    console.error('Resume error:', error);
    res.status(500).json({ error: error.message });
  }
});

// POST /api/billing/change-plan - Upgrade/downgrade
router.post('/change-plan', authenticateUser, async (req, res) => {
  try {
    const { newPlan } = req.body;
    
    const priceId = pricingPlans[newPlan];
    
    if (!priceId) {
      return res.status(400).json({ error: 'Invalid plan' });
    }
    
    const subscription = await changeSubscriptionPlan(req.userId, priceId);
    
    res.json({
      success: true,
      subscription: {
        id: subscription.id,
        status: subscription.status,
      },
    });
  } catch (error) {
    console.error('Change plan error:', error);
    res.status(500).json({ error: error.message });
  }
});

// GET /api/billing/subscription - Get subscription details
router.get('/subscription', authenticateUser, async (req, res) => {
  try {
    const subscription = await getSubscriptionDetails(req.userId);
    
    if (!subscription) {
      return res.json({ subscription: null });
    }
    
    res.json({ subscription });
  } catch (error) {
    console.error('Get subscription error:', error);
    res.status(500).json({ error: error.message });
  }
});

export default router;

webhooks/stripeWebhooks.js — Process Stripe webhook events:

javascript
import express from 'express';
import { stripe } from '../config/stripe.js';
import {
  getCustomerByStripeId,
  createSubscription,
  updateSubscriptionStatus,
  logPayment,
  isEventProcessed,
  markEventProcessed,
} from '../db/database.js';

const router = express.Router();

// Webhook endpoint - must use raw body for signature verification
router.post('/', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
  
  let event;
  
  try {
    // Verify webhook signature
    event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
  } catch (err) {
    console.error('⚠️ Webhook signature verification failed:', err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }
  
  // Check if event already processed (idempotency)
  const alreadyProcessed = await isEventProcessed(event.id);
  if (alreadyProcessed) {
    console.log(`Event ${event.id} already processed, skipping`);
    return res.json({ received: true, skipped: true });
  }
  
  console.log(`\n📥 Webhook received: ${event.type}`);
  
  try {
    // Handle different event types
    switch (event.type) {
      case 'checkout.session.completed':
        await handleCheckoutCompleted(event.data.object);
        break;
        
      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.paid':
        await handleInvoicePaid(event.data.object);
        break;
        
      case 'invoice.payment_failed':
        await handlePaymentFailed(event.data.object);
        break;
        
      default:
        console.log(`Unhandled event type: ${event.type}`);
    }
    
    // Mark event as processed
    await markEventProcessed(event.id, event.type);
    
    res.json({ received: true });
  } catch (error) {
    console.error('Error processing webhook:', error);
    res.status(500).json({ error: 'Webhook processing failed' });
  }
});

// Handle checkout session completed
async function handleCheckoutCompleted(session) {
  console.log(`  Checkout completed for customer: ${session.customer}`);
  
  // Retrieve subscription details
  const subscription = await stripe.subscriptions.retrieve(session.subscription);
  
  const customer = await getCustomerByStripeId(session.customer);
  
  if (!customer) {
    console.error('Customer not found in database');
    return;
  }
  
  // Save subscription to database
  await createSubscription(customer.id, {
    stripeSubscriptionId: subscription.id,
    stripeCustomerId: session.customer,
    status: subscription.status,
    planId: subscription.items.data[0].price.id,
    planName: subscription.items.data[0].price.nickname || 'Subscription',
    currentPeriodStart: new Date(subscription.current_period_start * 1000),
    currentPeriodEnd: new Date(subscription.current_period_end * 1000),
  });
  
  console.log('✓ Subscription created in database');
}

// Handle subscription created
async function handleSubscriptionCreated(subscription) {
  console.log(`  Subscription created: ${subscription.id}`);
  
  const customer = await getCustomerByStripeId(subscription.customer);
  
  if (!customer) {
    console.error('Customer not found');
    return;
  }
  
  await createSubscription(customer.id, {
    stripeSubscriptionId: subscription.id,
    stripeCustomerId: subscription.customer,
    status: subscription.status,
    planId: subscription.items.data[0].price.id,
    planName: subscription.items.data[0].price.nickname || 'Subscription',
    currentPeriodStart: new Date(subscription.current_period_start * 1000),
    currentPeriodEnd: new Date(subscription.current_period_end * 1000),
  });
  
  console.log('✓ Subscription saved');
}

// Handle subscription updated
async function handleSubscriptionUpdated(subscription) {
  console.log(`  Subscription updated: ${subscription.id}, status: ${subscription.status}`);
  
  await updateSubscriptionStatus(
    subscription.id,
    subscription.status,
    subscription.cancel_at_period_end
  );
  
  console.log('✓ Subscription status updated');
}

// Handle subscription deleted/canceled
async function handleSubscriptionDeleted(subscription) {
  console.log(`  Subscription deleted: ${subscription.id}`);
  
  await updateSubscriptionStatus(subscription.id, 'canceled', false);
  
  console.log('✓ Subscription marked as canceled');
}

// Handle successful invoice payment
async function handleInvoicePaid(invoice) {
  console.log(`  Invoice paid: ${invoice.id}, amount: ${invoice.amount_paid / 100}`);
  
  const customer = await getCustomerByStripeId(invoice.customer);
  
  if (!customer) {
    console.error('Customer not found');
    return;
  }
  
  // Log payment
  await logPayment(customer.id, {
    invoiceId: invoice.id,
    paymentIntentId: invoice.payment_intent,
    amount: invoice.amount_paid,
    currency: invoice.currency,
    status: 'paid',
  });
  
  console.log('✓ Payment logged');
}

// Handle failed payment
async function handlePaymentFailed(invoice) {
  console.log(`  Payment failed: ${invoice.id}`);
  
  const customer = await getCustomerByStripeId(invoice.customer);
  
  if (!customer) {
    console.error('Customer not found');
    return;
  }
  
  // Log failed payment
  await logPayment(customer.id, {
    invoiceId: invoice.id,
    paymentIntentId: invoice.payment_intent,
    amount: invoice.amount_due,
    currency: invoice.currency,
    status: 'failed',
  });
  
  // Update subscription status to past_due
  if (invoice.subscription) {
    await updateSubscriptionStatus(invoice.subscription, 'past_due', false);
  }
  
  console.log('✓ Failed payment logged');
  
  // TODO: Send email notification to customer
}

export default router;

src/server.js — Express server with all routes:

javascript
import express from 'express';
import dotenv from 'dotenv';
import { testConnection } from '../db/database.js';
import billingRoutes from '../routes/billing.js';
import stripeWebhooks from '../webhooks/stripeWebhooks.js';

dotenv.config();

const app = express();
const PORT = process.env.PORT || 3000;

// IMPORTANT: Webhook route must come BEFORE json middleware
// (webhooks need raw body for signature verification)
app.use('/webhooks/stripe', stripeWebhooks);

// Standard middleware for other routes
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});

// Billing routes
app.use('/api/billing', billingRoutes);

// Start server
async function startServer() {
  try {
    // Test database connection
    await testConnection();
    
    app.listen(PORT, () => {
      console.log(`\n🚀 Server running on http://localhost:${PORT}`);
      console.log(`\nAPI Endpoints:`);
      console.log(`  POST /api/billing/checkout - Create checkout session`);
      console.log(`  POST /api/billing/subscribe - Create subscription`);
      console.log(`  POST /api/billing/cancel - Cancel subscription`);
      console.log(`  POST /api/billing/resume - Resume subscription`);
      console.log(`  POST /api/billing/change-plan - Change plan`);
      console.log(`  GET  /api/billing/subscription - Get subscription`);
      console.log(`\nWebhook:`);
      console.log(`  POST /webhooks/stripe - Stripe webhooks\n`);
    });
  } catch (error) {
    console.error('Failed to start server:', error);
    process.exit(1);
  }
}

startServer();

Step 4: Testing

Test 1: Start Server and Stripe CLI

Terminal 1:

bash
npm run dev

Terminal 2 (for webhook testing):

bash
stripe listen --forward-to localhost:3000/webhooks/stripe

Copy the webhook signing secret and update .env:

env
STRIPE_WEBHOOK_SECRET=whsec_...

Test 2: Create Checkout Session

bash
curl -X POST http://localhost:3000/api/billing/checkout \
  -H "Content-Type: application/json" \
  -H "x-user-id: 1" \
  -d '{
    "email": "customer@example.com",
    "name": "John Doe",
    "plan": "pro"
  }'

Expected response:

json
{
  "success": true,
  "sessionId": "cs_test_...",
  "url": "https://checkout.stripe.com/c/pay/..."
}
```

Open the URL in browser to complete test payment using card `4242 4242 4242 4242`.

**Test 3: Verify Webhook Events**

After checkout, check server logs for:
```
📥 Webhook received: checkout.session.completed
  Checkout completed for customer: cus_...
✓ Subscription created in database

📥 Webhook received: invoice.paid
  Invoice paid: in_..., amount: 29.99
✓ Payment logged

Test 4: Check Database

bash
psql -U your_username -d stripe_billing
sql
-- View customers
SELECT * FROM customers;

-- View subscriptions
SELECT * FROM subscriptions;

-- View payment history
SELECT * FROM payment_history;

-- Verify webhook processing
SELECT * FROM webhook_events ORDER BY created_at DESC LIMIT 5;

Test 5: Get Subscription Details

bash
curl http://localhost:3000/api/billing/subscription \
  -H "x-user-id: 1"

Test 6: Cancel Subscription

bash
curl -X POST http://localhost:3000/api/billing/cancel \
  -H "Content-Type: application/json" \
  -H "x-user-id: 1" \
  -d '{"immediate": false}'

Test 7: Test Failed Payment (Trigger Webhook)

bash
stripe trigger payment_intent.payment_failed

Check server logs for invoice.payment_failed webhook.

Testing Checklist:

  • ✓ Server starts without errors
  • ✓ Database connection succeeds
  • ✓ Checkout session creates successfully
  • ✓ Payment completes in Stripe Checkout
  • ✓ Webhooks received and processed
  • ✓ Subscription saved to database
  • ✓ Payment logged in payment_history
  • ✓ Subscription cancellation works
  • ✓ Webhook events marked as processed (no duplicates)

Common Errors & Troubleshooting

Error 1: “No signatures found matching the expected signature for payload”

Problem: Webhook signature verification fails with 400 error.

Solution: This happens when the raw body is not preserved for signature verification. The webhook route must use express.raw() middleware and be registered BEFORE express.json().

Fix in src/server.js:

javascript
// CORRECT ORDER - webhook first with raw body
app.use('/webhooks/stripe', stripeWebhooks);

// Then JSON middleware for other routes
app.use(express.json());

Also verify webhook secret:

bash
# Get from Stripe CLI
stripe listen --forward-to localhost:3000/webhooks/stripe

# Or from Stripe Dashboard → Developers → Webhooks

Update .env with the correct secret starting with whsec_.

Error 2: “You cannot use a Stripe token more than once”

Problem: Attempting to reuse the same payment method token causes error.

Solution: Payment method tokens (from Stripe.js) are single-use. After creating a payment method, attach it to the customer rather than reusing the token.

Incorrect approach:

javascript
// Don't do this - token can't be reused
const paymentMethod = await stripe.paymentMethods.create({
  type: 'card',
  card: { token: 'tok_visa' }, // ❌ Single use
});

Correct approach:

javascript
// Create payment method once
const paymentMethod = await stripe.paymentMethods.create({
  type: 'card',
  card: {
    number: '4242424242424242',
    exp_month: 12,
    exp_year: 2025,
    cvc: '123',
  },
});

// Attach to customer for reuse
await stripe.paymentMethods.attach(paymentMethod.id, {
  customer: customerId,
});

// Use payment method ID (pm_...) for subscriptions

For frontend integration with Stripe Elements:

javascript
// Frontend collects card, creates payment method
const {paymentMethod} = await stripe.createPaymentMethod({
  type: 'card',
  card: cardElement,
});

// Send paymentMethod.id to backend (not token)
fetch('/api/billing/subscribe', {
  method: 'POST',
  body: JSON.stringify({ paymentMethodId: paymentMethod.id })
});

Error 3: Duplicate Webhook Processing

Problem: Same webhook event processes multiple times, creating duplicate database entries.

Solution: Implement idempotency using the event.id from Stripe. The code includes a webhook_events table to track processed events.

Verify idempotency is working:

sql
-- Check for duplicate event IDs (should return 0 rows)
SELECT stripe_event_id, COUNT(*) 
FROM webhook_events 
GROUP BY stripe_event_id 
HAVING COUNT(*) > 1;

Ensure webhook handler checks before processing:

javascript
// In stripeWebhooks.js - already implemented
const alreadyProcessed = await isEventProcessed(event.id);
if (alreadyProcessed) {
  console.log(`Event ${event.id} already processed, skipping`);
  return res.json({ received: true, skipped: true });
}

// Process event...

// Mark as processed
await markEventProcessed(event.id, event.type);

If duplicate subscriptions exist in database:

sql
-- Find duplicates
SELECT stripe_subscription_id, COUNT(*) 
FROM subscriptions 
GROUP BY stripe_subscription_id 
HAVING COUNT(*) > 1;

-- Remove duplicates (keep most recent)
DELETE FROM subscriptions 
WHERE id NOT IN (
  SELECT MAX(id) 
  FROM subscriptions 
  GROUP BY stripe_subscription_id
);

Security Checklist

Critical security practices for production Stripe integrations:

  • Never expose secret keys client-side — Use publishable keys (pk_) in frontend code only. Secret keys (sk_) must remain server-side. Rotate keys immediately if exposed.
  • Verify webhook signatures — Always validate stripe-signature header to prevent forged webhooks:
javascript
  const event = stripe.webhooks.constructEvent(
    req.body,
    req.headers['stripe-signature'],
    process.env.STRIPE_WEBHOOK_SECRET
  );
  • Use HTTPS in production — Stripe rejects webhook URLs using HTTP. Configure SSL certificates for your server.
  • Implement proper authentication — The example uses x-user-id header for simplicity. In production, use JWT tokens, sessions, or OAuth:
javascript
  function authenticateUser(req, res, next) {
    const token = req.headers.authorization?.split(' ')[1];
    if (!token) return res.status(401).json({ error: 'Unauthorized' });
    
    try {
      const decoded = jwt.verify(token, process.env.JWT_SECRET);
      req.userId = decoded.userId;
      next();
    } catch (error) {
      res.status(401).json({ error: 'Invalid token' });
    }
  }
  • Validate user ownership — Before modifying subscriptions, verify the user owns the subscription:
javascript
  const subscription = await getActiveSubscriptionByUserId(req.userId);
  if (!subscription) {
    return res.status(403).json({ error: 'No subscription found' });
  }
  • Use idempotency keys for API calls — Prevent duplicate charges during network failures:
javascript
  await stripe.subscriptions.create({
    customer: customerId,
    items: [{ price: priceId }],
  }, {
    idempotencyKey: `sub_${userId}_${Date.now()}`,
  });
```

- **Store minimal PII** — Don't store full card numbers. Stripe handles sensitive data. Only store customer IDs and subscription metadata.

- **Enable webhook IP allowlisting** — Restrict webhook endpoint to Stripe IPs in firewall rules:
```
  Stripe webhook IPs (check official docs for current list):
  - 3.18.12.63
  - 3.130.192.231
  - 13.235.14.237
  (Full list: https://stripe.com/docs/ips)
  • Log all billing events — Maintain audit trail for compliance (PCI DSS, GDPR):
javascript
  // Log to secure system (not just console)
  await auditLog.create({
    userId: req.userId,
    action: 'subscription_canceled',
    metadata: { subscriptionId },
    timestamp: new Date(),
  });
  • Handle failed payments gracefully — Implement dunning management to retry failed payments:
javascript
  // In webhook handler
  case 'invoice.payment_failed':
    // Send email notification
    await sendEmail(customer.email, 'Payment Failed', template);
    
    // Update subscription status
    await updateSubscriptionStatus(subscription.id, 'past_due');
    
    // Restrict access after 3 failed attempts
    if (invoice.attempt_count >= 3) {
      await disableUserAccess(customer.user_id);
    }
  • Use Stripe’s test mode extensively — Test cards: 4242 4242 4242 4242 (success), 4000 0000 0000 0002 (decline). Never test with real cards.
  • Encrypt database connections — Enable SSL for PostgreSQL in production:
javascript
  const pool = new Pool({
    ssl: {
      rejectUnauthorized: true,
      ca: fs.readFileSync('/path/to/ca-cert.crt').toString(),
    },
  });

Leave a Comment

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