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:
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:
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:
-- 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:
psql -U your_username -d your_database -f db/schema.sql
package.json — Add scripts:
{
"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:
echo "node_modules/
.env
*.log
.DS_Store" > .gitignore
Step 2: Configuration
.env — Store credentials securely:
# 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:
- Go to https://dashboard.stripe.com/test/apikeys
- Copy Secret key (starts with
sk_test_) - Copy Publishable key (starts with
pk_test_) - For webhook secret (local testing):
stripe login
stripe listen --forward-to localhost:3000/webhooks/stripe
# Copy the webhook signing secret (whsec_...)
config/stripe.js — Initialize Stripe client:
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:
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:
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:
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:
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:
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:
npm run dev
Terminal 2 (for webhook testing):
stripe listen --forward-to localhost:3000/webhooks/stripe
Copy the webhook signing secret and update .env:
STRIPE_WEBHOOK_SECRET=whsec_...
Test 2: Create Checkout Session
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:
{
"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
psql -U your_username -d stripe_billing
-- 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
curl http://localhost:3000/api/billing/subscription \
-H "x-user-id: 1"
Test 6: Cancel Subscription
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)
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:
// 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:
# 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:
// 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:
// 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:
// 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:
-- 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:
// 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:
-- 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-signatureheader to prevent forged webhooks:
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-idheader for simplicity. In production, use JWT tokens, sessions, or OAuth:
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:
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:
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):
// 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:
// 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:
const pool = new Pool({
ssl: {
rejectUnauthorized: true,
ca: fs.readFileSync('/path/to/ca-cert.crt').toString(),
},
});

Moiz Anayat is a CRM operations specialist with more than 5+ years of hands-on experience optimizing workflows, organizing customer data structures, and improving system usability within SaaS platforms. He has contributed to CRM cleanup projects, automation redesigns, and performance optimization strategies for growing teams

