Diagnostic guide
stripe webhook not updating database
Stripe recorded the payment. Your database did not. The gap between those two facts is almost always one of five things, and four of them look identical from the Stripe dashboard: the endpoint shows a 200 and you move on. The difference is whether the write happened, not whether the request arrived.
most likely causes, in order
01The handler returns 200 before the write finishes+
how it looks
Stripe shows a successful delivery. The row is unchanged. Nothing is logged, because nothing threw where you were looking.
how to check
Find the response in your handler and check what runs after it. If the database call is not awaited before the response, or sits in a fire-and-forget promise, this is it.
the fix
Await the write, then respond. Return 500 on failure so Stripe retries. A 200 tells Stripe to stop trying, and it will.
02The endpoint URL changed on a redeploy+
how it looks
Everyone who signed up before a certain date is fine. Everyone after is broken. The date is your last deploy.
how to check
Stripe Dashboard → Developers → Webhooks. Compare the registered URL against the route that exists in production right now. Preview URLs and renamed routes are the usual culprits.
the fix
Point the endpoint at the production URL, then replay the failed events from the dashboard. They are not gone.
03Signature verification fails silently+
how it looks
Deliveries show 400. Easy to miss if you only look at whether events were sent.
how to check
Your signing secret must match the endpoint that received the event. Test and live secrets differ, and so does each endpoint's.
the fix
Verify against the raw request body. Any framework that parses JSON before you verify will break the signature.
04RLS blocks the write from inside the handler+
how it looks
The handler runs, the query succeeds, zero rows are affected. Common on Supabase.
how to check
Check which key the handler uses. The anon key is subject to row-level security and has no authenticated user in a webhook context.
the fix
Use the service-role key in the webhook only, server-side. Never expose it to the browser.
05The user reference was never written back+
how it looks
A row exists with a Stripe customer id you cannot match to anyone, or no row at all.
how to check
Look for client_reference_id or metadata on the Checkout Session. If it is empty, the link between payer and account was never made.
the fix
Set client_reference_id when you create the session, and read it in checkout.session.completed.
check it yourself
Find rows that were paid for but never granted access. Run this in your SQL editor, adjusting the table and column names to match your schema.
-- everyone with a Stripe customer id but no access select id, email, stripe_customer_id, subscribed, created_at from public.subscribers where stripe_customer_id is not null and subscribed = false order by created_at desc; -- and the opposite: access with no payment record select id, email, subscribed, subscription_tier from public.subscribers where subscribed = true and stripe_customer_id is null;
what this does not tell you
This tells you who is broken in your database. It cannot tell you who is broken in Stripe: a customer who cancelled two months ago and still has access has a perfectly normal-looking row. That direction only shows up if you compare the two records against each other.
Read-only, both sides. Three findings free, no account.
related
Customer paid but has no access
Someone paid and got nothing. Fix the one in front of you in five minutes, then find out how many others are in the same state.
Lovable Stripe subscription not working
Checkout succeeds but the app still shows the free plan. The specific things that break in Lovable and Supabase builds, and how to verify each one.