Paid in Stripe, but your Lovable or Bolt app didn't unlock
Short answer: Getting paid and unlocking the account are two separate steps. Stripe takes the money. Then your app has to find out, match the payment to the right user and save it. The break is almost always in that second part. Find which link broke.
First: how does your app find out about payments?
Lovable, with your own Stripe account: by default there is no webhook. Lovable's docs say your app asks Stripe directly if the user has an active subscription. Subscriptions are matched by email. The email the user signs in with must match the email on their Stripe customer. If a user pays with a different email, the app won't see the subscription.
Lovable, built-in payments: Lovable sets up the payment account, keys and webhooks for you. If those break, ask Lovable support.
Bolt: Bolt's built-in Stripe setup needs Supabase (or Bolt Database), login, and Edge Functions. Bolt's docs say it registers the webhook and checks signatures for you.
Custom code or a webhook you added: keep reading.
Step 1: Did Stripe deliver the event?
In Stripe, go to Developers → Webhooks, open your endpoint and check Event deliveries.
- No endpoint at all? Your app never hears about payments. Add one that points at your webhook function, and copy its signing secret into your app's secrets.
- Errors (401, 404, 3xx, 5xx)? Stripe couldn't reach your code. For a 401 from a Supabase function, see our Supabase 401 guide.
- 200, but nothing changed? Go to Step 2.
Also check you're looking at the right mode. Test and live have separate endpoints.
Step 2: Does the event say which user paid?
When you create the Checkout Session, pass your user's ID. Otherwise the webhook has to guess from the email, and emails don't always match.
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: PRICE_ID, quantity: 1 }],
customer_email: user.email,
client_reference_id: user.id, // comes back on checkout.session.completed
subscription_data: { metadata: { user_id: user.id } }, // copied onto the subscription
success_url: `${APP_URL}/success`,
cancel_url: `${APP_URL}/pricing`,
})
In the webhook, read session.client_reference_id to find the user.
Step 3: Did the database write work?
This is the most common silent break.
- Row Level Security (RLS): a webhook has no logged-in user. If it uses the public (anon) key, RLS can block the update with no error. Use the service role key only inside the server function. Never put it in the browser.
- Wrong table or column: the webhook writes
subscription_status, but the app readsis_pro. Make them match. - No row found: the user ID or email didn't match any row, so zero rows changed.
- Errors ignored: check the result of the update. If it fails, return a 500 so Stripe tries again. If you return 200 anyway, Stripe stops and the user stays locked.
// inside the webhook, after the signature check
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session
const userId = session.client_reference_id
if (!userId) return new Response('No user id on session', { status: 400 })
if (session.payment_status === 'unpaid') return Response.json({ received: true }) // not paid yet
const { data, error } = await supabaseAdmin // created with the service role key
.from('profiles')
.update({ plan: 'pro' })
.eq('id', userId)
.select('id')
if (error || !data?.length) {
console.error('Unlock failed', error)
return new Response('DB write failed', { status: 500 }) // Stripe will retry
}
}
return Response.json({ received: true })
Change profiles and plan to your real table and column.
Step 4: Are you listening for the right events?
- One-time payment:
checkout.session.completed. Stripe's guide says to checksession.payment_statusfirst: if it isunpaid, don't unlock yet. - Subscriptions: also handle
customer.subscription.updatedandcustomer.subscription.deletedso access changes when the plan changes or ends. Stripe's guide also usesinvoice.paidto keep access going each month. - Make sure those events are selected on the endpoint in Stripe.
Step 5: Does the app show the new state?
The database may be right while the screen is stale. Log out and in, or reload. If that fixes it, refresh the user's plan after the success page loads.
Fix the users who already paid
After the fix, resend the failed events from the Stripe Dashboard (Resend works for up to 15 days), or update those users by hand.
Check it with a free scan
Paste your webhook function's address into the free Tillping scan: https://tillping.online/#scan. No account is needed. Only scan an address you own or are allowed to test.
It sends a few safe test requests. It can flag common walls in front of your code: a Supabase JWT block, a Vercel login wall, a Cloudflare bot challenge, a redirect, 404, 405 or 5xx. It also warns you if your endpoint says OK to a fake signature.
It never asks for keys and can't see your database, so it can't check Steps 2 to 5. If your app has no webhook at all (Lovable's default way), there is nothing for the scan to test.
Sources
- Lovable, Connect your own Stripe account: https://docs.lovable.dev/integrations/stripe
- Bolt, Stripe for payments: https://support.bolt.new/integrations/stripe
- Stripe, Using webhooks with subscriptions: https://docs.stripe.com/billing/subscriptions/webhooks
- Stripe, Fulfill orders (Checkout): https://docs.stripe.com/checkout/fulfillment
- Stripe, Webhooks (resend, retries): https://docs.stripe.com/webhooks
- Supabase, Row Level Security: https://supabase.com/docs/guides/database/postgres/row-level-security
Run the free check
Paste your webhook address and see what your endpoint answers. No account. No keys.
Check my webhook