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():
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);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:
stripe listen --forward-to localhost:4242/webhookThe CLI prints a webhook signing secret for that session—put it in STRIPE_WEBHOOK_SECRET while testing.
Trigger fixtures without clicking through UI:
stripe trigger checkout.session.completedstripe trigger invoice.payment_failedCombine 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