The Stripe subscription lifecycle, mapped
September 16, 2026
Every post on this blog turns out to be about the same five-state map, viewed from a different angle. A subscription enters incomplete, moves to trialing or straight to active, and from there either keeps renewing or starts failing — and at every one of those transitions, there’s a specific event, a specific status string, and a specific access decision that most integrations get right for the common path and wrong for at least one edge.
Click through the states below, or hit play to watch one subscription move through all five.
invoice.payment_succeededactiveIncomplete: the state that shouldn’t grant anything
A subscription is created the moment checkout starts, before the first payment has actually cleared — Stripe needs somewhere to attach the payment intent, 3D Secure challenge, and retry logic to. If your signup flow grants access as soon as the subscription object exists rather than waiting for confirmation that it’s actually paid, every abandoned 3DS challenge and every card that fails on the first attempt becomes a free account. This status resolves quickly in the common case — usually seconds — which is exactly why it’s easy to never test the failure path where it doesn’t resolve.
Trialing: the state everyone builds for, then stops watching
Trials get generous engineering attention at signup — the countdown, the upgrade prompt, the reminder email — and then almost no attention at the moment they end, because that transition doesn’t feel like it needs a decision. It does. A trial with a card on file converts through invoice.payment_succeeded; a trial with no card just expires, and depending on your settings, either cancels cleanly or leaves the subscription in a state your access logic was never written to check for specifically.
Active: the state that isn’t as simple as it looks
active is the status every access check is fundamentally built around, and it’s also the status that hides the most detail, because a subscription doesn’t just have a status — it has a price, attached to a subscription item, and that price is what actually determines what the customer should be able to do. Two subscriptions can both read active while one customer is on a $29 plan and the other on $299. Access logic that only checks the top-level status and not the current price is correct for a single-tier product and silently wrong the moment a second tier exists.
Past due: the state that’s still counted as revenue
This is the transition with the widest gap between what the status implies and what’s actually true. A renewal fails, Stripe starts retrying on whatever dunning schedule your account uses, and the subscription sits in past_due for anywhere from a few days to a few weeks — still technically not canceled, which means naive logic built around “is this subscription not canceled” keeps treating it as a paying customer the entire time. It also means whatever dashboard is summing up MRR from subscription status, rather than from actual settled payments, is overstating revenue for as long as the retry window runs.
Unpaid or canceled: two different endings that look identical from outside
When retries are exhausted, what happens next depends on a setting most teams never explicitly chose — Stripe either cancels the subscription (firing customer.subscription.deleted, the event most access-revocation logic is built around) or moves it to unpaid and leaves it there indefinitely, firing nothing further. Both outcomes look, from a distance, like “this customer stopped paying.” Only one of them actually tells your application that.
What this map doesn’t cover
The five states above are the subscription’s status lifecycle — but two other categories of drift live entirely outside this diagram, because they don’t change the status at all:
- A refund or chargeback happens on the charge object, not the subscription. A fully refunded, disputed customer can sit in
activefor months with nothing in this map ever changing. - A duplicate customer object means your app might be watching the wrong subscription’s lifecycle entirely — the one that’s actually being paid lives on a different customer ID than the one your database has on file.
Both are worth reading on their own, because both produce the exact same symptom as everything above — a customer with access they shouldn’t have, or without access they paid for — without ever touching a single state in this map.
The check that catches all of it at once
Every individual fix in the posts linked above closes one specific gap, for one specific transition, assuming the relevant webhook actually fires and actually gets handled correctly. The only check that doesn’t depend on any single event having worked is a direct comparison, done on a schedule: for every customer your app currently grants access to, what does Stripe say their subscription — the real one, not a cached copy — currently looks like? That comparison is what Venwai runs once a day, with a read-only Stripe key, alerting you the moment the two disagree, regardless of which specific state transition or webhook caused the gap.
Free while it’s in beta, no card required: venwai.com.