A Stripe webhook in a Lovable app usually fails for one of three reasons. A 401 means Supabase rejected the request because Stripe doesn't send a login token. A 400 means the signature check failed, because the body was parsed first or the secret is wrong. A 200 with no upgrade usually means the database write was blocked or never matched the user.
A webhook is Stripe calling your app to say something happened: a checkout completed, a renewal failed, a subscription was cancelled. In a Lovable app it's usually a Supabase Edge Function, a small piece of server code. When it breaks, customers pay and stay locked out, or cancel and keep access. This guide goes from the symptom Stripe shows you to the cause, with the fix for each, and a handler you can compare yours against. It's written for founders and for the developer helping them.
Which Stripe setup does your Lovable app use?
Lovable has two ways to take payments, and they handle webhooks differently. Check which one you have before changing anything.
You connected your own Stripe account
Lovable's docs say this integration doesn't use webhooks by default: the app asks Stripe for payment and subscription status directly, and subscriptions are matched to users by email address. If you have a webhook, it's because you asked Lovable to add one. You then manage the endpoint and its signing secret yourself in Stripe. Everything below applies to you.
You use Lovable's built-in payments
Here Lovable registers the webhook endpoints on your Stripe account itself, one for your app and one for Lovable's monitoring, in both test and live. Most of this guide doesn't apply: skip to the section on webhook errors with built-in payments, near the end.
Read the webhook status code in Stripe first
In Stripe, open Workbench, select your webhook endpoint under Webhooks, and open the Event deliveries tab. Each event shows whether it was delivered, pending, or failed. Click a failed one to see the status code and the response body your function sent back. That code tells you where to look.
| Stripe shows | Usual cause | Fix |
|---|---|---|
| 401, "Missing authorization header" | Supabase checks for a login token before your code runs | Turn off JWT verification for that function |
| 400, "No signatures found matching…" | Body parsed before checking, or the wrong signing secret | Read the raw body; copy the secret from this endpoint, in this mode |
| 500, "SubtleCryptoProvider cannot be used in a synchronous context" | The code calls constructEvent, which can't run on Deno | Use constructEventAsync with the crypto provider |
| 500, other | A missing secret, a name mismatch, or a database error | Read the function logs; list secrets and match the names |
| 404 | Function not deployed, renamed, or the URL points at an old project | Use the function's exact URL from Supabase |
| 3xx | The URL redirects, for example to www | Use the final URL; Stripe counts redirects as failures |
| 200, but nothing changes | Write blocked by RLS, user not matched, or event not handled | See the "200 but not upgraded" section |
| No deliveries at all | The endpoint isn't subscribed to that event, or is in the other mode | Edit the endpoint's events; check test vs live |
Stripe webhook 401: Missing authorization header
Supabase Edge Functions check for a valid login token by default. Supabase's docs explain that when this check, called verify_jwt, is on, the platform inspects the Authorization header of every request before your function runs, and returns a 401 if it's missing. Stripe doesn't send one: it signs the body with its own secret instead. So your code never runs, and the function logs show nothing.
The two messages Supabase returns are Missing authorization header and Invalid Token or Protected Header formatting. If the 401 body says something else, or nothing, your own code returned it. Turn the check off for the webhook function only, in the project's supabase/config.toml:
[functions.stripe-webhook]
verify_jwt = falseYou can also switch it off in the Supabase dashboard, on the function's details, or deploy with supabase functions deploy stripe-webhook --no-verify-jwt. Prefer the config file. In a Supabase GitHub discussion from February 2026, a user had turned the check off in the dashboard and still got 401s, and a maintainer explained that the local config overwrote the platform setting on deploy. If it's in config.toml, every deploy keeps it.
Turning the check off is safe only because the function then checks Stripe's signature itself. A webhook with neither check is open to anyone who finds the URL.
Stripe webhook 400: signature verification failed
The full message is usually Webhook signature verification failed. Err: No signatures found matching the expected signature for payload. Stripe signs the exact bytes it sends. Its docs say any change to the raw body makes verification fail. There are three common causes.
The body was parsed before checking
AI-written handlers often call await req.json() first and pass the result on. Read the body as text with await req.text(), verify it, and only then use the event Stripe's library gives back.
The signing secret is from somewhere else
Stripe creates a different whsec_secret for every endpoint, and the same endpoint has different secrets in test and live mode. Stripe's docs name the wrong secret as the most common cause, and warn that the secret printed by stripe listenon your computer differs from the one for the endpoint in the dashboard. Copy the secret from this exact endpoint, in the mode you're testing, and store it as a secret: in Lovable Cloud under Cloud, then Secrets, or with supabase secrets set. Supabase's docs say functions read a new secret immediately, without a redeploy.
The code uses the synchronous check
Edge Functions run on Deno, which has no synchronous Node crypto. Supabase's docs say constructEvent()throws " SubtleCryptoProvider cannot be used in a synchronous context" there. Use constructEventAsync with Stripe.createSubtleCryptoProvider(), as in the handler below.
Webhook returns 200 but the user isn't upgraded
This is the expensive one, because Stripe thinks everything worked and stops retrying. The customer has paid and the app still shows the free plan. Check these in order.
- The write was blocked by RLS. If the function creates its Supabase client with the public key, the update runs as a logged-out visitor, row level security matches no rows, and nothing changes, without an error. Inside the webhook function, use the secret key, which bypasses RLS. It stays in the function and never reaches the browser. More on how RLS fails silently in the Supabase RLS checklist.
- The event isn't matched to a user. A Stripe event knows the customer, not your user ID, unless you told it. When the app creates the checkout session, pass
client_reference_idandsubscription_data.metadata.user_idwith the user's ID. The second matters because subscription events can arrive before the checkout event. Lovable's own-Stripe integration matches by email instead, so a customer who checks out with a different email stays locked. - The webhook writes one table and the app reads another. Common after several rounds of prompting. Find where the app decides someone is on a paid plan, and make sure the webhook writes there.
- The handler ignores the event. A
switchwithout a case for that event type returns 200 and does nothing. That's correct for events you don't need, and wrong for the ones you do. - A database error still returned 200. If the write fails, return a 500 so Stripe retries. Returning 200 on failure loses the event.
- A field is undefined.Stripe's March 2025 API version removed
current_period_endfrom the subscription itself; it now lives on each subscription item. Older AI-written code that readssubscription.current_period_endgetsundefinedand may save a broken expiry date.
A Stripe webhook handler for Supabase Edge Functions
Compare yours against this one, built from Supabase's official Stripe example and Stripe's webhook guide. It assumes a subscription app, a checkout session created with the user's ID in subscription_data.metadata.user_id, and the two tables below. It does four things AI-generated handlers often skip: it verifies the raw body, it ignores events it has already processed, it fetches the subscription's current state instead of trusting the event order, and it lets Stripe retry when something fails.
create table public.stripe_events (
id text primary key,
created_at timestamptz not null default now()
);
create table public.subscriptions (
user_id uuid primary key references auth.users (id) on delete cascade,
stripe_customer_id text,
stripe_subscription_id text,
status text,
current_period_end timestamptz
);
alter table public.stripe_events enable row level security;
alter table public.subscriptions enable row level security;
-- Only the webhook (secret key) writes. Users can read their own row.
grant select, insert, update, delete
on public.stripe_events, public.subscriptions to service_role;
grant select on public.subscriptions to authenticated;
create policy "Users read own subscription"
on public.subscriptions for select
to authenticated
using ((select auth.uid()) = user_id);import Stripe from "npm:stripe@^22";
import { createClient } from "npm:@supabase/supabase-js@2";
const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!);
const cryptoProvider = Stripe.createSubtleCryptoProvider();
// The secret key bypasses RLS. It stays in this function, never the browser.
// Newer projects provide SUPABASE_SECRET_KEYS; older ones the service role key.
const secretKeys = Deno.env.get("SUPABASE_SECRET_KEYS");
const adminKey = secretKeys
? JSON.parse(secretKeys).default
: Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const supabase = createClient(Deno.env.get("SUPABASE_URL")!, adminKey);
Deno.serve(async (req) => {
const signature = req.headers.get("stripe-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
// Raw body: never call req.json() before verifying.
const body = await req.text();
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature,
Deno.env.get("STRIPE_WEBHOOK_SECRET")!,
undefined,
cryptoProvider,
);
} catch (err) {
console.error("Signature check failed:", err);
return new Response("Bad signature", { status: 400 });
}
// Record the event first. A duplicate key means Stripe sent it before.
const { error: seen } = await supabase
.from("stripe_events")
.insert({ id: event.id });
if (seen?.code === "23505") return Response.json({ received: true });
if (seen) return new Response("Database error", { status: 500 });
try {
let subscriptionId: string | null = null;
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode === "subscription") {
subscriptionId = session.subscription as string;
}
} else if (event.type.startsWith("customer.subscription.")) {
subscriptionId = (event.data.object as Stripe.Subscription).id;
}
if (subscriptionId) {
// Fetch the current state, so a late event can't undo a newer one.
const sub = await stripe.subscriptions.retrieve(subscriptionId);
const userId = sub.metadata.user_id;
if (!userId) throw new Error("No user_id on subscription " + sub.id);
const periodEnd = sub.items.data[0]?.current_period_end;
const { error } = await supabase.from("subscriptions").upsert(
{
user_id: userId,
stripe_customer_id: sub.customer as string,
stripe_subscription_id: sub.id,
status: sub.status,
current_period_end: periodEnd
? new Date(periodEnd * 1000).toISOString()
: null,
},
{ onConflict: "user_id" },
);
if (error) throw error;
}
} catch (err) {
// Forget the event so Stripe's retry is processed, not skipped.
console.error("Processing failed, Stripe will retry:", err);
await supabase.from("stripe_events").delete().eq("id", event.id);
return new Response("Processing error", { status: 500 });
}
return Response.json({ received: true });
});The app then treats a user as paid when their row's status is active or trialing. Store the two secrets, deploy with JWT verification off as above, and test locally with stripe listen and stripe trigger, remembering that the CLI prints its own signing secret.
Thin or snapshot events
When you add a new event destination in Stripe, it asks for a payload format: thin or snapshot. This handler, like Supabase's example, reads event.data.object, which is the snapshot format. A thin destination sends a lighter event that needs different code, so choose snapshot for this handler.
Which Stripe events a subscription app needs
Subscribe the endpoint to these, and no more than you handle. Each description is from Stripe's subscription webhook guide.
| Event | When Stripe sends it | What the app does |
|---|---|---|
checkout.session.completed | A customer finished checkout | Link the subscription to the user |
customer.subscription.updated | A renewal, plan change, or discount | Update status and period end |
customer.subscription.deleted | The subscription ended | Remove paid access |
invoice.paid | An invoice was paid | Extend access once status is active |
invoice.payment_failed | A renewal payment failed | Warn the customer; don't cut access yet |
Stripe's docs say events can arrive more than once and out of order, and advise tracking event IDs rather than timestamps. That's why the handler records each ID and fetches the subscription's current state.
Going live: Stripe webhooks in live mode
Test mode and live mode are separate in Stripe. Before launch, check each of these:
If deliveries failed while you fixed things, Stripe retries live events for up to three days, and sandbox events only three times over a few hours. After that, resend them by hand from the event in the dashboard, which works for 15 days, or with the CLI for 30.
Webhook errors with Lovable's built-in payments
With built-in payments, Lovable owns the endpoints, so the fixes are different. Lovable's docs warn not to delete or disable either endpoint it registered: deleting your app's endpoint means purchases still succeed but subscriptions never activate.
For "Invalid webhook signature", the docs suggest checking that the webhook handler still exists in your app, and that no duplicate endpoint you added points at the same URL. A duplicate delivers events your app can't verify, so removing it can fix delivery. If that doesn't, ask Lovable to rotate the webhook secret for that environment. In these URLs, the test environment is called sandbox.
When to get help with Stripe in a Lovable app
Fix it yourself if it's one of the clear cases above: a 401 that goes away with verify_jwt = false, or a 400 that goes away with the right secret. Get help if customers have already paid and been locked out, if the app has several plans, trials, or upgrades, or if you can't tell which table decides who's paid. Those are about the payment logic, not one setting.
Verified, retry-safe Stripe webhooks are part of my Production Sprint, after a review that checks payments alongside data access and logins. What each step costs is in the cost to fix a vibe-coded app. Money is where mistakes cost the most: on the social challenge app, which an agency left half-built, users stake real money on challenges, so wallets, contributions, and payouts all had to be right before launch. It now has 10,000 users.
If your app was built with AI tools and payments are only one of several worries, start with when an AI-built app needs a developer. Otherwise, send me what Stripe's event deliveries show.
Sources
- Stripe integration: no webhooks by default, matching by email, Lovable Docs
- Built-in payments: Lovable-registered webhook endpoints, Lovable Docs
- Auth headers and verify_jwt in Edge Functions, Supabase Docs
- Edge Function 401 error responses, Supabase Docs
- Stripe webhooks example Edge Function, Supabase on GitHub
- Edge Function returns 401 with Verify JWT off: local config overwrote it on deploy, Supabase on GitHub
- Edge Function secrets and default environment variables, Supabase Docs
- Receive Stripe events in your webhook endpoint, Stripe Docs
- Manage webhook endpoints: signature verification errors, Stripe Docs
- Using webhooks with subscriptions, Stripe Docs
- current_period_start and current_period_end removed from subscriptions, Stripe Docs
