Photo by Eric Er on Unsplash

On this page

This chapter follows Custom payment forms with the Payment Element.

Webhooks are HTTP POST requests from Stripe to your server. Each payload wraps an Event—checkout.session.completed, payment_intent.succeeded, and dozens of others. Subscriptions and asynchronous payment methods require webhooks; one-time Checkout integrations should use them too so fulfillment survives closed browser tabs.

Events and endpoint configuration

Register an endpoint in the Dashboard (Developers → Webhooks) or via API. Stripe sends JSON with a Stripe-Signature header. Store the signing secret (whsec_...) in STRIPE_WEBHOOK_SECRET.

Select events you handle—start with payment and subscription types you act on. Unused events add noise and load.

Verify signatures in Express

The raw body bytes must be available for signature verification. Register the webhook route before express.json():

server.js
import express from 'express';
import { handleWebhook } from './routes/webhook.js';
import { createCheckoutSession } from './routes/checkout.js';
const app = express();
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
handleWebhook,
);
app.use(express.json());
app.post('/create-checkout-session', createCheckoutSession);
routes/webhook.js
import { stripe } from '../stripe-client.js';
const handledEvents = new Set();
export function handleWebhook(req, res) {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET,
);
} catch (err) {
console.error('Webhook signature verification failed.', err.message);
return res.sendStatus(400);
}
if (handledEvents.has(event.id)) {
return res.json({ received: true });
}
switch (event.type) {
case 'checkout.session.completed':
fulfillCheckout(event.data.object);
break;
case 'checkout.session.async_payment_succeeded':
fulfillCheckout(event.data.object);
break;
case 'payment_intent.succeeded':
break;
case 'invoice.paid':
break;
case 'invoice.payment_failed':
break;
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
break;
default:
break;
}
handledEvents.add(event.id);
res.json({ received: true });
}
function fulfillCheckout(session) {
if (session.payment_status === 'unpaid') return;
const userId = session.client_reference_id ?? session.metadata?.appUserId;
console.log('Grant access for', userId, session.id);
}

Return 2xx quickly; heavy work can enqueue a job. Stripe retries non-2xx responses for up to several days.

Idempotent handlers

Stripe retries the same event.id if your server times out or returns 5xx. Persist processed event ids (database or cache) instead of an in-memory Set in production. Handlers should be safe to run twice: upsert order status, do not double-ship.

Ordering is not guaranteed across unrelated events. Design state machines that tolerate invoice.paid arriving before a related customer.subscription.updated.

Key event types

Event Typical use
checkout.session.completed One-time or subscription Checkout finished
checkout.session.async_payment_succeeded Async method now paid
payment_intent.succeeded Custom Payment Element flow paid
invoice.paid Subscription cycle paid
invoice.payment_failed Dunning, email, downgrade
customer.subscription.updated Plan change, status change
customer.subscription.deleted Revoke access at period end or cancel

Use event.data.object as the Session, PaymentIntent, Invoice, or Subscription payload.

Local testing with the CLI

Forward events to your dev server:

Terminal window
stripe listen --forward-to localhost:4242/webhook

The CLI prints a webhook signing secret for that session—put it in STRIPE_WEBHOOK_SECRET while testing.

Trigger fixtures without clicking through UI:

Terminal window
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed

Combine with test Checkout payments to see the full pipeline.

Security checklist

  • Verify every request with constructEvent; reject missing or invalid signatures.
  • Use HTTPS in production; restrict endpoint to POST from Stripe IP ranges only as defense in depth (signature verification is the real gate).
  • Do not expose webhook routes to CSRF from browsers—Stripe server POSTs JSON, not your users’ cookies for state-changing app actions.
  • Rotate signing secrets through Dashboard and env vars without dropping events—support two secrets briefly during cutover if needed.

Next: Subscriptions in Stripe.

Check your understanding

Question 1 of 6

0 of 6 checked

  1. 1. What environment variable holds the webhook signing secret?

Search articles

Type a word to search published articles.

Open the search page