# Stripe Subscription Webhooks: Which Events to Handle and When to Grant Access

> Which Stripe subscription webhook events to handle: invoice.paid, payment_failed, subscription updated/deleted, trial_will_end. Lifecycle, statuses, access.

Source: https://webhooker.eu/blog/stripe-subscription-webhooks
Last updated: 2026-10-16

A Stripe subscription integration needs six webhook events. `invoice.paid` extends access each billing period. `invoice.payment_failed` starts your dunning emails. `customer.subscription.updated` catches plan changes, status changes and cancellations scheduled for the end of the period. `customer.subscription.deleted` revokes access when the subscription ends. `customer.subscription.trial_will_end` warns you three days before a trial converts. `invoice.payment_action_required` tells you a renewal is stuck waiting for 3D Secure. Everything else is optional until a specific feature needs it.

Choosing the events is the easy part. The bugs come from the sequence: the order in which these events arrive, what the subscription `status` is at each step, and which of them should move a customer’s access. This guide walks through every stage of the lifecycle with the events Stripe sends. All Stripe behaviour below links to docs.stripe.com, checked on 16 October 2026.

## The events at a glance

| Event | Fires when | What to do |
| --- | --- | --- |
| `customer.subscription.created` | A subscription is created; `status` may be `incomplete` or `trialing` | Store the subscription id against your user. Do not grant access yet unless it is `trialing` |
| `invoice.paid` | An invoice payment succeeds, or an invoice is marked paid out of band | Re-read the subscription; if `active`, extend access to the new period end |
| `invoice.payment_failed` | A payment attempt is declined, including soft declines, or there is no payment method | Email the customer a link to update their card; keep access during retries if your policy allows |
| `invoice.payment_action_required` | The payment needs customer authentication, usually 3D Secure | Send the customer back to complete the payment |
| `customer.subscription.updated` | Anything on the subscription changes: status, price, quantity, `cancel_at_period_end`, renewal | Re-sync status, plan and period end; read `previous_attributes` to see what changed |
| `customer.subscription.deleted` | The subscription ends, immediately or at the end of the period | Revoke access |
| `customer.subscription.trial_will_end` | Three days before a trial ends, or immediately when it is ended early | Check there is a payment method; remind the customer they will be charged |
| `invoice.upcoming` | A configurable number of days before a renewal is charged | Optional: add usage or one-off items to the next invoice |
| `invoice.created` | A renewal invoice is created as a draft | Answer `2xx` quickly. Stripe holds the charge until you do |
| `invoice.finalization_failed` | Stripe cannot finalise the invoice, often missing tax location | Fix the customer’s address; no payment can be collected until then |
| `customer.subscription.paused` / `resumed` | A subscription enters or leaves `status: paused` | Suspend and restore access |

