Why this happens
Stripe never touches your database. Your app has to listen for events and update its own records. The usual weak points:
- Missing events. You only handle
checkout.session.completed, so renewals, cancellations and failed payments never update the row. Or the live endpoint doesn't exist. - No reliable link to the user. Matching by email breaks when someone pays with a different address than they signed up with. Pass your user id instead.
- A silent RLS no-op. An update with the anon or publishable key from a server route runs as
anon. RLS filters it to zero rows and returns no error, so everything looks fine. - Returning 200 on failure. The handler catches the database error and still returns
200, so Stripe never retries. - The signature or redirect issues covered in the related guides.
How to fix it, step by step
1. Pass the user into Checkout when creating the session (server-side):
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
client_reference_id: user.id,
customer_email: user.email,
subscription_data: { metadata: { user_id: user.id } },
success_url: `${process.env.APP_URL}/billing/success`,
cancel_url: `${process.env.APP_URL}/pricing`,
});2. Store Stripe IDs on your side:
alter table public.profiles
add column if not exists stripe_customer_id text unique,
add column if not exists subscription_status text,
add column if not exists price_id text;3. Handle the lifecycle events, and subscribe the endpoint to them: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, plus invoice.payment_failed if you act on failed payments.
import { createClient } from '@supabase/supabase-js';
// Server only. Never ship this key to the browser.
const admin = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SECRET_KEY!);
async function handle(event: Stripe.Event) {
if (event.type === 'checkout.session.completed') {
const s = event.data.object as Stripe.Checkout.Session;
const sub = await stripe.subscriptions.retrieve(s.subscription as string);
const { error } = await admin.from('profiles').update({
stripe_customer_id: s.customer as string,
subscription_status: sub.status,
price_id: sub.items.data[0]?.price.id,
}).eq('id', s.client_reference_id!);
if (error) throw error;
}
if (event.type === 'customer.subscription.updated' || event.type === 'customer.subscription.deleted') {
const sub = event.data.object as Stripe.Subscription;
const { error } = await admin.from('profiles').update({
subscription_status: sub.status,
price_id: sub.items.data[0]?.price.id,
}).eq('stripe_customer_id', sub.customer as string);
if (error) throw error;
}
}In the route, verify the signature, await handle(event), and return 500 if it throws so Stripe retries. Only return 200 once the write has succeeded.
4. Use the right key. Use the secret key (sb_secret_..., or the legacy service_role key) only in this server code or Edge Function. On older keys, the env var is often called SUPABASE_SERVICE_ROLE_KEY.
5. Fix existing customers. In the Stripe Dashboard, find the failed events under the endpoint's Event deliveries and resend them, or update those users by hand.
Lovable with your own Stripe: by default Lovable doesn't use webhooks. It checks subscription status with Stripe directly and matches users by email, so make sure customers sign in with the same email they paid with.
Fix prompt for your AI builder
Paste this into Lovable, Bolt, Cursor, Claude, ChatGPT, Replit, v0 or whichever tool you build with. Replace the bracketed parts first.
Users pay successfully in Stripe but their plan isn't updated in Supabase. Please fix the billing sync only:
1. When creating the Checkout Session, set client_reference_id and subscription_data.metadata.user_id to the signed-in user's id.
2. In the webhook handler, after verifying the signature, handle checkout.session.completed, customer.subscription.updated and customer.subscription.deleted. Update profiles (stripe_customer_id, subscription_status, price_id) using a server-only Supabase client created with the secret or service role key.
3. Match by user id or stripe_customer_id, never by email. Throw on any database error and return HTTP 500 so Stripe retries. Return 200 only after a successful write.
4. Add the SQL migration for the new columns.
Never expose the secret key to the browser and don't change the pricing UI.How to catch this next time
Billing sync bugs cost you twice: angry paying customers and support time. After any change to billing code, keys or domains, make one test-mode purchase and confirm the row updates.
Heedza checks the customer-facing part on a schedule. It opens your live checkout page, confirms the payment form or pay button shows, and stops before paying. If that breaks, it emails a screenshot and a fix prompt. Its passive scan also flags a Supabase service_role key or a Stripe secret key showing up in your public JavaScript, which matters here because this fix uses those keys. Run the free scan on your live URL.