If you're getting a 400 Bad Request with "Invalid signature" from your
Stripe webhook on Cloudflare Workers or Cloudflare Pages, the actual
problem might not be your webhook secret at all.
The symptom#
The Stripe webhook endpoint returns:
400 Bad Request
Invalid signature
In Stripe Dashboard under Developers → Webhooks, the delivery shows:
400 ERR
Bad Request
Response body: Invalid signature
I've triple-checked the STRIPE_WEBHOOK_SECRET. Regenerated it.
Redeployed. Still failing.
The misleading error#
The standard Stripe webhook verification code looks like this:
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
} catch (err) {
console.error("Webhook signature verification failed:", err)
return new Response("Invalid signature", { status: 400 })
}
The problem is that the error is being caught and "Invalid signature" is returned without looking at what the actual error is.
The real error#
When I added proper logging and checked Cloudflare's real-time logs:
wrangler pages deployment tail --project=your-project
I saw this:
(error) Webhook signature verification failed: Error: SubtleCryptoProvider cannot be used in a synchronous context.
Use `await constructEventAsync(...)` instead of `constructEvent(...)`
The signature was fine. The crypto provider was the problem.
Why this happens#
Cloudflare Workers uses the Web Crypto API (SubtleCrypto), which is
async-only. The Stripe SDK's constructEvent() method tries to use crypto
synchronously, which fails in the Workers environment.
The Stripe SDK actually tells you exactly what to do in the error message, but if you're catching and swallowing the error (like most example code does), you'll never see it.
The fix#
Change from synchronous:
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
To async:
event = await stripe.webhooks.constructEventAsync(
body,
signature,
webhookSecret
)
That's it. One word change: constructEvent → constructEventAsync (and
add await).
Full working example#
import type { APIRoute } from "astro"
import Stripe from "stripe"
export const POST: APIRoute = async ({ request, locals }) => {
const stripe = new Stripe(locals.runtime.env.STRIPE_SECRET_KEY)
const webhookSecret = locals.runtime.env.STRIPE_WEBHOOK_SECRET
const body = await request.text()
const signature = request.headers.get("stripe-signature")
if (!signature) {
return new Response("Missing signature", { status: 400 })
}
let event
try {
// Use constructEventAsync for Cloudflare Workers/Pages
event = await stripe.webhooks.constructEventAsync(
body,
signature,
webhookSecret
)
} catch (err) {
console.error("Webhook verification failed:", err)
return new Response("Invalid signature", { status: 400 })
}
// Handle the event
if (event.type === "checkout.session.completed") {
const session = event.data.object
// Process the checkout...
}
return new Response("OK", { status: 200 })
}
Debugging tips#
If you're still having issues, add temporary logging to see what's actually happening:
console.log("Webhook debug:", {
hasSecret: !!webhookSecret,
secretPrefix: webhookSecret?.substring(0, 10),
hasSignature: !!signature,
bodyLength: body.length,
})
Then check logs with:
wrangler pages deployment tail --project=your-project
In this endeavor I have learned how to debug such situations. This is akin
to console.log in browser. Not the absolute best way but can get the job
done. Enjoy!