Webhooks are how Stripe tells your server when a payment happens. Add an endpoint, verify the signature, test locally with the Stripe CLI — about ten minutes end to end.
Open the Stripe Dashboard. Toggle to Test mode (orange banner) while developing.
Go to Developers → Webhooks. Click Add endpoint.
Enter your webhook URL:
https://yourdomain.com/api/stripe/webhook
Select events. Common ones:
checkout.session.completed — Stripe Checkout finishedpayment_intent.succeeded — custom payment flow succeededinvoice.paid / invoice.payment_failed — subscriptionscustomer.subscription.deleted — subscription endedcharge.refunded — charge refundedFull list: Stripe event types reference ↗. Start with one or two; add more as you grow.
Click Add endpoint. Stripe shows the Signing secret:
whsec_xxxxxxxxxxxxxxxxxxxxxx
Copy it. Store in your backend env:
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxx
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express();
app.post(
'/api/stripe/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature'];
try {
const event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
if (event.type === 'checkout.session.completed') {
console.log('Payment successful', event.data.object);
// Grant access, send email, etc.
}
res.sendStatus(200);
} catch (err) {
console.error('Webhook error:', err.message);
res.sendStatus(400);
}
}
);
SDK reference: stripe-node ↗.
express.raw(), not express.json(), on the webhook route. Signature verification reads the raw bytes. If a JSON middleware parses the body first, verification fails.
Install (Stripe CLI docs ↗):
brew install stripe/stripe-cli/stripe
stripe login
Forward events to your local server:
stripe listen --forward-to localhost:3000/api/stripe/webhook
The CLI prints a webhook signing secret (separate from the dashboard's). Use it as STRIPE_WEBHOOK_SECRET in your local .env.
Trigger a test event:
stripe trigger checkout.session.completed
Your local handler should receive and verify it.
Stripe retries on failure. The same event ID can arrive multiple times. Dedupe on event.id:
const exists = await db.processedEvents.findOne({ id: event.id });
if (exists) return res.sendStatus(200); // Already handled
// ... process event ...
await db.processedEvents.insert({ id: event.id });
Test mode and live mode have separate webhook endpoints. When you're ready:
STRIPE_WEBHOOK_SECRET in your production env.constructEvent in all official SDKs — same pattern (raw body, signature header, signing secret). Quick links: