Stripe + Supabase: The Pattern for SaaS Subscriptions in 2026
A founder asked me last month why his Supabase subscriptions table showed "active" for a customer who had cancelled three days earlier. Nothing dramatic had broken. Stripe was charging correctly, the dashboard looked fine, and his database just hadn't been told the truth. Not because a webhook failed, but because nobody had built the plumbing to keep two independent systems honest with each other. That gap is the entire problem this pattern solves.
The canonical stripe supabase subscription setup has four moving parts: a Stripe webhook handler that verifies every incoming event, a Postgres table that is the single source of truth for plan state, Row-Level Security policies that gate access by that state, and a customer-portal launcher so users can manage billing without you building a UI for it. Get those four right and subscription bugs stop being a weekly occurrence.
Where Does Subscription State Actually Live?

In your Postgres database, not Stripe's API directly, since Stripe processes payments but isn't your app's source of truth for access control. Supabase's own official pattern treats the webhook Edge Function as the one place subscription state gets written, and every other part of the app reads from a subscriptions table instead of calling Stripe's API on every page load.
That single decision resolves a category of bugs before they exist. If the customer portal, the app UI, and your RLS policies all read from the same Postgres row, they cannot disagree with each other. If they each call Stripe independently, they will disagree eventually, usually during a plan change or a failed payment retry.
Edge Function or Route Handler: Which One Should Own the Webhook?

Either one works correctly for signature verification and event handling, and the real decider is which runtime the rest of your backend already lives on. Stripe does not require a specific one.
| Dimension | Supabase Edge Function | Next.js Route Handler |
|---|---|---|
| Runtime | Deno, isolated from your app deploy | Node/Edge runtime, same deploy as your app |
| Signature verification | constructEventAsync + Web Crypto (sync constructEvent does not run in Deno) | constructEvent works natively in Node |
| Auth | Set verify_jwt = false in config; Stripe's signature is the actual auth | No JWT concept to disable, just skip middleware for this path |
| Coupling | Decoupled from your Next.js deploy cadence | Redeploys with every app push, including unrelated ones |
| Best fit | Supabase-first stacks, or billing logic isolated from app code | Next.js-first stacks already running Route Handlers for everything else |
The official Supabase example uses stripe.webhooks.constructEventAsync() paired with Stripe.createSubtleCryptoProvider(). That's a Deno-specific detail that trips up anyone porting a Node.js webhook handler over verbatim, since Node's synchronous constructEvent simply does not run on that runtime. If you're already building your Supabase Edge Functions for other backend logic, keeping the webhook there avoids maintaining two separate serverless environments. If your app is Next.js-heavy and Supabase is just the database, a Route Handler is one less deploy target to reason about.
Why the Naive insert Breaks the First Time Stripe Retries

