Stripe says they paid, but nothing unlocked in my vibe-coded app
Short answer: your app almost certainly never heard about the payment. Stripe tells your app "this person paid" by sending a webhook to a web address on your backend. If that address is missing, points at an old preview, uses the wrong secret, or can't find which user paid, the money arrives but your database never changes. Open the Webhooks section of your Stripe Dashboard, look at the event deliveries, and the failed one will tell you which of the causes below it is.
First, look at what Stripe sent
- In your Stripe Dashboard, make sure you're in the right mode. Test mode and live mode are separate: they have separate webhook endpoints and separate signing secrets.
- Open the Webhooks section (under Developers, or Workbench in the newer dashboard) and click your endpoint. If there's no endpoint at all, that's your problem: see cause 1.
- Open the event deliveries for the time of the payment. A failed delivery shows the response your app sent back, such as 400, 401, 404 or 500. That number points to the cause.
- Once fixed, use Resend on the failed event so the customer who already paid gets unlocked. In live mode Stripe also keeps retrying a failed event for up to three days.
The usual causes
1. The app unlocks on the "thank you" page instead of a webhook
Many AI-built apps mark the user as paid when they land on the success page after checkout. If the customer closes the tab, their connection drops, or the redirect goes to the wrong domain, they never land there, so nothing unlocks. Anyone who guesses the success address can also unlock for free. The reliable fix is to unlock from a webhook on your backend and treat the success page as a "thanks, we're setting things up" screen.
Prompt idea for your builder: "Unlock paid access only from a Stripe webhook handler on the server, not from the checkout success page. Handle checkout.session.completed, verify the Stripe signature, and update the user's plan in the database."
2. The webhook points at an old address
If you set up Stripe while the app lived on a preview link, the endpoint may still point there. After moving to your own domain, update the endpoint URL in Stripe (or add a new endpoint) and copy the new signing secret into your app's environment settings.
3. Wrong signing secret, or the body was changed before checking it
A response of 400 often means signature verification failed. Each endpoint has its own signing secret starting with whsec_, and test and live mode secrets differ. Verification also needs the raw request body: if your code parses the body as JSON first and then checks the signature, it fails every time.
4. The function rejects Stripe before your code runs
A response of 401 from a Supabase Edge Function usually means the function expects a logged-in user's token, and Stripe doesn't send one. Turn off JWT verification for the webhook function only (deploy it with --no-verify-jwt, or set verify_jwt = false for that function in supabase/config.toml) and rely on the Stripe signature check instead. Don't remove the signature check.
5. Listening for the wrong event
For one-time payments through Checkout, listen for checkout.session.completed. For subscriptions, also handle invoice.paid for each renewal and customer.subscription.deleted for cancellations. Make sure the endpoint is subscribed to those events in Stripe, not just your code.
6. The app can't tell which user paid
The webhook arrives, but your code can't match it to an account, so it does nothing. When you create the Checkout Session, pass your user's ID as client_reference_id (or in metadata) and read it back in the webhook. Payment Links accept a client_reference_id added to the link's address too. Matching by email alone breaks when people pay with a different email.
7. The database write is blocked
If the webhook runs but the update silently does nothing, row-level security may be blocking it, because there's no logged-in user during a webhook. Server-side webhook code should use a key meant for servers (Supabase's service role key), stored as a secret on the server and never in your front-end code.
Check it end to end
In test mode, sign up as a new user in a private window, pay with Stripe's test card, and confirm the plan changes without you touching anything. Then check the event delivery shows a 200 response. Repeat once in live mode, for example with a 100%-off promotion code.
Check your live app in under a minute
AppFirstAid's free check looks at your live site from the outside: leaked keys, files left in public, safety settings, and whether your sign-up, login and checkout pages open. No signup. Journey tests that sign up, log in and try checkout like a real customer are coming soon (email us to hear when).
Run the free checkSettings move around as these tools update. If a menu name has changed, look for the same words nearby. Spotted something out of date? Tell us.