Short answer: Supabase Edge Functions run on Deno, which uses the Web Crypto API. Web Crypto only works asynchronously, so Stripe's synchronous stripe.webhooks.constructEvent() throws SubtleCryptoProvider cannot be used in a synchronous context. Replace it with await stripe.webhooks.constructEventAsync(...) and pass a crypto provider made with Stripe.createSubtleCryptoProvider(). This is the pattern in Supabase's own Stripe webhook example. If the error then changes to a signature error, the causes are almost always the raw body, the wrong signing secret, or Supabase's JWT check. All three are covered below.
This error shows up a lot in apps built with Lovable, Bolt and other AI tools, because they often put the Stripe webhook in a Supabase Edge Function and generate code from Node.js examples.
Why the error happens
Stripe signs every webhook it sends. Your function checks that signature with your webhook signing secret, so nobody can fake a "payment succeeded" event and unlock paid features for free.
In Node.js, that check can run synchronously, and most Stripe tutorials use constructEvent(). Deno, Cloudflare Workers and other edge runtimes don't have Node's crypto module. They have the Web Crypto API (crypto.subtle), and all of its functions return promises. The Stripe library detects this and raises SubtleCryptoProvider cannot be used in a synchronous context rather than guessing. Stripe's issue tracker has the same error and the same fix: use the async method (stripe-node #1942).
The fix
Here is the working pattern, based on Supabase's Stripe webhook example:
import Stripe from "npm:stripe";
const stripe = new Stripe(Deno.env.get("STRIPE_API_KEY") as string);
const cryptoProvider = Stripe.createSubtleCryptoProvider();
Deno.serve(async (req) => {
const signature = req.headers.get("Stripe-Signature");
const body = await req.text(); // raw body, not req.json()
let event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature!,
Deno.env.get("STRIPE_WEBHOOK_SIGNING_SECRET")!,
undefined,
cryptoProvider
);
} catch (err) {
return new Response((err as Error).message, { status: 400 });
}
// Handle event.type here, e.g. "checkout.session.completed"
return Response.json({ received: true });
});
Three changes matter:
constructEventAsyncinstead ofconstructEvent, withawaitin front of it.Stripe.createSubtleCryptoProvider()passed as the fifth argument.await req.text()for the body. The next section explains why.
Supabase's current example also wraps the handler in its own helper, but the Stripe part is the same.
If the error changes to "No signatures found matching the expected signature"
Once the async fix is in, the next error you may see is a signature mismatch. Check these in order.
1. You parsed the body before verifying it
Stripe signs the exact bytes it sent. If your code calls req.json() and then JSON.stringify()s it again, spacing or key order can change and the signature no longer matches. Supabase's docs say it plainly: the verification "relies on the raw request body". Read it once with req.text(), verify, and only then use event.data.object.
2. The signing secret is the wrong one
The webhook signing secret starts with whsec_. It is not your Stripe secret API key (sk_live_... or sk_test_...). Each webhook endpoint in the Stripe dashboard has its own whsec_ secret. So do these:
- Test mode and live mode endpoints each have a different secret.
- The Stripe CLI (
stripe listen) prints yet another secret for local testing. - If you deleted and re-created the endpoint, the secret changed.
Set it as a function secret, for example supabase secrets set STRIPE_WEBHOOK_SIGNING_SECRET=whsec_.... Then redeploy.
3. Supabase is rejecting Stripe before your code runs
By default, Supabase Edge Functions expect a Supabase JWT in the Authorization header. Stripe doesn't send one, so the request is refused with a 401 and your code never runs. In the Stripe dashboard's webhook log this shows up as failed deliveries with status 401. Turn off JWT verification for the webhook function only. You can set verify_jwt = false for it in supabase/config.toml, or deploy it with --no-verify-jwt. This is safe for this one function because the Stripe signature check is now what protects it. Keep JWT checks on for every other function.