If you're building a SaaS on self-hosted Supabase, you'll eventually need to handle payments. Stripe is the go-to choice for most developers, but integrating it with a self-hosted backend requires different approaches than Supabase Cloud's managed environment. There's no one-click integration—you need to set up webhook handling, sync subscription data, and ensure your Row Level Security policies gate features based on payment status.
This guide walks through the complete integration: from configuring Edge Functions to handle Stripe webhooks, to syncing subscription data with your database, to implementing feature gating with RLS. Along the way, we'll cover the pain points the community has encountered and the patterns that work reliably in production.
Why Payment Integration Requires Extra Work on Self-Hosted
On Supabase Cloud, you can use Supabase's built-in infrastructure and follow their official guides directly. Self-hosting adds complexity:
- Edge Functions need explicit configuration - They run differently than on the managed platform
- Webhook URLs must be publicly accessible - Your self-hosted instance needs a stable, SSL-secured endpoint
- No managed secrets - You handle Stripe API keys and webhook secrets manually
- Database sync is your responsibility - There's no automatic Stripe-to-database sync
The trade-off for this extra work is complete control over your payment infrastructure. You can customize webhook handling, add complex business logic, and avoid per-seat pricing that multiplies with your customer base.
Architecture Overview
Before diving into implementation, understand the data flow:
- Customer initiates checkout → Stripe Checkout session created
- Payment succeeds → Stripe fires webhook to your Edge Function
- Edge Function validates and processes → Inserts/updates your
subscriptionstable - RLS policies check subscription status → Features are gated accordingly
This architecture powers thousands of SaaS products. The key is making each step reliable and debuggable.
Setting Up the Database Schema
Start with a schema that tracks what you need without over-engineering. Create a 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, status text not null default 'inactive', price_id text, current_period_end timestamptz, cancel_at_period_end boolean default false, created_at timestamptz default now(), updated_at timestamptz default now() ); -- Index for common queries create index idx_subscriptions_user_id on public.subscriptions(user_id); create index idx_subscriptions_stripe_customer_id on public.subscriptions(stripe_customer_id); -- Automatic updated_at trigger create or replace function update_updated_at_column() returns trigger as $$ begin new.updated_at = now(); return new; end; $$ language plpgsql; create trigger update_subscriptions_updated_at before update on public.subscriptions for each row execute function update_updated_at_column();
The status field maps to Stripe's subscription statuses: active, canceled, past_due, trialing, etc. Storing current_period_end lets you show users when their subscription renews.
Row Level Security for Subscriptions
Users should only see their own subscription data:
alter table public.subscriptions enable row level security; create policy "Users can view their own subscription" on public.subscriptions for select using (auth.uid() = user_id); -- Only service role can insert/update (via Edge Functions) create policy "Service role can manage subscriptions" on public.subscriptions for all using (auth.role() = 'service_role');
This pattern ensures customers can't tamper with subscription data while your backend maintains full control.
Configuring Edge Functions for Webhook Handling
If you haven't set up Edge Functions yet, follow the Edge Functions setup guide first. For payment webhooks, you need a function that:
- Verifies the Stripe signature
- Parses the event type
- Updates your database accordingly
Create a function at supabase/functions/stripe-webhook/index.ts:
import { serve } from 'https://deno.land/[email protected]/http/server.ts'
import Stripe from 'https://esm.sh/[email protected]?target=deno'
import { createClient } from 'https://esm.sh/@supabase/[email protected]'
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {
apiVersion: '2024-11-20.acacia',
httpClient: Stripe.createFetchHttpClient(),
})
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
serve(async (req) => {
const signature = req.headers.get('stripe-signature')
if (!signature) {
return new Response('Missing signature', { status: 400 })
}
const body = await req.text()
let event: Stripe.Event
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature,
Deno.env.get('STRIPE_WEBHOOK_SECRET')!
)
} catch (err) {
console.error('Webhook signature verification failed:', err)
return new Response('Invalid signature', { status: 400 })
}
// Handle specific events
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object as Stripe.Checkout.Session)
break
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
await handleSubscriptionChange(event.data.object as Stripe.Subscription)
break
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object as Stripe.Invoice)
break
}
return new Response(JSON.stringify({ received: true }), {
headers: { 'Content-Type': 'application/json' },
})
})
Handling Checkout Completion
When a customer completes checkout, link their Stripe customer ID to your user:
async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
const userId = session.client_reference_id
if (!userId) {
console.error('No client_reference_id in checkout session')
return
}
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
)
const { error } = await supabase.from('subscriptions').upsert({
user_id: userId,
stripe_customer_id: session.customer as string,
stripe_subscription_id: subscription.id,
status: subscription.status,
price_id: subscription.items.data[0]?.price.id,
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
cancel_at_period_end: subscription.cancel_at_period_end,
}, {
onConflict: 'user_id'
})
if (error) {
console.error('Failed to upsert subscription:', error)
throw error
}
}
Handling Subscription Changes
Stripe sends customer.subscription.updated for renewals, plan changes, and cancellations:
async function handleSubscriptionChange(subscription: Stripe.Subscription) {
const { error } = await supabase
.from('subscriptions')
.update({
status: subscription.status,
price_id: subscription.items.data[0]?.price.id,
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
cancel_at_period_end: subscription.cancel_at_period_end,
})
.eq('stripe_subscription_id', subscription.id)
if (error) {
console.error('Failed to update subscription:', error)
throw error
}
}
Environment Variables and Secrets
For self-hosted deployments, add these to your .env file:
STRIPE_SECRET_KEY=sk_live_xxxxx STRIPE_WEBHOOK_SECRET=whsec_xxxxx
Then configure your Edge Functions to access them. If you're managing secrets with a tool like Vault, ensure your functions container has access.
For production, never commit secrets to version control. Use environment variable management to inject them at runtime.
Exposing Your Webhook Endpoint
Your self-hosted Supabase needs a publicly accessible URL for Stripe to reach. If you've configured custom domains, your webhook URL will be:
https://api.yourdomain.com/functions/v1/stripe-webhook
In the Stripe Dashboard:
- Go to Developers → Webhooks
- Click Add endpoint
- Enter your function URL
- Select events:
checkout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.payment_failed - Copy the webhook signing secret to your environment variables
Testing Webhooks Locally
During development, use the Stripe CLI to forward webhooks to your local instance:
stripe listen --forward-to localhost:54321/functions/v1/stripe-webhook
The CLI provides a temporary webhook secret for local testing. Remember to switch to your production secret before deploying.
Creating Checkout Sessions
On your frontend, create checkout sessions through a server-side endpoint or Edge Function:
// supabase/functions/create-checkout/index.ts
import { serve } from 'https://deno.land/[email protected]/http/server.ts'
import Stripe from 'https://esm.sh/[email protected]?target=deno'
import { createClient } from 'https://esm.sh/@supabase/[email protected]'
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {
apiVersion: '2024-11-20.acacia',
httpClient: Stripe.createFetchHttpClient(),
})
serve(async (req) => {
// Get user from JWT
const authHeader = req.headers.get('Authorization')
if (!authHeader) {
return new Response('Unauthorized', { status: 401 })
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{ global: { headers: { Authorization: authHeader } } }
)
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) {
return new Response('Unauthorized', { status: 401 })
}
const { priceId, successUrl, cancelUrl } = await req.json()
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: successUrl,
cancel_url: cancelUrl,
client_reference_id: user.id, // Links Stripe to your user
customer_email: user.email,
})
return new Response(JSON.stringify({ url: session.url }), {
headers: { 'Content-Type': 'application/json' },
})
})
Feature Gating with RLS
Now that subscription data lives in your database, use RLS to gate premium features. For a premium_content table:
create policy "Premium users can access premium content"
on public.premium_content for select
using (
exists (
select 1 from public.subscriptions
where subscriptions.user_id = auth.uid()
and subscriptions.status = 'active'
)
);
This pattern is powerful: the database itself enforces payment status. No application code can bypass it unless using the service role key.
Checking Subscription in Application Code
For UI logic (showing upgrade prompts, hiding features), query the subscription:
const { data: subscription } = await supabase
.from('subscriptions')
.select('status, current_period_end')
.eq('user_id', user.id)
.single()
const isPremium = subscription?.status === 'active'
The Stripe Sync Engine Alternative
For more comprehensive Stripe data syncing, consider Supabase's stripe-sync-engine. It's a separate service that:
- Creates a
stripeschema in your database - Syncs all Stripe objects (customers, subscriptions, invoices, products, prices)
- Handles webhooks automatically
- Provides a ready-to-use Docker image
This approach is ideal if you need to query Stripe data extensively—for analytics, billing history, or complex pricing logic. The trade-off is running another service alongside your Supabase stack.
Handling Edge Cases
Payment integrations have many failure modes. Plan for these:
Failed Payments
When invoice.payment_failed fires, update the subscription status and notify the user:
async function handlePaymentFailed(invoice: Stripe.Invoice) {
if (!invoice.subscription) return
await supabase
.from('subscriptions')
.update({ status: 'past_due' })
.eq('stripe_subscription_id', invoice.subscription)
// Optionally trigger notification via database webhook or pg_net
}
Webhook Idempotency
Stripe may send the same webhook multiple times. Your handler should be idempotent—processing the same event twice shouldn't cause issues. Using upsert with conflict handling ensures this.
Reconciliation Jobs
Webhooks can fail or be missed. Run a periodic reconciliation job that syncs subscription status directly from Stripe's API:
// Run daily via pg_cron or external scheduler
async function reconcileSubscriptions() {
const { data: subscriptions } = await supabase
.from('subscriptions')
.select('stripe_subscription_id')
.not('stripe_subscription_id', 'is', null)
for (const sub of subscriptions || []) {
const stripeSub = await stripe.subscriptions.retrieve(sub.stripe_subscription_id)
await supabase
.from('subscriptions')
.update({ status: stripeSub.status })
.eq('stripe_subscription_id', sub.stripe_subscription_id)
}
}
Simplifying the Stack with Supascale
Managing Edge Functions, environment variables, and webhook endpoints across multiple projects gets tedious. Supascale streamlines this by providing:
- Centralized secret management - Configure Stripe keys once, deploy across projects
- Custom domain support - Stable webhook URLs with automatic SSL
- Project templates - Pre-configured stacks with common integrations
For teams running multiple self-hosted Supabase instances, this reduces the operational overhead significantly. Check the pricing page to see if it fits your scale.
Conclusion
Integrating Stripe with self-hosted Supabase requires more setup than managed platforms, but the result is a payment system you fully control. You've learned to:
- Structure your database schema for subscription tracking
- Handle webhooks securely with Edge Functions
- Gate features using Row Level Security
- Plan for edge cases and failures
The combination of PostgreSQL's reliability, RLS's security model, and Stripe's payment infrastructure creates a solid foundation for any SaaS. Start with the basic webhook handler, verify it works in production, then expand to handle more complex scenarios as your product grows.
