Stripe subscription billing architecture that survives webhooks
Design Stripe subscriptions for a SaaS that stay correct when webhooks repeat or arrive out of order: data model, verified endpoint, worker and access rules.
Updated 9 min read

In this article
A reliable Stripe subscription integration has four parts: Stripe Checkout to start the subscription, a webhook endpoint that verifies and records every event, a background worker that re-reads the subscription from Stripe and updates your database, and an access check that reads only your database. Built this way, duplicate, late and out-of-order webhooks cannot leave a customer with the wrong access.
This article lays out that architecture for a typical SaaS, with TypeScript code type-checked against the official stripe Node.js library version 23 (which uses Stripe API version 2026-09-30.endive), and the subscription statuses and events you actually need to handle.

The architecture at a glance
| Component | Responsibility | Must never |
|---|---|---|
| Checkout and Customer Portal | Collect payment details, start subscriptions, let customers change plans, cancel or update cards | Grant access by itself (the redirect can be skipped or replayed) |
| Webhook endpoint | Verify the signature, store the event ID, enqueue a job, return 200 quickly | Do slow work or call other APIs before responding |
| Billing worker | Fetch the current subscription from Stripe and upsert it into your database | Assume events arrive once or in order |
| Your database | Store customer and subscription state used for access decisions | Store card details (Stripe holds those) |
| Access check | Read subscription status from your database on each request | Call the Stripe API on every request |
Step 1: Model billing state in your own database
Keep a small, explicit copy of what Stripe knows. Three tables cover most products:
CREATE TABLE billing_customers (
account_id BIGINT PRIMARY KEY,
stripe_customer_id VARCHAR(255) NOT NULL UNIQUE
);
CREATE TABLE billing_subscriptions (
stripe_subscription_id VARCHAR(255) PRIMARY KEY,
stripe_customer_id VARCHAR(255) NOT NULL,
status VARCHAR(32) NOT NULL, -- trialing, active, past_due, canceled, unpaid, ...
price_ids JSON NOT NULL,
current_period_end DATETIME NULL,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (stripe_customer_id)
);
CREATE TABLE stripe_events (
event_id VARCHAR(255) PRIMARY KEY, -- the unique key is the de-duplication
type VARCHAR(100) NOT NULL,
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
processed_at DATETIME NULL
);Create the Stripe Customer when the account is created (or at first checkout) and store its ID. Linking by your own account ID avoids the classic bug of matching customers by email address, which breaks when someone changes their email or has two accounts.
Step 2: Start subscriptions with Checkout, manage them with the portal
Stripe-hosted Checkout handles card entry, Strong Customer Authentication and tax collection settings, and the Customer Portal handles plan changes, cancellations and card updates. That keeps card data out of your systems entirely.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function startCheckout(customerId: string, priceId: string, accountId: string) {
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId, // create the Customer once, at sign-up
line_items: [{ price: priceId, quantity: 1 }],
client_reference_id: accountId, // your own account ID, echoed back in the event
success_url: 'https://app.example.com/billing?checkout=success',
cancel_url: 'https://app.example.com/billing?checkout=cancelled',
});
return session.url;
}
export async function openBillingPortal(customerId: string) {
const portal = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: 'https://app.example.com/billing',
});
return portal.url;
}The success page should only say "we are confirming your payment". Access is granted when the webhook pipeline updates your database, usually within seconds.
Step 3: A webhook endpoint that verifies, records and returns
Stripe's webhook documentation sets three rules: verify that each event comes from Stripe using the Stripe-Signature header and your endpoint's whsec_ secret; use the raw request body, because any change to it breaks verification; and return a 2xx quickly, before any complex logic that could time out. It also recommends handling events through an asynchronous queue, because renewals cluster (for example, at the start of the month).
import express from 'express';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
// Your persistence and queue; implement against your own database.
declare const db: {
recordEventOnce(eventId: string, type: string): Promise<boolean>; // false if already seen
};
declare const jobs: { add(name: string, data: { eventId: string }): Promise<void> };
const app = express();
// The raw body is required for signature verification, so this route
// must be registered before any global express.json() middleware.
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, req.header('stripe-signature') ?? '', endpointSecret);
} catch (err) {
res.status(400).send(`Webhook signature verification failed: ${(err as Error).message}`);
return;
}
// Store the event ID once; duplicates are acknowledged and ignored.
if (await db.recordEventOnce(event.id, event.type)) {
await jobs.add('stripe-event', { eventId: event.id });
}
res.sendStatus(200); // acknowledge fast; the worker does the real work
});
app.use(express.json()); // other routes
app.listen(4242);We tested the verification step locally: a signature generated over the exact bytes verified, while the same JSON re-serialised with different whitespace was rejected with StripeSignatureVerificationError. That is what happens when a global express.json() middleware runs before the webhook route, and it is the most common reason webhooks fail on first deploy. The libraries also reject signatures older than a default tolerance of five minutes to prevent replay attacks, so keep server clocks in sync with NTP.
Step 4: A worker that converges on the truth
Stripe states plainly that events are not guaranteed to arrive in order and that an endpoint may occasionally receive the same event more than once. Creating a subscription can produce customer.subscription.created, invoice.created, invoice.paid and charge.created in any order. Stripe advises tracking event IDs to spot duplicates and not using the event's created timestamp for ordering.
The simplest robust design ignores the event payload's snapshot and treats each event as a signal: "something changed on subscription X". The worker fetches the subscription's current state from the API and overwrites your row. Processing the same event twice, or an old event after a new one, produces the same final state.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
declare const db: {
upsertSubscription(row: {
stripeSubscriptionId: string;
stripeCustomerId: string;
status: Stripe.Subscription.Status;
priceIds: string[];
currentPeriodEnd: Date | null;
cancelAtPeriodEnd: boolean;
}): Promise<void>;
};
const HANDLED = new Set([
'checkout.session.completed',
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted',
'invoice.paid',
'invoice.payment_failed',
]);
// Runs in a background worker. Re-fetches current state instead of trusting
// event order, so duplicate or out-of-order events converge on the truth.
export async function processStripeEvent(eventId: string): Promise<void> {
const event = await stripe.events.retrieve(eventId);
if (!HANDLED.has(event.type)) return;
const subscriptionId = subscriptionIdFrom(event);
if (!subscriptionId) return;
const sub = await stripe.subscriptions.retrieve(subscriptionId);
const periodEnds = sub.items.data.map((item) => item.current_period_end);
await db.upsertSubscription({
stripeSubscriptionId: sub.id,
stripeCustomerId: typeof sub.customer === 'string' ? sub.customer : sub.customer.id,
status: sub.status,
priceIds: sub.items.data.map((item) => item.price.id),
currentPeriodEnd: periodEnds.length ? new Date(Math.max(...periodEnds) * 1000) : null,
cancelAtPeriodEnd: sub.cancel_at_period_end,
});
}
function subscriptionIdFrom(event: Stripe.Event): string | null {
const obj = event.data.object as unknown as Record<string, unknown>;
if (event.type.startsWith('customer.subscription.')) return obj.id as string;
if (event.type === 'checkout.session.completed') {
const s = obj.subscription;
return typeof s === 'string' ? s : null;
}
if (event.type.startsWith('invoice.')) {
const invoice = event.data.object as Stripe.Invoice;
const s = invoice.parent?.subscription_details?.subscription;
return typeof s === 'string' ? s : (s?.id ?? null);
}
return null;
}
export const hasAccess = (status: Stripe.Subscription.Status) =>
status === 'active' || status === 'trialing';Two details reflect recent API versions. Since 2025-03-31.basil, the billing period lives on each subscription item (items.data[].current_period_end) rather than on the subscription, and an invoice points to its subscription through parent.subscription_details.subscription. Code written against older API versions breaks on both.

