All posts
SaaS Development··10 min read

Stripe Billing for SaaS: Subscriptions, Webhooks, and the Edge Cases

Wiring Stripe for a SaaS looks easy until proration, failed payments, and webhook ordering hit. Here is how to build billing that survives production.

By

On this page

The demo takes an afternoon. Drop in Stripe Checkout, redirect the user, flip a is_pro boolean when they come back to your success URL, ship it. It works in the demo because in the demo nothing fails. In production, cards expire mid-cycle, customers upgrade from a phone on a flaky connection and refresh three times, webhooks arrive out of order, and someone's subscription gets cancelled by Stripe's dunning logic two weeks after the charge first failed. The boolean you flipped on the success URL is now lying to you, and you have no idea who is actually paying.

The single most important decision in Stripe billing is this: your application's source of truth for "what can this account do" must be driven by webhooks, never by the client. The success URL redirect is a UX convenience. It is not a billing event. Treat it as such and most of the edge cases collapse into one well-understood pipeline.

Three ways in, and when each one wins

Stripe gives you three front doors. People reach for the wrong one constantly.

ApproachYou buildBest forCost
Checkout (hosted)A button + one API callNew subscriptions, free trials, most B2B SaaSLowest. Stripe hosts the form, handles SCA/3DS, tax, wallets
Billing Portal (hosted)A button + one API callPlan changes, card updates, cancellations, invoice historyLowest. No UI to maintain
Custom flow (Elements + API)The entire payment UIEmbedded checkout, unusual flows, deep brand controlHighest. You own SCA handling, error states, retries

My default for a B2B SaaS is Checkout for the first subscription, Billing Portal for everything after. The Portal alone handles plan upgrades with proration, payment method updates, cancellation with end-of-period scheduling, and invoice history — features that would otherwise be weeks of work, all PCI-compliant and localized for free. I only reach for custom Elements when the embedded experience is a hard product requirement, and I budget for the extra SCA and error-handling work when I do.

Creating a Checkout session is the easy part. Note that I attach my own userId as metadata and reuse a stored customerId — both matter later when the webhook arrives and I need to know who this is.

import Stripe from "stripe";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-11-17.clover",
});
 
export async function createCheckoutSession(
  userId: string,
  customerId: string,
  priceId: string,
) {
  return stripe.checkout.sessions.create({
    mode: "subscription",
    customer: customerId,
    line_items: [{ price: priceId, quantity: 1 }],
    subscription_data: {
      trial_period_days: 14,
      metadata: { userId },
    },
    // Belt and suspenders: metadata on both session and subscription.
    metadata: { userId },
    success_url: `${process.env.APP_URL}/billing?status=success`,
    cancel_url: `${process.env.APP_URL}/billing?status=cancelled`,
    allow_promotion_codes: true,
  });
}

The success_url here does nothing except show a friendly page. It does not grant access. That happens in the webhook.

The webhook handler is the whole product

Everything that matters to your billing state arrives as a webhook. Get this handler right and the rest is detail. Get it wrong and you will be issuing refunds and manually fixing entitlements for the life of the product.

Three non-negotiables: verify the signature, read the raw body, and be idempotent. Let's take them in order.

Signature verification is how you know the request actually came from Stripe and not from someone who found your endpoint URL. It requires the raw, unparsed request body — if your framework has already run JSON.parse on it, the signature check fails. In Next.js App Router you get the raw body with req.text().

Idempotency matters because Stripe will deliver the same event more than once. It guarantees at-least-once delivery, not exactly-once. Retries happen on timeouts, on network blips, and whenever your handler returns a non-2xx. If processing an event twice corrupts state — double-counting usage, sending two welcome emails — you have a bug waiting for traffic. The fix is a dedupe table keyed on the Stripe event ID.

import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import { syncSubscription } from "@/lib/billing";
 
