All tutorials

Tutorial

SaaS Subscription Billing That Survives Contact With Real Customers

Why checkout.session.completed isn't enough: a webhook-driven state machine for subscription upgrades, downgrades, and failed payments.

webhookspostgresautomation

Your first Stripe integration took an afternoon. Checkout session, webhook for checkout.session.completed, flip a is_subscribed boolean in your database, done. Then a customer upgrades mid-cycle. Then one downgrades and expects the change to wait until renewal. Then a card gets declined on the 3rd of the month and you realize you have no idea what’s supposed to happen next. Damian Hodgkiss covers the webhook-driven state machine that keeps your database and Stripe in sync in his Stripe billing integration tutorial at damianhodgkiss.com.

That gap — between “first payment succeeds” and “subscription lifecycle actually works” — is where most SaaS billing code quietly rots. The fix isn’t more Stripe API calls bolted onto your checkout handler. It’s a data model that treats subscriptions as a stateful entity with history, and a webhook handler that treats Stripe as the source of truth your database reconciles against, not the other way around.

The direct answer: model state, don’t chase events

The durable pattern has three parts: a Postgres schema that stores subscription state independently of any single Stripe event, a webhook endpoint that’s the only writer to that state, and idempotent handlers that can replay the same event ten times without corrupting anything. Get those three right and proration, dunning, and cancel/reactivate all become straightforward branches in the same state machine rather than one-off hacks.

A subscription data model that survives plan changes

The mistake I see most often is storing subscription data as flat columns on the users table — plan, status, stripe_customer_id — and overwriting them on every webhook. That works until a customer changes plans mid-cycle and you need to know what they were on five minutes ago for proration math, support tickets, or revenue reporting. Model it as its own table with an append-only history instead:

CREATE TABLE subscriptions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id),
    stripe_subscription_id TEXT UNIQUE NOT NULL,
    stripe_customer_id TEXT NOT NULL,
    stripe_price_id TEXT NOT NULL,
    status TEXT NOT NULL,              -- active, past_due, canceled, trialing, incomplete
    current_period_end TIMESTAMPTZ NOT NULL,
    cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE subscription_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    subscription_id UUID REFERENCES subscriptions(id),
    stripe_event_id TEXT UNIQUE NOT NULL,
    event_type TEXT NOT NULL,
    payload JSONB NOT NULL,
    received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

The subscription_events table is your audit log and your idempotency guard in one. Because stripe_event_id is unique, inserting the same webhook delivery twice fails cleanly instead of double-processing a plan change. The status column mirrors Stripe’s own subscription statuses rather than inventing your own vocabulary — fewer translation bugs when you’re debugging at 11pm.

The webhook-driven state machine

Stripe, not your frontend and not your checkout handler, is the source of truth for subscription state. Your FastAPI service’s job is to listen, verify, and reconcile:

from fastapi import APIRouter, Request, HTTPException
import stripe

router = APIRouter()

@router.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
    payload = await request.body()
    sig_header = request.headers.get("stripe-signature")

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
        )
    except (ValueError, stripe.error.SignatureVerificationError):
        raise HTTPException(status_code=400, detail="Invalid signature")

    # Idempotency: insert-or-skip on stripe_event_id
    inserted = await insert_event_if_new(event["id"], event["type"], event["data"])
    if not inserted:
        return {"status": "already_processed"}

    handler = EVENT_HANDLERS.get(event["type"])
    if handler:
        await handler(event["data"]["object"])

    return {"status": "ok"}

Signature verification isn’t optional — without it, anyone who finds your webhook URL can forge subscription upgrades. The insert-or-skip step is what makes retries safe: Stripe will redeliver events on timeout or non-2xx response, and your handler must treat the second delivery as a no-op.

The handlers themselves map cleanly onto lifecycle events: customer.subscription.updated reconciles status and current_period_end, customer.subscription.deleted marks canceled, invoice.payment_failed kicks off dunning, invoice.payment_succeeded clears any dunning flags. Each handler does one thing: upsert the local row to match what Stripe just told you. Resist the temptation to compute business logic (like “should this user lose access”) inside the webhook handler — keep that derived from status and current_period_end wherever you check entitlements, so a missed webhook doesn’t permanently desync the two systems.

Proration on upgrades and downgrades

Stripe calculates proration automatically when you update a subscription’s price, but your code chooses the proration_behavior and the timing of when the change takes effect. The common pattern: upgrades apply immediately with prorated charge, downgrades apply at the next renewal.

@router.post("/subscriptions/{subscription_id}/change-plan")
async def change_plan(subscription_id: str, new_price_id: str, immediate: bool):
    sub = stripe.Subscription.retrieve(subscription_id)
    stripe.Subscription.modify(
        subscription_id,
        items=[{"id": sub["items"]["data"][0]["id"], "price": new_price_id}],
        proration_behavior="create_prorations" if immediate else "none",
        billing_cycle_anchor="unchanged" if immediate else "now",
    )