Step 5: Decide access from subscription status
Stripe's subscription statuses map to access decisions like this:
| Status | Meaning (from Stripe's documentation) | Typical access |
|---|---|---|
trialing | In a trial; safe to provision | Full |
active | In good standing | Full |
incomplete | First payment needs to succeed within 23 hours, or needs customer action | None yet; prompt to complete payment |
incomplete_expired | First payment never succeeded; no billing | None |
past_due | Latest invoice failed or was not attempted; Stripe may retry | Your choice: often a grace period with a banner |
unpaid | Retries exhausted (if configured); invoices still created | Revoke |
canceled | Terminal | Revoke (or read-only) |
paused | Trial ended without a payment method, with pause configured | Revoke until resumed |
Stripe's own guidance is to revoke access when a subscription becomes canceled or unpaid, to notify the customer when it becomes past_due, and, after invoice.paid, to confirm the subscription is active before extending access. If you offer a grace period for past_due, make it an explicit product rule with a fixed number of days.
The events worth subscribing to
checkout.session.completed: links the new subscription to your account viaclient_reference_id.customer.subscription.created,.updated,.deleted: status, plan and cancellation changes, including those made in the portal.invoice.paidandinvoice.payment_failed: renewals succeeded or failed; send your own emails here if you want them.customer.subscription.trial_will_end: sent three days before a trial ends; check that a payment method exists.invoice.payment_action_required: the customer must authenticate a payment.charge.dispute.createdandcharge.refunded: decide whether disputes or refunds should affect access.
Stripe recommends subscribing only to the events you need. If you listen to invoice.created, respond to it promptly: if Stripe does not get a successful response, it delays finalising automatically collected invoices for up to 72 hours.
Testing before you go live
The Stripe CLI forwards real sandbox events to your laptop and can trigger them on demand:
stripe login
stripe listen --forward-to localhost:4242/webhooks/stripe
# in another terminal
stripe trigger customer.subscription.created
stripe trigger invoice.payment_failedstripe listen prints a temporary signing secret; use it as STRIPE_WEBHOOK_SECRET locally. Then walk a sandbox subscription through trial end, a failed renewal and a card update before launch.
Gotchas
- Failing endpoints are retried, then you are emailed. In live mode Stripe retries delivery for up to three days with exponential back-off; in a sandbox it retries three times over a few hours. Monitor your endpoint's error rate rather than relying on retries.
- Redirects count as failures. A 301 from
httptohttpsor from a trailing slash fails delivery. Register the exact final URL. - CSRF middleware blocks webhooks. Exempt the webhook route; the signature is its authentication.
- One endpoint secret per endpoint and mode. Test and live endpoints have different secrets; roll them periodically from Workbench.
- Webhooks are not a substitute for reconciliation. Run a nightly job that lists recently updated subscriptions from the API and repairs any row that drifted.
What to do next
- Add the three tables and the access check, and route every "is this account paid?" decision through it.
- Move your webhook route above any JSON body parser and add signature verification if it is missing.
- Put event processing on your existing job queue and make it re-fetch state from Stripe.
- Write down your
past_duegrace rule and dunning emails before the first failed renewal arrives.
Frequently asked questions
Why does Stripe webhook signature verification fail in Express?
Usually because a JSON body parser ran before the webhook route, so the library receives a re-serialised object instead of the raw bytes Stripe signed. Register the webhook route with
express.raw({ type: 'application/json' })before any globalexpress.json().Should I grant access on the Checkout success page?
No. The redirect can be skipped, repeated or forged. Grant access when your webhook worker has confirmed the subscription status from Stripe and updated your database.
How do I handle duplicate or out-of-order Stripe events?
Store each event ID under a unique key and skip IDs you have seen. For subscription events, re-fetch the subscription from the API and overwrite your row, so the final state is correct whatever order events arrive in.
Which subscription statuses should allow access?
activeandtrialingare safe to provision. Stripe advises revoking access forcanceledandunpaid.past_dueis a product decision; many SaaS apps allow a short, fixed grace period while prompting the customer to update their card.How long does Stripe retry a failed webhook?
In live mode, for up to three days with exponential back-off. In a sandbox, three times over a few hours. You can also resend events from the Dashboard for up to 15 days, or with the Stripe CLI for up to 30 days.
Where is current_period_end on newer API versions?
From API version
2025-03-31.basil, the billing period is on each subscription item, asitems.data[].current_period_end, not on the subscription object.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Using webhooks with subscriptions — Stripe
- Receive Stripe events in your webhook endpoint — Stripe
- stripe-node (official Node.js library) — Stripe
- Subscription lifecycle — Stripe
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

Industry updateNode.js
Which Node.js version to use in October 2026: LTS lines and the new schedule
Node.js 26 becomes LTS on 28 October 2026, Node.js 24 moves to maintenance and 20.x is already end of life. Here is every supported line, the new yearly release model from Node.js 27, and which version to run.
By Cloudsoft Zone

ComparisonNode.js
Express 5 vs Fastify 5: which Node.js framework to pick in 2026
Express 5 and Fastify 5 compared on support status, validation, error handling, upgrades and performance, with the same API built and tested in both.
By Cloudsoft Zone

Architecture guideAI and Automation
How to build a safe AI content pipeline with human fact review
An architecture for AI-assisted publishing where people stay responsible: untrusted source snapshots, claim checking with citations, a review queue AI cannot skip, labels and rollback.
By Cloudsoft Zone
