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:

StatusWhat Stripe says it meansDoes the customer have access?What moves it on
trialingIn a trial periodYes: Stripe says you “can safely provision” the productactive 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
incompleteThe first payment must succeed within 23 hours, or it needs an action such as customer authenticationNo (my rule)active once the first invoice is paid; incomplete_expired if it is not paid within 23 hours
incomplete_expiredThe 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_duePayment on the latest finalized invoice failed or was not attemptedYes, 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
canceledStripe calls it “a terminal state that can’t be updated”No: Stripe says to “revoke access”Nothing: a returning customer needs a new subscription
unpaidSet only when your Dashboard settings choose it; invoices keep being created but no payment is attemptedNo: Stripe says to “revoke access”active when the required invoice is settled
pausedYou 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 createdNo (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 changeEventWhat changed at StripeWhat your app does
Creationcheckout.session.completedA Checkout Session completedLink the session to your user and the Stripe customer id, then write the row
Creationcustomer.subscription.createdThe subscription exists; its status “might be incomplete”Write the row; grant nothing until the status allows it
Creationinvoice.paidAn invoice payment succeeded, or the invoice was marked paid out-of-bandWrite the row; Stripe says you can provision when the subscription status is active
Updatecustomer.subscription.updatedThe subscription started or changed: a renewal, a plan change, a coupon, a status moveWrite the row; access follows the new status
Updatecustomer.subscription.trial_will_endThe trial ends in 3 days, or sooner if the trial is shorterCheck that a payment method is on file; tell the customer if you want to
Updatecustomer.subscription.pausedThe status changed to paused; not sent when only payment collection is pausedWrite the row; remove access
Updatecustomer.subscription.resumedA paused subscription was resumedWrite the row; access follows the new status
Cancellationcustomer.subscription.deletedThe subscription endedWrite the row; remove access
Payment failureinvoice.payment_failedAn invoice payment attempt failedWrite the row; tell the customer; start the grace period
Payment failureinvoice.payment_action_requiredThe invoice needs customer authenticationAsk the customer to finish the step
Refundrefund.createdA refund was createdApply your refund policy
Refundcharge.refundedA charge was refunded, including partial refundsFind the subscription behind the charge, then apply the policy
Disputecharge.dispute.createdA customer disputed a charge with their bankAlert a person; apply the dispute policy
Disputecharge.dispute.closedThe dispute closed as lost, warning_closed or wonApply 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:

SymptomThe transition nobody handledWhere it is fixed
Order created but payment record missingThe app wrote the order on the redirect, and the payment confirmed later or the webhook branch was never writtenThe event list, above
The customer paid, then got locked outAn older event arrived late and overwrote the newer rowEvents arriving out of order
The subscription ended at Stripe and the app still says Procustomer.subscription.deleted has no branchCancellation events
The refund did not remove paid featuresNo branch listens for refund eventsRefunds and disputes
The card failed and the app never noticedinvoice.payment_failed has no branchThe failed-payments article
The subscription renewed a day earlyA UTC timestamp was printed as a local dateRenewal 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:

  1. 01 Find the subscription id the event points to; for a refund or a dispute, resolve the charge to its subscription first
  2. 02 Retrieve that subscription from the Stripe API instead of reading the event payload
  3. 03 Upsert status, period end and cancel_at_period_end from the retrieved object, keyed on the subscription id
  4. 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.

EventWhat it meansAccessCreditsWho is told
refund.created for the full current periodThe money for this period went backEnd it and cancel the subscriptionKeep the usage recordThe customer, with the cancellation
refund.created for part of the amountA partial or goodwill refundNo changeNo change; add a note to the accountNobody beyond the note
charge.dispute.createdThe customer disputed the charge with their bankKeep itFreeze the unspent balanceA person, the same day
charge.dispute.closed with status lostThe issuer decided for the customerEnd itStays frozenYou 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:

  1. 01 Look the customer up by the Stripe customer id stored on your user, and never create a second customer
  2. 02 Ask Stripe whether that customer already has a subscription that is active, trialing or past_due before creating one
  3. 03 Make one idempotency key when the resubscribe page loads, and send it with every submit of that page
  4. 04 Disable the button on submit
  5. 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:

  1. 01 Subscribe with a test card and read the row: status active (trialing on a trial price) and the period end set
  2. 02 Change the plan and read the row again
  3. 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
  4. 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
  5. 05 Refund the last payment from the Dashboard and confirm the access decision matches your written policy
  6. 06 Pay with the fraudulent-dispute test card and confirm the dispute alert fired
  7. 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.