Skip to content
Tech Stack9 July 2026 · 9 min read

SaaS Subscription Lifecycle in 2026: Trial, Active, Past Due, Cancelled

Stripe subscriptions have eight states, not two, and the costliest bug isn't a missing webhook. It's a status column that quietly stops matching what actually gates access.

SaaS Subscription Lifecycle in 2026: Trial, Active, Past Due, Cancelled

Ask ten engineers to describe a SaaS subscription lifecycle and most give you two states: paying, or not paying. That model ships fine until a card gets declined at 2am and nobody notices for three days. Stripe's actual subscription lifecycle runs through eight distinct states, and the space between how developers picture it and how Stripe implements it is where most of the embarrassing production bugs live. I've wired this state machine into two live products, BookBed and Callidus, from opposite starting points, one on Firebase, one on Stripe billing directly, and both taught me the same lesson.

Key Takeaways

A cross-sectioned seed pod with a ring of glowing cyan-lit chambers, growing from a vine wrapped around a cracked concrete server block

  • Stripe subscriptions have 8 possible statuses, not the two most apps design around: trialing, active, incomplete, incomplete_expired, past_due, unpaid, paused, and canceled.
  • The single most common billing bug isn't a missing webhook. It's writing subscription status to one database column while your actual access check reads a different one entirely.
  • Smart Retries defaults to 8 attempts spread across 2 weeks, and Stripe times each retry per card instead of on a fixed day-3, day-7 schedule.
  • A deleted Stripe Customer object doesn't mean a deleted subscriber. Recovering that mapping is a five-minute fix if you built for it, and a support fire if you didn't.
  • Gate features per subscription state, not per boolean. A trial, a grace-period past_due subscriber, and a canceled-but-not-yet-ended subscription all deserve different access rules.

What States Make Up a SaaS Subscription's Lifecycle?

Eight leaves in a row along a glowing circuit trace, progressing from a small green bud through a bright cyan bloom to curled brown dead leaves

A production Stripe subscription moves through eight possible states, from trialing before the first charge to canceled after the last one. Stripe's own subscription documentation lays out the full set, and most tutorials only cover two of them, treating the rest as edge cases. That's backwards: past_due and unpaid are where subscription revenue actually gets lost, not canceled.

| State | What it means | What triggers it | |---|---|---| | trialing | Trial running, no charge yet | Subscription created with a trial period | | active | Good standing | First payment succeeded, or a past_due invoice got paid | | incomplete | Awaiting first payment | First charge failed, or needs 3D Secure | | incomplete_expired | First payment never completed | 23-hour window closed with no successful charge | | past_due | Latest invoice unpaid | A renewal charge failed | | unpaid | Retries exhausted, still on the books | Smart Retries ran out, per your Dashboard settings | | paused | Trial ended, no payment method | trial_settings.end_behavior set to pause | | canceled | Terminal | Explicit cancellation, or config after unpaid |

The pair worth actually understanding is incomplete and incomplete_expired, because they only exist because of Strong Customer Authentication. A first charge that needs 3D Secure sits in incomplete while the customer finishes a bank confirmation step you have no control over, and if they never come back, Stripe expires it after 23 hours rather than leaving it hanging forever. Treat that subscriber as a lost signup, not a billing failure, and your churn dashboard stops lying to you about why people leave.

Eight states. One boolean can't hold that.

The Column That Lies: subscription_status vs. What Actually Gates Access

Two green vines climbing the same wire-grid trellis, one rooted in a visible soil planter and the other trailing off above the floor with no roots shown

The most common Stripe bug in production isn't a missing webhook handler. It's a mismatch between the column your webhook writes and the column your feature-access check actually reads.

One SaaS security checklist calls this out directly: teams update a cosmetic subscription_status field on every webhook event, then gate real feature access from role, plan, or has_pro somewhere else in the codebase. The two drift the first time someone edits the access logic without touching the webhook handler, and nothing throws an error. Nobody notices, because nothing breaks. It just quietly stays wrong.

