Stripe webhook signature verification on Vercel
The endpoint receives the event, then returns:
Webhook Error: No signatures found matching the expected signature for payload
Nine times out of ten the body was parsed before verification. Stripe signs the exact bytes it sent. If your framework parses JSON and you call JSON.stringify(req.body), key order, whitespace and number formatting can all change — the HMAC no longer matches, even though the payload looks identical.
The fix: disable the body parser and read the stream
import type { VercelRequest, VercelResponse } from '@vercel/node'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-06-20' })
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
// Stripe needs the untouched body. Keep the platform parser out of the way.
export const config = { api: { bodyParser: false } }
async function getRawBody(req: any): Promise<string> {
if (typeof req.body === 'string') return req.body
if (Buffer.isBuffer(req.body)) return req.body.toString('utf8')
if (req.rawBody) return req.rawBody as string
const chunks: Buffer[] = []
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
return Buffer.concat(chunks).toString('utf8')
}
export default async function handler(req: VercelRequest, res: VercelResponse) {
if (req.method !== 'POST') return res.status(405).end()
const signature = req.headers['stripe-signature'] as string | undefined
if (!signature) return res.status(400).send('Missing Stripe-Signature header')
let event: Stripe.Event
try {
const raw = await getRawBody(req)
event = stripe.webhooks.constructEvent(raw, signature, webhookSecret)
} catch (err: any) {
return res.status(400).send(`Webhook Error: ${err.message}`)
}
// handle event.type here
res.json({ received: true })
}
constructEvent inside the try, before any other parsing or validation. Once you have the event object, you can safely read fields from it.Still failing? Check these four things
1. Test secret vs live secret
stripe listen prints a whsec_... for your local machine. The Dashboard endpoint shows a different one, per mode. Mixing test mode events with the live endpoint secret produces exactly this error.
2. The secret is missing on the deployment
Locally it lives in .env; on Vercel it must exist as an environment variable for Production (and Preview, if you test there). Redeploy after adding it — env changes do not apply to existing deployments.
3. A proxy or middleware rewrote the body
Any middleware that parses, clones or transforms the request will break the signature. Exclude /api/webhook from it.
4. You are testing the wrong endpoint
Stripe retries failed deliveries, so an old endpoint can keep producing errors after you fix the code. Check the endpoint URL in Developers → Webhooks.
Local testing loop
stripe listen --forward-to localhost:5175/api/webhook
# other terminal
stripe trigger checkout.session.completed
Then confirm the database row changed — the webhook is only done when subscription_status actually flips.
Map events, don't handle all of them
For a subscription SaaS you need three:
checkout.session.completed→active(only whenpayment_status === 'paid')customer.subscription.updated→active/past_due/canceledcustomer.subscription.deleted→canceled
Everything else can return 200 and be ignored. Always return quickly: Stripe treats slow responses as failures and retries.
Free: the Supabase + Stripe launch checklist — all 23 checks in one page, no email required to read the guides.
Get the free checklistWant the working version?
The starter includes this raw-body handler, the three event mappings, and the service-role write back to profiles — tested against real Stripe test-mode events.