Stripe delivers webhooks at-least-once, never guaranteed exactly-once, and that distinction alone accounts for most of the bugs in this integration.
A webhook handler written with insert works perfectly in every manual test, then throws a unique-constraint error the first time Stripe retries a delivery, which it does routinely, not as an edge case. One developer's write-up on wiring Stripe to Supabase in Next.js nails the fix in one line: use upsert with an onConflict target instead, so a duplicate delivery updates the existing row instead of colliding with it. The same piece flags something tutorials tend to skip. Production Supabase apps typically need three separate clients: browser, server, and a service-role client for webhooks. Using the wrong one produces RLS errors inside a webhook handler that has no business hitting RLS at all, since the service-role key exists specifically to bypass it.
Linking the Stripe customer back to the right Supabase user has the same fragility problem in miniature. Matching by email breaks the moment someone changes their billing address or ends up with two Stripe customer objects. Passing the Supabase user_id in the subscription's metadata at checkout-session creation avoids the whole class of problem, and it is the detail most tutorials leave for you to discover the hard way.
The RLS Trap That Doesn't Show Up Until You're at Scale
You wrote auth.uid() = user_id in an RLS policy, tested it, and it worked. Actually, "worked" undersells it. It passed code review too. You've felt that false confidence before, and it keeps holding right up until your subscriptions table has real row counts, at which point it gets slow in a way none of your tests caught.
Postgres re-evaluates a bare auth.uid() call once per row scanned, not once per query. Wrap it as (select auth.uid()) instead, and the planner hoists it into a single init-plan that runs exactly once. A benchmark repo built specifically to measure this puts the improvement at over 100x on large tables. One production team building a learning platform on Supabase found and rewrote 76 policies with this exact bug in a single migration, and the part worth sitting with is how they found it: not from user complaints or a failing test, but from Supabase's own auth_rls_initplan advisor lint, sitting quietly in a dashboard tab nobody checks daily.
That is the sharp edge of RLS-gated subscription access specifically. A plan_tier column checked on every row of every query looks correct at ten customers and turns into a real latency line item at ten thousand, and nothing in a green test suite tells you which one you're looking at.
Setting Up the Webhook Handler, Step by Step
- Create the Stripe webhook endpoint in the Dashboard, pointed at your Edge Function or Route Handler URL, and copy the signing secret into your environment as
STRIPE_WEBHOOK_SIGNING_SECRET. - In the handler, read the raw request body, not the parsed JSON, because signature verification needs the exact bytes Stripe sent.
- Verify the signature with
constructEventAsyncon Deno orconstructEventon Node before touching your database. A failed verification returns a 4xx and stops there, full stop. - Switch on
event.typefor the events you actually care about:checkout.session.completed,customer.subscription.updated,customer.subscription.deleted, and any invoice-failure events your dunning flow needs. - Write the result with
upsert, keyed on the Stripe subscription ID or customer ID, neverinsert. - Return a 200 immediately after the write succeeds. Stripe's retry logic treats anything else as a delivery failure and tries again.
Skip step three and you've built an endpoint anyone on the internet can POST to and flip someone's account to paid. That's not hypothetical. It's the reason verify_jwt = false on the Supabase side is safe only because the signature check is still mandatory inside the function body itself.
Recent Change: Stripe's Move to Thin Events
Stripe's v2 API introduced "thin events": a lighter webhook payload carrying just an event ID, type, and a reference to the changed object, instead of a full snapshot of that object's state at delivery time. Payload size drops by more than 90%, but your handler now has to make a follow-up API call to fetch current state rather than trusting the payload it was handed. The tradeoff is bandwidth traded for an extra round trip, and whether that's worth it depends on how many events you process and how much staleness you can tolerate during that round trip. Most SaaS subscription flows are still comfortably on the older snapshot-event model. Stripe's current API version is pinned per-endpoint, so migrating is not something that happens to you silently on an unrelated deploy.
What This Looks Like Outside a Tutorial
I have not shipped a Stripe-plus-Supabase subscription flow on a client project yet. Callidus runs Stripe Connect against Firebase, not Supabase, and BookBed's Stripe checkout flow syncs through Firestore with a BroadcastChannel-plus-webhook fallback for cross-tab state. That solves the identical "does the UI know payment actually completed" problem this pattern solves, just on a different database. What I have shipped on Supabase is the RLS and Edge Function half of this: Pizzeria Bestek's order-confirmation flow runs three Supabase Edge Functions with Postgres RLS gating every table, which is the exact infrastructure this subscription pattern sits on top of, minus the Stripe piece. The database-and-edge-function plumbing is identical whether you're gating rows by "does this order belong to this customer" or "is this account's plan_tier active."
That's not a dodge. It's the honest boundary between what I can claim first-hand and what's the documented, sourced pattern everyone building this in 2026 converges on regardless.
One Piece Most Write-Ups Skip
Don't build a billing-management UI. Stripe's customer portal already handles plan switches, card updates, and cancellations, and building your own version duplicates something Stripe maintains for free. The launcher itself is small: a server action that creates a portal session for the current Stripe customer ID and redirects the user there, then trusts the webhook to write back whatever changed. Budget a half-day for it, not a sprint.
So Which Pieces Do You Actually Need on Day One?
All four pieces matter eventually: webhook handler, subscriptions table, RLS policies, and portal launcher, though day one doesn't need equal rigor on each. Ship the webhook handler and the table first, with upsert and metadata-based user linking from the start, since retrofitting idempotency after a duplicate-row incident is a worse conversation than building it correctly the first time. RLS can start simple, auth.uid() = user_id unwrapped, and get the (select ...) treatment once you have real row counts to justify the change. The portal launcher can genuinely wait until week two. For the full checklist beyond just the webhook piece, the Stripe plus Supabase stack guide covers the auth and storage decisions this post doesn't touch, and if you're still deciding the rest of your SaaS MVP stack at the same time, the ORM and hosting choices should settle before this one, not after.
Open your subscriptions table right now and ask whether every column in it can be recomputed from the last webhook event you received. If the answer is no, something in there is already drifting from Stripe's truth, whether or not anyone's noticed yet. What does your table say?
