PsProdseal

Guides · Updated October 15, 2026

Stripe webhook not granting access after payment in Lovable or Bolt apps

A customer pays, Stripe shows the charge, and your app still treats them as a free user. In apps built with Lovable, Bolt, Replit or v0, the step that turns a payment into access should be a webhook: Stripe sends an event to a URL on your server, and your code marks the user as paid. This guide covers the usual reasons that step breaks and how to test it.

Start in the Stripe Dashboard

Open Workbench, select your endpoint under Webhooks, and open the Event deliveries tab. Each event shows as Delivered, Pending or Failed, and clicking one shows the HTTP status code your server returned (Stripe docs).

The common causes

1. Wrong endpoint URL or mode. Stripe delivers only to a registered public HTTPS URL (Stripe docs). A typo, an old preview URL or a redirect will fail, since Stripe counts a 3xx redirect as a failure and a 404 means the URL does not exist (Stripe docs). Sandbox and live mode each have their own keys, and objects in one mode are not available in the other (Stripe docs), so an endpoint you made while testing will not receive live payments.

2. Test and live secrets mixed up. Your code needs a signing secret that starts with whsec_. Each endpoint gets its own secret, and the test and live secrets differ even on the same endpoint (Stripe docs). The secret from stripe listen also differs from the Dashboard one, and Stripe names the wrong secret as the most common cause of signature errors (Stripe docs). When you go live, copy each endpoint’s new signing secret and switch to live API keys (Stripe docs).

3. Event types not selected. An endpoint receives only the event types you choose (Stripe docs). With Checkout and Payment Links, a payment creates checkout.session.completed, and delayed methods like ACH direct debit later send checkout.session.async_payment_succeeded, so Stripe’s example handles both (Stripe docs). Code that only handles payment_intent.succeeded ignores checkout.session.completed events, so match the events selected in Stripe to the names your code checks.

Your webhook also has to know who paid. The client_reference_id field on a Checkout Session can hold your own ID to match the session with your records (Stripe API), so pass the user ID there when you create the session.

4. Signature fails because the body was parsed. Stripe needs the raw request body to verify the signature, and any change to it makes verification fail (Stripe docs). Some frameworks parse or reformat the body before your code sees it, and the log then shows “No signatures found matching the expected signature for payload” (Stripe docs). In a Supabase Edge Function, read the body with req.text() before verifying (Supabase docs).

5. Function not deployed or returning errors. A 4xx or 5xx status means your function answered with an error (Stripe docs). Supabase Edge Functions require a valid JWT by default, and Stripe does not have your user tokens, so a Stripe webhook function needs verify_jwt = false in supabase/config.toml (Supabase docs). That lets anyone call the function, which makes the signature check essential.

Confirm the function is deployed (Supabase docs) and its secrets are set with supabase secrets set or in the Dashboard (Supabase docs). The function’s Logs view shows uncaught exceptions and your console.log output (Supabase docs). Return a 2xx status quickly, before slow work, to avoid timeouts (Stripe docs).

6. Duplicate or out-of-order events. Stripe can send the same event more than once and does not guarantee order (Stripe docs). It retries failed deliveries for up to three days in live mode (Stripe docs). Save processed event IDs and skip repeats (Stripe docs). Stripe also says your fulfillment step must handle being called many times, even at once, for the same Checkout Session (Stripe docs).

Never grant access from the success page alone

Some apps mark the buyer as paid on the success page, from browser code. Stripe warns that you cannot rely on the landing page for fulfillment, because customers are not guaranteed to reach it, for example when they lose their connection right after paying (Stripe docs).

It is also unsafe: anyone can open a success URL or run browser code, so a page that writes “paid” to your database lets people give themselves access. Stripe does let the landing page speed things up by sending the Checkout Session ID to your server, which retrieves the session from Stripe and checks payment_status. The webhook is still required (Stripe docs).

How to verify the signature

Stripe recommends its official libraries, which take the raw body, the Stripe-Signature header and your endpoint secret, and return an error when the check fails. Without this check, an attacker could send fake events that grant account access (Stripe docs). Supabase’s official Edge Function example, lightly trimmed, looks like this (Supabase docs):

import Stripe from 'npm:stripe@^22'
import { withSupabase } from 'npm:@supabase/server@^1'

const stripe = new Stripe(Deno.env.get('STRIPE_API_KEY') as string)
const cryptoProvider = Stripe.createSubtleCryptoProvider()

// Stripe verifies the request via its signature, so deploy with verify_jwt = false.
export default {
  fetch: withSupabase({ auth: 'none' }, async (req) => {
    const signature = req.headers.get('Stripe-Signature')
    const body = await req.text()
    let receivedEvent
    try {
      receivedEvent = await stripe.webhooks.constructEventAsync(
        body,
        signature!,
        Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!,
        undefined,
        cryptoProvider
      )
    } catch (err) {
      return new Response(err.message, { status: 400 })
    }
    console.log(`Event received: ${receivedEvent.id}`)
    return Response.json({ ok: true })
  }),
}

Keep STRIPE_API_KEY and STRIPE_WEBHOOK_SIGNING_SECRET as function secrets. Your own version would go on to check the event type, find the user from client_reference_id, and mark them as paid. Our guide to keys in your public JavaScript covers what to do when a Stripe secret key has ended up in browser code.

Test with the Stripe CLI and the Dashboard

Run stripe login, then forward events to your local function (Stripe docs):

stripe listen --forward-to localhost:4242/webhook

The command prints a whsec_ secret that your code must use while you test this way (Stripe docs). In a second terminal, fire a test event (Stripe docs):

stripe trigger checkout.session.completed

A triggered event will not carry your user ID, so also run your own checkout in a sandbox with test card 4242 4242 4242 4242, any future expiry and any 3-digit CVV (Stripe docs).

Then confirm the event shows Delivered with a 200, your logs show no errors, and the user’s row changed. To replay a failed event after a fix, click Resend in the Dashboard (up to 15 days) or run stripe events resend (up to 30 days) (Stripe docs).

Get a second look

Prodseal checks apps built with Lovable, Bolt, Replit, v0 and similar tools on Supabase. The free check looks at what any browser receives from your public URL, such as keys in your JavaScript. Connect GitHub (Prodseal reads code and opens pull requests and never merges them) or Supabase (read-only) for a free full report covering payments (including Stripe webhook signatures), API routes and edge functions, database access rules, keys in git history and more. For $49 one time, Prodseal writes and tests fixes for the Critical and High issues it can fix automatically, delivered as a GitHub pull request or a SQL-plus-Lovable-prompt kit. The $199 one-time Done-for-you security fix is a separate offer: Prodseal fixes the Manual steps items from your free report, with a full refund if it can’t. Nothing is merged or applied without you.

Questions go through prodseal.dev/contact.

Run my free check
Stripe webhook not giving access after payment? Fix it — Prodseal