Fix "SubtleCryptoProvider cannot be used in a synchronous context" in Lovable Stripe Webhooks
This error means your Stripe webhook edge function called constructEvent — the synchronous signature verifier — inside Deno, which Lovable uses for Supabase edge functions. Deno only exposes the async Web Crypto API, so the sync verifier cannot run. The fix is one line: switch to constructEventAsync and await it. Until you do, every webhook returns 500 and Stripe stops retrying.
By Founder Name · Last verified: 2026-06-25
What does "SubtleCryptoProvider cannot be used in a synchronous context" actually mean?
Stripe verifies your webhook signature by hashing the payload. In Deno — the runtime behind Lovable's Supabase edge functions — hashing is only available through the asynchronous Web Crypto API (crypto.subtle). The synchronous stripe.webhooks.constructEvent() tries to hash without awaiting, the provider refuses, and Stripe throws this exact error. Your function then returns a 500 and the event never gets processed.
The error is not a bug in your business logic or your Stripe keys. It is a runtime mismatch: code written for Node.js (where synchronous crypto exists) running on Deno (where it does not). Lovable generates Stripe webhook handlers that work fine in a Node example but break the moment they are deployed as a Supabase edge function. The signing secret, the endpoint URL, and the payload can all be perfectly correct and you will still see this error.
Because the failure happens during signature verification — before any of your code runs — the symptom looks like a total webhook outage. Payments succeed in Stripe, but your app never hears about them, so subscriptions never activate and orders never get marked paid.
Why does this happen in Lovable but not in a normal Node project?
Lovable runs Stripe webhooks as Supabase edge functions, which execute on Deno, not Node. Node ships a synchronous crypto module, so the classic stripe.webhooks.constructEvent(body, sig, secret) works there. Deno deliberately omits synchronous crypto and exposes only crypto.subtle, which is promise-based. The same line of code that passes on your laptop fails the instant it runs on the edge.
This is a textbook case of What-Breaks-on-Export: code that is correct against one runtime silently breaks against another. The AI that generated your webhook handler pattern-matched on the most common Stripe tutorial — which is written for Node — and never reconciled it with the Deno target Lovable actually deploys to.
It is also a quiet failure. The function deploys without a build error because the syntax is valid; the mismatch only surfaces at request time, when Stripe sends a real event and signature verification executes. That is why builders often see green deploys and a dead webhook at the same time.
Related: the related case where the webhook never fires at all · what generally breaks when Lovable code leaves its native runtime
How do I fix it? (constructEventAsync, with code)
Replace the synchronous verifier with the asynchronous one and await it. stripe.webhooks.constructEvent() becomes await stripe.webhooks.constructEventAsync(). You also need to read the raw request body as text — not parsed JSON — because Stripe hashes the exact bytes it sent. Pass that raw string, the stripe-signature header, and your signing secret into the async call.
Here is a minimal working Deno/Supabase edge function. Note req.text(), constructEventAsync, and the await — those three together are the entire fix:
import Stripe from "https://esm.sh/stripe@14?target=deno"; const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { apiVersion: "2024-06-20", httpClient: Stripe.createFetchHttpClient(), }); const webhookSecret = Deno.env.get("STRIPE_WEBHOOK_SECRET")!; Deno.serve(async (req) => { const signature = req.headers.get("stripe-signature"); const body = await req.text(); // raw bytes, NOT req.json() let event; try { event = await stripe.webhooks.constructEventAsync( body, signature!, webhookSecret, undefined, Stripe.createSubtleCryptoProvider(), // use Deno Web Crypto ); } catch (err) { console.error("Signature verification failed:", err.message); return new Response("Invalid signature", { status: 400 }); } // handle the event if (event.type === "checkout.session.completed") { // mark order paid / activate subscription } return new Response(JSON.stringify({ received: true }), { status: 200 }); });
The createSubtleCryptoProvider() argument is what tells the Stripe SDK to use Deno's async Web Crypto explicitly. Passing it removes any ambiguity and is the cleanest way to keep the verifier on the async path.
- Open your Stripe webhook edge function in the Lovable editor (commonly supabase/functions/stripe-webhook/index.ts).
- Read the raw body with const body = await req.text() — do NOT use await req.json(), which re-serializes and breaks the signature.
- Find stripe.webhooks.constructEvent(...) and change it to await stripe.webhooks.constructEventAsync(...).
- Make sure the enclosing handler function is declared async so you can await the call.
- Wrap the verification in try/catch and return a 400 on failure so Stripe sees a clear rejection, not a 500.
- Redeploy the edge function and send a test event from the Stripe dashboard to confirm a 200 response.
I switched to constructEventAsync and still get an error — now what?
If verification still fails after moving to the async call, the cause has shifted from the runtime to the inputs. The usual culprits are a mismatched signing secret (test vs live), a body that was parsed before verification, or a missing await that lets the function return before the promise resolves. Work down this table in order before re-prompting Lovable.
The most common second failure is the secret mismatch. Stripe generates a separate signing secret per endpoint, and test mode and live mode have different ones. Copying the test secret into a live deployment produces a perfectly valid-looking whsec_ value that will never match a live signature.
If the secret reads correctly at deploy time but goes missing later, you may be hitting The Vanishing Env-Var — an environment variable that exists in the editor but is absent from the deployed function's runtime. Verify the secret is present in the deployed edge function's own secrets, not just in the Lovable project settings.
| What you see | Likely cause | Fix |
|---|---|---|
| Still "synchronous context" | An await was dropped, or constructEvent still called elsewhere | Search the file for constructEvent( — every call must be constructEventAsync and awaited |
| "No signatures found matching the expected signature" | Body was parsed/re-serialized before verifying | Use await req.text() and pass that exact string — never req.json() |
| "No signatures found" with correct body | Wrong signing secret — test secret on a live endpoint or vice versa | Copy the whsec_... that matches this exact endpoint from the Stripe dashboard |
| Webhook 200s but app does nothing | Verification passes but event handler never runs | Confirm event.type matches what you handle; log event.type to check |
| Intermittent 500s under load | Function returns before the async handler resolves | Ensure the outer handler is async and every Stripe call is awaited |
| Works locally, fails deployed | STRIPE_WEBHOOK_SECRET not set in the deployed function env | Add the secret in the Supabase edge function secrets, then redeploy |
Where do I put the Stripe signing secret so it does not leak?
Store STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET as Supabase edge function secrets, read with Deno.env.get(). Never hardcode them in the function file and never expose them to the browser with a VITE_ prefix — anything VITE_ is bundled into client JavaScript and publicly readable. The webhook handler is server-side, so the secret belongs only in the edge function environment.
A surprisingly common misconfiguration is a Stripe secret key that ended up in client-side code because it was given a VITE_ prefix during an earlier prompt. If your secret key is reachable from the browser bundle, treat it as compromised: rotate it in the Stripe dashboard and move the new value into edge function secrets only.
This is the right architectural home for the secret regardless of the SubtleCrypto fix — the signature verification has to happen server-side, so the signing secret should never travel to the client.
Related: how to move Stripe and other secrets into edge functions safely
How do I confirm the webhook is fully working before I trust it with real payments?
Send a real test event from the Stripe dashboard and confirm an end-to-end success: a 200 response in Stripe, the event logged in your function, and the resulting state change in your database. A 200 alone is not enough — the handler can return 200 and still skip your business logic if the event type does not match.
Verifying the state change — not just the HTTP status — is what separates a working integration from one that silently drops events. This is one of The 5 Production Gaps: a webhook that returns 200 but never updates the database looks healthy in Stripe while leaving paying customers without access.
- In the Stripe dashboard, open Developers, Webhooks, your endpoint, and click Send test webhook for the event you handle (e.g. checkout.session.completed).
- Confirm Stripe shows a 200 response for that delivery attempt.
- Open the Supabase edge function logs and confirm the event was received and the expected branch ran.
- Check your database (e.g. the orders or subscriptions table) to confirm the row was actually written or updated.
- Run a real test-mode checkout end to end and confirm the same path fires on a live event, not just a manually sent one.
- Only after all four pass should you switch the endpoint to live mode and live keys.
When should I stop debugging this myself and bring in an engineer?
If you have switched to constructEventAsync, confirmed the raw body and the correct signing secret, and the webhook still fails — or if payments are live and customers are not getting what they paid for — stop iterating. Continued re-prompting risks the Bug Doom Loop, where each Fix attempt burns a credit and edits more files without resolving the real runtime issue. A broken payment webhook is a revenue emergency.
Signs it is time to escalate: paid subscriptions are not activating in production; the signature error persists after the async fix; or you have spent several Fix credits re-prompting the same webhook without progress. A senior engineer can verify the signature flow, the secret handling, and the database write end to end, then leave you with a deploy that is provably correct.
Because this is live payment infrastructure, the cost of guessing is customers paying and getting nothing. Getting it verified by a human is almost always cheaper than the refunds and support load of a silently dropped webhook.
Related: our Lovable app rescue service for urgent payment and webhook failures · book an emergency review call
Frequently asked questions
What is the one-line fix for "SubtleCryptoProvider cannot be used in a synchronous context"?
Why does my Stripe webhook work locally in Node but fail when deployed on Lovable?
Do I have to change my Stripe signing secret to fix this?
Why does using req.json() break my webhook signature?
What is createSubtleCryptoProvider() and do I need it?
My webhook returns 200 but subscriptions still are not activating — why?
Is my Stripe secret key safe in a Lovable app?
Could this error also happen on Cloudflare Workers or other edge runtimes?
How fast can someone fix a broken Lovable Stripe webhook?
Will I lose any code or data getting this fixed professionally?
App down or leaking data? Get an expert on it within 24–48h.
Book a free 30-minute audit call. We'll diagnose what's wrong and tell you exactly what it costs to fix.