SaaS11 min

Stripe subscription best practices: webhooks, idempotency, and dunning

Production Stripe patterns for SaaS — raw-body signature verification, idempotency keys, smart retries, dunning flows, and the reconciliation steps that keep billing quiet at scale.

Stripe handles the card network, the vaulting, and the PCI envelope. Everything that sits between Stripe and your database — webhook delivery, idempotency, subscription state, and failed-payment recovery — is still your problem. Teams that treat billing as a simple SDK integration are the same teams that, six months in, discover silent double-charges, orphaned subscriptions, and a dunning flow that quietly loses 20% of renewals. The patterns below are what production SaaS billing actually requires: raw-body signature verification, idempotent event handling, smart retries, reconciliation, and a dunning sequence that behaves like a system rather than a hope.

Webhook signature verification: raw body or nothing

Stripe signs every webhook event with a timestamp and HMAC over the exact request body. If any middleware parses, re-serialises, or mutates that body before your verifier sees it, the signature check fails and you're left with a blind endpoint. The Express pattern most teams start with — app.use(express.json()) — silently breaks Stripe verification because it replaces the raw buffer with a parsed object.

// Correct raw-body Stripe webhook route in Express
import express from "express";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;

app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }), // raw bytes, not JSON
  async (req, res) => {
    const sig = req.headers["stripe-signature"] as string;

    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
    } catch (err) {
      return res.status(400).send("signature verification failed");
    }

    // 1. Acknowledge fast (Stripe times out at ~10s and retries).
    res.status(200).send("ok");

    // 2. Hand off to a queue for idempotent processing.
    await queue.publish("stripe.events", { id: event.id, payload: event });
  },
);

Three things break signature verification in production: a global JSON middleware mounted before the webhook route, a reverse proxy that rewrites content-encoding, and a logging middleware that reads the request stream. Mount the raw-body parser only on the webhook route, and test the signature path in CI with a recorded Stripe payload.

Events you actually need to handle

Stripe emits around 200 event types. Most SaaS products need a consistent response to roughly a dozen of them. The rest can be logged and ignored until a feature depends on them. Here's the minimum set for a subscription product with a self-serve billing portal.

EventWhen it firesWhat your handler does
checkout.session.completedNew subscription created via CheckoutProvision the account, link customer ID, send welcome email
customer.subscription.createdSubscription begins (including trials)Record status, plan, trial end, seats
customer.subscription.updatedPlan change, seat change, status flipSync plan, seat count, and status to your DB
customer.subscription.deletedSubscription cancelled at period endDowngrade or disable the account on period end
invoice.paidSuccessful payment, including renewalsExtend access, emit receipt, reset past-due state
invoice.payment_failedCharge failed on renewal or trial endMark past-due, start dunning sequence
invoice.payment_action_required3DS or SCA step requiredEmail the customer a portal link to authenticate
customer.subscription.trial_will_end3 days before trial endsNudge the customer to add a payment method
charge.refundedRefund issuedRevoke access if applicable, update ledger
charge.dispute.createdChargeback openedFreeze account, notify finance, gather evidence

Idempotency: the double-charge trap

Stripe delivers webhooks at-least-once. Your endpoint will, at some point, receive the same event twice. If your handler is not idempotent, the second delivery can provision the account again, email the customer again, or extend their subscription by another month. The fix is not clever — it's discipline: every side effect is keyed to the event ID, and every write checks whether that event ID has already been processed.

// Idempotent webhook consumer using a processed-events table
async function handleStripeEvent(event: Stripe.Event) {
  const alreadySeen = await db.processedEvents.findUnique({
    where: { id: event.id },
  });
  if (alreadySeen) return; // duplicate delivery, nothing to do

  await db.$transaction(async (tx) => {
    await tx.processedEvents.create({
      data: { id: event.id, type: event.type, receivedAt: new Date() },
    });

    switch (event.type) {
      case "invoice.paid":
        await extendAccess(tx, event.data.object as Stripe.Invoice);
        break;
      case "invoice.payment_failed":
        await startDunning(tx, event.data.object as Stripe.Invoice);
        break;
      // ...
    }
  });
}

The same discipline applies on the outbound side. Every Stripe API call that creates money — subscription creation, one-off charge, refund — needs an Idempotency-Key header. Stripe deduplicates for 24 hours per key, which means a retry from your job queue can't silently create two subscriptions.

Don't derive idempotency keys from request inputs alone (for example, customerId+planId). If a customer legitimately subscribes, cancels, and subscribes again, the two requests share inputs but are distinct intents. Use a per-request UUID and persist it with the job that triggered the call.

Failed payments: smart retries and dunning

