# How to Integrate Supabase with Stripe

- Tool: Supabase
- Difficulty: Beginner
- Time required: 20-30 min
- Compatibility: Supabase (all plans), Stripe API, @supabase/supabase-js v2+, Deno runtime
- Last updated: March 2026

## 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.

## Before you start

- 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.

```
-- Create the subscriptions table
create table public.subscriptions (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  stripe_customer_id text unique,
  stripe_subscription_id text unique,
  plan text not null default 'free',
  status text not null default 'inactive',
  current_period_end timestamptz,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- Enable RLS
alter table public.subscriptions enable row level security;

-- Users can read their own subscription
create policy "Users can view own subscription"
on public.subscriptions for select
to authenticated
using (auth.uid() = user_id);

-- Only service role can insert/update (Edge Function)
-- No INSERT/UPDATE policies for authenticated role
-- The Edge Function uses SUPABASE_SERVICE_ROLE_KEY which bypasses RLS

-- Create index for fast lookups
create index idx_subscriptions_user_id on public.subscriptions(user_id);
create 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.

```
# Get your keys from Stripe Dashboard > Developers > API keys
# Webhook signing secret from Stripe Dashboard > Developers > Webhooks > endpoint > Signing secret

# Set secrets in Supabase
supabase secrets set STRIPE_SECRET_KEY=sk_live_your_key_here
supabase secrets set STRIPE_WEBHOOK_SIGNING_SECRET=whsec_your_secret_here

# Verify secrets are set
supabase secrets list

# For local development, add to supabase/functions/.env:
# STRIPE_SECRET_KEY=sk_test_your_test_key
# 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.

```
// supabase/functions/stripe-webhook/index.ts
import Stripe from 'npm:stripe@14'
import { createClient } from 'npm:@supabase/supabase-js@2'

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {
  apiVersion: '2024-12-18.acacia',
})

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, stripe-signature',
}

Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }

  try {
    const body = await req.text()
    const signature = req.headers.get('stripe-signature')!
    const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!

    // Verify the webhook signature
    const event = await stripe.webhooks.constructEventAsync(
      body,
      signature,
      webhookSecret
    )

    // Create Supabase client with service role (bypasses RLS)
    const supabase = createClient(
      Deno.env.get('SUPABASE_URL')!,
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
    )

    console.log('Processing event:', event.type)

    // Handle the event
    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object as Stripe.Checkout.Session
        // Handle in next step
        break
      }
      case 'customer.subscription.updated':
      case 'customer.subscription.deleted': {
        const subscription = event.data.object as Stripe.Subscription
        // Handle in next step
        break
      }
    }

    return new Response(JSON.stringify({ received: true }), {
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
    })
  } catch (err) {
    console.error('Webhook error:', err.message)
    return new Response(
      JSON.stringify({ error: err.message }),
      { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    )
  }
})
```

**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.

```
// Add these handlers inside the switch statement:

case 'checkout.session.completed': {
  const session = event.data.object as Stripe.Checkout.Session
  const userId = session.metadata?.user_id // Set when creating checkout

  if (userId && session.subscription) {
    const subscription = await stripe.subscriptions.retrieve(
      session.subscription as string
    )

    await supabase.from('subscriptions').upsert({
      user_id: userId,
      stripe_customer_id: session.customer as string,
      stripe_subscription_id: subscription.id,
      plan: subscription.items.data[0]?.price?.lookup_key || 'pro',
      status: subscription.status,
      current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
      updated_at: new Date().toISOString(),
    }, { onConflict: 'user_id' })

    console.log('Subscription created for user:', userId)
  }
  break
}

case 'customer.subscription.updated': {
  const subscription = event.data.object as Stripe.Subscription

  await supabase
    .from('subscriptions')
    .update({
      status: subscription.status,
      plan: subscription.items.data[0]?.price?.lookup_key || 'pro',
      current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
      updated_at: new Date().toISOString(),
    })
    .eq('stripe_subscription_id', subscription.id)

  console.log('Subscription updated:', subscription.id)
  break
}

case 'customer.subscription.deleted': {
  const subscription = event.data.object as Stripe.Subscription

  await supabase
    .from('subscriptions')
    .update({
      status: 'canceled',
      plan: 'free',
      updated_at: new Date().toISOString(),
    })
    .eq('stripe_subscription_id', subscription.id)

  console.log('Subscription canceled:', subscription.id)
  break
}
```

**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.

