Learn Build a Real SaaS with Claude Code Stripe Webhooks: Subscription Lifecycle

Stripe Webhooks: Subscription Lifecycle

Intermediate 🕐 28 min Lesson 13 of 25
What you'll learn
  • Generate a Stripe webhook signing secret via the Stripe CLI for local testing and via the dashboard for production
  • Verify a webhook request's signature using the raw request body before trusting its contents
  • Handle checkout.session.completed and the three customer.subscription.* events covering a subscription's full lifecycle
  • Explain why Stripe does not guarantee webhook delivery order and what that implies for handler logic
  • Separate pure reconciliation logic from the route handler so it can be unit tested without a live Stripe account
  • Wire up the Stripe Billing Portal for self-serve subscription management

Why Checkout Alone Is Not Enough

Lesson 12 built a Checkout flow that redirects to ?checkout=success when it finishes -- but that redirect is just a UX nicety, never trusted to grant Pro access on its own. A user can close the browser tab before the redirect fires, or replay the success URL without ever completing payment. The webhook endpoint is what actually updates businesses.subscriptionTier/subscriptionStatus, because it is Stripe itself telling your server what happened, signed so you can verify it really came from Stripe.

Setting Up a Webhook Endpoint and Signing Secret

You need a webhook signing secret before the endpoint will accept events, and there are two ways to get one depending on whether you are testing locally or running in production:

  • Local development -- Stripe CLI: install the Stripe CLI (free), log in with stripe login, then run stripe listen --forward-to localhost:3000/api/billing/webhook. The CLI prints a signing secret starting with whsec_... the moment it starts listening -- copy that into STRIPE_WEBHOOK_SECRET in your local .env. This secret is temporary and regenerates each time you run stripe listen.
  • Production -- Stripe Dashboard: under Developers → Webhooks, add an endpoint pointing at https://<your-domain>/api/billing/webhook, subscribe it to the events this lesson handles, and the dashboard reveals a permanent signing secret for that endpoint specifically. Each endpoint has its own secret -- the local CLI secret and the production dashboard secret are different values, both valid for STRIPE_WEBHOOK_SECRET in their respective environments.

With the CLI listener running, trigger a test event from a second terminal: stripe trigger checkout.session.completed.

Verifying the Signature: app/api/billing/webhook/route.ts

Signature verification needs the request's raw, unparsed body -- so this handler reads request.text() instead of request.json(). Next.js Route Handlers do not parse the body automatically, which makes this safe by default:

export async function POST(request: NextRequest) {
  const signature = request.headers.get("stripe-signature");
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

  if (!signature || !webhookSecret) {
    return NextResponse.json({ error: "Missing webhook signature/secret" }, { status: 400 });
  }

  const rawBody = await request.text();

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    return NextResponse.json({ error: `Webhook signature verification failed: ${message}` }, { status: 400 });
  }
  // ...
}

stripe.webhooks.constructEvent does the verification: it checks the signature against your secret and confirms the payload has not been tampered with in transit. A request with a missing or invalid signature is rejected before any of your event-handling logic runs.

Handling the Events That Matter

switch (event.type) {
  case "checkout.session.completed": {
    const session = event.data.object as Stripe.Checkout.Session;
    if (session.mode === "subscription" && typeof session.subscription === "string") {
      const subscription = await stripe.subscriptions.retrieve(session.subscription);
      await syncSubscription(subscription);
    }
    break;
  }

  case "customer.subscription.created":
  case "customer.subscription.updated":
  case "customer.subscription.deleted": {
    const subscription = event.data.object as Stripe.Subscription;
    await syncSubscription(subscription);
    break;
  }

  default:
    break;
}

return NextResponse.json({ received: true });

The three customer.subscription.* events together cover the full lifecycle a Pro subscription goes through: upgrades, downgrades, payment failures (which move a subscription to past_due), cancellations, and reactivations all surface as one of those three event types. Unhandled event types are intentionally ignored rather than erroring, with a 200 still returned -- Stripe retries an endpoint that doesn't return a 2xx, so acknowledging events you don't act on avoids needless retries.

Reconciliation Logic: lib/billing/reconcile.ts

The actual decision of "should this event change what's stored for this business" lives in a separate, pure function -- kept apart from the route so it can be unit tested without a live database or a real signed Stripe payload:

export function reconcileSubscriptionUpdate(
  business: Business,
  subscription: Stripe.Subscription,
): SubscriptionPatch | null {
  const hasDifferentSubscriptionOnFile =
    business.stripeSubscriptionId !== null &&
    business.stripeSubscriptionId !== subscription.id;
  const storedSubscriptionStillCurrent = !TERMINAL_STATUSES.includes(business.subscriptionStatus);

  const isStale = hasDifferentSubscriptionOnFile && storedSubscriptionStillCurrent;
  if (isStale) {
    return null;
  }

  return {
    subscriptionTier: tierFromStatus(subscription.status),
    subscriptionStatus: subscription.status,
    stripeSubscriptionId: subscription.id,
  };
}

Stripe does not guarantee that webhook events arrive in the order they happened -- their own docs are explicit that retries, network delays, and replays can all reorder delivery. A handler that blindly applies every event by customer id, with no concept of "is this event about the subscription I currently have on file," is vulnerable to an older event overwriting a newer state. This function guards against that: it only treats an incoming event for a different subscription id as stale (and skips it) when the business's currently-stored subscription still looks active. If the stored subscription has already reached a terminal status like canceled, a new subscription id is treated as a legitimate resubscription and applied normally.

Writing this kind of reconciliation as a pure function you can unit test -- feed it a business state and a fake Stripe subscription object, assert on the returned patch -- is what makes it possible to verify webhook logic without standing up ngrok, the CLI, and a live test account every time you change it.

The Billing Portal: app/api/billing/portal/route.ts

For canceling or updating payment methods, this app does not build custom UI -- it redirects to Stripe's own hosted Billing Portal:

const session = await stripe.billingPortal.sessions.create({
  customer: business.stripeCustomerId,
  return_url: `${appUrl}/dashboard/billing`,
});

return NextResponse.redirect(session.url, { status: 303 });

Any change a customer makes in the portal -- canceling, switching payment method, updating billing details -- fires the same customer.subscription.updated webhook event already handled above, so no separate sync logic is needed for portal-initiated changes.

Billing is now fully wired: Checkout to start a subscription, webhooks as the source of truth, and the portal for self-serve management. Lesson 14 adds the email side of Pro -- the weekly digest and negative-review alerts.

Key takeaways
  • The Checkout success redirect is never the source of truth for subscription state -- only a verified webhook event is
  • Signature verification requires the raw, unparsed request body (request.text()), not the parsed JSON body
  • Local webhook testing uses the Stripe CLI's stripe listen command, which prints its own temporary whsec_ secret
  • Production webhook endpoints get a permanent signing secret from the Stripe dashboard, separate from the CLI's local secret
  • Stripe does not guarantee event delivery order -- reconciliation logic should not assume the most recently received event is the most recent in time
  • Returning 200 for unhandled-but-acknowledged event types avoids needless Stripe retries on events you don't act on