Between 5% and 15% of subscription renewals fail on the first attempt. The gap between teams that recover 50% of that revenue and teams that recover 10% comes down to retry strategy and how the customer hears about it. Stripe's Smart Retries uses machine learning against billions of transactions to pick retry times and materially outperforms fixed schedules. Turn it on unless you have a specific reason not to.

The dunning sequence that actually works

  1. Day 0 — payment fails. Mark the subscription past-due. Do not lock the account yet. Send a calm, specific email: which card, which amount, and a one-click link to the Stripe Billing Portal to update it.
  2. Day 3 — second retry. If still failing, send a second email and trigger an in-app banner for users on that account. Avoid scary language; you're reminding, not threatening.
  3. Day 7 — third retry plus a last-chance email. Offer a concrete downgrade path for customers who want to stay on a smaller plan rather than churn.
  4. Day 14 — cancel or pause. Stop the subscription per your policy. Keep data in place for a defined retention window so win-backs can happen.

The single biggest recovery lever is using the Stripe Customer Portal for card updates instead of rolling your own form. It handles SCA, 3DS, and regional payment methods correctly, and every update triggers a retry automatically. You inherit Stripe's work without shipping card UI.

Subscription lifecycle state — source of truth

A common bug: the app shows the customer as active while Stripe has them as past_due, or the app denies access while Stripe shows paid. This happens when the app derives state from its own writes rather than from the webhook stream. The rule to keep this quiet: Stripe is the source of truth for subscription state. Your database mirrors it. Every subscription write in your app is a reaction to an event, not a primary action.

  • Store the Stripe customer ID, subscription ID, and current status on the account row.
  • Persist the current_period_end timestamp — this is what gates access, not a local boolean.
  • Record the last processed event ID on the subscription so you can detect stale writes.
  • Never update subscription state from a button click alone; trigger the Stripe API, wait for the webhook, and let the webhook write the state.

Reconciliation: the nightly job that saves weekends

Webhooks are reliable but not complete. Networks drop events, your endpoint has bad days, and Stripe retries for three days and then stops. A nightly reconciliation job closes the gap: pull subscriptions updated in the last 36 hours from the Stripe API, compare status and period end against your DB, and reconcile any drift. The job usually finds nothing; on the day it does, it saves a support escalation.

// Nightly reconciliation — run via cron at 02:00 UTC
async function reconcileSubscriptions() {
  const since = Math.floor(Date.now() / 1000) - 36 * 60 * 60;
  for await (const sub of stripe.subscriptions.list({
    created: { gte: since },
    status: "all",
    limit: 100,
  })) {
    const local = await db.subscription.findUnique({
      where: { stripeId: sub.id },
    });
    if (!local) {
      await logMissingSubscription(sub);
      continue;
    }
    if (
      local.status !== sub.status ||
      local.currentPeriodEnd.getTime() / 1000 !== sub.current_period_end
    ) {
      await db.subscription.update({
        where: { stripeId: sub.id },
        data: {
          status: sub.status,
          currentPeriodEnd: new Date(sub.current_period_end * 1000),
          reconciledAt: new Date(),
        },
      });
    }
  }
}

Testing Stripe without breaking things

The Stripe CLI forwards test-mode webhooks to localhost and lets you trigger specific events on demand. Combine it with test clocks for subscription flows — you can simulate a trial ending, a renewal, and a failed payment in seconds instead of waiting a month. Every webhook handler in your codebase should have a unit test that feeds it a real recorded Stripe payload and asserts the side effect.

  • stripe listen --forward-to localhost:3000/webhooks/stripe during development.
  • stripe trigger invoice.payment_failed to rehearse the dunning path.
  • Test clocks to advance subscriptions through trials, renewals, and cancellations in minutes.
  • Record representative payloads in a fixtures directory and replay them in CI — this catches breakage from Stripe API version bumps.

Key takeaways

  • Verify every webhook on the raw body with the exact stripe-signature header — any middleware that parses the body first will break you.
  • Every webhook side effect is idempotent, keyed on the Stripe event ID, and persisted in a processed-events table inside the same transaction.
  • Every Stripe API call that moves money carries an Idempotency-Key so retries from your queue can't double up.
  • Treat Stripe as the source of truth for subscription state. Your database mirrors it via the webhook stream, never the other way around.
  • Turn on Smart Retries, route card updates through the Stripe Customer Portal, and instrument the dunning emails — recovery rates of 40–55% are achievable.
  • Run a nightly reconciliation job against the Stripe API. The day it finds drift is the day it pays for itself ten times over.
#stripe#webhooks#idempotency#dunning#saas#billing#payments
Working on something similar?

Let's build it together.

We ship production SaaS, marketplaces, and web apps. If you want an engineering partner — not a consultancy — let's talk.