Skip to content
Tech Stack18 July 2026 · 7 min readUpdated 30 September 2026

Stripe + Supabase: The Pattern for SaaS Subscriptions in 2026

Stripe can deliver the same webhook more than once and out of order. Here is the Supabase pattern that keeps subscription state correct.

Stripe + Supabase: The Pattern for SaaS Subscriptions in 2026

Consider a two-person team whose Supabase subscriptions table still says "active" for a customer who cancelled three days ago. Stripe billed correctly and the dashboard looks fine, but the database was never told. That's a hypothetical, and it's the failure this pattern prevents. The first action: open your subscriptions table and check whether every column can be recomputed from the last webhook event you received.

A working Stripe Supabase subscription setup has four parts: a Stripe webhook handler that verifies every event, a Postgres table that holds plan state, row-level security policies that gate access by that state, and a customer portal so users manage billing without a UI you build yourself.

How do you keep Stripe subscription state in sync with Supabase?

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

Write subscription state into a Postgres table from one place only, your webhook handler, and let every other part of the app read that table instead of calling Stripe on each page load.

Stripe's own subscription webhook guide describes the same shape: you store an access-expiration value per customer, Stripe sends events as the subscription changes, and your app acts on them. Two rules from that page matter most. After invoice.paid, retrieve the subscription and confirm its status is active before extending access. When the status becomes canceled or unpaid, revoke access. The Supabase Edge Function webhook example is the reference implementation for the receiving side.

If the portal, the app UI and your RLS policies all read one row, they can't disagree. If each calls Stripe separately, they eventually will, usually during a plan change or a failed payment retry. That's my reasoning, not a Stripe statement.

How do you link a Stripe customer to a Supabase user?

Pass your Supabase user ID when you create the Checkout Session, then read it back in the webhook. Stripe's Checkout Session API describes client_reference_id as a unique string, "a customer ID, a cart ID, or similar", used to "reconcile the session with your internal systems", and it also accepts metadata key-value pairs. Matching by email is the fragile alternative: emails change, and a person can end up with two Stripe customers. That last point is my recommendation.

Should the Stripe webhook run in a Supabase Edge Function or a Next.js Route Handler?

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 works, so choose by where the rest of your backend already runs. Stripe doesn't require a specific runtime, only that you verify the signature against the raw request body and return a 2xx quickly.

DimensionSupabase Edge FunctionNext.js Route Handler
Signature checkOfficial example uses constructEventAsync with Stripe.createSubtleCryptoProvider()Your Node runtime's Stripe SDK; keep the body raw
AuthSet verify_jwt = false for the function, then rely on the Stripe signatureNo Supabase JWT involved
DeploySeparate from your appShips with every app deploy
Limits2 s CPU time per request, 150 s wall clock on the Free plan, 400 s on paid plansWhatever your host allows
Best fitSupabase-first stacks, billing isolated from app codeNext.js-first stacks that already use Route Handlers

Those details come from primary pages. The example's source reads the body with req.text() and verifies with STRIPE_WEBHOOK_SIGNING_SECRET. Supabase's function auth docs say to disable JWT verification for webhooks because providers like Stripe don't send Supabase credentials and sign their own requests, so you verify that signature inside the function. The limits page lists the CPU and wall-clock figures. Stripe's webhook docs add that "Stripe requires the raw body of the request", and any framework that touches the body breaks verification.

If you already run other Supabase Edge Functions, keeping billing there avoids a second serverless environment. If Supabase is mainly your database, a Route Handler is one fewer deploy target.

Why does a plain insert break when Stripe retries a webhook?

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

Because Stripe can deliver the same event more than once, an insert that passes every manual test will hit a unique-constraint error on a duplicate delivery. Write with upsert instead.

Stripe's docs are explicit under best practices: endpoints "might occasionally receive the same event more than once", and you guard against that by logging processed event IDs. Two more behaviours from the same page shape your schema. In live mode Stripe retries for up to three days with exponential backoff, and it doesn't guarantee delivery in the order events were generated, so a handler that assumes customer.subscription.created arrives first will eventually break.

The practical response, which is my recommendation, has two layers. Upsert the subscription row keyed on the Stripe subscription ID, so a repeated event rewrites the same values. When an event's data is ambiguous or out of order, fetch the current subscription from Stripe and write that, because Stripe's docs say you can retrieve missing objects through the API.

Build the handler in this order:

  1. Register the endpoint in Stripe and store its whsec_ signing secret in your environment.
  2. Read the raw body, not parsed JSON.
  3. Verify the signature before touching the database. On failure, return a 4xx and stop.
  4. Switch on the event types you need: customer.subscription.updated, customer.subscription.deleted, invoice.paid, invoice.payment_failed, plus checkout.session.completed if you use Checkout.
  5. Upsert the row, keyed on the Stripe subscription ID, and record the event ID.
  6. Return 200 immediately. Stripe says to return success before any complex logic that might time out.

Skip step 3 and anyone can POST to your endpoint and flip an account to paid. verify_jwt = false is only safe because the signature check inside the function is mandatory.

