Hire Lovable Xperts
Integrations & APIs

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.

Quick read: the word that matters is "synchronous." Stripe's SDK has two verifiers — a sync one for Node and an async one for Deno and Cloudflare Workers. You are calling the wrong one for the runtime Lovable deploys to.

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.

  1. Open your Stripe webhook edge function in the Lovable editor (commonly supabase/functions/stripe-webhook/index.ts).
  2. Read the raw body with const body = await req.text() — do NOT use await req.json(), which re-serializes and breaks the signature.
  3. Find stripe.webhooks.constructEvent(...) and change it to await stripe.webhooks.constructEventAsync(...).
  4. Make sure the enclosing handler function is declared async so you can await the call.
  5. Wrap the verification in try/catch and return a 400 on failure so Stripe sees a clear rejection, not a 500.
  6. Redeploy the edge function and send a test event from the Stripe dashboard to confirm a 200 response.
Do not parse the body with req.json() before verification. Stripe signs the raw payload byte-for-byte; re-serializing it will produce a signature mismatch even after you switch to constructEventAsync. Read req.text() first, verify, then JSON.parse the event if you need the object.

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.

SubtleCryptoProvider Webhook Diagnostic — Failure After the Async Switch
What you seeLikely causeFix
Still "synchronous context"An await was dropped, or constructEvent still called elsewhereSearch the file for constructEvent( — every call must be constructEventAsync and awaited
"No signatures found matching the expected signature"Body was parsed/re-serialized before verifyingUse await req.text() and pass that exact string — never req.json()
"No signatures found" with correct bodyWrong signing secret — test secret on a live endpoint or vice versaCopy the whsec_... that matches this exact endpoint from the Stripe dashboard
Webhook 200s but app does nothingVerification passes but event handler never runsConfirm event.type matches what you handle; log event.type to check
Intermittent 500s under loadFunction returns before the async handler resolvesEnsure the outer handler is async and every Stripe call is awaited
Works locally, fails deployedSTRIPE_WEBHOOK_SECRET not set in the deployed function envAdd 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.

If you ever see your Stripe secret key (sk_live_...) in a client bundle, the browser network tab, or a public repo, rotate it immediately in the Stripe dashboard. A leaked secret key lets anyone charge cards on your account.

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.

  1. In the Stripe dashboard, open Developers, Webhooks, your endpoint, and click Send test webhook for the event you handle (e.g. checkout.session.completed).
  2. Confirm Stripe shows a 200 response for that delivery attempt.
  3. Open the Supabase edge function logs and confirm the event was received and the expected branch ran.
  4. Check your database (e.g. the orders or subscriptions table) to confirm the row was actually written or updated.
  5. 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.
  6. 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"?
Change stripe.webhooks.constructEvent(...) to await stripe.webhooks.constructEventAsync(...) and make the surrounding handler async. Deno — the runtime behind Lovable's Supabase edge functions — only offers asynchronous Web Crypto, so the synchronous verifier cannot run. The async verifier uses the same arguments and returns the same event object once you await it.
Why does my Stripe webhook work locally in Node but fail when deployed on Lovable?
Node ships a synchronous crypto module, so constructEvent works on your laptop. Lovable deploys webhooks as Supabase edge functions on Deno, which deliberately omits synchronous crypto and exposes only the async crypto.subtle API. The same code is valid syntax in both, so it deploys cleanly, but signature verification only fails at request time on the edge. Use constructEventAsync.
Do I have to change my Stripe signing secret to fix this?
No. This error is purely a runtime mismatch in how the signature is verified, not a problem with your signing secret. Keep the same STRIPE_WEBHOOK_SECRET and just switch to constructEventAsync with await. If verification still fails afterward, then check that the secret matches this specific endpoint and the correct mode (test vs live).
Why does using req.json() break my webhook signature?
Stripe signs the exact raw bytes of the payload it sends. Calling req.json() parses and re-serializes the body, which changes the byte order or formatting, so the recomputed signature no longer matches. Always read the raw body with await req.text(), verify the signature against that string, then JSON.parse it afterward if you need the object.
What is createSubtleCryptoProvider() and do I need it?
It is a Stripe SDK helper that explicitly tells the verifier to use Deno's asynchronous Web Crypto API. Passing it into constructEventAsync removes any ambiguity about which crypto backend is used on the edge. It is the cleanest, most explicit way to keep verification on the async path and avoid the SubtleCryptoProvider error.
My webhook returns 200 but subscriptions still are not activating — why?
A 200 only means the function ran and the signature verified. If your handler does not match the incoming event.type, your business logic never executes and nothing updates. Log event.type, confirm it matches the events you subscribed to in Stripe (such as checkout.session.completed), and verify the database row actually changes — not just the HTTP status.
Is my Stripe secret key safe in a Lovable app?
Only if it lives in the edge function's server-side secrets, read via Deno.env.get(). If your secret key has a VITE_ prefix or appears anywhere reachable by the browser, it is bundled into public client JavaScript and effectively leaked. Move it to edge function secrets, and if it was ever exposed, rotate it in the Stripe dashboard immediately.
Could this error also happen on Cloudflare Workers or other edge runtimes?
Yes. Any runtime that exposes only the asynchronous Web Crypto API — Cloudflare Workers, Deno Deploy, and Supabase edge functions — will reject the synchronous constructEvent. The fix is identical everywhere: use constructEventAsync and await it. The synchronous verifier is specific to Node, which is the only major runtime with built-in synchronous crypto.
How fast can someone fix a broken Lovable Stripe webhook?
The core change is a single function swap, so a verified fix is usually fast. Because it is live payment infrastructure, we treat it as urgent: book a call, share the failing function and the Stripe error, and we can typically verify the full signature-to-database path the same day and confirm a clean 200 on a real event.
Will I lose any code or data getting this fixed professionally?
No. The fix edits one edge function and its environment, nothing else. We back up before touching anything, keep your Stripe keys server-side, and leave you with full ownership of your code and a written note of exactly what changed and why — so the same runtime mismatch does not recur on your next webhook.

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.

Get emergency help