The descriptions follow Stripe’s [event types reference](https://docs.stripe.com/api/events/types) and the [subscription webhooks guide](https://docs.stripe.com/billing/subscriptions/webhooks). If you use [Entitlements](https://docs.stripe.com/billing/entitlements), `entitlements.active_entitlement_summary.updated` can replace part of this: Stripe computes which features a customer has and tells you when that set changes.

## A new subscription, event by event

When you create a subscription that charges immediately, Stripe creates the subscription, its first invoice and a PaymentIntent together. Per the [subscription lifecycle docs](https://docs.stripe.com/billing/subscriptions/overview), the subscription starts as `incomplete` and becomes `active` once the first invoice is paid. A successful card sign-up produces roughly this set:

```text
customer.subscription.created    status: incomplete
invoice.created                  status: draft → open
invoice.finalized
payment_intent.succeeded
invoice.paid                     status: paid
invoice.payment_succeeded
customer.subscription.updated    status: incomplete → active
```

Those events can reach your endpoint in any order. Stripe does not guarantee delivery order, and the `created` timestamps have one-second resolution, so several of these share the same second. If your code grants access in `customer.subscription.created` because “a subscription exists now”, a declined card still gets a free account. If it waits for `customer.subscription.updated` with `status: active`, it works, but only because you happened to pick the event that carries the final state.

The first invoice has a deadline. If the payment fails or needs authentication, the customer has 23 hours to complete it. After that the subscription moves to `incomplete_expired`, the invoice is voided, and the only way back is a new subscription. `incomplete_expired` subscriptions never bill anyone; they exist so you can see who abandoned the sign-up.

If you sign customers up through Stripe Checkout, `checkout.session.completed` also fires once, when the subscription is created. It is a good moment to link the Stripe customer to your user record. For whether it means the money arrived, see [checkout.session.completed vs payment_intent.succeeded](https://webhooker.eu/blog/checkout-session-completed-vs-payment-intent-succeeded).

## Trials and customer.subscription.trial_will_end

A subscription with a trial starts as `trialing`, and Stripe says you can [safely provision access](https://docs.stripe.com/billing/subscriptions/webhooks#state-changes) in that state. No payment happens up front, so there is no `invoice.paid` for real money to wait for: the first invoice is a zero-amount trial invoice.

`customer.subscription.trial_will_end` fires three days before the trial ends. It also fires immediately in two cases that catch people out. If the trial is shorter than three days, there is no “three days before”. And if the trial is ended early, with `trial_end=now` or because the customer switched plans in the Customer Portal, the event can fire in the same transaction that collects the first payment. Stripe’s own [event description](https://docs.stripe.com/api/events/types) advises checking the subscription status and latest invoice before sending a “your card will be charged” email. Otherwise customers get a reminder about a charge that already happened.

At the end of the trial Stripe charges the card. Success sends `invoice.paid` and a `customer.subscription.updated` from `trialing` to `active`. What happens if the customer never added a card depends on `trial_settings.end_behavior.missing_payment_method`: `cancel` ends the subscription (`customer.subscription.deleted`), `pause` moves it to `paused` (`customer.subscription.paused`), and `create_invoice` bills anyway, so the invoice goes unpaid and the subscription goes `past_due`.

## Renewals and the invoice.created delay

Every renewal starts with `invoice.created`. Stripe creates the invoice as a draft and then waits. Per the [invoice finalisation section](https://docs.stripe.com/billing/subscriptions/webhooks#understand), Stripe waits an hour after it receives a successful response to `invoice.created` before attempting payment. If your endpoint never answers successfully, it keeps retrying for up to 72 hours before finalising the invoice and charging anyway.

The delay counts responses from every webhook endpoint on the account, not only the one that cares about invoices. A dead endpoint left over from an old integration, or a Connect platform’s broken endpoint, delays every subscription renewal on the account by up to three days. Stripe also emails you that the webhook is failing. If renewals lag days behind their dates, check the endpoint list in Workbench first.

After finalisation (`invoice.finalized`), Stripe charges the default payment method. On success you receive `invoice.paid`, `invoice.payment_succeeded` and a `customer.subscription.updated` whose `previous_attributes` show the billing period moving forward. A few days before all of this, if you configured it, `invoice.upcoming` gives you a chance to add usage charges. Its invoice object has no id yet, because the invoice does not exist.

## Failed renewals: past_due, retries and dunning

A declined renewal sends `invoice.payment_failed`, and the subscription moves from `active` to `past_due`. The invoice stays `open`, and Stripe retries according to your [Smart Retries or custom retry rules](https://docs.stripe.com/billing/revenue-recovery/smart-retries). Each retry that fails sends another `invoice.payment_failed`. The invoice’s `next_payment_attempt` tells you when the next try is, and it is `null` when there is none left.

What happens after the last retry is a Dashboard setting, not something the API decides for you. The subscription can move to `canceled` (you receive `customer.subscription.deleted`), to `unpaid` (`customer.subscription.updated`; invoices keep being created but nobody tries to charge them), or stay `past_due`. By default Stripe [cancels after up to eight unsuccessful attempts](https://docs.stripe.com/billing/subscriptions/cancel). Find out which one your account uses before writing the access logic, because each produces a different final event.

When a retry finally succeeds, or the customer pays the open invoice from the hosted invoice page, `invoice.paid` arrives and the subscription goes back to `active`.

Renewals can also fail for a different reason. Under SCA in Europe, the issuing bank can demand 3D Secure on a renewal. You then get `invoice.payment_action_required` instead of a plain decline. The PaymentIntent sits in `requires_action`, and only the customer can resolve it. Send them to the invoice’s `hosted_invoice_url`, or bring them back into your app and call `stripe.handleNextAction` with the PaymentIntent’s client secret. Retrying the charge on your side does nothing until they authenticate.

## Plan changes and cancellations

Upgrades, downgrades, quantity changes and coupons all arrive as `customer.subscription.updated`. The event carries the full new subscription in `data.object`. It also includes a `data.previous_attributes` object containing only the fields that changed, with their old values. A trimmed event for an upgrade:

```json
{
  "type": "customer.subscription.updated",
  "data": {
    "object": {
      "id": "sub_1Q9...",
      "status": "active",
      "cancel_at_period_end": false,
      "items": {
        "data": [{
          "price": { "id": "price_team_monthly" },
          "quantity": 5,
          "current_period_end": 1763251200
        }]
      }
    },
    "previous_attributes": {
      "items": {
        "data": [{
          "price": { "id": "price_starter_monthly" },
          "quantity": 5,
          "current_period_end": 1763251200
        }]
      }
    }
  }
}
```

Use `previous_attributes` to decide what to do (send an “upgraded to Team” email, log a downgrade) and the current object for what to store.

Cancellation produces one or two events, depending on the timing:

- **Immediate cancellation** (API `DELETE` or the Dashboard): `customer.subscription.deleted` straight away. A canceled subscription is final. To bring the customer back you create a new one.
- **Cancel at period end**: setting `cancel_at_period_end: true` sends `customer.subscription.updated`. The customer has paid for the period, so keep access and show “ends on 15 November”. When the period ends, Stripe sends `customer.subscription.deleted`. If the customer changes their mind first, `cancel_at_period_end` goes back to `false` in another `updated` event.
- **`cancel_at` a specific date**: same as above, but the period end moves to that date.

A common bug is revoking access on the first `customer.subscription.updated` because the customer clicked “Cancel” in the Customer Portal. Only `customer.subscription.deleted`, or a status of `canceled`, means the subscription is over.

## Which statuses get access

Stripe’s [status table](https://docs.stripe.com/billing/subscriptions/overview#subscription-statuses) gives clear guidance at both ends and leaves the middle to you:

| `status` | Meaning | Access |
| --- | --- | --- |
| `trialing` | In a trial | Yes |
| `active` | In good standing | Yes |
| `past_due` | Latest invoice failed; retries may follow | Your call: a grace period is common |
| `incomplete` | First payment pending, failed or awaiting 3DS, 23-hour window | No |
| `incomplete_expired` | First payment never completed | No |
| `unpaid` | Retries exhausted, subscription kept | No. Stripe says to revoke |
| `canceled` | Ended, terminal | No |
| `paused` | Trial ended without a payment method, pause behaviour | No |

Two details from Stripe’s docs matter here. After `invoice.paid`, Stripe tells you to [retrieve the subscription and confirm it is `active`](https://docs.stripe.com/billing/subscriptions/webhooks#active-subscriptions) before extending access, because a paid invoice does not move the subscription to `active` when status resolution counts all outstanding invoices. And with delayed payment methods such as ACH Direct Debit, a new subscription can go straight to `active` while the payment is still `processing`. If that payment fails later, Stripe voids the invoice and the subscription stays `active`, so you will need to handle that `invoice.payment_failed` yourself.

## The 2025 basil field moves

Stripe API version `2025-03-31.basil` moved two fields that nearly every subscription webhook handler reads. Code written against older examples breaks silently: the old fields are simply `undefined`.

- **Subscription on an invoice.** `invoice.subscription` is gone. Use `invoice.parent.subscription_details.subscription`, after checking that `invoice.parent.type` is `subscription_details` ([changelog](https://docs.stripe.com/changelog/basil/2025-03-31/adds-new-parent-field-to-invoicing-objects)). One-off invoices have a different parent type.
- **Billing period.** `current_period_start` and `current_period_end` were removed from the subscription and now live on each subscription item: `items.data[].current_period_end` ([changelog](https://docs.stripe.com/changelog/basil/2025-03-31/deprecate-subscription-current-period-start-and-end)). As a side effect, `previous_attributes` on `customer.subscription.updated` now also includes the items whenever a period rolls over.

Your webhook endpoint has its own API version, set when you registered it, and the payload shape follows that version, not the version your SDK uses for API calls. After upgrading the SDK, [upgrade the endpoint version](https://docs.stripe.com/webhooks/versioning) too, or your handler will receive old-shaped events and read them with new-shaped code.

## A handler that syncs from the API

The most robust pattern ignores the snapshot in the event. Take the subscription id from whichever event arrived, fetch the subscription from Stripe, and write its current state to your database. Order stops mattering, because whichever event is processed last writes the latest state. Duplicates stop mattering, because writing the same state twice changes nothing.

```js
import express from "express";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();

const SYNC_EVENTS = new Set([
  "customer.subscription.created",
  "customer.subscription.updated",
  "customer.subscription.deleted",
  "customer.subscription.paused",
  "customer.subscription.resumed",
  "invoice.paid",
  "invoice.payment_failed",
  "invoice.payment_action_required",
]);

app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (request, response) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      request.body,
      request.headers["stripe-signature"],
      process.env.STRIPE_WEBHOOK_SECRET,
    );
  } catch {
    return response.sendStatus(400);
  }

  if (SYNC_EVENTS.has(event.type) || event.type === "customer.subscription.trial_will_end") {
    await jobQueue.add("stripe-subscription-event", { eventId: event.id, type: event.type });
  }
  response.sendStatus(200);
});

function subscriptionIdFromEvent(event) {
  const object = event.data.object;
  if (object.object === "subscription") return object.id;
  if (object.parent?.type === "subscription_details") {
    return object.parent.subscription_details.subscription;
  }
  return null;
}

async function processSubscriptionEvent({ eventId }) {
  const event = await stripe.events.retrieve(eventId);
  const subscriptionId = subscriptionIdFromEvent(event);
  if (!subscriptionId) return;

  await syncSubscription(subscriptionId);

  if (event.type === "invoice.payment_failed") await sendUpdateCardEmail(event.data.object);
  if (event.type === "invoice.payment_action_required") await sendAuthenticateEmail(event.data.object);
  if (event.type === "customer.subscription.trial_will_end") await sendTrialEndingEmail(subscriptionId);
}

const STATUSES_WITH_ACCESS = new Set(["trialing", "active", "past_due"]);

async function syncSubscription(subscriptionId) {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);
  const periodEnd = Math.max(...subscription.items.data.map((item) => item.current_period_end));

  await database.query(
    `INSERT INTO subscriptions
       (stripe_subscription_id, stripe_customer_id, status, price_id, has_access,
        cancel_at_period_end, current_period_end)
     VALUES ($1, $2, $3, $4, $5, $6, to_timestamp($7))
     ON CONFLICT (stripe_subscription_id) DO UPDATE SET
       status = EXCLUDED.status,
       price_id = EXCLUDED.price_id,
       has_access = EXCLUDED.has_access,
       cancel_at_period_end = EXCLUDED.cancel_at_period_end,
       current_period_end = EXCLUDED.current_period_end`,
    [
      subscription.id,
      subscription.customer,
      subscription.status,
      subscription.items.data[0].price.id,
      STATUSES_WITH_ACCESS.has(subscription.status),
      subscription.cancel_at_period_end,
      periodEnd,
    ],
  );
}
```

Why it is built this way:

- `express.raw` keeps the exact bytes Stripe signed. Parsing JSON before verifying is the usual cause of [`No signatures found matching the expected signature`](https://webhooker.eu/blog/webhook-signature-verification-failed).
- The route verifies, enqueues and answers. It answers `invoice.created` too, even though it does nothing with it: answering quickly is what stops the 72-hour finalisation delay. Enqueue before returning `200`, otherwise a failed enqueue loses the event for good, because Stripe considers it delivered.
- `past_due` keeps access here, as a grace period while Stripe retries. If your product should lock on the first failure, remove it from the set. That is a business decision, not a Stripe one.
- `subscriptionIdFromEvent` uses the basil `parent` path. On an older endpoint version it would be `object.subscription`.
- The emails are not idempotent by themselves. Stripe [can send the same event more than once](https://docs.stripe.com/webhooks#handle-duplicate-events), so record the event id before sending, for example with a unique row as in [webhook idempotency keys](https://webhooker.eu/blog/webhook-idempotency-keys). The sync itself needs no dedupe.
- The access check in your app reads `has_access` and `current_period_end` from your own table. It never calls Stripe on a page load.

The cost of syncing from the API is one `GET` per event. On accounts with thousands of renewals on the first of the month that adds up, so check your [rate limits](https://docs.stripe.com/rate-limits) and let the queue worker process jobs at a steady rate instead of all at once.

## Testing the lifecycle

`stripe listen --forward-to localhost:3000/webhooks/stripe` forwards sandbox events to your machine. `stripe trigger customer.subscription.created` and `stripe trigger invoice.payment_failed` send fixture events, which are fine for checking that the handler parses them. They do not test the lifecycle, because a renewal only happens when time passes.

For that, Stripe has [test clocks](https://docs.stripe.com/billing/testing/test-clocks). Create a test clock, create a customer attached to it, subscribe them with a trial, then advance the clock past the trial end. Stripe generates the real sequence (`trial_will_end`, `invoice.created`, `invoice.paid`, the `updated` event) and sends it to your endpoint as it would in production. Attach the card `4000000000000341`, which can be saved but declines when charged, and advance another month to watch the `invoice.payment_failed` and `past_due` path run through your retry settings. The [Stripe webhooks guide](https://webhooker.eu/blog/stripe-webhooks-guide) covers the CLI flags, and [how to test webhooks](https://webhooker.eu/blog/how-to-test-webhooks) covers replaying captured payloads.

## Routing subscription events through a gateway

Subscription events usually have more than one consumer. Billing state goes to the app’s database, failed payments go to the dunning tool, cancellations go to the CRM, and everything goes to analytics. With one Stripe endpoint, the handler grows a `switch` for all of them. With several endpoints, each one verifies signatures separately, and because of the `invoice.created` rule any one of them failing delays renewals for the whole account.

A [webhook gateway](https://webhooker.eu/blog/what-is-a-webhook-gateway) such as [Webhooker](https://webhooker.eu/#features) gives Stripe a single ingest URL (`https://app.webhooker.eu/in/<token>`). Webhooker verifies the `Stripe-Signature` with your [`whsec_` secret](https://webhooker.eu/blog/stripe-webhook-signing-secret), stores the event and answers `200` straight away. That covers `invoice.created` even while your billing service is down for a deploy. [Filters on the body](https://docs.webhooker.eu/deliver/filters-and-transformations/) route by `type`: `type` contains `customer.subscription.` or `invoice.` goes to the billing service, `type` in `invoice.payment_failed`, `invoice.payment_action_required` goes to the dunning tool, and everything goes to analytics.

Each destination gets its own retries with exponential backoff, a dead-letter queue and [replay](https://docs.webhooker.eu/deliver/retries-and-replay/). A stable `X-Webhooker-Event-Id` header lets every consumer deduplicate. Subscription events carry customer emails, names and billing addresses, which makes them [personal data under GDPR](https://webhooker.eu/blog/are-webhooks-personal-data-gdpr); Webhooker processes and stores them [in the EU](https://webhooker.eu/blog/eu-hosted-webhook-infrastructure). The [free plan](https://webhooker.eu/pricing) covers 10,000 events a month.

## Frequently asked questions

### Which Stripe webhook events do I need for subscriptions?

Six cover most integrations: `invoice.paid` to extend access, `invoice.payment_failed` to start dunning, `invoice.payment_action_required` for 3D Secure on renewals, `customer.subscription.updated` for plan, status and scheduled-cancellation changes, `customer.subscription.deleted` to revoke access, and `customer.subscription.trial_will_end` if you offer trials. Add `invoice.upcoming` for usage billing and `invoice.finalization_failed` if you use Stripe Tax.

### Should I use invoice.paid or invoice.payment_succeeded?

Use `invoice.paid`. It fires when a payment attempt succeeds and also when an invoice is marked as paid out of band, for example a bank transfer you reconcile by hand. `invoice.payment_succeeded` only fires on a successful payment attempt, so access tied to it misses invoices settled any other way. Listening to both for the same action processes every renewal twice.

### When should I grant access to a Stripe subscription?

When the subscription `status` is `active` or `trialing`. Don’t grant access on `customer.subscription.created` alone, because a new subscription starts as `incomplete` until the first invoice is paid. After `invoice.paid`, retrieve the subscription and confirm it is `active` before extending access, as Stripe recommends.

### What is customer.subscription.trial_will_end?

It is sent three days before a subscription’s trial ends, so you can check that the customer has a payment method and tell them a charge is coming. It fires immediately if the trial is shorter than three days or is ended early, sometimes in the same transaction as the first payment. Check the subscription status before sending a reminder.

### When does customer.subscription.deleted fire?

When the subscription actually ends: right away for an immediate cancellation, or at the end of the billing period when `cancel_at_period_end` was `true`. It also fires when Stripe cancels a subscription after the final failed payment retry, if your settings say so. Setting `cancel_at_period_end` itself sends `customer.subscription.updated`, not `deleted`.

### Why did my subscription renewal not get charged on time?

Most often because a webhook endpoint on the account is not answering `invoice.created` with a `2xx`. Stripe waits for a successful response before finalising renewal invoices and holds them for up to 72 hours. Check every endpoint in Workbench, including old or third-party ones, and disable the ones that fail.

### What does past_due mean on a Stripe subscription?

The latest invoice’s payment failed or was not attempted, and the subscription keeps creating invoices. Stripe may retry according to your Smart Retries or custom rules. After the last retry, your Dashboard settings move the subscription to `canceled`, to `unpaid`, or leave it `past_due`. Whether customers keep access during `past_due` is up to you.

### Where is invoice.subscription in new Stripe API versions?

Since API version `2025-03-31.basil` it is at `invoice.parent.subscription_details.subscription`, used when `invoice.parent.type` is `subscription_details`. The subscription’s `current_period_end` moved too, to `items.data[].current_period_end` on each subscription item. Webhook payloads follow the API version of the endpoint, so upgrade the endpoint when you upgrade your code.

### How do I test Stripe subscription webhooks?

Use `stripe listen` to forward events locally and `stripe trigger` for single fixture events. To test trials, renewals and failed payments, create a test clock in a sandbox, attach a customer and subscription to it, and advance time. Stripe then sends the real event sequence to your endpoint.
