Grant access from webhooks, not from the checkout redirect, and make every handler safe to run twice. This stripe subscription webhooks tutorial shows how to do that in Node.js with Express. It also covers failed-payment retries and proration that won't surprise your customers.

The problem in two sentences

Subscriptions change state outside your app. Cards expire, renewals fail and customers upgrade mid-cycle, and Stripe tells you about all of it only through webhooks that can arrive late, twice or out of order.

Prerequisites for this stripe subscription webhooks tutorial

  • Node.js 18 or later, with Express 4 or 5.
  • The official stripe npm package. Each major release pins a Stripe API version, so check which one yours uses.
  • A database that supports a unique constraint. We use Postgres here.
  • A Stripe account in test mode, with at least one recurring Price.
  • The Stripe CLI, which forwards events to localhost.

Step 1: Verify the signature on the raw body

Stripe signs every event. You must check that signature against the exact bytes Stripe sent, so this route needs a raw body parser, not a JSON one.

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { let event; try { event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET); } catch (err) { return res.status(400).send(`Bad signature: ${err.message}`); } /* steps 2 and 3 */ res.sendStatus(200); });

The signing secret starts with whsec_. The CLI gives you a different one from your Dashboard endpoint, which trips people up when they deploy. Stripe's webhook documentation covers this check in other languages.

Billing is the part of an app you least want to debug in production. We build this for clients — ask us about yours and skip the trial and error.

Step 2: Make the handler idempotent

Stripe may deliver the same event more than once. Record each event ID behind a unique constraint and skip any you have already seen.

const { rowCount } = await db.query('INSERT INTO stripe_events (id) VALUES ($1) ON CONFLICT (id) DO NOTHING', [event.id]); if (rowCount === 0) return res.sendStatus(200);

There is a trade-off here. If you insert the ID and then the work fails, you have marked an event done that wasn't. Run the insert and the work in one database transaction, so a failure rolls both back and Stripe's retry gets a clean second attempt.

Step 3: Map events to access

Keep one rule: a user has access when their subscription status allows it. These are the events that change that status, per Stripe's subscription webhooks guide.

  • checkout.session.completed: link the Stripe customer ID to your user record.
  • customer.subscription.created and .updated: store status, price and period end.
  • customer.subscription.deleted: revoke access.
  • invoice.paid: confirm the renewal went through.
  • invoice.payment_failed: email the customer to update their card.

For subscription events, don't trust the payload snapshot. Fetch the current object instead.

case 'customer.subscription.updated': case 'customer.subscription.deleted': { const sub = await stripe.subscriptions.retrieve(event.data.object.id); await saveSubscription(sub); break; }

Treat active and trialing as paid. Treat canceled, unpaid and incomplete_expired as no access. One caveat: in API versions from 2025-03-31 onward, the period end date moved from the subscription onto its items, so read it from the item if you're on a recent version.

Step 4: Handle failed payments and retries

When a renewal fails, the subscription moves to past_due. Stripe then retries the card on a schedule you set in Billing settings, either Smart Retries or a custom schedule. After the last attempt, it cancels the subscription, marks it unpaid or leaves it past_due, depending on those settings.

Decide what past_due means in your product. A grace period of 7 to 14 days is a common choice. It keeps customers whose card simply expired, while still cutting off accounts that never pay.

Webhook delivery has its own retries. In live mode, Stripe retries a failed delivery with exponential backoff for up to three days. So return a 2xx quickly and push slow work, like sending emails, onto a queue.

Step 5: Change plans with proration

To upgrade or downgrade, update the subscription item's price.

await stripe.subscriptions.update(sub.id, { items: [{ id: sub.items.data[0].id, price: newPriceId }], proration_behavior: 'always_invoice' });

Pick the proration behavior on purpose:

  • create_prorations (the default): adds credits and charges to the next invoice. Simple, but the customer sees the cost a month later.
  • always_invoice: bills the difference now. Best for upgrades, because payment happens before you grant the new features.
  • none: no adjustment. The new price starts at the next renewal, which suits downgrades.

Show the customer the amount before they confirm. On recent API versions, call stripe.invoices.createPreview with the subscription and the new items under subscription_details. Older versions use stripe.invoices.retrieveUpcoming, so check your pinned version first.

Two mistakes that cause production bugs

1. Parsing the body before the webhook route

If app.use(express.json()) runs before your webhook route, the raw bytes are gone. Every signature check fails, Stripe keeps retrying, and your app never records a payment. Mount the webhook route before the global JSON parser.

2. Trusting event order

Stripe does not guarantee order. A stale subscription.updated can arrive after a newer one and downgrade a paying customer. Fetching the latest object, as in Step 3, fixes this. Comparing event.created against your last saved timestamp also works.

How to test it

  1. Forward events locally: stripe listen --forward-to localhost:3000/webhooks/stripe.
  2. Fire single events with stripe trigger invoice.payment_failed.
  3. Subscribe with card 4242 4242 4242 4242 for success. Use 4000 0000 0000 0341, which attaches but declines charges, to test renewals that fail.
  4. Use test clocks to jump a month ahead and watch real renewal and retry events.
  5. Resend one event twice with stripe events resend and the event ID. Confirm nothing changes the second time.

FAQ

Which Stripe webhook events do I need for subscriptions?

At minimum, handle checkout.session.completed, the three customer.subscription events, invoice.paid and invoice.payment_failed. That covers sign-up, plan changes, renewals, failures and cancellation. Add trial and dispute events only if your product uses them.

Why does Stripe webhook signature verification fail?

The usual cause is a JSON parser changing the body before verification. The second most common is using the CLI's signing secret in production, or the reverse. Check both before anything else.

How long does Stripe retry failed webhooks?

In live mode, Stripe retries a failed delivery for up to three days with exponential backoff. In test mode, retries are much shorter. Either way, your handler must be idempotent, because a retry can follow a success your server didn't acknowledge in time.