PayHook

Stripe webhook not working in Lovable, Bolt or Supabase Edge Functions

The payment went through and the app stays locked: why a Stripe webhook fails in a Lovable, Bolt or Supabase app, from the 401 JWT check to the secret.

If a customer pays and your app stays locked, start with the endpoint in Stripe: in Workbench, open it under Webhooks, then the Event deliveries tab, which shows the status code your endpoint answered. In a Lovable or Bolt app, that endpoint is usually a Supabase edge function, which runs on Deno, and four things break it: Supabase refuses the request before your code runs (a 401), the code checks the signature in the synchronous way Deno does not support, the body is parsed before the check, or the secret the function reads is missing or belongs to another endpoint.

What the status in Stripe tells you

Status in StripeWhat happenedWhat to do
401Supabase refused the request before your code ran: the function expects a JWT, and Stripe sends noneTurn off JWT verification for this function
400Your function ran and rejected the signatureRead the message in the function's logs
404Nothing answers at that URL: the function has another name, or it was removedCopy the function's URL into the endpoint again
500, or a timeoutYour code failed, or took too long, after the checkRead the function's logs
200, and the app is still lockedYour function accepted the event and did not unlock the userRead the function's logs, and check it handles this event type

The function's logs are in Lovable under More → Cloud → Edge functions, then View logs; in Bolt under the database icon → Server Functions → View Logs; and with your own Supabase project, on the function's page in the Supabase dashboard.

401: Supabase expects a JWT that Stripe never sends

By default, Supabase checks every request to an edge function for a valid JWT in the Authorization header and refuses one without it before your code runs, with {"code": 401, "message": "Missing authorization header"}. Stripe proves its requests with a signature instead, and Supabase's own guide names Stripe webhooks as the case for turning the check off:

# supabase/config.toml: the name in brackets is the function's folder
[functions.stripe-webhook]
verify_jwt = false

The same switch is supabase functions deploy stripe-webhook --no-verify-jwt on the command line, or the JWT verification toggle on the function's page in the Supabase dashboard. In Lovable or Bolt, ask in the chat to turn off JWT verification for the webhook function; Bolt's troubleshooting guide gives exactly this fix. With the check off, anyone can call the function, so the Stripe signature check is its only lock: keep it.

400: the signature check fails

The message in the function's logs names the cause:

  • SubtleCryptoProvider cannot be used in a synchronous context. The code calls stripe.webhooks.constructEvent(). In Deno, stripe-node computes the signature with Web Crypto, which has no synchronous API. Use await stripe.webhooks.constructEventAsync().
  • No signatures found matching the expected signature for payload. The body was parsed before the check, or the secret is not this endpoint's. Read the body with await req.text(), never await req.json(), pass that text on, then check the secret, below.
  • No webhook secret value was provided. The variable the code reads is empty where the function runs.

Every Stripe signature message, with its causes, is on our page about Stripe's signature errors.

Where the signing secret lives

The function reads the secret by name, for example Deno.env.get("STRIPE_WEBHOOK_SECRET"), so a secret stored under another name is as good as none.

Your appWhere its functions' secrets are
Lovable, built-in backendMore → Cloud → Secrets
Lovable or Bolt with your own Supabase projectSupabase dashboard, Edge Functions → Secrets
Bolt DatabaseThe database icon → Secrets
Supabase from the command linesupabase secrets set --env-file .env, with a .env file that git ignores

Functions read a new secret right away, without a redeploy. The value must be the whsec_ of the endpoint that sends the events, in the same mode: a test-mode endpoint and a live one have different secrets, and so do stripe listen and each Dashboard endpoint.

A webhook function that passes

# supabase/config.toml
[functions.stripe-webhook]
verify_jwt = false
// supabase/functions/stripe-webhook/index.ts
import Stripe from "npm:stripe@23";

const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!);

Deno.serve(async (req) => {
  const body = await req.text(); // the exact body Stripe signed
  const signature = req.headers.get("stripe-signature") ?? "";
  let event;
  try {
    event = await stripe.webhooks.constructEventAsync(body, signature, Deno.env.get("STRIPE_WEBHOOK_SECRET")!);
  } catch (err) {
    console.warn(`Stripe webhook rejected: ${(err as Error).message}`); // never log the secret or the header
    return new Response("invalid signature", { status: 400 });
  }
  // Skip event.id if you have handled it already, then update the user.
  return new Response("ok", { status: 200 });
});

Lovable: built-in payments and your own Stripe account

With built-in payments, Lovable registers two endpoints for each environment on the Stripe account: your app's, whose URL ends in ?env=sandbox or ?env=live, and a monitoring endpoint of its own. Leave both alone. Lovable's documentation is blunt about it: delete or disable your app's endpoint, and purchases still succeed while subscriptions never activate. An endpoint you add yourself with the same URL delivers events your app cannot verify, and removing it can fix delivery.

With your own Stripe account, Lovable sets up no webhook by default: the app asks Stripe whether the signed-in user has an active subscription, matched by email address. A customer who paid with another address than the one they sign in with stays locked out. If you asked Lovable for a webhook, you create the endpoint in Stripe and give Lovable its signing secret yourself.

Bolt

Bolt's Stripe integration writes the checkout and webhook functions, registers the webhook endpoint on your Stripe account and verifies the signatures, so there is nothing to set up in the Stripe dashboard. When deliveries fail anyway, the function's logs are under the database icon → Server Functions → View Logs, a secret the code expects but nobody created shows up as "Missing secrets", and a 401 is the JWT check above: ask Bolt to turn it off for the webhook function.

How PayHook helps

PayHook, the webhook inspector we are building, gives your project its own endpoint for Stripe events, checks each signature as it arrives, and follows each subscription's events to say whether access should be on and why, with a fix prompt you can paste into the chat of Lovable or Bolt. PayHook is in closed beta; the home page has the details.

Sources

Back to the blog