Lovable Stripe webhook not working
The success page is not proof of payment. Stripe tells your app about the payment in a webhook, and that request is what should flip the user to paid. Lovable apps often return 200 from the webhook while reading the user id from the wrong field.
Why this happens
- The Checkout Session stores the user on client_reference_id, but the handler reads metadata.user_id, or the other way around.
- The webhook URL in the Stripe dashboard still points at a preview URL that no longer exists.
- Signature verification throws, the function crashes, and the user only looks at the browser redirect.
Common causes
Wrong webhook signing secret
Each Stripe endpoint has its own signing secret, and it starts with whsec_. The Stripe CLI prints a different secret while it forwards events. If the deployed handler verifies with the CLI secret, or with the secret from another endpoint, Stripe's signature check fails and the plan is never written.
How to check. In the Stripe dashboard, open the endpoint whose URL is the deployed app and reveal its signing secret. Compare that value to the secret in the deployed environment. It has to be this endpoint's whsec_, not the secret from a different endpoint and not the one the CLI printed, unless the CLI is what is actually forwarding the event.
Test mode and live mode are mixed
Test mode and live mode do not share events. A test secret key, a live-mode webhook endpoint, and a live price id are three different setups. A test payment is delivered only to endpoints that exist in test mode, and Checkout only uses prices from the same mode as the secret key.
How to check. Look at the secret key (sk_test_ or sk_live_), the mode toggle on the webhook endpoint, and the price ids sent to Checkout. All three have to be in the same mode. A test card payment will not call an endpoint that exists only in live mode.
The raw body was parsed before verification
Stripe signs the exact bytes of the request. constructEvent checks those bytes against the Stripe-Signature header. If the framework, or your own code, reads the body as JSON before that call, the bytes change and verification fails even when the secret is right.
How to check. Open the webhook route and confirm it passes the raw body to constructEvent. In the App Router that is request.text(), or the framework's raw-body helper. If an earlier step calls request.json(), or a JSON body parser runs first, that is the failure.
The endpoint still points at a preview or old URL
Stripe stores the URL you saved. Preview hosts from Lovable and other hosts change when you redeploy. Stripe keeps posting to the old URL, which 404s or hits a deployment that is not the one your customers use.
How to check. Read the endpoint URL in the dashboard. It should be the public HTTPS URL of the current deployment, including the webhook path. Open a recent delivery and see which host Stripe called. A preview hostname or a retired URL means the endpoint was never updated.
The Supabase edge function is missing or still verifying a JWT
If the webhook is a Supabase Edge Function, Stripe calls it with no Supabase user JWT. When JWT verification is left on, Supabase returns 401 before your handler runs. If the function was never deployed, the URL 404s.
How to check. In the Supabase functions list, confirm this function is deployed and that JWT verification is off for it. Send a test event from Stripe and read both the Stripe delivery status and the function logs. A 401 with no log line from your code means the platform rejected the request.
checkout.session.completed is not subscribed
An endpoint does not receive every event type. The event that should mark the user paid, after a Checkout Session, is checkout.session.completed. If that type is not selected, Stripe never sends it and the success page is the only thing that runs.
How to check. Open the endpoint and read the event list. checkout.session.completed has to be selected. Pay in test mode and look for a delivery of that event. No delivery, while other event types still arrive, means the subscription list is the problem.
The handler returns 200 before the database write
Stripe treats a 2xx response as success and does not retry. If the handler responds 200 and writes the plan afterward, or responds 200 when the write throws, the customer stays on the free plan and Stripe stops trying.
How to check. Read the handler from the event parse to the response. The update that sets the user to paid has to finish first. If that write fails, return a non-2xx status so Stripe retries. The browser redirect to the success page is not the upgrade.
How the daily check catches it
ShipWatch does not call your webhook and it does not need the whsec_ secret. Once a day it signs up or reuses the test account you stored, logs in, completes checkout with Stripe test card 4242 4242 4242 4242, and then opens the paid area. If that area still shows the locked or free state, the run fails. The pass condition is that paid access was actually granted after the test payment.
What ShipWatch looks for
- ShipWatch logs in with your test account and pays with Stripe test card 4242.
- It then opens the members area. If the lock is still up, the run fails and includes a paste-ready prompt.
Prompt to paste
Checkout reaches the success page but the customer stays unpaid. Find the Stripe webhook handler for checkout.session.completed. Read the user id from the exact field set when the Checkout Session is created. Write the plan to paid and return 200 only after that write succeeds.