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: constructEventconstructEventAsync (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!