Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
SaaS and CloudDevelopmentJavaScriptNode.jsArchitecture guide

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.

Cloudsoft Zone

Updated 9 min read

Blue chip bank card resting on the keyboard of an open laptop
Photograph: Shixart1985 / CC BY 2.0, via Wikimedia Commons
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.

Hand holding a payment card in front of an open laptop on a wooden table
Checkout collects the card; your webhook pipeline decides when the account is actually paid.

The architecture at a glance

ComponentResponsibilityMust never
Checkout and Customer PortalCollect payment details, start subscriptions, let customers change plans, cancel or update cardsGrant access by itself (the redirect can be skipped or replayed)
Webhook endpointVerify the signature, store the event ID, enqueue a job, return 200 quicklyDo slow work or call other APIs before responding
Billing workerFetch the current subscription from Stripe and upsert it into your databaseAssume events arrive once or in order
Your databaseStore customer and subscription state used for access decisionsStore card details (Stripe holds those)
Access checkRead subscription status from your database on each requestCall 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:

SQL
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.

TypeScript
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).

TypeScript
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.

TypeScript
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.

Small black iZettle card payment terminal with a numeric keypad on a white shelf
Whether in a shop or online, a payment is an event; your system should react to it once, even if told twice.

Step 5: Decide access from subscription status

Stripe's subscription statuses map to access decisions like this:

StatusMeaning (from Stripe's documentation)Typical access
trialingIn a trial; safe to provisionFull
activeIn good standingFull
incompleteFirst payment needs to succeed within 23 hours, or needs customer actionNone yet; prompt to complete payment
incomplete_expiredFirst payment never succeeded; no billingNone
past_dueLatest invoice failed or was not attempted; Stripe may retryYour choice: often a grace period with a banner
unpaidRetries exhausted (if configured); invoices still createdRevoke
canceledTerminalRevoke (or read-only)
pausedTrial ended without a payment method, with pause configuredRevoke 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 via client_reference_id.
  • customer.subscription.created, .updated, .deleted: status, plan and cancellation changes, including those made in the portal.
  • invoice.paid and invoice.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.created and charge.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:

Shell
stripe login
stripe listen --forward-to localhost:4242/webhooks/stripe
# in another terminal
stripe trigger customer.subscription.created
stripe trigger invoice.payment_failed

stripe 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 http to https or 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

  1. Add the three tables and the access check, and route every "is this account paid?" decision through it.
  2. Move your webhook route above any JSON body parser and add signature verification if it is missing.
  3. Put event processing on your existing job queue and make it re-fetch state from Stripe.
  4. Write down your past_due grace 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 global express.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?

    active and trialing are safe to provision. Stripe advises revoking access for canceled and unpaid. past_due is 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, as items.data[].current_period_end, not on the subscription object.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Using webhooks with subscriptions — Stripe
  2. Receive Stripe events in your webhook endpoint — Stripe
  3. stripe-node (official Node.js library) — Stripe
  4. 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.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.