export async function POST(req: Request) {
  const body = await req.text(); // raw body, NOT req.json()
  const sig = req.headers.get("stripe-signature");
 
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig!,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch (err) {
    // Bad signature => never reached Stripe's retry path, just reject.
    return new Response(`Webhook signature failed: ${err}`, { status: 400 });
  }
 
  // Idempotency gate. INSERT fails on the unique event.id if we have
  // seen this event before; we ack with 200 so Stripe stops retrying.
  const fresh = await db.markEventProcessed(event.id);
  if (!fresh) return new Response("duplicate", { status: 200 });
 
  try {
    switch (event.type) {
      case "checkout.session.completed": {
        const s = event.data.object as Stripe.Checkout.Session;
        if (s.mode === "subscription" && s.subscription) {
          await syncSubscription(s.subscription as string);
        }
        break;
      }
      case "customer.subscription.created":
      case "customer.subscription.updated":
      case "customer.subscription.deleted": {
        const sub = event.data.object as Stripe.Subscription;
        await syncSubscription(sub.id);
        break;
      }
      case "invoice.payment_failed": {
        const inv = event.data.object as Stripe.Invoice;
        // Basil moved the subscription ref off the invoice root and onto
        // invoice.parent.subscription_details. Re-sync; the subscription
        // is now past_due or unpaid.
        const subId = inv.parent?.subscription_details?.subscription;
        if (subId) await syncSubscription(subId as string);
        await notifyPaymentFailed(inv);
        break;
      }
      default:
        // Log unhandled types in dev; ignore quietly in prod.
        break;
    }
  } catch (err) {
    // Roll back the idempotency marker so Stripe retries this event.
    await db.unmarkEventProcessed(event.id);
    return new Response("handler error", { status: 500 });
  }
 
  return new Response("ok", { status: 200 });
}

Notice the pattern: nearly every event funnels into one function, syncSubscription(id). That is deliberate.

Stop trusting the event payload. Re-fetch.

Webhooks arrive out of order. Stripe says so plainly. You can get customer.subscription.updated before checkout.session.completed, or two updated events where the older one lands second. If you write the payload of each event into your database, a stale event can clobber newer state and silently downgrade a paying customer.

The robust pattern is to treat each webhook as a trigger, not a value. When any subscription-related event fires, ignore the body's details and re-fetch the current subscription from Stripe's API. Stripe's API always returns the latest truth. Order no longer matters, because every handler converges on the same fresh read.

import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
 
export async function syncSubscription(subscriptionId: string) {
  const sub = await stripe.subscriptions.retrieve(subscriptionId, {
    expand: ["items.data.price"],
  });
 
  const item = sub.items.data[0];
  const userId = sub.metadata.userId;
 
  // Store the MINIMUM needed to gate access. Everything else
  // (invoices, payment methods, history) we query Stripe for on demand.
  await db.upsertSubscription({
    userId,
    stripeCustomerId: sub.customer as string,
    stripeSubscriptionId: sub.id,
    priceId: item.price.id,
    status: sub.status, // active | trialing | past_due | canceled | unpaid ...
    // Basil (2025-03) moved the period boundaries off the Subscription
    // and onto each item. With a 2025-11 API version, read it here.
    currentPeriodEnd: new Date(item.current_period_end * 1000),
    cancelAtPeriodEnd: sub.cancel_at_period_end,
  });
}

The discipline here is store the minimum. Your database needs exactly enough to answer "can this account do X right now" without a network call on every request: the price/plan, the status, the period end, and the cancellation flag. Everything else — past invoices, the card brand, proration line items — lives in Stripe and you fetch it on demand when someone opens the billing page. Mirroring Stripe's entire object graph into your DB is a synchronization nightmare you do not need.

A schema that holds exactly that, plus the idempotency table:

create table subscriptions (
  user_id                  uuid primary key references users(id),
  stripe_customer_id       text not null,
  stripe_subscription_id   text not null unique,
  price_id                 text not null,
  status                   text not null,
  current_period_end       timestamptz not null,
  cancel_at_period_end     boolean not null default false,
  updated_at               timestamptz not null default now()
);
 
-- The idempotency ledger. The unique key IS the dedupe mechanism.
create table processed_events (
  event_id     text primary key,
  processed_at timestamptz not null default now()
);

Entitlements: one function, no booleans

Now the payoff. Because the database always reflects Stripe's truth, the access check is pure and synchronous. Map price IDs to plans, plans to feature limits, and let a trialing subscription count as active.

type Plan = "free" | "pro" | "scale";
 
const PRICE_TO_PLAN: Record<string, Plan> = {
  price_1QabcProMonthly: "pro",
  price_1QabcProYearly: "pro",
  price_1QabcScaleMonthly: "scale",
};
 