Test the duplicate path before launch by replaying a real event. Stripe's docs say the Dashboard's Resend button works for up to 15 days after an event is created, and the Stripe CLI's stripe events resend command works for up to 30 days. Your handler should finish in the same state both times.

Does row-level security slow down subscription checks?

Yes, if a policy calls auth.uid() bare, because Postgres can evaluate it for every row, and wrapping it as (select auth.uid()) lets the planner compute it once per statement.

Supabase's RLS performance guide, read on 2026-09-30, benchmarks the change: one test dropped from 179 ms to 9 ms, about 20x, and a combined is_admin() policy test dropped from 11,000 ms to 10 ms. The guide notes the trick only applies when the result doesn't depend on the row. Supabase's database linter also has a check for it, lint 0003, described as a policy "running its check on every single row instead of once per query". Supabase's RLS docs add advice to index every column your policies filter on.

A plan_tier check on a big table looks fine at ten customers and can turn into a latency cost later. Nothing in a green test suite will say which one you're in, so run the linter. For the multi-tenant side of this, see the Supabase RLS multi-tenant post. Webhook handlers using the service-role key bypass RLS by design, per Supabase's docs, so don't debug RLS errors there.

What changed with thin events?

Stripe now offers thin events, compact notifications where you fetch the details you need, and its migration guide says thin events for API v1 resources are in private preview as of 2026-09-30. Previously they covered only v2 resources. Most subscription flows still run on snapshot events, and the guide advises against running both handlers indefinitely. I'm not quoting a payload-size figure because I couldn't confirm one on Stripe's pages this run.

Should you build your own billing screen?

No, start with the customer portal. Stripe's customer portal docs list updating payment methods, switching or cancelling subscriptions, and downloading invoices. Read its limits before you rely on it: subscriptions with multiple products or usage-based billing can be cancelled but not updated there, a new portal session expires after 5 minutes if the customer doesn't open it, and the portal can't be shown inside an iframe. Write the portal launcher as a server action that creates a session for the stored customer ID, then let your webhook record whatever changed.

What do the case studies show, and what don't they?

None of my projects is a Stripe plus Supabase subscription product, so this pattern is documented, not shipped by me. BookBed is Flutter, Firebase and Stripe. Its case study describes Stripe LIVE mode, webhook handlers, and a cross-tab flow where a BroadcastChannel gives an instant signal and Firestore, written by the webhook, stays authoritative. It's the same problem on a different database. Pizzeria Bestek is React with Supabase, three Edge Functions and Row-Level Security on all tables, so it covers the Supabase half without Stripe.

So which pieces do you need on day one? The webhook handler and the table, with upsert and checkout-time user linking from the start. Plain RLS can come first and get the select wrapper once row counts justify it, and the portal can wait a week. The Supabase and Stripe stack guide and the SaaS MVP stack cover the rest.

Now open the table again. If you replayed your last ten Stripe events against it, would it end in the same state?

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 using AI-augmented workflows — without agency coordination overhead. 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 to false for it, and verify the Stripe signature inside the function before touching your database. Supabase's official example reads the raw body with req.text() and verifies it with constructEventAsync and Stripe.createSubtleCryptoProvider(), using STRIPE_WEBHOOK_SIGNING_SECRET. Stripe requires the raw request body for verification, so do not parse JSON first. Return a 2xx quickly, then write the result with an upsert keyed on the subscription ID, because Stripe may deliver the same event more than once.

How should I store SaaS subscription state in Supabase?

Keep one Postgres table as the source of truth and write to it only from your webhook handler. Every other surface, including the app UI and your RLS policies, should read from that table instead of calling Stripe directly. Stripe's subscription webhook guide says to retrieve the subscription after invoice.paid and confirm it is active before extending access, and to revoke access when the status becomes canceled or unpaid. Link the Stripe customer to your user by passing your user ID as client_reference_id or metadata at checkout, rather than matching emails.

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 auth.uid() as (select auth.uid()) so Postgres evaluates it once per statement instead of once per row. Supabase's RLS performance guide measured one test dropping from 179 ms to 9 ms, about 20x, and its database linter has a check for the unwrapped form. Index the columns your policies filter on, and remember that the service role bypasses RLS.

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

Either works, so let the location of the rest of your backend decide. Stripe only needs a verified signature over the raw body and a fast 2xx response. Choose an Edge Function if you already run other Supabase functions and want billing isolated from your app's deploys. Choose a Route Handler if your stack is Next.js-first and Supabase is mainly the database. Supabase lists 2 seconds of CPU time per request and a wall-clock limit of 150 seconds on Free and 400 seconds on paid plans, so keep the handler short and defer heavy work.

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

Writing the webhook handler with insert instead of upsert, which passes every manual test and then fails when Stripe delivers an event twice. Stripe's docs say endpoints might occasionally receive the same event more than once, that live-mode retries continue for up to three days, and that delivery order is not guaranteed. Track processed event IDs, upsert on the subscription ID, and fetch the current subscription from Stripe when an event looks stale or out of order.