You followed the docs. Copied the example. Added the signature check. Still getting:
No signatures found matching the expected signature for payload
Here's every real reason it happens and how to fix each one.
if you just want all of this handled without thinking about it Tern fixes every issue below out of the box. keep reading to understand why.
1. Your body is already parsed
This gets almost everyone the first time.
Stripe needs the raw bytes of the request body — exactly what it sent, untouched. If anything touches the body before your handler runs, the signature won't match.
In Express the problem is usually this:
// ❌ express.json() runs first — raw bytes are gone
app.use(express.json())
app.post('/webhook', (req, res) => {
stripe.webhooks.constructEvent(req.body, sig, secret) // fails
})
Fix — give the webhook route its own raw body parser, separate from everything else:
// ✅ raw body only for this route
app.post('/webhook',
express.raw({ type: '*/*' }),
(req, res) => {
stripe.webhooks.constructEvent(req.body, sig, secret) // works
}
)
app.use(express.json()) // other routes unaffected
Middleware order matters. Webhook route gets raw body — everything else gets parsed JSON.
2. Test secret vs Live secret
Stripe gives you a different signing secret for test mode and live mode.
The Stripe CLI gives you one secret when you run stripe listen. Your Dashboard endpoint has a completely different one. Mixing them always fails silently.
# CLI secret only works with stripe listen
whsec_abc123...
# Dashboard secret — only works with real Stripe events
whsec_xyz789...
Keep them separate and explicit:
STRIPE_WEBHOOK_SECRET_TEST=whsec_cli_secret
STRIPE_WEBHOOK_SECRET_LIVE=whsec_dashboard_secret
3. Timestamp tolerance — the debugging trap
Stripe embeds a timestamp in the signature. By default anything older than 5 minutes gets rejected. Replay attack protection.
When you replay an old event from the Dashboard, the timestamp is from when Stripe originally sent it — not now. If that was more than 5 minutes ago, verification fails even with the correct secret and correct body.
Temporarily extend tolerance while debugging:
// debugging only — never in production
stripe.webhooks.constructEvent(payload, sig, secret, 99999)
Better — use stripe trigger locally instead of replaying old events. Fresh timestamps every time.
stripe listen --forward-to localhost:3000/webhook
stripe trigger payment_intent.succeeded
4. A proxy is sitting in front of your server
Cloudflare, nginx, or any reverse proxy can modify request bytes before they reach your handler. Compression, encoding rules, security filters — any of these can break signature verification.
Symptom works locally, fails in production with the correct secret.
Quick check — bypass the proxy and hit your server directly. If verification passes, the proxy is the culprit. For Cloudflare, whitelist Stripe's IP ranges to pass through without transformation.
The pattern underneath all of this
Every one of these failures has the same root cause — something between Stripe and your verification code modified or lost the original request.
The verification logic itself is not complicated. The problem is always the plumbing around it.
How Tern fixes all of this
We kept hitting every one of these issues while building our own product Hookflo — raw body bugs, wrong secrets, localhost pain, silent failures in production. So we pulled the whole thing out into Tern, an open source SDK that absorbs all of it.
Signature verification — just works
Raw body extraction, timestamp validation, correct header parsing — handled internally for every framework. No middleware ordering to think about. If verification fails you get a specific errorCode like INVALID_SIGNATURE, TIMESTAMP_TOO_OLD, or MISSING_HEADER instead of the generic Stripe error.
import { createWebhookHandler } from '@hookflo/tern/nextjs'
export const POST = createWebhookHandler({
platform: 'stripe',
secret: process.env.STRIPE_WEBHOOK_SECRET!,
handler: async (payload) => {
// verified — raw body handled, timestamp checked
}
})
Works the same on Nextjs, Express, Cloudflare Workers and Hono. Same code, native adapter for each runtime.
No localhost tunneling headaches
Tern works with Stripe CLI out of the box. No ngrok, no extra setup:
stripe listen --forward-to localhost:3000/webhook
stripe trigger payment_intent.succeeded
After verification — the full loop
Once the signature passes, what happens next? Events fail silently. Customers email saying they missed 200 order updates. No visibility, no retries.
Tern has an optional reliability layer for exactly this — queue, retries, deduplication, dead letter queue and replay. Opt-in, bring your own Upstash account:
export const POST = createWebhookHandler({
platform: 'stripe',
secret: process.env.STRIPE_WEBHOOK_SECRET!,
queue: {
token: process.env.QSTASH_TOKEN!,
retries: 3,
},
handler: async (payload) => {
// queued, retried on failure, deduplicated
}
})
Know before your users do
Slack and Discord alerts when events fail or land in the dead letter queue. Optional, one config line:
alerts: {
slack: { webhookUrl: process.env.SLACK_WEBHOOK_URL! },
}
verify → queue → retry → dedup → DLQ → replay → alert. the whole inbound webhook loop, closed.
Docs
github.com/Hookflo/tern — open source, MIT licensed, zero lock-in.
Top comments (0)