The first renewal, one billing period after launch, is the day an integration built around a single checkout event meets the rest of Stripe. What is the subscription lifecycle in Stripe? It is every status a subscription passes through, from incomplete to canceled, and the events that announce each move. Your app has to handle 6 kinds of change: creation, update, cancellation, payment failure, refund and dispute.
What is the subscription lifecycle in Stripe: the statuses, and the events that move between them
The subscription lifecycle in Stripe is the set of statuses a subscription can hold, 8 in Stripe’s current documentation, and the events fired on each move between them. An app handles the lifecycle when every status maps to one access decision and each of the six kinds of change has a tested branch.
The lifecycle is one control inside a wider set of SaaS billing process best practices; this page stays with the subscription itself and the events it sends.
Stripe’s guide to how subscriptions work says a subscription “moves through a predictable set of states, from creation to cancellation.” In its order: the subscription is created, its first invoice is paid or not, access is provisioned, the subscription may be updated (a price upgrade or downgrade, or paused payment collection), it may fall behind on payment, and it is canceled. Each phase shows up as a status on the Subscription object:
| Status | What Stripe says it means | Does the customer have access? | What moves it on |
|---|---|---|---|
trialing | In a trial period | Yes: Stripe says you “can safely provision” the product | active once the trial is over and the first payment goes through; paused if the trial ends without a default payment method and the trial is set to pause |
active | ”in good standing” | Yes (my rule) | past_due when a renewal payment fails; canceled when the subscription ends |
incomplete | The first payment must succeed within 23 hours, or it needs an action such as customer authentication | No (my rule) | active once the first invoice is paid; incomplete_expired if it is not paid within 23 hours |
incomplete_expired | The first payment failed and was not made within 23 hours; “These subscriptions don’t bill customers” | No (my rule) | Nothing: Stripe says to create a new subscription |
past_due | Payment on the latest finalized invoice failed or was not attempted | Yes, for the grace period you wrote down (my rule) | active when the required invoice is settled; after the last retry, canceled, unpaid or still past_due, as your Dashboard settings choose |
canceled | Stripe calls it “a terminal state that can’t be updated” | No: Stripe says to “revoke access” | Nothing: a returning customer needs a new subscription |
unpaid | Set only when your Dashboard settings choose it; invoices keep being created but no payment is attempted | No: Stripe says to “revoke access” | active when the required invoice is settled |
paused | You paused the subscription in the Dashboard or the API, or the trial ended without a default payment method and the trial was set to pause; no invoices are created | No (my rule) | active after you resume it; after a trial pause, once a default payment method is attached |
Where Stripe states an access rule, the table quotes it. The other access answers are my working rule for a small SaaS, not Stripe’s, and your own written policy wins over them.
The status lives on Stripe’s Subscription object, and your app keeps a copy of it in its own database. The whole job is keeping that copy true. A one-time payment has no lifecycle beyond paid, refunded and disputed, so for a one-off product only the refund and dispute section below applies.
In the Production Hardening Sprint, this lifecycle is deliverable 5.3, Complete billing event lifecycle: handle creation, updates, cancellation, payment failure, refunds, and disputes, including delayed or out-of-order events.
The event list: which Stripe webhook events your app actually has to handle
The Stripe webhook events a subscription app has to handle are a short subset of the full catalog: 14 event types across six kinds of change. Three of them decide access and come first. Everything else in the catalog can stay unsubscribed until a feature needs it.
Stripe’s full list of event types describes itself as “a list of all public snapshot events we currently send for /v1 resources, which is continually evolving and expanding.” It is a reference. Stripe’s subscription webhooks guide narrows it for billing and says “Subscribe only to the events your integration requires.” My Stripe webhook events list for a subscription app is the subset that changes entitlement or money, grouped by the kind of change:
| Kind of change | Event | What changed at Stripe | What your app does |
|---|---|---|---|
| Creation | checkout.session.completed | A Checkout Session completed | Link the session to your user and the Stripe customer id, then write the row |
| Creation | customer.subscription.created | The subscription exists; its status “might be incomplete” | Write the row; grant nothing until the status allows it |
| Creation | invoice.paid | An invoice payment succeeded, or the invoice was marked paid out-of-band | Write the row; Stripe says you can provision when the subscription status is active |
| Update | customer.subscription.updated | The subscription started or changed: a renewal, a plan change, a coupon, a status move | Write the row; access follows the new status |
| Update | customer.subscription.trial_will_end | The trial ends in 3 days, or sooner if the trial is shorter | Check that a payment method is on file; tell the customer if you want to |
| Update | customer.subscription.paused | The status changed to paused; not sent when only payment collection is paused | Write the row; remove access |
| Update | customer.subscription.resumed | A paused subscription was resumed | Write the row; access follows the new status |
| Cancellation | customer.subscription.deleted | The subscription ended | Write the row; remove access |
| Payment failure | invoice.payment_failed | An invoice payment attempt failed | Write the row; tell the customer; start the grace period |
| Payment failure | invoice.payment_action_required | The invoice needs customer authentication | Ask the customer to finish the step |
| Refund | refund.created | A refund was created | Apply your refund policy |
| Refund | charge.refunded | A charge was refunded, including partial refunds | Find the subscription behind the charge, then apply the policy |
| Dispute | charge.dispute.created | A customer disputed a charge with their bank | Alert a person; apply the dispute policy |
| Dispute | charge.dispute.closed | The dispute closed as lost, warning_closed or won | Apply the policy for the outcome |
The third column is Stripe’s wording, shortened; the fourth is my working rule. The order I would build them in: invoice.paid, customer.subscription.updated and customer.subscription.deleted first, because those three decide access, then payment failure, then refund and dispute.
Subscribe the endpoint only to the events it has a branch for. In the Dashboard that choice sits on the endpoint itself: open the Webhooks tab in Workbench, click Create an event destination, and at the step to select event types tick only those in the table. Stripe’s webhooks guide warns that listening for extra or all events “puts undue strain on your server.” One more creation path matters if you accept bank debits: some payment methods “aren’t instant, such as ACH direct debit,” finish Checkout first and send checkout.session.async_payment_succeeded later, which is why Stripe’s fulfillment guide puts fulfillment on webhooks.
What goes wrong without it
Most subscription billing bugs, in my reading, are a transition nobody wrote a branch for, while the checkout itself works fine. The six I would check first, with the section of this page that covers each:
| Symptom | The transition nobody handled | Where it is fixed |
|---|---|---|
| Order created but payment record missing | The app wrote the order on the redirect, and the payment confirmed later or the webhook branch was never written | The event list, above |
| The customer paid, then got locked out | An older event arrived late and overwrote the newer row | Events arriving out of order |
| The subscription ended at Stripe and the app still says Pro | customer.subscription.deleted has no branch | Cancellation events |
| The refund did not remove paid features | No branch listens for refund events | Refunds and disputes |
| The card failed and the app never noticed | invoice.payment_failed has no branch | The failed-payments article |
| The subscription renewed a day early | A UTC timestamp was printed as a local date | Renewal dates |
Stripe’s fulfillment guide explains the first row: “You can’t rely on triggering fulfillment only from your checkout landing page, because it’s not guaranteed customers visit that page.” Card failures have their own walkthrough of what your app should do when a Stripe subscription payment fails. The mirror failure, free users reaching paid features they were never granted, is a different bug with different checks.
A test suite would catch most of these before a customer does, in my reading. Only 1 of the 21 third-party apps, a healthcare FHIR hub, was credited with a real test suite, and even it skipped sign-up, login and the payment webhook. Those 21 were the third-party apps I audited in June and July 2026, a selected set rather than a random sample, so read the figure as what those apps showed, not a rate for AI-built apps in general.
How to do it: one subscription row, one state map, six transitions
A complete billing lifecycle is one row per subscription, one function that writes that row from Stripe’s current object, and 6 tested transitions. Every webhook branch calls the same function, which retrieves the subscription from Stripe and writes what it gets back, so a late or repeated event cannot roll the row back.
The procedures below follow Stripe’s documentation; where a step is my own working rule it says so, and nothing here was benchmarked. The examples use Postgres and a server route, which covers Supabase, Next.js and Node apps without a framework tutorial.
One row per subscription, and one function that writes it
The subscription row holds 4 things from Stripe: the customer id, the subscription id, the status and the current period end. Access is worked out from the status and the period end on every request, never saved as a plan flag at checkout.
The reconciliation function for created, updated and deleted events, and why it needs a per-subscription lock, are already covered by the cancellation article, the one on canceled Stripe subscriptions that still have access, in its section on syncing subscription state instead of writing one-way plan flags. What this page adds is the row and the rule that every branch ends in the same write. A minimal table:
create table billing_subscriptions (
stripe_subscription_id text primary key,
stripe_customer_id text not null,
user_id uuid not null references users (id),
status text not null,
current_period_end timestamptz not null,
cancel_at_period_end boolean not null default false -- only if the UI shows "cancels on"
);
The user_id column is your own key, not Stripe data. On API versions from 2025-03-31.basil on, Stripe moved the period end off the subscription: the changelog says current_period_end is “no longer available on the subscription resource” and lives on each item as items.data.current_period_end, so read it there. The write function, as my working rule:
- 01 Find the subscription id the event points to; for a refund or a dispute, resolve the charge to its subscription first
- 02 Retrieve that subscription from the Stripe API instead of reading the event payload
- 03 Upsert status, period end and cancel_at_period_end from the retrieved object, keyed on the subscription id
- 04 Leave the access decision to the request path, which reads the row each time
Every branch for every kind of change, refunds and disputes included, ends by calling this function for the subscription it touched. A new event type is then one line, and a late or repeated event writes the current state again. There is deliberately no column for the event’s created time as an order guard. Stripe’s webhooks guide explains that “Snapshot events record created in seconds, so distinct events can share a timestamp,” says “Don’t use created to determine event order or whether you’ve already processed an event,” and adds “You can also use the API to retrieve any missing objects.” The retrieve in step 2 follows that advice.
Access is then a server-side check of the user’s rows at request time, and how to enforce plan limits on the backend is the other half of this design. A handler that runs twice must still produce one effect, which is a separate job: how to make a webhook handler idempotent.
Stripe events arriving out of order, or days late
Stripe can retry failed live webhook deliveries for up to three days and does not guarantee event order. A late subscription event can describe a state that has already changed. The handler retrieves the current subscription instead of trusting the payload, so a late event writes the current state, not the one it describes.
Stripe’s webhooks guide gives the retry pattern: live retries use exponential backoff over that window, while in a sandbox Stripe retries three times over a few hours, so a broken test endpoint goes quiet much sooner. The race itself, an invoice.paid overwritten by an older creation event, is told step by step in the worked race when Stripe delivers events out of order, along with the stored-timestamp guard that article offers for handlers that must write from the payload. This page’s rule is the retrieve in the row function above, which follows Stripe’s current advice against using created for order.
In my reading, an endpoint that was down for longer than the live window gets no more automatic retries for those events. You can replay them yourself within the limits given in that same article’s FAQ on replaying an event after fixing the handler. Past those limits, only a comparison against Stripe finds the gap, which is the job of a scheduled check to keep Stripe subscriptions in sync with the database.
How to handle subscription cancellation events
Subscription cancellation events come in two forms. An updated event with cancel_at_period_end set means the customer scheduled the end and keeps access until the period closes. A customer.subscription.deleted event means the subscription has ended, and of the two only that one removes access.
Stripe’s cancellation docs describe customer.subscription.updated as “Sent for any subscription update, including when cancel_at_period_end is set to true,” and say the deleted event can come from “a direct call to delete the subscription” or from a scheduled cancellation that “reaches the end of its billing period.” Those descriptions name the change, not where it was made, so in my reading one branch covers a cancellation from the customer portal, the Dashboard or the API; setting up the portal is its own job: how to set up the Stripe customer portal. Stripe can also cancel on its own after failed payment retries or after a dispute, depending on your Dashboard settings. Retries that end in cancellation arrive as the same deleted event. The dispute setting is “only supported for disputed credit and debit card payments opened in the full amount,” and either cancels at once (the deleted event) or at the end of the period with cancel_at_period_end set to true, which reaches you first as an updated event. Everything else, from the cancellation date field by API version to testing the whole path, is in a canceled Stripe subscription that still has access.
Refunds and disputes: the money went back, the paid features did not
A refund and dispute policy is four lines: full refund, partial refund, dispute opened and dispute lost, each with what happens to access and to credits already spent. Each needs its own webhook branch, because none of the four arrives as a cancellation event.
Why a refund or a dispute is not a cancellation, which refund event Stripe recommends, and the audited token-credit app that handled neither are all in the cancellation article’s section on refunds and disputes. That section asks for a separate refund and dispute policy. When a refund did not remove paid features, the missing piece is often that policy as much as the branch, in my reading. This table is one: defaults a small SaaS can adopt and change, every action cell my recommendation.
| Event | What it means | Access | Credits | Who is told |
|---|---|---|---|---|
refund.created for the full current period | The money for this period went back | End it and cancel the subscription | Keep the usage record | The customer, with the cancellation |
refund.created for part of the amount | A partial or goodwill refund | No change | No change; add a note to the account | Nobody beyond the note |
charge.dispute.created | The customer disputed the charge with their bank | Keep it | Freeze the unspent balance | A person, the same day |
charge.dispute.closed with status lost | The issuer decided for the customer | End it | Stays frozen | You and the customer |
Stripe’s refunds docs describe charge.refunded as “Sent when a charge is refunded, including partial refunds.” A refund event is about a charge, not a subscription, so the branch resolves the charge to its customer and subscription before acting. For API version 2025-03-31.basil or later, Stripe’s subscription webhooks guide gives the path: take the payment_intent from the charge or the dispute, then use the List all payments for an invoice endpoint to find the invoice and its subscription.
Disputes change status as they run. The charge.dispute.closed event fires when the status “changes to lost, warning_closed, or won,” and, per how disputes work at Stripe, once the issuer decides, “Stripe updates the status of the dispute to won or lost.” The same page notes that “in rare cases the status can change from lost to won,” so an access decision made on a loss should be reversible. That makes created the alert and closed the decision, in my reading. Who presses refund, and how evidence gets submitted, is a human procedure that belongs in a billing operations runbook checklist.
Reactivating a canceled subscription without a duplicate charge
Reactivating a Stripe subscription depends on its status: a past_due one comes back when the invoices that govern its status are settled, and a customer returning after cancellation needs a new subscription. The app creates that subscription once, guarded by an existing-subscription check and an idempotency key, so a double click cannot charge twice.
If you want to reactivate a Stripe subscription that is only scheduled to cancel, or need to know why a finished one cannot come back, the cancellation article’s FAQ on reactivation covers both cases. Stripe’s overview adds the rest. For past_due, it says to “pay the most recent non-voided invoice or manually mark it as uncollectible” when status resolution uses the most recent invoice. After incomplete_expired: “To reactivate their access, create a new subscription.” For anyone returning after a cancellation: “If your customer wants to resubscribe, you need to collect new payment information from them and create a new subscription.” A paused subscription comes back through the resume endpoint, which “Initiates resumption of a paused subscription”; after a trial pause, attach a default payment method to the customer first.
The new-subscription case is where a second charge happens. My working rule is five steps:
- 01 Look the customer up by the Stripe customer id stored on your user, and never create a second customer
- 02 Ask Stripe whether that customer already has a subscription that is active, trialing or past_due before creating one
- 03 Make one idempotency key when the resubscribe page loads, and send it with every submit of that page
- 04 Disable the button on submit
- 05 Let the webhook, not the redirect, write the subscription row
On step 3, Stripe’s idempotent requests page says a key lets you “safely repeat the request without risk of creating a second object,” suggests “V4 UUIDs,” and says “Avoid using sensitive data (for example, email addresses or personal identifiers) as idempotency keys.” Making the key once per page load, so a double click and a retry share it, is my own rule.
Here is how it goes wrong without the guard. A customer whose subscription ended comes back and clicks Resubscribe, and the page takes a moment to respond. They click again, and your app creates a subscription on each click, because nothing checks Stripe for an existing live subscription and the create request carries no idempotency key, so the customer now has two subscriptions for one account. A returning customer needs a new subscription, created once, and the existing-subscription check plus the idempotency key stop the second one. The lesson I take from it: the guard against a second charge belongs on the server, before the create call, not on the button.
The new subscription has a new id, so it gets its own row, and the old row stays canceled; a late event for the old subscription then rewrites only the old row.
Renewal dates, trials and the billing clock
A subscription that renewed a day early has often renewed on time: Stripe bills on the billing cycle anchor and reports the period end as a UTC timestamp, and the app printed that moment as a local date. An anchor on the 31st also moves in shorter months. Store the timestamp and convert only for display.
Stripe’s billing cycle docs say “Billing cycle anchors are UNIX timestamps in seconds from the current epoch,” and that the anchor “uses Coordinated Universal Time (UTC)”: a subscription created with billing_cycle_anchor_config at 5 PM EST, without an hour set, is recorded as 10 PM UTC. So a customer west of UTC can see the charge land on what is, for them, the day before the date your app printed, or your app printed a UTC date as if it were local; both are my reading of how the complaint arises. On month ends, Stripe’s own example: an anchor of January 31 “bills the last day of the month closest to the anchor date, so February 28 (or February 29 in a leap year), then March 31, April 30, and so on.”
A trial end is a status move out of trialing, announced ahead by customer.subscription.trial_will_end (its timing is in the event table). The fix for the dates: keep the timestamp exactly as Stripe sent it, compare in UTC, convert to the customer’s zone at the last moment, and print the zone beside the date. The wider date rules are in the time zone handling checklist.
How to verify it
A billing lifecycle is verified by driving 6 transitions in a Stripe sandbox and reading the account row after each one: subscribe, change plan, schedule and undo a cancellation, fail a renewal, refund, and dispute. A test clock moves time forward, so a renewal is tested without waiting a month.
Start in the Dashboard. Create a test customer and subscription in a sandbox, then keep the endpoint’s Event deliveries tab open in Workbench, which lists each event as “Delivered, Pending, or Failed.” Then run these checks, each of which a broken handler fails:
- 01 Subscribe with a test card and read the row: status active (trialing on a trial price) and the period end set
- 02 Change the plan and read the row again
- 03 Schedule a cancellation, read the row and the access-until date if the UI shows one, then undo it and read the row again
- 04 In a simulation, add a new customer and subscription and advance past the renewal; switch the customer to the card that fails after attaching, advance again and read past_due, then advance past the last retry and read the status your failed-payment settings chose
- 05 Refund the last payment from the Dashboard and confirm the access decision matches your written policy
- 06 Pay with the fraudulent-dispute test card and confirm the dispute alert fired
- 07 Resend an older customer.subscription.updated event after a newer one and confirm the row did not move backwards
Check 3 is the short form; the full cancellation test is in the cancellation article. For check 4, Stripe test clocks now sit under Simulations, and “Simulations use test clocks to control time.” You “can’t choose existing customers during simulations,” you can add “up to three new customers to each simulation” with up to three subscriptions each, and a monthly plan advances “up to two months at a time.” Stripe’s test cards include 4000000000000341, where “Attaching this card to a Customer object succeeds, but attempts to charge the customer fail,” and 4000000000000259, where, “With default account settings, charge succeeds, only to be disputed as fraudulent.”
For check 7, resend the older event with the Stripe CLI:
# CLI resend works up to 30 days after the event was created
stripe events resend evt_OLDER_SUBSCRIPTION_UPDATE --webhook-endpoint=we_YOUR_ENDPOINT
The command needs the event id from the delivery log and your endpoint id, passed with --webhook-endpoint. The stripe trigger command is quicker but different: Stripe CLI trigger commands create test fixtures before causing webhook deliveries, so “all necessary API objects will be created in the process.” My working rule is to use it to show a branch runs, and the Dashboard or a test clock to show that your customer’s row changes. To verify subscription status for one customer at any time, retrieve their subscription from Stripe and compare its status and period end with the row.
Keep the evidence: the event ids from the delivery log, the row before and after each check, and the date you ran it. In the sprint, deliverable 5.3 is verified this way: test the relevant lifecycle transitions and verify the final account state. The step after a manual pass is how to write end to end smoke tests for the same transitions, and the step before it, for a new endpoint, is how to test webhooks.
Where the sprint does this
Deliverable 5.3 sits beside Idempotent webhook handling (5.2), the Billing reconciliation check (5.6), Failed-payment recovery (5.7) and Auth and billing unit tests (10.7). We record each of them in the Production readiness report, deliverable 13.1, which accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. Outside this sprint: building new product features or modules, completing unfinished core features or business workflows, and rebuilding core functionality that does not yet perform its intended job. The fee covers our engineering work. Hosting, paid tools, and API usage remain in your accounts. We explain any required third-party costs before enabling them. Every item is on the published list of 123 deliverables.
Common questions about Stripe subscription events
Can I pause a Stripe subscription?
Yes, in two ways that mean different things for access. Pausing payment collection keeps the subscription active and its invoices generating without collecting payment, and Stripe says “Your customer retains access to the service during this time.” Pausing the subscription itself, from the Dashboard or the API on flexible billing mode, sets the paused status, and “Stripe pauses invoice generation until you resume the subscription.” Stripe also sets paused when a trial ends without a default payment method and the trial is set to pause. My working rule is to keep access while only collection is paused and remove it while the status is paused.
Does Stripe charge subscriptions immediately?
Yes, for a subscription that collects automatically and has no trial. Stripe creates an invoice and a PaymentIntent at creation, and the status starts as incomplete and becomes active “after the customer pays the first invoice.” With a trial, the status starts as trialing and moves to active “when the trial ends and payment succeeds.” A subscription billed by sending an invoice, with no trial, starts active “even if the first invoice is unpaid.”
Can a refund be undone?
Sometimes, and only briefly. Stripe says “Some card refunds support cancellation for a short period of time,” the refund must not have been processed as a charge reversal, card refunds can be canceled only from the Dashboard, and a canceled refund moves to a canceled status. If your refund policy already ended access, restore it only once the refund shows canceled; that is my working rule.
How do I manage subscriptions in Stripe?
From three places: the Dashboard, the API, and the Stripe-hosted customer portal. Stripe’s customer management docs list what the portal can let customers do, including updating payment methods, updating subscriptions, canceling them immediately or at the end of the period, and paying and viewing invoices. Wherever a change is made, it reaches your app through the same subscription events, so one handler covers all three.
If you have a working app built with these tools and need it ready for real customers, this is what we do.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase