©Code byMilanParmar

Stripe webhook not working in a Lovable app: how to fix it

How to tell which Stripe setup your Lovable app uses, what each status code in Stripe's event deliveries means, the fix for 401, 400, and 'paid but still free', a working Supabase Edge Function handler, and what changes when you go live.

11 min read

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 webhook status codes and what they mean in a Lovable app
Stripe showsUsual causeFix
401, "Missing authorization header"Supabase checks for a login token before your code runsTurn off JWT verification for that function
400, "No signatures found matching…"Body parsed before checking, or the wrong signing secretRead 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 DenoUse constructEventAsync with the crypto provider
500, otherA missing secret, a name mismatch, or a database errorRead the function logs; list secrets and match the names
404Function not deployed, renamed, or the URL points at an old projectUse the function's exact URL from Supabase
3xxThe URL redirects, for example to wwwUse the final URL; Stripe counts redirects as failures
200, but nothing changesWrite blocked by RLS, user not matched, or event not handledSee the "200 but not upgraded" section
No deliveries at allThe endpoint isn't subscribed to that event, or is in the other modeEdit 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:

supabase/config.toml
[functions.stripe-webhook]
verify_jwt = false

You 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.

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.

SQL: tables the webhook writes to
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);
supabase/functions/stripe-webhook/index.ts
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.

Stripe events for a subscription app
EventWhen Stripe sends itWhat the app does
checkout.session.completedA customer finished checkoutLink the subscription to the user
customer.subscription.updatedA renewal, plan change, or discountUpdate status and period end
customer.subscription.deletedThe subscription endedRemove paid access
invoice.paidAn invoice was paidExtend access once status is active
invoice.payment_failedA renewal payment failedWarn 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

  1. Stripe integration: no webhooks by default, matching by email, Lovable Docs
  2. Built-in payments: Lovable-registered webhook endpoints, Lovable Docs
  3. Auth headers and verify_jwt in Edge Functions, Supabase Docs
  4. Edge Function 401 error responses, Supabase Docs
  5. Stripe webhooks example Edge Function, Supabase on GitHub
  6. Edge Function returns 401 with Verify JWT off: local config overwrote it on deploy, Supabase on GitHub
  7. Edge Function secrets and default environment variables, Supabase Docs
  8. Receive Stripe events in your webhook endpoint, Stripe Docs
  9. Manage webhook endpoints: signature verification errors, Stripe Docs
  10. Using webhooks with subscriptions, Stripe Docs
  11. current_period_start and current_period_end removed from subscriptions, Stripe Docs
Milan Parmar

The developer

Freelance full-stack developer in Bengaluru with 5+ years of experience. I redesign websites, take over broken apps, and build SaaS products, with 18 case studies to show for it.

FAQs

Do I need Stripe webhooks if Lovable checks Stripe directly?

Not always. Lovable's own-Stripe integration asks Stripe for the subscription status when the app needs it, which is enough for many apps. You need webhooks when something must happen without the user in the app: sending a receipt, cutting off access after a failed renewal, or recording revenue in your own database. If your app does any of those, add them.

Why does my Stripe webhook work in test mode but not live?

Usually because live mode is a separate setup. Stripe gives each endpoint its own signing secret, and the test and live secrets differ, so the live endpoint needs its own secret stored in Supabase. The live endpoint must also exist, and your code must use live price IDs, which Lovable's docs note differ from test ones.

How do I resend Stripe webhook events that failed?

Fix the handler first, then open the event in Stripe and click Resend. Stripe's docs say this works for up to 15 days after the event, and the Stripe CLI command stripe events resend works for up to 30 days. In live mode Stripe also retries failed events automatically for up to three days, so recent failures may clear themselves.

Where do I see my webhook function's logs in Lovable?

On a Lovable Cloud project, open the Cloud view and check the function logs. If the app uses your own Supabase project, open the Supabase dashboard and go to Edge Functions, then the function's logs. If a 401 shows in Stripe but nothing appears in the logs, the request was rejected before your code ran.