Actually, calling it a bug undersells it. It's a design decision nobody made on purpose, which is a worse category of problem because there's no commit to point at and revert.

On BookBed, the fix was making the webhook handler and the access check point at the same field. The write that Stripe's event triggers and the read that gates a booking screen both land on accountType in Firestore, so an account can't stay "active" behind the scenes after its subscription is actually cancelled. The full build notes on BookBed's webhook-driven billing state cover the rest of that pattern.

Callidus taught the same lesson from the opposite direction. It runs on Stripe Connect rather than a single merchant account, so a clinic's own past_due subscription and a past_due payout to a connected account are two entirely different failure modes wearing the same status word. Reading a Connect account's status as a stand-in for the platform subscription status is the cosmetic-column mistake again, just one layer removed, and it took a genuinely confusing support ticket to see it clearly the first time.

How Do You Handle a past_due Subscription Without Locking Out a Paying Customer?

Give past_due customers a grace period with a visible warning, not an immediate hard lock, while Stripe keeps retrying their card automatically.

Wednesday afternoon: a Slack alert fires because a customer's card failed, and the on-call engineer's first instinct is to flip a switch and lock the account. Don't. That instinct is exactly what this state machine exists to override.

  1. Listen for invoice.payment_failed. This is the event that actually tells you a renewal charge bounced, not customer.subscription.updated, which fires for reasons that have nothing to do with payment.
  2. Set a grace flag, not a lockout. Keep the account fully functional. A failed card is usually a bank hiccup, not a decision to stop paying.
  3. Surface the problem in-app. A dismissible banner pointing at the billing page beats a support ticket every time, and it beats silence every time too.
  4. Let Smart Retries do its job. Stripe's default schedule attempts payment 8 times across two weeks, timing each attempt around signals like device history rather than a blind fixed interval.
  5. Only cut access at unpaid or canceled. By the time retries are exhausted, you've earned the right to lock the account. Not before.

The Deleted-Customer Recovery Case

Sometime around your two-hundredth customer, someone will delete a Stripe Customer object by hand in the Dashboard, chasing a duplicate, and the subscription orphans in your database with a stripe_customer_id pointing at nothing. The fix is boring: recreate the Customer, reattach the payment method, and let your existing webhook handler run the same upsert it always does. Document that recovery path before you need it, not after.

What the Failed-Payment Dunning Ladder Actually Looks Like

A dunning ladder is just the sequence of retries, emails, and grace windows between a failed charge and a cancelled subscription, and Stripe's own retry engine does more of that work than most teams realize. Kiran Chandran, a Stripe Revenue Optimization engineer, wrote in a January 2024 engineering post that Smart Retries recovers $9 in revenue for every $1 businesses spend on Billing, and that recovered subscriptions keep paying for seven more months on average once they're back in good standing.

That number only holds if your app actually waits for Stripe to finish retrying instead of cancelling access the moment past_due fires first. You've felt the other side of this too, a subscription you use quietly re-authorizes itself a day after your bank clears some hold. Ever notice that? That's Smart Retries working as intended, and your own customers are benefiting from the exact same mechanism whether they know it or not.

None of this replaces basic bookkeeping either. A subscription that comes back from past_due mid-cycle still needs its invoice history reconciled against whatever ledger your finance side actually trusts, and Smart Retries won't do that part of the job for you.

Feature Gating Per State, Not Per Boolean

Most feature-gating code checks one thing: is this account active. A trial subscriber and a grace-period past_due subscriber are not the same customer, and treating them identically either annoys the trial user with upsell friction or strands the paying one mid-renewal.

