What you're doing and why it's painful
Stripe webhooks let your server respond to payment events — a subscription renewing, a checkout completing, a payment failing. Without them your app has no way to know what happened after a user left your checkout page.
The pain: setting them up requires navigating three separate sections of the Stripe dashboard, generating a signing secret, adding it to your environment, wiring up signature verification in your code, and making your local endpoint publicly accessible for testing. Miss any step and webhooks silently fail.
Prerequisites
- A Stripe account (test or live)
- A publicly accessible server URL (or a tunnel like Stripe CLI for local dev)
- Your server running and able to accept POST requests
Step 1 — Create a webhook endpoint in Stripe
- Log in to the Stripe Dashboard
- Go to Developers → Webhooks
- Click Add endpoint
- Enter your endpoint URL:
https://yourdomain.com/api/webhooks/stripe - Under Select events, choose the events you want to receive. For a basic checkout integration, add:
checkout.session.completedpayment_intent.succeededpayment_intent.payment_failed
- Click Add endpoint
Step 2 — Get the signing secret
After creating the endpoint, Stripe shows you a Signing secret starting with whsec_. Copy it — you'll only see it once in this view (you can reveal it again later from the endpoint page).
Step 3 — Add the signing secret to your environment
Add the signing secret to your environment variables:
STRIPE_WEBHOOK_SECRET=whsec_your_secret_here
On Vercel: Project Settings → Environment Variables → Add. Set it for Production and Preview.
On local: add it to your .env file (never commit this to git).
Step 4 — Handle the webhook in your server
Your endpoint must return a 200 response quickly, then process the event asynchronously. Always verify the signature using the signing secret before processing anything.
import Stripe from 'stripe';
import express from 'express';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const app = express();
// Important: use raw body for signature verification
app.post(
'/api/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature']!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return res.status(400).send(`Webhook Error: ${err}`);
}
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object as Stripe.Checkout.Session;
// Fulfil the purchase, provision access, send confirmation email, etc.
console.log('Payment successful for session:', session.id);
break;
case 'payment_intent.payment_failed':
// Handle failed payment — notify user, retry, etc.
break;
}
res.json({ received: true });
}
);
Critical: the route must use express.raw() not express.json(). If you parse the body as JSON before signature verification, Stripe's SDK can't verify it and every webhook will fail.
Step 5 — Test locally with the Stripe CLI
Install the Stripe CLI and forward events to your local server:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
The CLI outputs a new local signing secret (different from your production one). Use it for local testing:
STRIPE_WEBHOOK_SECRET=whsec_local_secret_from_cli
Trigger a test event:
stripe trigger checkout.session.completed
Common errors and gotchas
No signatures found matching the expected signature for payload
You're using express.json() instead of express.raw(). The body is being parsed before Stripe can verify it. Fix: switch the route middleware to express.raw({ type: 'application/json' }).
Webhook signing secret mismatch
Your production and local secrets are different — this is correct and expected. Make sure you're using the Stripe CLI's local secret for dev, and the dashboard secret for production. Don't mix them.
Events not arriving
Check that your endpoint URL is publicly accessible. If you're running locally, you need the Stripe CLI tunnel — Stripe cannot reach localhost directly.
Duplicate event processing
Stripe may send the same event more than once (it retries on failure). Make your handler idempotent — check if you've already processed a given event.id before acting on it.