← Back to blog

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.

Click any state, or watch a subscription move through all five
Selected state
Active
Eventinvoice.payment_succeeded
statusactive
AccessFull access. This is the only status that should map one-to-one to "paying customer" in your access logic — and the price ID on the subscription item, not a cached plan name, decides which tier.
Why the price ID matters more than "active" alone →

Incomplete: 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:

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.