```
# Deploy the Edge Function (no JWT verification for webhooks)
supabase functions deploy stripe-webhook --no-verify-jwt

# The function URL will be:
# https://your-project-ref.supabase.co/functions/v1/stripe-webhook

# Configure in Stripe Dashboard:
# 1. Go to Stripe Dashboard > Developers > Webhooks
# 2. Click 'Add endpoint'
# 3. URL: https://your-project-ref.supabase.co/functions/v1/stripe-webhook
# 4. Select events:
#    - checkout.session.completed
#    - customer.subscription.updated
#    - customer.subscription.deleted
#    - invoice.payment_succeeded
#    - invoice.payment_failed
# 5. Copy the Signing secret (whsec_...)
# 6. Update your Supabase secret if needed:
#    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.

```
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  'https://your-project.supabase.co',
  'your-anon-key'
)

// Check current user's subscription
async function getSubscription() {
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return null

  const { data, error } = await supabase
    .from('subscriptions')
    .select('plan, status, current_period_end')
    .eq('user_id', user.id)
    .single()

  if (error || !data) {
    return { plan: 'free', status: 'inactive', current_period_end: null }
  }

  return data
}

// Check if user has active premium
async function isPremium(): Promise<boolean> {
  const sub = await getSubscription()
  return sub?.plan !== 'free' && sub?.status === 'active'
}

// Usage in React:
// const subscription = await getSubscription()
// if (subscription.plan === 'pro') { show premium features }
```

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

## Complete code example

File: `supabase/functions/stripe-webhook/index.ts`

```typescript
// Supabase Edge Function: Stripe Webhook Handler
// Deploy: supabase functions deploy stripe-webhook --no-verify-jwt

import Stripe from 'npm:stripe@14'
import { createClient } from 'npm:@supabase/supabase-js@2'

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {
  apiVersion: '2024-12-18.acacia',
})

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, stripe-signature',
}

Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }

  try {
    // Verify webhook signature
    const body = await req.text()
    const signature = req.headers.get('stripe-signature')!
    const event = await stripe.webhooks.constructEventAsync(
      body,
      signature,
      Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!
    )

    // Service role client bypasses RLS for server-side writes
    const supabase = createClient(
      Deno.env.get('SUPABASE_URL')!,
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
    )

    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object as Stripe.Checkout.Session
        const userId = session.metadata?.user_id
        if (userId && session.subscription) {
          const sub = await stripe.subscriptions.retrieve(session.subscription as string)
          await supabase.from('subscriptions').upsert({
            user_id: userId,
            stripe_customer_id: session.customer as string,
            stripe_subscription_id: sub.id,
            plan: sub.items.data[0]?.price?.lookup_key || 'pro',
            status: sub.status,
            current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
            updated_at: new Date().toISOString(),
          }, { onConflict: 'user_id' })
        }
        break
      }
      case 'customer.subscription.updated': {
        const sub = event.data.object as Stripe.Subscription
        await supabase.from('subscriptions').update({
          status: sub.status,
          plan: sub.items.data[0]?.price?.lookup_key || 'pro',
          current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
          updated_at: new Date().toISOString(),
        }).eq('stripe_subscription_id', sub.id)
        break
      }
      case 'customer.subscription.deleted': {
        const sub = event.data.object as Stripe.Subscription
        await supabase.from('subscriptions').update({
          status: 'canceled',
          plan: 'free',
          updated_at: new Date().toISOString(),
        }).eq('stripe_subscription_id', sub.id)
        break
      }
    }

    return new Response(JSON.stringify({ received: true }), {
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
    })
  } catch (err) {
    return new Response(
      JSON.stringify({ error: (err as Error).message }),
      { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    )
  }
})
```

## Common mistakes

- **Not deploying the webhook function with --no-verify-jwt, causing Stripe webhooks to be rejected with 401** — undefined Fix: Deploy with: supabase functions deploy stripe-webhook --no-verify-jwt. Stripe sends unauthenticated HTTP requests, so JWT verification must be disabled for webhook endpoints.
- **Creating INSERT/UPDATE RLS policies on the subscriptions table for the authenticated role, allowing users to fake premium status** — undefined Fix: Only the Edge Function (using SUPABASE_SERVICE_ROLE_KEY) should write to the subscriptions table. Create only a SELECT policy for the authenticated role.
- **Not verifying the Stripe webhook signature, making the endpoint vulnerable to forged requests** — undefined Fix: Always verify the signature using stripe.webhooks.constructEventAsync() with your webhook signing secret. This ensures the event actually came from Stripe.
- **Forgetting to pass user_id as metadata when creating Stripe Checkout Sessions** — undefined Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/supabase-tutorial/how-to-integrate-supabase-with-stripe
© RapidDev — https://www.rapidevelopers.com/supabase-tutorial/how-to-integrate-supabase-with-stripe
