Skip to main content
RapidDev - Software Development Agency
supabase-tutorial

How to Integrate Supabase with Stripe

To integrate Supabase with Stripe, create a Supabase Edge Function that receives Stripe webhooks, verifies the webhook signature, and syncs payment events to your database. Store your Stripe secret key and webhook signing secret as Supabase secrets. Create a subscriptions table to track customer plans, and use database triggers or the Edge Function to update user access based on payment status. This approach keeps sensitive payment logic server-side while your frontend reads subscription data from Supabase.

What you'll learn

  • How to create a Stripe webhook handler as a Supabase Edge Function
  • How to verify Stripe webhook signatures for security
  • How to store subscription data in Supabase and sync with Stripe events
  • How to use RLS policies to control access based on subscription status
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner10 min read20-30 minSupabase (all plans), Stripe API, @supabase/supabase-js v2+, Deno runtimeMarch 2026RapidDev Engineering Team
TL;DR

To integrate Supabase with Stripe, create a Supabase Edge Function that receives Stripe webhooks, verifies the webhook signature, and syncs payment events to your database. Store your Stripe secret key and webhook signing secret as Supabase secrets. Create a subscriptions table to track customer plans, and use database triggers or the Edge Function to update user access based on payment status. This approach keeps sensitive payment logic server-side while your frontend reads subscription data from Supabase.

Building a Stripe Payment Integration with Supabase Edge Functions

Stripe handles payments; Supabase handles your data and auth. The bridge between them is a webhook: Stripe sends payment events to your Supabase Edge Function, which updates your database accordingly. This tutorial walks you through the complete setup: creating the database schema for subscriptions, building a webhook Edge Function, verifying Stripe signatures, and reading subscription status from your frontend with proper RLS policies.

Prerequisites

  • A Supabase project with Authentication configured
  • A Stripe account with API keys (Dashboard > Developers > API keys)
  • Supabase CLI installed for Edge Function development
  • Basic understanding of webhooks and async event processing

Step-by-step guide

1

Create the subscriptions table and RLS policies

Create a table to store customer subscription data synced from Stripe. Link it to auth.users so each subscription belongs to a Supabase user. Enable RLS so users can only read their own subscription data. The Edge Function will use the service role key to write subscription data, bypassing RLS for server-side operations.

typescript
1-- Create the subscriptions table
2create table public.subscriptions (
3 id uuid primary key default gen_random_uuid(),
4 user_id uuid references auth.users(id) on delete cascade not null,
5 stripe_customer_id text unique,
6 stripe_subscription_id text unique,
7 plan text not null default 'free',
8 status text not null default 'inactive',
9 current_period_end timestamptz,
10 created_at timestamptz default now(),
11 updated_at timestamptz default now()
12);
13
14-- Enable RLS
15alter table public.subscriptions enable row level security;
16
17-- Users can read their own subscription
18create policy "Users can view own subscription"
19on public.subscriptions for select
20to authenticated
21using (auth.uid() = user_id);
22
23-- Only service role can insert/update (Edge Function)
24-- No INSERT/UPDATE policies for authenticated role
25-- The Edge Function uses SUPABASE_SERVICE_ROLE_KEY which bypasses RLS
26
27-- Create index for fast lookups
28create index idx_subscriptions_user_id on public.subscriptions(user_id);
29create index idx_subscriptions_stripe_customer on public.subscriptions(stripe_customer_id);

Expected result: The subscriptions table is created with RLS policies that allow users to read but not modify their subscription.

2

Store Stripe secrets in Supabase

Set your Stripe secret key and webhook signing secret as Supabase secrets. These are available inside Edge Functions as environment variables. Never hardcode these values in your function code. The webhook signing secret is used to verify that incoming webhooks actually came from Stripe.

typescript
1# Get your keys from Stripe Dashboard > Developers > API keys
2# Webhook signing secret from Stripe Dashboard > Developers > Webhooks > endpoint > Signing secret
3
4# Set secrets in Supabase
5supabase secrets set STRIPE_SECRET_KEY=sk_live_your_key_here
6supabase secrets set STRIPE_WEBHOOK_SIGNING_SECRET=whsec_your_secret_here
7
8# Verify secrets are set
9supabase secrets list
10
11# For local development, add to supabase/functions/.env:
12# STRIPE_SECRET_KEY=sk_test_your_test_key
13# STRIPE_WEBHOOK_SIGNING_SECRET=whsec_your_test_secret

Expected result: Stripe secrets are stored securely and accessible inside Edge Functions.

3

