Stripe subscription webhooks that survive the edge cases
Stripe subscription webhooks only stay correct if you treat Stripe as the source of truth, verify the Stripe-Signature header, dedupe on event id, and refetch instead of trusting event order. Here is the lifecycle map and the failure modes that bit us.
Stripe subscription webhooks only stay correct if you treat Stripe as the source of truth, verify the Stripe-Signature header on every request, dedupe on the event id because Stripe retries, and refetch the live object instead of trusting the order events arrive in. Everything else — the database row, the "active" flag, the access gate — is a projection of that truth, not the truth itself. This is the lesson we paid for the hard way building the billing suite for Delicious Diamonds, a luxury chocolate maker in Leiria, and it is the principle that keeps the system honest months later.
How to handle Stripe subscription webhooks without corrupting your billing state
The naive mental model is: a customer subscribes, Stripe sends one webhook, you flip a boolean, done. That model survives the demo and dies in production. Real subscription billing is a long-lived state machine that Stripe runs on its own clock — renewals at month boundaries, retries on failed cards, proration on tier changes, dunning emails, involuntary churn. Your server hears about all of it through a firehose of events that arrive late, duplicated, and out of order. The job of your webhook handler is not to "process payments". It is to be a faithful projector of a state machine that lives entirely inside Stripe.
At Amplified Creations we build server-rendered PHP 8.3 on OVH shared hosting behind Cloudflare, with Cockpit CMS for content and no build step. For Delicious Diamonds we shipped a full Stripe suite: one-off orders, bespoke commissions, hand-numbered limited editions, and monthly chocolate subscriptions with pause, cancel, and tier changes — all driven by lifecycle webhooks. The design rule we landed on after the first round of bugs was blunt: store the minimum local state needed to render a page fast, and re-derive everything important from Stripe whenever a webhook tells us something moved.
Stripe is the source of truth, not your database
Your database is a cache. Say it out loud before you write a single handler, because every painful subscription bug traces back to forgetting it. If your local status column and Stripe disagree, Stripe is right and you are stale. We enforce this by never computing subscription state ourselves — we never decide "this payment failed, so set status to past_due". Instead we wait for Stripe to tell us the subscription is now past_due and we copy that down. The handler's only authored logic is mapping Stripe's reality onto our access rules (can this customer log in and see their subscriber pricing?), never inventing billing reality of its own.
The practical payoff: when something looks wrong, the debugging question is always "what does Stripe think?", answered in one dashboard lookup or one API call. There is no second opinion to reconcile.
Which lifecycle events actually matter
Stripe emits dozens of event types. For subscriptions you can ignore most of them. These are the ones we actually wire up, and what each one means.
checkout.session.completed
Fires when the customer finishes Stripe Checkout. This is your signal that a subscription intent succeeded and you can link the Stripe customer and subscription ids to your local user. Do not treat it as "payment confirmed forever" — it is the start of the relationship, not a guarantee of every future renewal. We read client_reference_id here to match the session back to the user who started it.
customer.subscription.created / customer.subscription.updated / customer.subscription.deleted
These are the spine of the state machine. created tells you the subscription exists; updated fires on nearly every meaningful change — status transitions (active, past_due, canceled, paused), tier swaps, scheduled cancellations, pause/resume. deleted means it is gone and access should end. We treat customer.subscription.updated as the single most important event: whenever it arrives we copy the subscription's current status, items (the tier), and current_period_end into our row. Ninety percent of "keep the local view correct" lives in this one handler.
invoice.paid
Fires when an invoice — including each monthly renewal invoice — is successfully paid. This is the event that says "they have paid for the next period". We use it to extend access and to record the payment for the customer's order history. Note it is distinct from checkout.session.completed: Checkout covers the first payment, invoice.paid covers every renewal after.
invoice.payment_failed
Fires when a renewal charge fails — expired card, insufficient funds, a bank declining a cross-border luxury purchase (which happens more than you would think). This is the entry point to dunning. It does not by itself mean the subscription is cancelled; Stripe will retry on the schedule you configure. We use it to flag the account and, where appropriate, nudge the customer to update their card.
invoice.payment_action_required
Fires when the payment needs Strong Customer Authentication — the customer has to complete a 3D Secure challenge before the charge clears. Under European SCA rules this is common, not exotic. The right response is to notify the customer that action is needed; the money is not collected until they authenticate.
The Stripe-Signature header and the webhook signing secret
Before you trust a single byte of a webhook, verify it came from Stripe. The endpoint is a public URL; anyone can POST to it. Stripe signs every delivery with the Stripe-Signature header, and your handler verifies that signature against a per-endpoint webhook signing secret (the whsec_... value from the dashboard, kept out of the repo). In PHP:
$payload = file_get_contents('php://input');
$sig = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
try {
$event = \Stripe\Webhook::constructEvent(
$payload, $sig, $webhookSecret
);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
http_response_code(400);
exit;
}
Two gotchas bit us. First: you must verify against the raw request body. If any middleware re-encodes or pretty-prints the JSON before you hash it, verification fails for legitimate events — read php://input directly, do not reach for an already-parsed array. Second: the signing secret from stripe listen in local testing is different from your production endpoint's secret. Mixing them up produces a stream of 400s that look like an attack and are actually a config mistake.
Idempotency: dedupe on the event id because Stripe retries
Stripe guarantees at-least-once delivery, not exactly-once. If your endpoint is slow, times out, or returns a non-2xx, Stripe retries — and it can also legitimately deliver the same event twice. If your handler is not idempotent, a duplicated invoice.paid double-credits an order, and a retried event re-sends a "your subscription is active" email the customer already got. The fix is simple and non-negotiable: every event has a stable id (the evt_... value). Record processed ids and skip anything you have already seen.
// Insert the event id first; the UNIQUE constraint is the lock.
$ok = $db->insertEventIfNew($event->id); // false if already present
if (!$ok) {
http_response_code(200); // already handled, ack and move on
exit;
}
// ... do the real work ...
We let the database's UNIQUE constraint on the event id do the deduplication — insert first, and if it collides we have already handled this event and return 200 immediately. Returning 200 on a duplicate matters: if you return an error, Stripe keeps retrying forever. Acknowledge fast, process idempotently.
Out-of-order events: use the object in the event, then refetch
This is the one that surprised us most. Webhooks are not guaranteed to arrive in the order the underlying changes happened. You can receive customer.subscription.updated for a cancellation before the invoice.paid for the renewal that preceded it, because the two HTTP deliveries raced. If your handler blindly applies whatever it last received, a late-arriving stale event clobbers fresh state and a paying customer loses access.
Two defences, used together. First, never trust webhook ordering for anything that matters — when an event tells you a subscription changed, refetch the live object from the Stripe API and project that, not the possibly-stale snapshot embedded in the event. The embedded object is fine for routing and logging; the live fetch is what you write down. Second, when you do use the embedded object, lean on Stripe's monotonic fields — current_period_end and the subscription status on a fresh fetch reflect the true current state regardless of which event woke you up. We learned this when a customer who upgraded tiers briefly appeared on the old tier because two updated events landed reversed; refetching on every handler made the symptom disappear.
Dunning and failed payments: past_due and canceled
When a renewal fails, Stripe does not cancel immediately — it runs dunning: a configurable retry schedule (Smart Retries or fixed) with optional reminder emails. During this window the subscription sits in past_due. Your job is to decide what past_due means for access. We keep subscriber pricing and the account active through the grace window — a declined card on a chocolate subscription is usually a card problem, not a churn signal — and only revoke when Stripe finally moves the subscription to canceled after exhausting retries. The states to handle: active (all good), past_due (paid before, currently failing, in grace), canceled (over, revoke access), and paused if you use pause collection. Crucially, we do not author any of these transitions — Stripe owns the retry policy, we just read the resulting status.
Implementing pause, cancel, and tier changes
All three are writes to the Stripe API; the resulting customer.subscription.updated webhook is what updates our side. We never mutate local state at the moment of the click and hope Stripe agrees — we make the API call, and the webhook closes the loop.
- Cancel: set
cancel_at_period_end = truefor a graceful end (customer keeps access until the period they paid for runs out), or cancel immediately if they demand it. The graceful path is almost always the right default and avoids refund arguments. - Pause: use Stripe's
pause_collectionon the subscription so renewals stop without losing the subscription record and its history. Resuming clears it. This is far cleaner than cancel-and-recreate, which loses the numbered-edition entitlement history we care about. - Tier change: update the subscription item to the new price. Stripe handles proration; the
updatedwebhook brings the new tier down to us. We let Stripe compute the money and never try to pre-calculate the proration ourselves.
Testing with the Stripe CLI: stripe listen and stripe trigger
You cannot test this against production payments, and you should not test it by clicking through Checkout fifty times. The Stripe CLI is the tool. stripe listen --forward-to localhost:8000/webhook opens a tunnel that forwards real test-mode events to your local handler and prints the signing secret to use. stripe trigger invoice.payment_failed (and the other event names above) fires a synthetic-but-realistic event so you can exercise the dunning path without owning a card that declines on command. We script a sequence — create, renew, fail, retry, cancel — and assert our projected state after each, which is the only way to catch the out-of-order bugs before customers do.
The short version
- Stripe is the source of truth; your database is a cache you re-derive from webhooks.
- Verify the
Stripe-Signatureheader against the per-endpointwhsec_secret, using the raw request body. - Dedupe on the event
id— Stripe delivers at-least-once and retries on any non-2xx. - Do not trust event ordering: refetch the live subscription/invoice and project that.
- Wire only the events that matter:
checkout.session.completed,customer.subscription.created/updated/deleted,invoice.paid,invoice.payment_failed,invoice.payment_action_required. - Let Stripe own dunning and the
past_due→canceledtransition; you just read it. - Implement pause/cancel/tier-change as Stripe API writes and let the resulting
updatedwebhook reconcile local state. - Test with
stripe listenandstripe triggerbefore you ship.
FAQ
Do I need to handle every Stripe webhook event?
No. For subscription billing you can ignore most event types and wire up a small set: checkout.session.completed, the three customer.subscription.* events, invoice.paid, invoice.payment_failed, and invoice.payment_action_required. Subscribe only to what you act on; the rest is noise.
How do I make a Stripe webhook handler idempotent?
Dedupe on the event id. Every Stripe event has a stable evt_... id; record processed ids (a UNIQUE constraint works well) and skip any id you have already handled, returning 200. This is required because Stripe delivers at-least-once and retries failed deliveries, so the same event can arrive twice.
Why are my Stripe webhook events arriving out of order?
Because Stripe does not guarantee delivery order — separate HTTP deliveries can race, so a newer event may land before an older one. Do not apply events blindly. When an event signals a change, refetch the live object from the Stripe API and project that current state, rather than trusting the snapshot embedded in the event.
What is the difference between past_due and canceled in Stripe?
past_due means a renewal payment failed and Stripe is retrying it on your dunning schedule — the subscription is still alive and usually worth keeping access open during the grace window. canceled means Stripe has given up after exhausting retries (or you cancelled it); that is when you revoke access. Let Stripe drive the transition rather than computing it yourself.
How do I test Stripe subscription webhooks locally?
Use the Stripe CLI. Run stripe listen --forward-to localhost:8000/webhook to tunnel real test-mode events to your local endpoint (it prints the signing secret to use), and stripe trigger invoice.payment_failed or any other event name to fire a realistic synthetic event. Script the full lifecycle — create, renew, fail, cancel — and assert your projected state after each step.