Skip to content
Tech Stack18 July 2026 · 9 min read

Stripe + Supabase: The Pattern for SaaS Subscriptions in 2026

Stripe delivers webhooks at-least-once, never exactly-once. That gap breaks naive insert handlers and untested RLS policies. Here is the pattern that keeps subscription state honest.

Stripe + Supabase: The Pattern for SaaS Subscriptions in 2026

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?

A single glowing cyan sphere rests on a concrete plinth at the center of a row of dark, unlit stone plinths

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?

Two identical white geometric paper sculptures sit on separate plinths, one glass and one concrete, the right sculpture split by a bright cyan facet

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

A stack of pale folded paper sheets with the top sheet crumpled into a duplicate fold, its overlapping seam glowing bright cyan

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

  1. 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.
  2. In the handler, read the raw request body, not the parsed JSON, because signature verification needs the exact bytes Stripe sent.
  3. Verify the signature with constructEventAsync on Deno or constructEvent on Node before touching your database. A failed verification returns a 4xx and stops there, full stop.
  4. Switch on event.type for the events you actually care about: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, and any invoice-failure events your dunning flow needs.
  5. Write the result with upsert, keyed on the Stripe subscription ID or customer ID, never insert.
  6. 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?

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps 2× faster than agencies using AI-augmented workflows. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

How do I handle Stripe webhooks in Supabase?

Create a Supabase Edge Function, set `verify_jwt = false` in its config, and verify the Stripe signature inside the function body before touching your database. Use `stripe.webhooks.constructEventAsync()` with `Stripe.createSubtleCryptoProvider()`, since Deno doesn't support Node's synchronous `constructEvent`. Read the raw request body, not parsed JSON, because signature verification needs the exact bytes Stripe sent. Write results with `upsert` keyed on the subscription or customer ID, since Stripe delivers events at-least-once and retries will collide with a plain `insert`. See [Supabase's official webhook pattern](https://supabase.com/docs/guides/functions/examples/stripe-webhooks) for the full reference implementation.

How should I store SaaS subscription state in Supabase?

Keep one Postgres table as the single source of truth, written only by your webhook handler, and never let the frontend call Stripe's API directly for status. Every other surface, the customer portal, RLS policies, the app UI, should read from that table, since reading from multiple sources is how a plan status and the actual charge end up disagreeing. Link the Stripe customer to the Supabase user by passing `user_id` in the subscription metadata at checkout, not by matching email addresses, which breaks the moment someone changes their billing details.

Can Postgres RLS gate access by Stripe subscription tier?

Yes, by adding a `plan_tier` or `status` column to your subscriptions table and referencing it in policies on the tables you want gated. Wrap any `auth.uid()` calls as `(select auth.uid())` so Postgres caches the result in a single init-plan instead of re-evaluating it per row; a dedicated [benchmark repo](https://github.com/GaryAustin1/RLS-Performance) measured over 100x improvement on large tables from that one change. This RLS approach works well at low row counts even unwrapped, but the cost becomes real once a table has thousands of rows and nothing in a passing test suite will flag it for you.

Should I use a Supabase Edge Function or a Next.js Route Handler for Stripe webhooks?

Either works correctly, so the decision should follow where the rest of your backend already lives rather than any technical requirement from Stripe. Pick the Edge Function if you're already building other [Supabase Edge Functions](/stack/supabase-edge-functions) for backend logic and want billing isolated from your app's deploy cadence. Pick a Route Handler if your stack is Next.js-first and Supabase is mainly the database, since it avoids maintaining a second serverless runtime just for one webhook.

What's the most common mistake in a Stripe and Supabase integration?

Writing the webhook handler with `insert` instead of `upsert`, which works in every manual test and then throws a unique-constraint error the first time Stripe retries a delivery. The second most common mistake is matching Stripe customers to Supabase users by email instead of by a `user_id` passed in checkout metadata, which silently breaks for anyone with a changed email or a duplicate Stripe customer object. Both mistakes pass code review and demo cleanly, which is exactly why they ship.