For upgrades, pass a proration_behavior value (such as create_prorations) so Stripe handles the adjustment for the price change — see Stripe’s subscription proration docs for the exact behaviour. For downgrades, I schedule the price change for period end instead of applying it immediately — it avoids refund logic entirely and matches what customers expect (“I paid for the month, I keep the features until it’s over”).

Cancellation and reactivation

Cancellation should almost always be “cancel at period end,” not immediate revocation — it’s friendlier and it’s what cancel_at_period_end=True gives you for free.

stripe.Subscription.modify(subscription_id, cancel_at_period_end=True)

Reactivation before the period ends is just flipping that flag back:

stripe.Subscription.modify(subscription_id, cancel_at_period_end=False)

Your subscriptions table already tracks cancel_at_period_end, so the frontend can show “Your plan ends on [date] — reactivate” without any extra Stripe calls, reading straight from Postgres. Only treat access as revoked once customer.subscription.deleted actually arrives — don’t revoke early just because the flag is set, or you’ll lock out customers who change their mind two days later.

Dunning: recovering failed payments

A declined card isn’t a cancellation — it’s a retry opportunity, and how you handle the retry window determines how much otherwise-healthy revenue you keep. What happens to the subscription status after a failed renewal payment depends on the retry and subscription settings configured in your Stripe Dashboard, so handle whatever status Stripe reports via the customer.subscription.updated event rather than assuming a fixed outcome.

async def handle_payment_failed(invoice: dict):
    subscription_id = invoice["subscription"]
    await update_subscription_status(subscription_id, status="past_due")
    user = await get_user_by_subscription(subscription_id)
    await send_dunning_email(
        user.email,
        attempt=invoice["attempt_count"],
        update_payment_url=f"{settings.APP_URL}/billing/update-card",
    )

async def handle_payment_succeeded(invoice: dict):
    subscription_id = invoice["subscription"]
    await update_subscription_status(subscription_id, status="active")

The practical dunning flow: on invoice.payment_failed, flag the subscription past_due, email the customer with a direct link to update their card, and gate any status-check in your app to show a visible “payment failed, update your card” banner rather than a silent lockout. What happens once retries are exhausted depends on the retry and subscription settings configured in your Stripe Dashboard — the subscription is canceled or marked unpaid per those settings, and you should handle the resulting customer.subscription.deleted event as well as customer.subscription.updated status changes so dunning and cancellation stay in sync.

Next.js: checkout and live subscription status

The frontend’s job is thin: start checkout, and reflect whatever Postgres says — never compute subscription state client-side from cached data.

// app/billing/checkout/route.ts
export async function POST(req: Request) {
  const { priceId } = await req.json();
  const session = await fetch(`${process.env.API_URL}/checkout-sessions`, {
    method: "POST",
    body: JSON.stringify({ price_id: priceId }),
  }).then(r => r.json());
  return Response.json({ url: session.url });
}
// app/billing/status/page.tsx
async function getSubscription(userId: string) {
  const res = await fetch(`${process.env.API_URL}/subscriptions/${userId}`, {
    cache: "no-store",
  });
  return res.json();
}

export default async function BillingStatus({ params }: { params: { userId: string } }) {
  const sub = await getSubscription(params.userId);
  return (
    <div>
      <p>Status: {sub.status}</p>
      {sub.status === "past_due" && <UpdateCardBanner />}
      {sub.cancel_at_period_end && <ReactivateButton subscriptionId={sub.id} />}
    </div>
  );
}

Fetching with cache: "no-store" matters here — subscription status changes via webhook, out-of-band from the request cycle, so stale server-rendered data is actively misleading.

Testing and verification before you ship

Use the Stripe CLI to forward webhooks to your local FastAPI server and fire test events for each lifecycle branch — subscription created, upgraded, downgraded, payment failed, canceled — before trusting any of this in production. Walk through each one manually: does the past_due banner appear, does reactivation clear the cancel flag, does a second delivery of the same event get silently skipped by your idempotency check. That last one is the test people skip and the one that saves you from double-billing incidents.

FAQ

Does Stripe calculate proration automatically? Yes — when you change a subscription’s price, Stripe computes the proration, and you pass proration_behavior to control it.

Should downgrades take effect immediately? Generally no. Scheduling downgrades for the next billing period avoids refund logic and matches customer expectations that paid-for time doesn’t disappear early.

What status should a subscription be in after a failed payment? Mirror whatever status Stripe reports rather than assuming a fixed outcome — it’s often past_due while retries run, but the exact behavior depends on the retry and subscription settings configured in your Stripe Dashboard. Key your dunning emails off invoice.payment_failed together with that reported status.

Why do I need an idempotency table for webhooks? Stripe can deliver the same event more than once — for example on retries after timeouts or non-2xx responses — so store processed event IDs and skip ones you’ve already handled. In this article’s schema, that’s implemented with a unique constraint on the event ID.

Can the frontend compute subscription status instead of the backend? Don’t — the frontend should only render what Postgres already reconciled from Stripe webhooks, never derive status from client-cached data or optimistic assumptions.