Quick Answer
The reliable way to sync Stripe subscriptions with Supabase is:
If checkout succeeds but the app does not unlock, the problem is almost always your webhook flow or database mapping.
The Core Rule
Stripe is the source of truth for billing.
Your database is the source of truth for product access inside the app.
The webhook is the bridge between them.
If that bridge is weak, users pay and still see the wrong plan.
What to Store in Supabase
At minimum, your subscriptions table should track:
That is enough to answer the important app questions:
A Practical Table Shape
create table if not exists subscriptions ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id), stripe_customer_id text unique, stripe_subscription_id text unique, stripe_price_id text, status text not null, current_period_end timestamptz, created_at timestamptz not null default now(), updated_at timestamptz not null default now() );
Then add RLS so users can read only their own row.
The Frontend Mistake to Avoid
Do not unlock access just because the browser returned from Stripe.
The success page is not proof.
The only trustworthy moment is when Stripe sends the webhook and your backend updates Supabase successfully.
Which Events You Actually Need
For most SaaS apps, handle these:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_failedThat covers the real lifecycle:
Minimal Webhook Logic
Your webhook should:
200 quicklyThe important part is not cleverness. It is consistency.
Example Handler Shape
switch (event.type) {
case "checkout.session.completed":
// store customer/subscription linkage if needed
break;
case "customer.subscription.created":
case "customer.subscription.updated":
// upsert status, price, period end
break;
case "customer.subscription.deleted":
// mark subscription inactive/cancelled
break;
case "invoice.payment_failed":
// downgrade access or flag billing issue
break;
}How to Map Stripe Back to the User
Best options:
metadata.user_idstripe_customer_idEmail works, but it is weaker than a deliberate ID link.
The cleanest pattern is to attach your internal user_id in Stripe metadata when creating the checkout session.
The Most Common Sync Failures
1. Payment succeeded, access did not change
Usually means:
2. Upgrade happened, old plan still shows
Usually means:
stripe_price_id never gets updated3. Cancellation in Stripe does nothing in the app
Usually means:
customer.subscription.deleted is not handled4. Failed payment does not downgrade access
Usually means:
invoice.payment_failed is ignoredThe Fastest Way to Debug
Check in this order:
If you start in the UI first, you usually lose time.
Local Testing
Use Stripe CLI for local webhook testing:
stripe listen --forward-to localhost:3000/api/webhooks/stripe stripe trigger customer.subscription.updated
That is much faster than guessing in production.
What the App Should Check
Your app UI should not guess from "has user paid once?"
It should read:
statusstripe_price_idcurrent_period_endand then decide what the user can access.
That keeps pricing, billing, and permissions aligned.
Good Access Rules
Examples:
active or trialing → allow accesspast_due → maybe limited access or billing warningcanceled or unpaid → remove premium accessThe exact rule is your product choice, but it should be explicit.
Best Prompt for Cursor or Lovable
Audit my Stripe + Supabase subscription flow. Check for: - where Stripe customer IDs are stored - whether webhook signature verification is correct - whether subscription lifecycle events are all handled - whether Supabase access state updates after checkout, renewals, failed payments, and cancellations - whether the frontend reads subscription state from the database or incorrectly trusts the success redirect Return the highest-risk issues first.