const LIMITS: Record<Plan, { seats: number; apiCallsPerMonth: number }> = {
  free: { seats: 1, apiCallsPerMonth: 1_000 },
  pro: { seats: 10, apiCallsPerMonth: 100_000 },
  scale: { seats: 100, apiCallsPerMonth: 5_000_000 },
};
 
// Statuses where the customer still gets paid features.
const ACTIVE = new Set(["active", "trialing", "past_due"]);
 
export async function getEntitlements(userId: string) {
  const sub = await db.getSubscription(userId);
  if (!sub || !ACTIVE.has(sub.status)) {
    return { plan: "free" as Plan, limits: LIMITS.free };
  }
  const plan = PRICE_TO_PLAN[sub.priceId] ?? "free";
  return { plan, limits: LIMITS[plan] };
}

The deliberate choice: past_due keeps access. When a renewal charge fails, Stripe does not cancel immediately — it enters dunning, retrying the card on a schedule (Smart Retries, typically over a few days to two weeks). Yanking access the moment the first charge fails punishes a customer whose card you will successfully charge on the retry tomorrow. That is a churn machine. Keep them in a grace period through past_due, and only drop to free when Stripe gives up and the subscription becomes canceled or unpaid. You configure that final outcome in the Stripe dashboard's retry settings; your code just honors the status.

Proration, upgrades, and the idempotency key you forgot

When a customer upgrades mid-cycle, Stripe prorates by default: it credits the unused portion of the old plan and charges the prorated difference for the new one, immediately. You usually want this. To change a plan, you update the subscription item — and this is a write, so it gets an idempotency key. If the user's request times out and the client retries, the key prevents a double charge.

export async function changePlan(
  userId: string,
  newPriceId: string,
  requestId: string, // a UUID generated per user action, not per HTTP call
) {
  const sub = await db.getSubscription(userId);
  const stripeSub = await stripe.subscriptions.retrieve(sub.stripeSubscriptionId);
 
  await stripe.subscriptions.update(
    sub.stripeSubscriptionId,
    {
      items: [{ id: stripeSub.items.data[0].id, price: newPriceId }],
      proration_behavior: "create_prorations", // or "none" for downgrades
      payment_behavior: "error_if_incomplete",
    },
    { idempotencyKey: `change-plan-${userId}-${requestId}` },
  );
  // The resulting customer.subscription.updated webhook calls
  // syncSubscription and updates our DB. We do not write state here.
}

A real tradeoff worth naming: I prorate upgrades (create_prorations) so the customer pays for the better plan right away, but I often set downgrades to proration_behavior: "none" so the downgrade takes effect at period end. Refunding the difference on a downgrade is rarely worth the support overhead, and end-of-period downgrades discourage plan-hopping abuse. Decide this per direction, not globally.

The edge-case checklist

Before you call Stripe billing "done," walk this list. Every item is a bug I have shipped or cleaned up after.

  • Verify the signature on every webhook using the raw request body. A parsed body silently breaks verification.
  • Dedupe on event.id. Stripe delivers at-least-once; assume every event can arrive twice.
  • Re-fetch from Stripe on every sync; never write the event payload directly. This makes out-of-order delivery a non-issue.
  • Idempotency keys on all writes (subscription create/update). Key on the user action, not the HTTP request.
  • Treat trialing and past_due as paid. Drop to free only on canceled/unpaid.
  • Let dunning run. Configure Smart Retries in the dashboard; do not cut access on the first invoice.payment_failed.
  • Notify the customer on payment_failed with a link to the Billing Portal to update their card. This recovers real revenue.
  • Store the minimum. Plan, status, period end, cancel flag. Query Stripe for invoices and payment methods on demand.
  • Return 200 fast. Do heavy work async if needed; a slow handler triggers retries and duplicate processing.
  • Test with the CLI: stripe listen --forward-to localhost:3000/api/webhooks/stripe and stripe trigger invoice.payment_failed to replay every path locally before you trust it.
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed

Build it this way and your billing has exactly one source of truth, it converges no matter what order events arrive in, and it survives the failure modes that only show up once real cards and real money start flowing through it. The boolean on the success URL was never the problem. Trusting it was.