For Stripe subscription webhooks, the events that matter most are customer.subscription.created, customer.subscription.updated and customer.subscription.deleted for the subscription's status and plan; invoice.paid and invoice.payment_failed for money; and customer.subscription.trial_will_end and invoice.payment_action_required for moments when the customer must act. Grant or extend access when an invoice is paid and the subscription is active, revoke it when the subscription is canceled or unpaid, and always derive access from the subscription's current state rather than from whichever event happened to arrive last.
This guide walks through each event, a state model you can adapt, the access rules we recommend and why, how upgrades, downgrades and cancellations show up, a Stripe subscription webhook example handler, and how to test every transition with test clocks. It assumes your endpoint already verifies signatures and deduplicates events; if it does not, start with our Stripe webhooks best practices guide.
Which Stripe subscription webhook events matter?
Stripe sends dozens of event types, but a subscription integration rests on a small core. The descriptions below follow Stripe's subscription webhooks guide. Check them against the API version your account is pinned to, because object shapes change between versions.
Subscription events
- customer.subscription.created is sent when the subscription is created. Its status may be incomplete if the first payment needs customer authentication or if you created it with payment_behavior=default_incomplete, so creation alone is not proof of payment.
- customer.subscription.updated is sent when a subscription starts or changes: a renewal, a plan or quantity change, a coupon or discount, an added invoice item, a status change, or setting cancel_at_period_end. It is the busiest event and the one most handlers get wrong, because it means “something changed,” not any particular change.
- customer.subscription.deleted is sent when the subscription ends, either because it was canceled immediately or because a subscription set to cancel at period end reached the end of its period. Canceled is terminal: a canceled subscription cannot be reactivated, only replaced by a new one.
- customer.subscription.trial_will_end is sent three days before a trial ends, or immediately if the trial is shorter than three days. Use it to confirm a payment method is on file and to remind the customer.
- customer.subscription.paused and customer.subscription.resumed are sent when the status moves to and from paused, for example when a free trial ends without a payment method and the subscription is configured to pause. Pausing payment collection is a different feature and does not send these events.
Invoice events
- invoice.created fires when an invoice is created for a new or renewing subscription. For automatically collected invoices, Stripe waits about an hour after your endpoint acknowledges this event before finalizing and attempting payment; if it never receives a successful response, finalization can be delayed for up to 72 hours.
- invoice.finalized means the invoice is final and ready to be paid. Depending on your settings, Stripe then charges the default payment method or sends the invoice to the customer.
- invoice.paid means the invoice was paid. Stripe's guide says you can provision access on this event when the subscription is active, and recommends retrieving the subscription to confirm its status before extending access.
- invoice.payment_failed means a payment attempt failed. On a subscription's first invoice the subscription stays incomplete; on a renewal it typically moves to past_due while retries run.
- invoice.payment_action_required means the payment needs the customer to complete authentication, such as 3D Secure. Your app should bring the customer back to finish it.
- invoice.finalization_failed means the invoice could not be finalized, for example when Stripe Tax lacks a valid customer location. Stripe notes the subscription stays active while you cannot collect, so this one deserves an alert.
- invoice.upcoming is sent a configurable number of days before renewal for automatically charged subscriptions. It is useful for reminders and for adding one-off items, and it is not sent for subscriptions that email invoices.
Checkout and entitlements
- checkout.session.completed fires when a customer finishes a Checkout Session. In subscription mode the session references the new customer and subscription, which makes it the natural place to connect Stripe IDs to your user. Check the session's payment_status before treating it as paid; delayed payment methods report success later through checkout.session.async_payment_succeeded.
- entitlements.active_entitlement_summary.updated fires when a customer's active entitlements change, if you model features with Stripe's Entitlements. You can provision or remove features directly from it.
Stripe's guide also recommends handling risk events (charge.refunded, charge.dispute.created and radar.early_fraud_warning.created) if your policy restricts access on a dispute or full refund, and customer.updated if you mirror customer details locally.
How does Stripe subscription status map to webhook events?
Events describe changes; status describes where the subscription is now. Your access logic should key off status, and the events are how you learn that it changed. Stripe defines these statuses:
- trialing: in a trial. Stripe says you can safely provision the product. It moves to active when the first payment succeeds.
- active: in good standing. Active does not necessarily mean every earlier invoice is paid; that depends on your status-resolution settings.
- incomplete: the first payment has not succeeded yet. The customer has 23 hours to pay or complete authentication.
- incomplete_expired: the first payment never succeeded within 23 hours. These subscriptions do not bill; the status exists so you can track failed sign-ups.
- past_due: payment on the latest finalized invoice failed or was not attempted. Retries may continue, and new invoices keep being created.
- unpaid: set only if your Dashboard settings choose it after retries are exhausted. Invoices keep being created but Stripe stops attempting payment, and Stripe's guidance is to revoke access.
- canceled: the subscription has ended. Terminal.
- paused: a trial ended without a default payment method and the subscription was configured to pause. No invoices are created until it resumes.
Two points in the diagram cause most of the confusion. First, every status change arrives as customer.subscription.updated, except the move to canceled, which arrives as customer.subscription.deleted. There is no separate past_due event; you see the new status inside the updated subscription. Second, what happens after the last failed retry is a setting in your account, not fixed behavior. Stripe can cancel the subscription, mark it unpaid, or leave it past_due, and your code must handle all three while your team knows which one is configured. Our guide to failed subscription payments and dunning covers those settings in depth.
Which event should grant access, and which should revoke it?
This is the decision that most affects revenue and support load, so it is worth stating as a rule. Grant or extend access when an invoice is paid and the subscription's current status is active, or while it is trialing. Revoke access when the subscription is canceled, unpaid, incomplete_expired or paused. Treat past_due as an explicit policy decision.
Why invoice.paid rather than customer.subscription.created or checkout.session.completed? Because those two can fire before money moves. A subscription can be created in incomplete status while 3D Secure is pending, and a Checkout Session can complete with a delayed payment method that has not settled. invoice.paid is the event that says the period has been paid for. Stripe's guide pairs it with a check: after receiving invoice.paid, retrieve the subscription and confirm it is active before extending access, because under some status-resolution settings a paid invoice does not by itself make the subscription active.
Trials are the exception that proves the rule. A trialing subscription has no paid invoice for real money yet, and Stripe explicitly says you can provision during a trial. So the practical rule is status-based: on any subscription or invoice event, fetch the subscription and set access from its status. invoice.paid is simply the event that most often moves the status to active.
For revocation, customer.subscription.deleted is the unambiguous signal. For unpaid and paused you will see customer.subscription.updated with the new status. For past_due, decide deliberately:
- Keep full access during retries. Lowest friction, and most customers whose card fails once will update it. The risk is that a long retry window means weeks of free service for customers who never pay.
- Grace period, then restrict. Keep access for a fixed number of days from the first failure, show an in-app banner, then limit features. This is usually the best balance for B2B SaaS.
- Restrict immediately. Appropriate where usage has a hard cost to you, such as metered compute or third-party fees passed through. Expect more support contacts.
Whatever you choose, write it down as a table (status, access level, in-app banner, emails) and implement exactly that table. Start the grace-period clock from a stored timestamp, such as when you first recorded past_due, not from a webhook arriving, so a late or duplicate event cannot reset it.
How to map Stripe events to your own entitlement state
Your application should not ask Stripe whether a user can use a feature on every request. Keep a local entitlement record per account, update it from webhooks, and read it locally. A minimal record holds the Stripe customer ID, subscription ID, current price or plan, subscription status, current period end, cancel_at_period_end, the derived access level, and the ID and time of the last event applied.
The table shows the intent of each event, but the implementation should be less clever than it looks. Rather than writing eight different update paths, robust integrations funnel every subscription-related event into one sync function: find the subscription ID, retrieve the subscription, compute access from status and price, and write the record. The event type then decides only side effects such as emails: trial_will_end sends a reminder, invoice.payment_failed sends a card-update link, invoice.payment_action_required sends an authentication link.
Two modeling details save pain later:
- Map prices to plans in code. A table from Stripe price IDs (or lookup keys) to plan names and limits makes upgrades a data change and turns an unknown price into an explicit error rather than a silent default.
- Read the period end from the subscription item. In recent Stripe API versions, current_period_start and current_period_end live on subscription items rather than on the subscription itself. If your code reads them from the top-level object, check your pinned API version before upgrading.
If you use Stripe Checkout, link IDs at checkout.session.completed: store the customer and subscription IDs against your user, using the client_reference_id or metadata you set when you created the session. If invoice.paid arrives before that link exists, which can happen because delivery order is not guaranteed, the sync function should find the account through the Stripe customer, or fail and retry, rather than silently updating nothing.
Handling the customer.subscription.updated webhook: upgrades, downgrades and proration
customer.subscription.updated fires for plan changes, quantity changes, renewals, discounts, cancellation scheduling and status changes. Snapshot events include previous_attributes, which shows which fields changed and is useful for analytics and emails. For access, still re-read the subscription: the current state matters, not the diff.
Plan changes have a billing side and an access side, and they do not have to happen at the same moment.
Upgrades
By default, changing a price creates proration invoice items (proration_behavior=create_prorations): a credit for unused time on the old price and a charge for the remaining time on the new one, normally collected on the next invoice. Pass always_invoice to invoice the difference immediately, or none to skip proration. Stripe's prorations guide explains each option, and you can preview the amount with the create preview invoice API before the customer confirms.
If you grant the higher plan immediately but bill the difference later, you are extending credit, which is usually fine for an existing paying customer. If you want the upgrade to apply only once payment succeeds, Stripe's pending updates do that: update with payment_behavior=pending_if_incomplete and, typically, proration_behavior=always_invoice. If payment fails, the subscription carries a pending_update instead of the new price, and you receive customer.subscription.pending_update_applied when it is applied or customer.subscription.pending_update_expired if it lapses (pending updates). Grant the higher plan when the subscription's items actually show the new price.
Downgrades
Downgrades are usually better applied at the next renewal, so the customer keeps what they paid for. You can schedule that with a subscription schedule phase, or apply the change at period end from your own job. Either way, the entitlement should drop when the subscription's items show the lower price, which you learn about through customer.subscription.updated. Note that a mid-period downgrade with prorations creates a credit, and Stripe does not automatically refund negative prorations; they reduce future invoices unless you refund manually.
One trap: if a customer changes plan while an invoice is unpaid, prorations can credit them for time they never paid for. Stripe's docs suggest disabling prorations in that case. Your plan-change endpoint should check the status of the latest invoice before allowing a change.
Cancel at period end vs immediate cancellation
The phrase “Stripe webhook subscription cancelled” covers two different moments, and handlers that conflate them either cut customers off early or never cut them off at all.
- Cancellation scheduled. When a customer cancels in the customer portal or you set cancel_at_period_end=true, the subscription stays active until the period ends. You receive customer.subscription.updated with cancel_at_period_end set to true. Keep access, store the flag, and show the end date in the app. The customer can still change their mind: setting cancel_at_period_end back to false before the period ends keeps the subscription, and you will see another updated event.
- Cancellation effective. When the period ends, or immediately if you cancel through the API without scheduling, Stripe sends customer.subscription.deleted and the status becomes canceled. Revoke access now.
- Custom dates. cancel_at sets a specific future timestamp. If it falls inside the current period, Stripe shortens the period and creates prorations unless you disable them.
Immediate cancellation does not refund anything by itself. If your policy is to refund unused time, issue that explicitly; the cancel API's prorate and invoice_now options and the Dashboard's refund choices are covered in Stripe's cancellation guide. Also note that when a subscription is canceled, Stripe turns off automatic collection on its open and draft invoices. If your finance team expects those to be chased, they need a manual process.
Trials: from trialing to paid
A trial with a card on file is the cleanest subscription flow to reason about, and it touches nearly every event in this guide.
At sign-up the subscription is created in trialing, and Stripe generates a zero-amount invoice for the trial period. Provision access now. Three days before the trial ends, customer.subscription.trial_will_end arrives. Check that the customer has a default payment method and prompt them if not, because a trial that ends without one will either fail its first charge or, if you configured trial_settings.end_behavior.missing_payment_method to pause or cancel, pause or cancel the subscription.
When the trial ends, Stripe creates the first paid invoice (invoice.created), leaves it in draft for about an hour, finalizes it (invoice.finalized) and attempts payment. On success you receive invoice.paid and a customer.subscription.updated showing status active. On failure you receive invoice.payment_failed and an update that typically shows past_due, and your grace policy takes over. The diagram shows the order in which Stripe does these things; the order in which your endpoint receives them can differ, which is one more reason to sync from the retrieved subscription.
Trials without a payment method upfront are common in self-serve products. There, trial_will_end is your main conversion moment, and paused is a status your access table must handle; the customer resumes by adding a payment method.
The minimal event set vs a robust event set
You do not need every billing event on day one, but the minimal set has to be genuinely complete for the states your product can reach.
A robust set adds the events that cover the edges: customer.subscription.trial_will_end for trial conversion; invoice.payment_action_required for authentication; invoice.finalization_failed for invoices you cannot collect; invoice.upcoming if you add usage or one-off items before renewal; customer.subscription.paused and customer.subscription.resumed if trials can end without a card; the pending-update events if you use pending updates; checkout.session.async_payment_succeeded and checkout.session.async_payment_failed if you accept delayed payment methods; and the dispute and refund events if your policy reacts to them.
Two rules apply to both sets. Subscribe only to events you handle, and acknowledge any unexpected type with a 200 rather than an error. And if your endpoint is subscribed to invoice.created, answer it quickly even when you do nothing with it, because a failing response to that event holds up finalization of automatically collected renewals.
A Stripe subscription webhook example: one idempotent sync handler
The example below is the worker side, called once per stored event after signature verification and event-ID deduplication have happened at the endpoint. It funnels every subscription-related event into a single re-fetch-and-write, so duplicates and out-of-order deliveries converge on the same answer.
// Called by the queue worker once per stored (deduplicated) event.
const HANDLED = new Set([
"customer.subscription.created", "customer.subscription.updated",
"customer.subscription.deleted", "invoice.paid", "invoice.payment_failed",
]);
function subscriptionIdFrom(obj) {
if (obj.object === "subscription") return obj.id;
// Invoices: newer API versions nest it under parent.subscription_details
return obj.parent?.subscription_details?.subscription ?? obj.subscription ?? null;
}
export async function processEvent(event) {
if (!HANDLED.has(event.type)) return;
const subId = subscriptionIdFrom(event.data.object);
if (!subId) return; // one-off invoice, not a subscription
// Re-fetch so current state wins, whatever order events arrive in.
const sub = await stripe.subscriptions.retrieve(subId);
const item = sub.items.data[0];
const access =
sub.status === "active" || sub.status === "trialing" ? "full"
: sub.status === "past_due" ? "grace" : "none";
await db.query(
"UPDATE accounts SET price_id = $1, sub_status = $2, access = $3, period_end = to_timestamp($4) WHERE stripe_subscription_id = $5",
[item.price.id, sub.status, access, item.current_period_end, sub.id]
);
}What makes it safe:
- It is idempotent. It writes absolute state (status, price, access, period end), so running it twice for the same event, or for two events about the same subscription, produces the same row.
- It ignores payload order. It reads the subscription from the API, so a late customer.subscription.updated carrying an old status cannot overwrite a newer one.
- It tolerates API version differences. Newer API versions nest an invoice's subscription under parent.subscription_details; older ones use invoice.subscription. Pin your API version and test before upgrading.
- It leaves side effects to separate, keyed steps. Emails for trial endings and failed payments should go out from their own jobs with a sent marker keyed on the event or invoice, so a retried sync never emails twice.
Production versions add three things: an upsert path for when the account row does not exist yet, support for multi-item subscriptions (map each item's price to a feature set rather than reading items.data[0]), and per-subscription serialization in the queue so two workers never write the same row at once.
Testing every subscription transition with test clocks
Subscription bugs live in time: renewals, trial ends, retry schedules and period-end cancellations. Stripe's test clocks, shown as Simulations in the Dashboard, let you create a clock with a frozen time, attach new test customers to it, and advance time to watch Stripe generate the events your handlers will see in production (test clocks).
A few constraints shape the test plan. You can advance a clock by up to two billing intervals at a time, based on the shortest interval among the subscriptions on it. A simulation holds up to three customers, each with up to three subscriptions. The clock emits test_helpers.test_clock.advancing and test_helpers.test_clock.ready, so automated tests know when advancing has finished. Simulations are deleted automatically 30 days after creation, and deleting one deletes its customers and cancels their subscriptions. Renewal invoices sit in draft for about an hour, so advance an extra hour to see them finalize and pay.
A transition matrix worth running before launch, and again before any billing change:
- New subscription, card succeeds: access granted once, status active.
- New subscription, authentication required: incomplete, then active after the customer authenticates, with no access before.
- New subscription, first payment never completes: incomplete_expired after 23 hours, with no access and no orphaned account.
- Trial with card: trialing, trial_will_end three days before the end, then active at trial end.
- Trial without card: trial_will_end prompt, then paused or canceled according to your trial settings.
- Renewal fails and the card is updated during retries: past_due with grace, back to active, access never fully lost.
- Renewal fails and retries are exhausted: canceled or unpaid according to your settings, access revoked once.
- Upgrade mid-period: proration on the expected invoice, new limits when the new price applies.
- Downgrade at renewal: old limits until period end, new limits after.
- Cancel at period end, then undo: access unchanged throughout.
- Cancel at period end: access ends exactly when customer.subscription.deleted arrives.
For each scenario, assert three things: the events received, your entitlement row, and the customer-facing emails. Run the same scenarios with duplicate and reordered deliveries using stripe events resend. Use Stripe's documented test cards for successful, declined and authentication-required payments. One documented caveat: test clock advancement does not currently support collecting bank-debit payments such as us_bank_account, so test those failure paths separately.
Common Stripe subscription webhook bugs
- Granting access on checkout before payment. Treating checkout.session.completed or customer.subscription.created as proof of payment. Either can occur while payment is incomplete or still processing. Grant from status after re-fetching.
- Trusting payload ordering. Writing data.object.status straight to the database. A delayed retry of an older event overwrites newer state and locks out a paying customer.
- Revoking on the first invoice.payment_failed. A single failed attempt usually starts a retry schedule, not the end of the subscription. Apply your grace policy instead.
- Ending access when cancel_at_period_end is set. The customer canceled for the end of the period, not today. Wait for customer.subscription.deleted.
- Ignoring the second subscription. Customers who resubscribe after canceling get a new subscription ID. If your account row is keyed only on the old one, the new subscription's events update nothing.
- Not handling unpaid or paused. Code that only knows active and canceled leaves these customers with access indefinitely.
- Slow or failing invoice.created responses. If your endpoint listens for invoice.created and does not return 2xx, finalization of automatically collected invoices can be delayed for up to 72 hours.
- Emails sent twice. Side effects inside a handler that Stripe or your queue retries. Key every email on the event or invoice.
Each of these is cheap to fix before launch and expensive afterwards, because by then your database and Stripe disagree for real customers and someone has to reconcile them by hand. If you are moving existing subscriptions into Stripe, these checks belong in the cutover plan too; see migrating subscriptions to Stripe Billing.
From here, this article is about Cevrynt
How Cevrynt approaches Stripe Billing webhook work
Cevrynt is a Stripe partner and a member of the Stripe Partner Ecosystem. Our Stripe Billing service covers subscription builds, billing audits and migrations, and webhook and entitlement synchronization sits at the center of most of them. The work is led by Arindam Pradhan, a Stripe Certified Professional (Implementation Architect), with senior engineers who build inside your repository and Stripe account.
We begin by writing the state table (every status, every event, the access level and the customer communication for each) and agreeing it with your product and finance teams before touching production. Then we implement a single sync path, a reconciler for missed events and a test-clock matrix like the one above, and hand over the evidence. Projects are scoped and priced in writing after a technical review. If your app and Stripe disagree about who has paid, describe what you are seeing or book a call.
