Stripe Webhooks: Subscription Lifecycle
- 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 runstripe listen --forward-to localhost:3000/api/billing/webhook. The CLI prints a signing secret starting withwhsec_...the moment it starts listening -- copy that intoSTRIPE_WEBHOOK_SECRETin your local.env. This secret is temporary and regenerates each time you runstripe 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 forSTRIPE_WEBHOOK_SECRETin 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.
- 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