Stripe webhook signature fails on Vercel Edge
Short answer: On the Edge runtime, Node's crypto is gone. Stripe's sync constructEvent needs it. You get:
SubtleCryptoProvider cannot be used in a synchronous context.
Use `await constructEventAsync(...)` instead of `constructEvent(...)`.
Fix it one of two ways: stay on Edge and use constructEventAsync with Stripe.createSubtleCryptoProvider(), or set export const runtime = 'nodejs' and keep the normal Node code. Always read the body with await req.text().
Signs you have this problem
- The route file has
export const runtime = 'edge'(or your host defaults this route to Edge). - Logs show
SubtleCryptoProvider cannot be used in a synchronous context. - Stripe's Event deliveries show 400 (or a crash → 500) on every live try.
- The same code worked with
stripe listenon your laptop (Node), then failed after deploy.
If the error is No signatures found matching the expected signature for payload, that is a different problem: wrong secret or a changed body. See /fix/stripe-webhook-signature-verification-failed.
Why Edge breaks the sync check
Stripe signs the raw body with HMAC. In Node, stripe-node uses Node's crypto and can check it in one sync call: constructEvent.
On Vercel Edge (and Deno, Cloudflare Workers, Supabase Edge Functions) you only get the Web Crypto API (crypto.subtle). That API is async. Stripe ships a SubtleCryptoProvider for it. If you call the sync constructEvent with that provider, the library throws the error above and tells you to use constructEventAsync.
So nothing is wrong with your secret yet. The runtime and the function do not match.
Fix A: Stay on Edge (async + SubtleCrypto)
Use the raw body, pass Stripe's SubtleCrypto provider, and await the check. This matches Supabase's own Stripe webhook example.
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
export const runtime = 'edge'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
// Web Crypto on Edge is async. Pass this provider into constructEventAsync.
const cryptoProvider = Stripe.createSubtleCryptoProvider()
export async function POST(req: Request) {
const secret = process.env.STRIPE_WEBHOOK_SECRET
if (!secret) {
return new Response('Missing STRIPE_WEBHOOK_SECRET', { status: 500 })
}
const signature = req.headers.get('stripe-signature')
if (!signature) {
return new Response('Missing signature', { status: 400 })
}
// Raw text. Do not use req.json() before the signature check.
const body = await req.text()
let event: Stripe.Event
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature,
secret,
undefined, // default tolerance (5 minutes)
cryptoProvider,
)
} catch (err) {
console.error('Bad signature:', (err as Error).message)
return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 })
}
// Handle event.type here (or push to a queue), then reply fast.
console.log(`Got ${event.type} (${event.id})`)
return new Response('ok', { status: 200 })
}
Notes:
- Use the
whsec_...secret for this endpoint. Dashboard secrets andstripe listensecrets are different. Test and live are different too. - Env changes on Vercel apply to new deployments. Redeploy after you set
STRIPE_WEBHOOK_SECRET. - If you later call the Stripe API from this Edge route, you may also need
httpClient: Stripe.createFetchHttpClient(). Signature checks alone do not need that.
Fix B: Switch the route to Node (often simpler)
Webhooks often need a database driver or other Node APIs. Edge may fight you for reasons beyond crypto. Prefer Node for this one route unless you have a clear Edge need.
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
export const runtime = 'nodejs'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const secret = process.env.STRIPE_WEBHOOK_SECRET
if (!secret) {
return new Response('Missing STRIPE_WEBHOOK_SECRET', { status: 500 })
}
const signature = req.headers.get('stripe-signature')
if (!signature) {
return new Response('Missing signature', { status: 400 })
}
const body = await req.text()
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, secret)
} catch (err) {
return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 })
}
console.log(`Got ${event.type} (${event.id})`)
return new Response('ok', { status: 200 })
}
constructEventAsync also works on Node. Use it if you want one style everywhere.
Thin events (v2) on Edge
Classic snapshot webhooks use constructEvent / constructEventAsync. Thin / v2.core.event notifications use parseEventNotification / parseEventNotificationAsync. The sync parse path can hit the same SubtleCrypto error on Edge. On stripe-node v22.0.2+, use:
const event = await stripe.parseEventNotificationAsync(body, signature, secret)
Most Checkout and payment flows still use snapshot events. Only switch if your endpoint is set up for thin events.
Step by step
- Read the error. If it names SubtleCrypto / synchronous context, fix the runtime or switch to
constructEventAsync. If it says "No signatures found…", fix the secret or raw body first. - Confirm the runtime. Search the route for
export const runtime. On Vercel, open the function details for that path and check Edge vs Node. - Pick Fix A or Fix B. Redeploy.
- Send a test event from the Stripe Dashboard (or
stripe trigger). Open Event deliveries. You want a 2xx. - Still failing with "No signatures found…"? Follow /fix/stripe-webhook-signature-verification-failed. Wrong secret and a re-parsed body are still the top causes on every runtime.
- Works on your laptop only? See /fix/stripe-webhook-works-locally-fails-in-production.
On Supabase Edge Functions the same SubtleCrypto rule applies, plus a JWT wall that returns 401 before your code runs. That full fix is here: /fix/supabase-edge-function-stripe-webhook-401.
Check it with a free scan
Paste your webhook address into the free Tillping scan: https://tillping.online/#scan and pick Stripe. No account is needed. Only scan an address you own or are allowed to test.
The scan sends a few safe test requests, with no real payment. One has no signature. One has a fake Stripe-Signature. It flags a 5xx, a 404, a redirect, a Vercel login wall, a Cloudflare challenge, a slow reply, a bad certificate, and an endpoint that says OK to a fake signature or to no signature.
What it can't do: the scan never sees your signing secret, so it cannot prove Edge crypto or that a real signed event passes. It also cannot tell Edge from Node. Use a Stripe test event and your host logs for that.
Sources
- Stripe Node,
constructEvent/constructEventAsyncand SubtleCrypto async hint: https://github.com/stripe/stripe-node/blob/master/src/Webhooks.ts - Stripe Node PR that added
SubtleCryptoProvider+ async webhooks: https://github.com/stripe/stripe-node/pull/1288 - Stripe Node v22.0.2 (
parseEventNotificationAsyncfor Edge / thin events): https://github.com/stripe/stripe-node/releases/tag/v22.0.2 - Supabase example (Edge +
createSubtleCryptoProvider+constructEventAsync): https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/stripe-webhooks/index.ts - Deno docs, Stripe Checkout webhook with
constructEventAsync: https://docs.deno.com/examples/stripe_checkout/ - Stripe, Resolve webhook signature verification errors: https://docs.stripe.com/webhooks/signature
- Vercel, Edge and Node.js runtimes for Next.js: https://vercel.com/docs/functions/runtimes
Check your webhook now
Paste your webhook address to see what it answers. Free, no account, no keys.
Check my webhookGet an email when Deep Scans open
One email to confirm, then one when we launch. Leave any time.
