Stripe webhook not working in Lovable? How to find and fix it
Published October 21, 2026 · About 8 minutes
The usual sign: Stripe shows a successful payment and the customer sees your success page, but your app still has them on the free plan. Or someone cancels and keeps their access. Nothing crashes, so you often find out only when a customer emails you.
This guide shows how to find where the problem is and how to fix the most common causes in Lovable apps. The same steps work for Bolt or Cursor apps that run on Supabase Edge Functions.
The short version
- Open the webhook in Stripe and look at Event deliveries. The HTTP status code tells you which fix you need.
- 401: turn off Supabase JWT verification for the webhook function only.
- 400 "No signatures found": verify the raw body with
constructEventAsyncand use the signing secret for this exact endpoint and mode. - 200 but the user isn't upgraded: the function ran but didn't update the right row. Check which events it handles, how it finds the user, and whether it writes with the service role key.
- No deliveries at all: the endpoint is in the wrong mode, has the wrong URL, or isn't subscribed to the event.
First, check if your app uses a webhook at all
When you connect your own Stripe account, Lovable doesn't use webhooks by default. Lovable's Stripe docs say the app asks Stripe directly whether the signed-in user has an active subscription. It matches subscriptions to users by email address.
So if you never asked Lovable for a webhook and a paying customer is stuck on the free plan, check the emails first. The email they use in your app must match the email on their Stripe customer record. If they paid with a different email (a work address, an Apple "Hide My Email" address, or a typo), your app won't recognize them.
If you did ask for a webhook (common when you store the plan in a profiles or subscriptions table), keep reading. It's usually an edge function with a name like stripe-webhook.
Step 1: Read Stripe's delivery log
In the Stripe Dashboard, open Webhooks (in Workbench, or Developers → Webhooks). Click your endpoint and open the Event deliveries tab. Make sure you're in the same mode (test/sandbox or live) as the payment that failed. Each attempt shows the HTTP status your function returned:
- 401: the request was rejected before your code ran. Go to step 2.
- 400 with a signature error: go to step 3.
- 404: the URL is wrong, or no function is deployed under that name.
- 3xx: Stripe treats redirects as failures. Use the final URL as the endpoint.
- 500: your function crashed. Read the function logs (Cloud view in Lovable, or Edge Functions → Logs in your own Supabase dashboard).
- Timed out: the function does too much work before it answers. Return a 2xx quickly.
- 200: delivery worked, so the bug is in what the function does with the event. Go to step 4.
Step 2: Fix the 401 (JWT verification)
Lovable's backend functions run as Supabase Edge Functions. By default, Supabase checks every request for a Supabase JWT before your code runs. Stripe doesn't have one, so Supabase answers {"code":401,"message":"Missing authorization header"} and your function's logs stay empty.
The fix, from Supabase's function configuration docs, is to turn the check off for the webhook function only:
# supabase/config.toml
[functions.stripe-webhook]
verify_jwt = falseThen redeploy the function, or deploy it with supabase functions deploy stripe-webhook --no-verify-jwt. You can also turn JWT verification off on the function's page in the Supabase dashboard. Keep it on for every function your own users call. For the webhook, the Stripe signature check in step 3 takes its place.
Step 3: Fix signature errors (400)
Once requests reach your code, the next common error is No signatures found matching the expected signature for payload. Stripe signs the exact bytes it sends, so you need to get these right:
- Use the raw body. Read it with
await req.text(). If the code callsreq.json()first and turns it back into a string, the bytes change and the signature no longer matches. - Use the async verifier. Edge Functions run on Deno, which only has the async Web Crypto API. The synchronous
stripe.webhooks.constructEventfails there (you may seeSubtleCryptoProvider cannot be used in a synchronous context). UseconstructEventAsyncwith a SubtleCrypto provider. - Use the right signing secret. Each endpoint has its own
whsec_…secret, and test and live mode have different ones. The Stripe CLI'sstripe listenprints another one. A secret from your test endpoint will reject every live event.
This is the pattern from Supabase's own Stripe webhook example:
const cryptoProvider = Stripe.createSubtleCryptoProvider();
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")!, // name it whatever your function reads
undefined,
cryptoProvider,
);
} catch (err) {
return new Response(err.message, { status: 400 });
}Don't remove this check to "make it work". With JWT verification off and no signature check, anyone can send your function a fake checkout.session.completed event and give themselves a paid plan.
Step 4: Stripe says 200, but the user still isn't upgraded
This case is the hardest to spot, because both Stripe and your logs look fine. Check these in order:
- Which events does it handle? Many generated handlers only handle
checkout.session.completed. That covers the first purchase. Plan changes arrive ascustomer.subscription.updatedand cancellations ascustomer.subscription.deleted. Check both the code and the events selected on the endpoint in Stripe. - How does it find the user? If the function looks users up by email, the same email problem from above applies. A safer way: pass your user's id when you create the Checkout Session (as
client_reference_idor inmetadata), save the Stripe customer id on the user, and use those to find the row. - Is the write being blocked? If the function uses the anon key, Row Level Security can block the update without an error. Zero rows change. Webhook code on the server should use the service role key (stored as a secret, never in the browser) and should check that a row was actually updated.
- Is it hiding errors? Some handlers wrap everything in
try/catchand always return 200. Stripe then thinks the event was handled and won't retry. Log the error and return a 500 when the database update fails, so Stripe tries again.
Step 5: No deliveries at all
- The endpoint was created in test mode but the payment was live (or the other way around). Each mode has its own endpoints.
- The endpoint URL doesn't match the deployed function. Supabase function URLs look like
https://<project-ref>.supabase.co/functions/v1/stripe-webhook. - The endpoint isn't subscribed to the event type you're waiting for.
- The endpoint is disabled. Stripe emails you when deliveries keep failing. If the endpoint shows as disabled in the Dashboard, turn it back on after you fix the cause.
Step 6: Test it end to end
- In test mode, buy your own plan with Stripe's test card
4242 4242 4242 4242(any future expiry date, any CVC). - Check that Event deliveries shows a 200, then check that the user's row in your database changed.
- Upgrade, downgrade and cancel from the customer portal. Check that each change reaches the database too.
- When you switch to live keys, create the live endpoint, copy its new signing secret into your function secrets, and make one real purchase.
You can also send single test events with the Stripe CLI, for example stripe trigger checkout.session.completed. In a sandbox, Stripe retries a failed delivery only three times over a few hours. In live mode, it keeps retrying for up to three days.
Fix instructions you can paste into Lovable
Check my Stripe webhook edge function:
1. Make sure JWT verification is off for this function only
(supabase/config.toml: [functions.stripe-webhook] verify_jwt = false).
2. Read the body with await req.text() and verify it with
stripe.webhooks.constructEventAsync and Stripe.createSubtleCryptoProvider().
Return 400 if verification fails.
3. Handle checkout.session.completed, customer.subscription.updated and
customer.subscription.deleted.
4. Find the user by client_reference_id or the saved Stripe customer id,
not only by email.
5. Write with the service role key, check a row was updated, and return
500 if the update fails so Stripe retries.
6. Skip events whose id was already processed, and return 200 quickly.Review the changes before you publish. The signature check (point 2) is what stops strangers from upgrading themselves.
After the fix: catch up on missed payments
Fixing the function doesn't fix the customers who paid while it was broken. Stripe retries failed live deliveries for up to three days. You can also resend a single event from the Dashboard (up to 15 days after it was created) or with stripe events resend in the CLI (up to 30 days). Retries and resends can deliver the same event twice, so make sure your handler skips event ids it has already processed.
For anything older, compare your active subscriptions in Stripe with the plans in your database and fix any that don't match. Stripe's webhook docs explain retries and resending in more detail.
Questions people ask
Does Lovable use Stripe webhooks by default?
No, not when you connect your own Stripe account. Lovable's docs say the app asks Stripe directly whether a user has an active subscription, and matches it by email address. Lovable only adds a webhook if you ask for one, for example to fulfill orders or to save subscription status in your own database.
Why does my Supabase Edge Function return 401 to Stripe?
Supabase Edge Functions check for a Supabase JWT before your code runs (verify_jwt is on by default). Stripe can't send one, so Supabase returns 401 and your code never sees the event. Turn JWT verification off for the webhook function only, and check the Stripe signature instead.
Is it safe to turn off JWT verification for the webhook?
Yes, but only if the function checks the Stripe-Signature header against the endpoint's signing secret and rejects anything that fails. Without that check, anyone could send a fake event and upgrade themselves for free.
How do I recover payments that were missed while the webhook was broken?
Stripe retries failed live-mode deliveries for up to three days. After you fix the endpoint, you can resend an event from the Dashboard (up to 15 days after the event) or with the Stripe CLI (up to 30 days). For anything older, compare your active subscriptions in Stripe with the plans in your database and fix any that don't match.
Know the next time it breaks
Webhooks often break again after a deploy, a renamed secret or a switch to live keys. The first sign is usually an unhappy customer. On paid plans, PaidPath checks your Stripe account every hour with a read-only key and flags webhooks that are failing or disabled. If you also connect your app's user data, it flags customers who paid but weren't upgraded, and cancelled or past-due customers who still have access. When something breaks, you get an email at about 8am your time.