| State | Recommended access | Why | |---|---|---| | trialing | Full access | Proving the product works before the first charge | | active | Full access | Good standing | | past_due | Full access, plus a warning banner | Stripe is still retrying; locking out here burns goodwill for nothing | | unpaid | Read-only, or heavily reduced | Retries exhausted; the payment method is genuinely broken | | canceled | No access, short data-export window | Terminal, but don't delete customer data immediately | | paused | Reactivation flow only | Trial ended with no payment method on file |

The paused row is the one teams forget entirely, because it only exists if you've explicitly configured trial_settings.end_behavior to pause instead of cancel. Most integrations never set it, so every trial that ends without a card on file just becomes canceled by default. That's a defensible choice. It's just worth making on purpose instead of by omission.

None of this state machine matters if what a Stripe webhook actually delivers never reaches your database, and it's one piece of what a full Stripe integration covers: checkout, billing, the portal, Connect if you need marketplace payouts. If you're already running a Supabase-and-Stripe stack, these same eight states map onto Postgres rows the same way; the webhook payload doesn't know or care what database is listening on the other end.

What Changed on Stripe's Subscription Lifecycle in 2026

Stripe shipped billing schedules on May 27, 2026, and it updates one assumption a lot of subscription lifecycle logic has quietly baked in. The new billing_schedules parameter and billed_until property, documented in Stripe's own changelog, let you prebill a subscription for future periods before they start, so a customer can pay twelve months upfront on a monthly price without you faking an annual plan underneath it. If your lifecycle code only models trialing → active as a clean transition, this is the release to read before you build anything upfront-billing shaped on top of it.

Pick your lifecycle logic before you pick the rest of your SaaS MVP stack, because retrofitting an eight-state machine onto a boolean is_active column later is the kind of migration nobody schedules on purpose. Go check one thing right now: does your app actually treat past_due any differently than canceled today?

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps 2× faster than agencies using AI-augmented workflows. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

What are the possible Stripe subscription statuses?

A Stripe subscription can be trialing, active, incomplete, incomplete_expired, past_due, unpaid, paused, or canceled at any given moment. [Stripe's own subscription documentation](https://docs.stripe.com/billing/subscriptions/overview) covers every transition between them, including the 23-hour window before an unpaid first charge expires the subscription entirely. Most SaaS apps only design for active and canceled, which is exactly why past_due and unpaid subscriptions generate the most support tickets.

How should I handle a past_due Stripe subscription without alienating a paying customer?

Keep the customer's access intact through past_due, add a visible payment-warning banner, and let Stripe's automatic retries run their course. Cutting access the moment a single charge fails punishes a customer for a bank hiccup rather than a decision to stop paying, and [Stripe's Smart Retries](https://docs.stripe.com/billing/revenue-recovery/smart-retries) is built specifically to recover that charge without your intervention. Reserve the hard lockout for unpaid or canceled, once retries are actually exhausted.

What is a dunning ladder in SaaS billing?

A dunning ladder is the sequence of payment retries, reminder emails, and grace-period rules between a failed charge and eventual cancellation. Stripe's Smart Retries handles the retry timing automatically, and [a Stripe engineering post from January 2024](https://stripe.com/blog/how-we-built-it-smart-retries) put the recovered-subscription lifespan at seven more months of billing on average. Your job is mostly the messaging layer on top: emails and in-app banners that match whatever state the subscription is actually in.

Should subscription state be a boolean or a state machine?

Model it as a state machine with at least the states Stripe itself exposes, not a single is_active boolean. A boolean collapses trialing, past_due, and paused into the same bucket as active, which means very different customers all get treated identically by your access logic. The state machine costs a bit more upfront code and saves a support queue full of confused, still-paying customers later.

What happens if a customer's Stripe Customer object gets deleted by accident?

The subscription orphans in your database with a stripe_customer_id pointing at nothing, recovered by recreating the Customer and reattaching the payment method. Your existing webhook handler should run the same upsert logic it always does once the new Customer id exists, provided your access check reads from your own database rather than a live Stripe lookup. Document this recovery path once, because it always surfaces at an inconvenient time.