Create the Stripe webhook Edge Function

Create an Edge Function that receives Stripe webhook events, verifies the signature, and processes payment events. The function must be deployed with --no-verify-jwt because Stripe sends unauthenticated HTTP requests. Use the Stripe library to verify the webhook signature, which ensures the request genuinely came from Stripe.

typescript
1// supabase/functions/stripe-webhook/index.ts
2import Stripe from 'npm:stripe@14'
3import { createClient } from 'npm:@supabase/supabase-js@2'
4
5const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {
6 apiVersion: '2024-12-18.acacia',
7})
8
9const corsHeaders = {
10 'Access-Control-Allow-Origin': '*',
11 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, stripe-signature',
12}
13
14Deno.serve(async (req) => {
15 if (req.method === 'OPTIONS') {
16 return new Response('ok', { headers: corsHeaders })
17 }
18
19 try {
20 const body = await req.text()
21 const signature = req.headers.get('stripe-signature')!
22 const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!
23
24 // Verify the webhook signature
25 const event = await stripe.webhooks.constructEventAsync(
26 body,
27 signature,
28 webhookSecret
29 )
30
31 // Create Supabase client with service role (bypasses RLS)
32 const supabase = createClient(
33 Deno.env.get('SUPABASE_URL')!,
34 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
35 )
36
37 console.log('Processing event:', event.type)
38
39 // Handle the event
40 switch (event.type) {
41 case 'checkout.session.completed': {
42 const session = event.data.object as Stripe.Checkout.Session
43 // Handle in next step
44 break
45 }
46 case 'customer.subscription.updated':
47 case 'customer.subscription.deleted': {
48 const subscription = event.data.object as Stripe.Subscription
49 // Handle in next step
50 break
51 }
52 }
53
54 return new Response(JSON.stringify({ received: true }), {
55 headers: { ...corsHeaders, 'Content-Type': 'application/json' },
56 })
57 } catch (err) {
58 console.error('Webhook error:', err.message)
59 return new Response(
60 JSON.stringify({ error: err.message }),
61 { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
62 )
63 }
64})

Expected result: The Edge Function receives and verifies Stripe webhook events.

4

Handle subscription events and sync to database

Add event handlers inside the webhook function to create and update subscription records in your database. The checkout.session.completed event fires when a customer completes payment. The customer.subscription.updated and customer.subscription.deleted events fire when subscriptions change status. Use upsert to handle both new and existing subscriptions.

typescript
1// Add these handlers inside the switch statement:
2
3case 'checkout.session.completed': {
4 const session = event.data.object as Stripe.Checkout.Session
5 const userId = session.metadata?.user_id // Set when creating checkout
6
7 if (userId && session.subscription) {
8 const subscription = await stripe.subscriptions.retrieve(
9 session.subscription as string
10 )
11
12 await supabase.from('subscriptions').upsert({
13 user_id: userId,
14 stripe_customer_id: session.customer as string,
15 stripe_subscription_id: subscription.id,
16 plan: subscription.items.data[0]?.price?.lookup_key || 'pro',
17 status: subscription.status,
18 current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
19 updated_at: new Date().toISOString(),
20 }, { onConflict: 'user_id' })
21
22 console.log('Subscription created for user:', userId)
23 }
24 break
25}
26
27case 'customer.subscription.updated': {
28 const subscription = event.data.object as Stripe.Subscription
29
30 await supabase
31 .from('subscriptions')
32 .update({
33 status: subscription.status,
34 plan: subscription.items.data[0]?.price?.lookup_key || 'pro',
35 current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
36 updated_at: new Date().toISOString(),
37 })
38 .eq('stripe_subscription_id', subscription.id)
39
40 console.log('Subscription updated:', subscription.id)
41 break
42}
43
44case 'customer.subscription.deleted': {
45 const subscription = event.data.object as Stripe.Subscription
46
47 await supabase
48 .from('subscriptions')
49 .update({
50 status: 'canceled',
51 plan: 'free',
52 updated_at: new Date().toISOString(),
53 })
54 .eq('stripe_subscription_id', subscription.id)
55
56 console.log('Subscription canceled:', subscription.id)
57 break
58}

Expected result: Subscription data is automatically synced from Stripe events to your Supabase database.

5

Deploy the webhook and configure Stripe

Deploy the Edge Function with --no-verify-jwt (required because Stripe sends unauthenticated requests), then register the endpoint URL in the Stripe Dashboard as a webhook endpoint. Select the events you want to receive.

typescript
1# Deploy the Edge Function (no JWT verification for webhooks)
2supabase functions deploy stripe-webhook --no-verify-jwt
3
4# The function URL will be:
5# https://your-project-ref.supabase.co/functions/v1/stripe-webhook
6
7# Configure in Stripe Dashboard:
8# 1. Go to Stripe Dashboard > Developers > Webhooks
9# 2. Click 'Add endpoint'
10# 3. URL: https://your-project-ref.supabase.co/functions/v1/stripe-webhook
11# 4. Select events:
12# - checkout.session.completed
13# - customer.subscription.updated
14# - customer.subscription.deleted
15# - invoice.payment_succeeded
16# - invoice.payment_failed
17# 5. Copy the Signing secret (whsec_...)
18# 6. Update your Supabase secret if needed:
19# supabase secrets set STRIPE_WEBHOOK_SIGNING_SECRET=whsec_new_secret

Expected result: The webhook endpoint is deployed and registered in Stripe, receiving payment events.

6

Read subscription status from the frontend

On the frontend, query the subscriptions table to check the user's current plan and subscription status. RLS ensures each user can only see their own subscription. Use this data to conditionally render premium features, show upgrade prompts, or restrict access to paid content.

typescript
1import { createClient } from '@supabase/supabase-js'
2
3const supabase = createClient(
4 'https://your-project.supabase.co',
5 'your-anon-key'
6)
7
8// Check current user's subscription
9async function getSubscription() {
10 const { data: { user } } = await supabase.auth.getUser()
11 if (!user) return null
12
13 const { data, error } = await supabase
14 .from('subscriptions')
15 .select('plan, status, current_period_end')
16 .eq('user_id', user.id)
17 .single()
18
19 if (error || !data) {
20 return { plan: 'free', status: 'inactive', current_period_end: null }
21 }
22
23 return data
24}
25
26// Check if user has active premium
27async function isPremium(): Promise<boolean> {
28 const sub = await getSubscription()
29 return sub?.plan !== 'free' && sub?.status === 'active'
30}
31
32// Usage in React:
33// const subscription = await getSubscription()
34// if (subscription.plan === 'pro') { show premium features }

Expected result: The frontend reads subscription data from Supabase to control access to premium features.

Complete working example

supabase/functions/stripe-webhook/index.ts
1// Supabase Edge Function: Stripe Webhook Handler
2// Deploy: supabase functions deploy stripe-webhook --no-verify-jwt
3
4import Stripe from 'npm:stripe@14'
5import { createClient } from 'npm:@supabase/supabase-js@2'
6
7const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {
8 apiVersion: '2024-12-18.acacia',
9})
10
11const corsHeaders = {
12 'Access-Control-Allow-Origin': '*',
13 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, stripe-signature',
14}
15
16Deno.serve(async (req) => {
17 if (req.method === 'OPTIONS') {
18 return new Response('ok', { headers: corsHeaders })
19 }
20
21 try {
22 // Verify webhook signature
23 const body = await req.text()
24 const signature = req.headers.get('stripe-signature')!
25 const event = await stripe.webhooks.constructEventAsync(
26 body,
27 signature,
28 Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!
29 )
30
31 // Service role client bypasses RLS for server-side writes
32 const supabase = createClient(
33 Deno.env.get('SUPABASE_URL')!,
34 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
35 )
36
37 switch (event.type) {
38 case 'checkout.session.completed': {
39 const session = event.data.object as Stripe.Checkout.Session
40 const userId = session.metadata?.user_id
41 if (userId && session.subscription) {
42 const sub = await stripe.subscriptions.retrieve(session.subscription as string)
43 await supabase.from('subscriptions').upsert({
44 user_id: userId,
45 stripe_customer_id: session.customer as string,
46 stripe_subscription_id: sub.id,
47 plan: sub.items.data[0]?.price?.lookup_key || 'pro',
48 status: sub.status,
49 current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
50 updated_at: new Date().toISOString(),
51 }, { onConflict: 'user_id' })
52 }
53 break
54 }
55 case 'customer.subscription.updated': {
56 const sub = event.data.object as Stripe.Subscription
57 await supabase.from('subscriptions').update({
58 status: sub.status,
59 plan: sub.items.data[0]?.price?.lookup_key || 'pro',
60 current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
61 updated_at: new Date().toISOString(),
62 }).eq('stripe_subscription_id', sub.id)
63 break
64 }
65 case 'customer.subscription.deleted': {
66 const sub = event.data.object as Stripe.Subscription
67 await supabase.from('subscriptions').update({
68 status: 'canceled',
69 plan: 'free',
70 updated_at: new Date().toISOString(),
71 }).eq('stripe_subscription_id', sub.id)
72 break
73 }
74 }
75
76 return new Response(JSON.stringify({ received: true }), {
77 headers: { ...corsHeaders, 'Content-Type': 'application/json' },
78 })
79 } catch (err) {
80 return new Response(
81 JSON.stringify({ error: (err as Error).message }),
82 { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
83 )
84 }
85})

Common mistakes when integrating Supabase with Stripe

Why it's a problem: Not deploying the webhook function with --no-verify-jwt, causing Stripe webhooks to be rejected with 401

How to avoid: Deploy with: supabase functions deploy stripe-webhook --no-verify-jwt. Stripe sends unauthenticated HTTP requests, so JWT verification must be disabled for webhook endpoints.

Why it's a problem: Creating INSERT/UPDATE RLS policies on the subscriptions table for the authenticated role, allowing users to fake premium status

How to avoid: Only the Edge Function (using SUPABASE_SERVICE_ROLE_KEY) should write to the subscriptions table. Create only a SELECT policy for the authenticated role.

Why it's a problem: Not verifying the Stripe webhook signature, making the endpoint vulnerable to forged requests

How to avoid: Always verify the signature using stripe.webhooks.constructEventAsync() with your webhook signing secret. This ensures the event actually came from Stripe.

Why it's a problem: Forgetting to pass user_id as metadata when creating Stripe Checkout Sessions

How to avoid: Include metadata: { user_id: supabaseUserId } when creating Checkout Sessions. Without this, you cannot link the Stripe customer to your Supabase user.

Best practices

  • Always verify Stripe webhook signatures to prevent forged events from modifying your database
  • Use the service role key in the Edge Function to bypass RLS for server-side subscription writes
  • Create only SELECT policies for the authenticated role on the subscriptions table — never allow client-side writes
  • Pass user_id as metadata when creating Stripe Checkout Sessions to link Stripe customers to Supabase users
  • Handle idempotency — Stripe may send the same webhook event multiple times, so use upsert instead of insert
  • Return a 200 response quickly and process heavy logic asynchronously to avoid Stripe timeout retries
  • Use Stripe test mode and Stripe CLI for local development before going live
  • Log all webhook events with event type and relevant IDs for debugging payment issues

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I need to integrate Stripe subscriptions with my Supabase project. Show me how to create a Supabase Edge Function that handles Stripe webhooks, verifies signatures, and updates a subscriptions table. Include the SQL for the table with RLS policies, the Deno Edge Function code, and how to create a Stripe Checkout Session from my Next.js frontend.

Supabase Prompt

Build a complete Stripe integration for my Supabase project: create a subscriptions table with RLS, write an Edge Function webhook handler that syncs checkout.session.completed and subscription lifecycle events, and show me how to check subscription status from the frontend to gate premium features.

Frequently asked questions

Why do I need an Edge Function for Stripe instead of handling payments on the frontend?

Stripe webhook verification requires your secret key, which must never be exposed in browser code. The Edge Function receives webhooks server-side, verifies signatures, and writes to your database using the service role key — all securely on the server.

How do I test Stripe webhooks locally?

Use the Stripe CLI: stripe listen --forward-to localhost:54321/functions/v1/stripe-webhook. This forwards test webhook events to your local Edge Function. Run supabase functions serve in another terminal.

Can I use Stripe Checkout with Supabase?

Yes. Create a Stripe Checkout Session from an Edge Function, passing the Supabase user_id as metadata. Redirect the user to the Checkout URL. When payment completes, Stripe sends a checkout.session.completed webhook to your Edge Function.

What if a webhook event is delivered multiple times?

Stripe may retry failed webhooks up to 3 days. Use upsert with onConflict to handle duplicate events safely. Check event.type and subscription status before making changes to avoid incorrect state transitions.

How do I handle failed payments?

Listen for the invoice.payment_failed event in your webhook. Update the subscription status to 'past_due' in your database. Show a banner to the user asking them to update their payment method. Stripe sends customer.subscription.deleted if payment is not resolved.

Can I restrict database access based on subscription status?

Yes. Write RLS policies that check the subscriptions table. For example: using (exists (select 1 from subscriptions where user_id = auth.uid() and plan = 'pro' and status = 'active')). This restricts table access to premium subscribers.

Can RapidDev help build a Stripe integration for my Supabase project?

Yes. RapidDev can build complete payment integrations including Stripe Checkout flows, webhook handlers, subscription management, and access control using Supabase Edge Functions and RLS.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.