← All articles

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

Webhooker Team 17 min read
Flat illustration of a loop of calendar pages and paid invoices feeding the blue Webhooker pulse node, which branches to an open padlock with a key for a paid period and a ringing bell beside a cracked bank card for a failed renewal.

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

EventFires whenWhat to do
customer.subscription.createdA subscription is created; status may be incomplete or trialingStore the subscription id against your user. Do not grant access yet unless it is trialing
invoice.paidAn invoice payment succeeds, or an invoice is marked paid out of bandRe-read the subscription; if active, extend access to the new period end
invoice.payment_failedA payment attempt is declined, including soft declines, or there is no payment methodEmail the customer a link to update their card; keep access during retries if your policy allows
invoice.payment_action_requiredThe payment needs customer authentication, usually 3D SecureSend the customer back to complete the payment
customer.subscription.updatedAnything on the subscription changes: status, price, quantity, cancel_at_period_end, renewalRe-sync status, plan and period end; read previous_attributes to see what changed
customer.subscription.deletedThe subscription ends, immediately or at the end of the periodRevoke access
customer.subscription.trial_will_endThree days before a trial ends, or immediately when it is ended earlyCheck there is a payment method; remind the customer they will be charged
invoice.upcomingA configurable number of days before a renewal is chargedOptional: add usage or one-off items to the next invoice
invoice.createdA renewal invoice is created as a draftAnswer 2xx quickly. Stripe holds the charge until you do
invoice.finalization_failedStripe cannot finalise the invoice, often missing tax locationFix the customer’s address; no payment can be collected until then
customer.subscription.paused / resumedA subscription enters or leaves status: pausedSuspend and restore access

The descriptions follow Stripe’s event types reference and the subscription webhooks guide. If you use 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, the subscription starts as incomplete and becomes active once the first invoice is paid. A successful card sign-up produces roughly this set:

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.

Trials and customer.subscription.trial_will_end

A subscription with a trial starts as trialing, and Stripe says you can safely provision access 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 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, 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. 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. 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:

{
  "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:

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 gives clear guidance at both ends and leaves the middle to you:

statusMeaningAccess
trialingIn a trialYes
activeIn good standingYes
past_dueLatest invoice failed; retries may followYour call: a grace period is common
incompleteFirst payment pending, failed or awaiting 3DS, 23-hour windowNo
incomplete_expiredFirst payment never completedNo
unpaidRetries exhausted, subscription keptNo. Stripe says to revoke
canceledEnded, terminalNo
pausedTrial ended without a payment method, pause behaviourNo

Two details from Stripe’s docs matter here. After invoice.paid, Stripe tells you to retrieve the subscription and confirm it is active 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.

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 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.

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:

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 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. 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 covers the CLI flags, and 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 such as Webhooker gives Stripe a single ingest URL (https://app.webhooker.eu/in/<token>). Webhooker verifies the Stripe-Signature with your whsec_ 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 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. 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; Webhooker processes and stores them in the EU. The free plan 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.