Skip to content
PixelRidge
← All guides

29 September 2026 · 4 min read

Stripe webhook failing in a Supabase Edge Function? Fixing "SubtleCryptoProvider cannot be used in a synchronous context"

Why Stripe webhooks break in Supabase Edge Functions (and Lovable or Bolt apps that use them), and the exact fix: constructEventAsync, the raw body, the right secret and turning off JWT checks for that one function.

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:

  1. constructEventAsync instead of constructEvent, with await in front of it.
  2. Stripe.createSubtleCryptoProvider() passed as the fifth argument.
  3. 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.

Don't "fix" it by skipping verification

When the error won't go away, AI assistants sometimes suggest deleting the signature check and just reading the JSON. The webhook starts "working", but now anyone who finds the function's URL can send a fake checkout.session.completed event and give themselves a paid plan. Always keep the signature check, and always return a 400 when it fails.

A quick checklist

  • constructEventAsync with await and Stripe.createSubtleCryptoProvider()
  • Body read once with req.text()
  • whsec_ secret that matches this endpoint and this mode (test or live)
  • JWT verification off for the webhook function only
  • The Stripe dashboard's webhook log shows 200 responses
  • Your handler is safe to run twice, because Stripe retries failed deliveries and can send the same event more than once

FAQ

Does this affect Lovable apps? If your Lovable app handles Stripe webhooks in a Supabase Edge Function that uses the Stripe library, yes, and the fix is the same. If you use a payment integration that Lovable manages for you, you may not have this code at all.

Why does it work locally but not in production? Usually because local testing uses the Stripe CLI's secret and production needs the dashboard endpoint's secret. It can also be the JWT setting, which may differ between your local config and the deployed function.

Is it safe to turn off JWT verification? For a Stripe webhook function, yes, as long as the signature check stays in place. Don't turn it off for functions that read or change user data.


Payments still not working? Our free Launch Check looks at your live app with your permission and shows the top risks we can see, including payment flows. If you want it fixed, see how we fix Supabase apps and Lovable apps, or try our pricing calculator for an estimate.

Sources: Supabase Stripe webhook example, stripe-node issue #1942, stripe-node issue #1827. Checked September 2026.

Rather get it by email?

Leave your name and email, plus your app link if you like. We reply with a free Launch Check, no obligation.

We email you about this request only, and you can reply “unsubscribe” at any time. Prefer email? Write to info@pixelridgeweb.com. See our privacy notice.

Need it fixed for you? Lovable app fix and security audit or Supabase RLS audit: row level security and key review.

Want a second pair of eyes on your app?

Send us your live link. We record a short video showing the top risks we can see, free and with no obligation.

Terms and conditions apply. Read the terms.