Stripe can't call your frontend — it calls your server. Webhooks are how you find out that a payment succeeded, a subscription renewed, or a card was declined. Today you build a webhook endpoint, verify the signature, and handle the three most critical events.
Build a verified webhook handler that processes checkout.session.completed, invoice.payment_failed, and customer.subscription.deleted without ever processing the same event twice.
The payment redirect tells you the user clicked through — it does not guarantee the money moved. Webhooks are Stripe's authoritative signal. Every production Stripe integration must handle webhooks; everything else is the optimistic path.
Stripe signs every webhook payload with your endpoint's secret. If you skip verification, any attacker can POST fake events to your endpoint and trigger payouts, provision accounts, or mark unpaid orders as complete.
app.post('/webhook', express.raw({type: 'application/json'}), (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) { return res.status(400).send(`Webhook Error: \${err.message}`); } // handle event.type below res.json({received: true}); });
express.raw(), not express.json(). Signature verification requires the raw request body. JSON parsing destroys whitespace and breaks the HMAC check.Most production Stripe integrations handle dozens of event types. Three are non-negotiable on day one:
checkout.session.completed — a Checkout flow finished with payment. Provision access here.invoice.payment_failed — a subscription renewal failed. Email the customer and start the dunning process.customer.subscription.deleted — a subscription was cancelled or expired. Revoke access here.switch (event.type) { case 'checkout.session.completed': { const session = event.data.object; await provisionAccess(session.customer, session.subscription); break; } case 'invoice.payment_failed': { const invoice = event.data.object; await sendPaymentFailedEmail(invoice.customer_email); break; } case 'customer.subscription.deleted': { const sub = event.data.object; await revokeAccess(sub.customer); break; } }
Stripe delivers webhooks at least once, meaning the same event may arrive multiple times during network retries. If your handler provisions access twice or charges twice, you have a real problem. Store processed event IDs in your database and skip re-processing.
const existing = await db.query( 'SELECT id FROM processed_events WHERE stripe_event_id = $1', [event.id] ); if (existing.rows.length > 0) { return res.json({received: true}); // already processed } // process event... await db.query( 'INSERT INTO processed_events (stripe_event_id) VALUES ($1)', [event.id] );
Before moving on, you should be able to answer these without looking:
express.raw() instead of express.json() for the webhook route?