Stripe subscriptions done right: webhooks, proration and failed payments
How to build Stripe subscription billing that stays correct: which webhook events to handle, signature verification, idempotent processing, proration, dunning, the customer portal and keeping your database in sync.
Muhammad Faizan
Founder & Lead Engineer
Getting a Stripe Checkout page working takes an afternoon. Keeping subscription state correct for years is the hard part. Users upgrade mid-cycle, cards expire, banks require 3D Secure on renewal, webhooks arrive late or out of order, and someone cancels from the customer portal while your app still shows them as active. Most billing bugs are not Stripe bugs; they are synchronisation bugs between Stripe and your database.
This article covers the design we use for subscription billing in SaaS products: Stripe is the source of truth for billing, your database holds a synchronised copy for fast access checks, and webhooks are processed in a way that tolerates retries, duplicates and reordering. Examples use the Node.js SDK in a Next.js route handler, but the design applies to any stack.
The events you actually need
Stripe emits a lot of event types. For a typical subscription product, a small set covers the lifecycle:
checkout.session.completed: link the Stripe customer and subscription IDs to your user or workspace.customer.subscription.created,customer.subscription.updatedandcustomer.subscription.deleted: plan changes, cancellations, trials ending and status transitions.invoice.paid: a renewal succeeded; extend access and record the payment.invoice.payment_failed: start your dunning flow and tell the customer.invoice.payment_action_required: the bank needs the customer to authenticate the payment.customer.subscription.trial_will_end: sent a few days before a trial ends, useful for reminder emails.
Subscribe your endpoint to these explicitly in the Dashboard or API rather than to all events. Fewer irrelevant deliveries mean less noise and fewer code paths to reason about.
Verify signatures on the raw body
Your webhook endpoint is a public URL. Without verification, anyone can POST a fake invoice.paid and unlock a paid plan. Stripe signs each delivery with your endpoint's signing secret, and the SDK verifies it, but only against the exact raw bytes it sent. Parsing JSON first and re-serialising breaks the signature. In a Next.js route handler, read the body with req.text().
import Stripe from "stripe";
import { db } from "@/lib/db";
import { syncSubscription } from "@/lib/billing";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const secret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const body = await req.text(); // raw body, required for signature checks
const signature = req.headers.get("stripe-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, secret);
} catch {
return new Response("Invalid signature", { status: 400 });
}
// Record the event ID; a duplicate delivery hits the unique constraint
const inserted = await db.query(
"INSERT INTO stripe_events (id, type) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING RETURNING id",
[event.id, event.type],
);
if (inserted.rowCount === 0) return new Response("Already processed", { status: 200 });
try {
switch (event.type) {
case "checkout.session.completed":
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted":
case "invoice.paid":
case "invoice.payment_failed":
await syncSubscription(stripe, event);
break;
}
} catch (err) {
// Allow Stripe to retry: forget the event and return an error
await db.query("DELETE FROM stripe_events WHERE id = $1", [event.id]);
throw err;
}
return new Response("ok", { status: 200 });
}Respond quickly. If processing is slow, such as sending emails or provisioning resources, record the event and hand it to a background queue, returning 200 once it is safely stored. Stripe retries failed deliveries with backoff for several days, so a temporary outage is recoverable as long as your handler is idempotent.
Sync state by fetching, not by trusting order
Stripe does not guarantee event ordering. A customer.subscription.updated can arrive before the created event, and two updates can arrive reversed. If each handler applies its payload as a diff, your database ends up in a state Stripe never had. The robust pattern is to treat every relevant event as a signal: extract the subscription ID, fetch the current subscription from the API, and upsert it.
import type Stripe from "stripe";
import { db } from "@/lib/db";
function subscriptionIdFrom(event: Stripe.Event): string | null {
const obj = event.data.object as { object: string; id: string; subscription?: unknown };
if (obj.object === "subscription") return obj.id;
if (obj.object === "checkout.session") return (obj as Stripe.Checkout.Session).subscription as string | null;
if (obj.object === "invoice") {
const invoice = obj as Stripe.Invoice;
const sub = invoice.parent?.subscription_details?.subscription;
return typeof sub === "string" ? sub : (sub?.id ?? null);
}
return null;
}
export async function syncSubscription(stripe: Stripe, event: Stripe.Event) {
const id = subscriptionIdFrom(event);
if (!id) return;
// Always read the latest state; never apply event payloads as diffs
const sub = await stripe.subscriptions.retrieve(id);
const item = sub.items.data[0];
await db.query(
`INSERT INTO subscriptions (id, customer_id, status, price_id, current_period_end, cancel_at_period_end)
VALUES ($1, $2, $3, $4, to_timestamp($5), $6)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
price_id = EXCLUDED.price_id,
current_period_end = EXCLUDED.current_period_end,
cancel_at_period_end = EXCLUDED.cancel_at_period_end`,
[sub.id, sub.customer as string, sub.status, item.price.id, item.current_period_end, sub.cancel_at_period_end],
);
}Field locations depend on your API version. In recent versions, billing period fields live on subscription items and an invoice's subscription is under parent.subscription_details, as above. Pin your API version, read the changelog before upgrading, and let the SDK's types catch the differences. Your access check then becomes a cheap local query: is there a subscription for this workspace with status active or trialing, and, if you offer a grace period, past_due?
Plan changes and proration
When a customer switches plans mid-cycle, Stripe calculates proration: credit for unused time on the old price and a charge for the remaining time on the new one. The proration_behavior parameter on the update controls what happens: create_prorations (the default) adds those line items to the next invoice, always_invoice bills them immediately, and none skips proration entirely.
A common, customer-friendly setup is always_invoice for upgrades, so the customer pays the difference and gets the new plan now, and scheduling downgrades for the end of the period with a subscription schedule or by updating at renewal. Before confirming a change, show the customer the amount using an invoice preview (invoices.createPreview in current API versions) so there are no surprises on their statement.
Failed payments, dunning and the customer portal
Renewals fail for ordinary reasons: expired cards, insufficient funds, banks requiring authentication. Stripe's Smart Retries retry failed payments at times more likely to succeed, and its automatic emails can ask customers to update their card. In the Billing settings, decide what happens when retries run out: cancel the subscription, mark it unpaid, or leave it past_due. Mirror that decision in your access logic.
- On
invoice.payment_failed, show an in-app banner with a link to update payment details. - On
invoice.payment_action_required, send the customer to the hosted invoice page to complete authentication. - Give a short grace period while the subscription is
past_dueinstead of locking users out on the first failure. - Downgrade or restrict access only when the subscription reaches
canceledorunpaid.
Test all of this before launch. The Stripe CLI can forward events to your local endpoint with stripe listen --forward-to localhost:3000/api/stripe/webhook and trigger them with stripe trigger invoice.payment_failed. Test clocks let you advance a subscription through renewals, trial endings and retries in minutes instead of waiting for real billing cycles.
For self-service changes, the Stripe customer portal handles payment method updates, invoice history, plan switches and cancellations with almost no code: configure it in the Dashboard and create a session with stripe.billingPortal.sessions.create({ customer, return_url }). Every change made there arrives as the same webhook events, so your sync logic covers it automatically.
Key takeaways
- Subscribe to a focused set of subscription and invoice events, and verify every signature against the raw body.
- Deduplicate by event ID and keep handlers idempotent so Stripe's retries are safe.
- Treat events as signals: fetch the current subscription and upsert it rather than applying payloads in arrival order.
- Choose
proration_behaviordeliberately and preview charges before plan changes. - Use Smart Retries, clear in-app dunning and the customer portal, and base access on synced subscription status.
Launching a SaaS product and need billing that just works? We build subscription billing, onboarding and the rest of v1 as part of MVP development. Talk to us.
Frequently asked questions
Should I grant access on checkout.session.completed or invoice.paid?
Use checkout.session.completed to link the Stripe customer to your user, and derive ongoing access from the subscription status kept in sync via customer.subscription events and invoice.paid.
Why does my webhook signature verification fail?
Almost always because the body was parsed before verification. Stripe signs the exact raw bytes, so pass the unparsed request body and the correct endpoint secret to constructEvent.
What happens when a subscription payment fails?
Stripe marks the invoice unpaid, moves the subscription to past_due and retries according to your settings. After retries are exhausted it cancels the subscription or marks it unpaid, depending on your configuration.
Need help with mvp & saas development?
From idea to a launched v1: multi-tenant SaaS with auth, billing and analytics built in.