TL;DR: This guide demonstrates how to build an event-driven SaaS billing system using Next.js, Node.js, and Stripe Checkout. You will learn how to design a Prisma database schema that acts as a read-replica of Stripe's state, implement a
StripeEventtable for webhook idempotency, and securely generate Checkout sessions usingclient_reference_idto map payments to internal users.
⚡ Key Takeaways
- Model your database (e.g., using Prisma) as a read-replica of Stripe's state with fields like
stripeCustomerIdandsubscriptionStatusto enable fast, local authorization checks. - Create a
StripeEventtable to store processed event IDs, guaranteeing webhook idempotency and preventing duplicate subscription processing during network anomalies. - Offload credit card collection entirely to Stripe Checkout to maintain strict PCI-DSS compliance and minimize your application's security surface area.
- Generate Checkout Sessions securely in a Node.js/Express backend using
mode: 'subscription'rather than exposing Stripe API keys on the client side. - Pass your internal database user ID into Stripe's
client_reference_idparameter during checkout creation so webhooks can securely map the resulting subscription back to the correct user.
Implementing SaaS billing seems straightforward when you first read the documentation. You grab a checkout snippet, hardcode a product ID, and redirect your user. A payment goes through, your database flags the user as a premium subscriber, and you deploy to production.
Fast forward three months. A user’s credit card expires. The payment processor tries to bill them and fails. Because you didn't implement robust event listeners for payment failures, your database still reflects an active premium status. They continue using your expensive AI features entirely for free. Meanwhile, another user wants to upgrade from the $10/mo plan to the $50/mo plan, but since you built a rigid custom billing page, they have to email your support team to make the change.
You are losing money on failed payments, leaking revenue through edge cases, and wasting precious engineering hours acting as manual customer support.
The solution is a decoupled, event-driven payment architecture. In this guide, we will implement a production-ready subscription engine using Next.js for the frontend, Node.js for the API layer, and Stripe’s hosted surfaces for PCI compliance. We will build a system that self-heals via webhooks, gracefully handles upgrades and downgrades, and automates billing management.
Modeling Your Database for Stripe Synchronization
Before writing any integration code, you must design your database schema to stay perfectly synced with Stripe's state. Stripe is the ultimate source of truth for billing. Your database should act as a read-replica of Stripe's state to ensure ultra-fast authorization checks on your frontend without hitting the Stripe API on every page load.
When we design SaaS architectures for enterprise clients—which you can review in our portfolio of shipped B2B products—we strictly map Stripe entity IDs directly to our user records.
Here is a Prisma schema demonstrating how to structure your relational database (like PostgreSQL) to handle recurring billing safely:
// schema.prisma
model User {
id String @id @default(uuid())
email String @unique
name String?
// Stripe Mapping
stripeCustomerId String? @unique
stripePriceId String?
// Subscription State
subscriptionId String? @unique
subscriptionStatus String @default("incomplete")
currentPeriodEnd DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model StripeEvent {
id String @id // Maps to Stripe's event.id
type String
processedAt DateTime @default(now())
}
Production Note: The
StripeEventtable is crucial for idempotency. Network anomalies can cause Stripe to send the exact same webhook event twice. By storing the event ID, you ensure your system only processes an upgrade or cancellation once.
Initiating Stripe Checkout from Next.js
To comply with PCI-DSS requirements and minimize your security surface area, you should offload the credit card collection process entirely to Stripe Checkout.
Instead of creating a checkout session directly from the client (which exposes your API keys and makes server-side validation impossible), we will request a session URL from our Node.js backend.
First, let's create the Express route in our Node.js backend to generate the Checkout Session.
// server/routes/billing.js
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const router = express.Router();
router.post('/create-checkout-session', async (req, res) => {
const { priceId, userId, userEmail } = req.body;
try {
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId, // The Stripe Price ID (e.g., price_1N...)
quantity: 1,
},
],
customer_email: userEmail, // Pre-fills the checkout form
// client_reference_id securely passes your internal user ID back to webhooks
client_reference_id: userId,
success_url: `${process.env.FRONTEND_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/pricing`,
});
res.status(200).json({ url: session.url });
} catch (error) {
console.error('Stripe Session Error:', error);
res.status(500).json({ error: 'Failed to create checkout session' });
}
});
module.exports = router;
On your Next.js frontend, you call this endpoint when the user clicks the "Subscribe" button. Because Stripe redirects the user to a fully hosted page, your frontend code remains exceptionally clean.
// app/pricing/page.tsx
'use client';
import { useState } from 'react';
export default function PricingPage() {
const [loading, setLoading] = useState(false);
// In a real application, this would come from your authentication context
const user = { id: 'user_123', email: 'hello@example.com' };
// The specific Price ID from your Stripe Dashboard
const PRICE_ID = 'price_1Pabc123...';
const handleSubscribe = async () => {
setLoading(true);
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/create-checkout-session`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
priceId: PRICE_ID,
userId: user.id,
userEmail: user.email,
}),
},
);
const { url } = await response.json();
if (url) {
// Redirect the user to Stripe Checkout
window.location.href = url;
}
} catch (error) {
console.error('Subscription failed:', error);
} finally {
setLoading(false);
}
};
return (
<div className="flex flex-col items-center p-10">
<h2 className="text-2xl font-bold">Pro Plan - $29/month</h2>
<button
onClick={handleSubscribe}
disabled={loading}
className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-md disabled:opacity-50"
>
{loading ? 'Redirecting to secure checkout...' : 'Subscribe Now'}
</button>
</div>
);
}
Building a Bulletproof Webhook Receiver in Node.js
The checkout session gets the user to pay, but how does your database know the transaction succeeded? You cannot rely on the success_url redirect, because users frequently close their browser tab immediately after paying, bypassing the redirect entirely.
If you need a robust, custom payment flow integrated with external systems where data loss is unacceptable, explore our bespoke payment gateway and API integration services. The foundation of reliable integrations is the webhook.
Stripe sends secure POST requests (webhooks) to your Node.js server whenever a state changes. To ensure hackers can't hit this endpoint and grant themselves free subscriptions, we must verify the cryptographic signature Stripe attaches to the headers.
To do this, Node.js needs the raw request body—not the parsed JSON.
// server/routes/webhooks.js
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const { processStripeEvent } = require('../services/billingService');
const router = express.Router();
// Notice: We use express.raw() instead of express.json() for this specific route
router.post('/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
// Cryptographically verify the event originated from Stripe
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.error(`Webhook signature verification failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
try {
// Process the event asynchronously
await processStripeEvent(event);
// Acknowledge receipt to Stripe ONLY after successful database updates
res.json({ received: true });
} catch (err) {
console.error('Failed to process Stripe event:', err);
// Returning a 500 ensures Stripe will retry the webhook later
res.status(500).send('Processing Error');
}
});
module.exports = router;
Tip: Always return a 2xx status code back to Stripe after processing complex business logic. If your database query fails, returning a 5xx status code tells Stripe to utilize its built-in retry mechanism to send the event again, preventing data desynchronization.
Mapping Subscription Lifecycle Events
With a secure webhook receiver in place, we must handle the specific events that dictate the subscription lifecycle. The three most critical events are creation, updates (like upgrades/downgrades or successful renewals), and deletions (cancellations or failed dunning cycles).
Here is the logic to map these events to your PostgreSQL database using the Prisma schema we defined earlier:
// server/services/billingService.js
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function processStripeEvent(event) {
// 1. Enforce Idempotency
const existingEvent = await prisma.stripeEvent.findUnique({
where: { id: event.id },
});
if (existingEvent) {
console.log(`Event ${event.id} already processed. Skipping.`);
return;
}
// Record the event to prevent duplicate processing
await prisma.stripeEvent.create({ data: { id: event.id, type: event.type } });
const dataObject = event.data.object;
switch (event.type) {
case 'checkout.session.completed':
// Map the internal user to the newly created Stripe Customer
if (dataObject.mode === 'subscription') {
await prisma.user.update({
where: { id: dataObject.client_reference_id },
data: {
stripeCustomerId: dataObject.customer,
subscriptionId: dataObject.subscription,
},
});
}
break;
case 'customer.subscription.updated':
case 'customer.subscription.created':
// Sync subscription state, plan ID, and expiration date
await prisma.user.update({
where: { stripeCustomerId: dataObject.customer },
data: {
stripePriceId: dataObject.items.data[0].price.id,
subscriptionStatus: dataObject.status, // 'active', 'past_due', etc.
currentPeriodEnd: new Date(dataObject.current_period_end * 1000),
},
});
break;
case 'customer.subscription.deleted':
// The user canceled or failed to pay after multiple retries (dunning)
await prisma.user.update({
where: { stripeCustomerId: dataObject.customer },
data: {
subscriptionStatus: 'canceled',
stripePriceId: null,
},
});
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
}
module.exports = { processStripeEvent };
Notice how we rely entirely on customer.subscription.deleted to remove access. Never write custom cron jobs to downgrade users when their billing period ends. Let Stripe's internal chronometers handle timezones, grace periods, and prorations. When Stripe decides the subscription is definitively over, it will fire the deleted event, and your system will react instantly.
Automating Upgrades and Cancellations with the Customer Portal
Historically, developers had to build complex settings pages allowing users to change credit cards, download invoices, or cancel their plans. Today, this is an anti-pattern.
Stripe provides the Customer Portal, a secure, pre-built interface where users can manage their subscriptions. Your only responsibility is generating a temporary link to this portal and redirecting the user.
Here is the Node.js route to provision a portal session:
// server/routes/billing.js (continued)
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
router.post('/create-portal-session', async (req, res) => {
const { userId } = req.body;
try {
// Retrieve the user from your database to get their Stripe Customer ID
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user || !user.stripeCustomerId) {
return res.status(400).json({ error: 'No billing account found.' });
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.FRONTEND_URL}/dashboard/settings`,
});
res.status(200).json({ url: portalSession.url });
} catch (error) {
console.error('Portal Error:', error);
res.status(500).json({ error: 'Failed to generate portal link' });
}
});
From your Next.js application, an authenticated user clicks "Manage Billing", hits this endpoint, and is transported to Stripe. When they are done updating their credit card or changing plans, they are seamlessly redirected back to your return_url. If they changed their plan, Stripe fires a customer.subscription.updated webhook, and your database syncs perfectly in the background.
Testing Webhooks Locally with the Stripe CLI
Testing this flow in a local development environment requires routing Stripe's webhooks from the internet to your localhost. Stripe provides the Stripe CLI specifically for this purpose.
Once installed, authenticate your CLI and use the listen command to proxy events directly to your local Node.js server:
# Forward all Stripe events to your local API
stripe listen --forward-to http://localhost:8080/api/webhooks/stripe
The CLI will output a webhook secret (e.g., whsec_...). Copy this value into your .env file as STRIPE_WEBHOOK_SECRET so your constructEvent logic can properly verify the signature during local testing.
Designing for Scale
When implementing recurring billing, your application’s logic must shift from imperative ("charge this user now") to reactive ("react to the state changes Stripe broadcasts"). By leveraging Stripe Checkout to capture payments, webhooks to synchronize your PostgreSQL database, and the Customer Portal to handle ongoing account management, you drastically reduce your codebase's complexity and liability.
If you need help auditing an existing payment flow, migrating off a legacy billing provider without downtime, or architecting complex usage-based metering, talk to our backend engineers for a comprehensive architecture review.
Need help building this in production?
SoftwareCrafting is a full-stack dev agency — we ship fast, scalable React, Next.js, Node.js, React Native & Flutter apps for global clients.
Get a Free ConsultationFrequently Asked Questions
How should I model my database for Stripe subscriptions?
Stripe must be treated as the ultimate source of truth for your billing state. Your database should act as a read-replica by mapping Stripe entity IDs (like stripeCustomerId and subscriptionId) directly to your user records to ensure fast, local authorization checks without hitting the Stripe API.
How do I prevent duplicate Stripe webhook events from breaking my app?
Network anomalies can cause Stripe to send the exact same webhook event twice. You should implement idempotency by creating a StripeEvent table in your database to store processed event IDs, ensuring that upgrades or cancellations are only processed once.
What is the most secure way to initiate a Stripe Checkout session in Next.js?
To minimize your security surface area and maintain PCI compliance, avoid creating checkout sessions directly from the client. Instead, request a session URL from your Node.js backend and redirect the user to Stripe's fully hosted checkout page.
How do I link a successful Stripe Checkout back to my internal user ID?
When creating the checkout session in your Node.js backend, pass your internal user ID to the client_reference_id parameter. Stripe will securely include this ID in the webhook payload, allowing you to easily identify and update the correct user record in your database.
Can SoftwareCrafting help us design a custom, event-driven billing architecture for our SaaS?
Yes, SoftwareCrafting specializes in designing robust SaaS architectures for enterprise clients. We can build a decoupled, event-driven payment system that self-heals via webhooks and gracefully handles edge cases like failed payments and complex upgrades.
Does SoftwareCrafting offer implementation services for Next.js and Node.js Stripe integrations?
Absolutely. Our team at SoftwareCrafting has extensive experience shipping B2B products with production-ready Stripe integrations using Next.js and Node.js. We handle everything from secure checkout flows to complex webhook synchronization and database modeling.
📎 Full Code on GitHub Gist: The complete
schema.prismafrom this post is available as a standalone GitHub Gist — copy, fork, or embed it directly.
