Stripe + Supabase: recurring billing without turning into a subscription SaaS
I’ve turned down more clients because of bad billing setups than because of high pricing. Client pays and the system lets them in for free. Client cancels and keeps having access. Client gets charged twice and nobody notices. Recurring billing looks simple until you actually have to build it.
This post is the setup I’ve been using for two years on smaller projects, and it hasn’t given me a headache yet. It’s not R$ 2M SaaS architecture. It’s the minimum viable thing that works to bill a real client every month.
The scenario
You’re building a custom system where the client pays a monthly fee. Could be a clinic management tool, a reporting panel for a marketing agency, a members area for a content creator. Ticket runs R$ 49 to R$ 299/month, no plan complications. One plan, monthly or annual recurrence.
Stack: React + Supabase + Stripe. Vercel deploy. Nothing exotic. The goal is to ship working billing in 1-2 days, not 1-2 weeks.
Why Stripe and not Asaas or Mercado Pago
Stripe is global, has impeccable docs, mature SDKs, and a robust webhook system. For Brazilian clients, Asaas is cheaper (lower fees) and supports PIX natively, which is a real advantage. Mercado Pago has reach in Brazil but operational pain.
For this setup, I go with Stripe. If the client requires PIX, I switch to Asaas (the structure stays the same, only the SDK changes). What matters is the database schema and the webhook logic, not the specific gateway.
Minimum schema in Supabase
Create 3 tables: users (already exists if you use Supabase Auth), subscriptions, and subscription_events (audit log).
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 not null,
stripe_subscription_id text unique not null,
status text not null check (status in ('active', 'past_due', 'canceled', 'trialing', 'incomplete')),
price_id text not null,
current_period_start timestamptz not null,
current_period_end timestamptz not null,
cancel_at_period_end boolean default false,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
create index idx_subscriptions_user on public.subscriptions(user_id);
create index idx_subscriptions_status on public.subscriptions(status);
The subscription_events table is your insurance against bugs:
create table public.subscription_events (
id uuid primary key default gen_random_uuid(),
subscription_id uuid references public.subscriptions(id),
stripe_event_id text unique not null,
event_type text not null,
payload jsonb,
processed_at timestamptz default now()
);
create index idx_subscription_events_stripe on public.subscription_events(stripe_event_id);
A unique stripe_event_id guarantees idempotency. If Stripe’s webhook fires twice (and it does), you don’t process it twice.
Row Level Security: the bug nobody sees
Without RLS, any authenticated user can list subscriptions from any other user via Supabase’s API. This isn’t hypothetical. I’ve seen production systems leaking this.
alter table public.subscriptions enable row level security;
alter table public.subscription_events enable row level security;
create policy "users see only their own subscriptions"
on public.subscriptions for select
using (auth.uid() = user_id);
-- Inserts and updates only via service_role (server-side webhook)
create policy "no client writes"
on public.subscriptions for insert
with check (false);
create policy "no client updates"
on public.subscriptions for update
using (false);
The client only reads what’s theirs. Writes only happen via server webhook (which uses service_role and bypasses RLS).
Creating customer and subscription
When the user signs up, create the Stripe customer right away. You need the stripe_customer_id before the first charge.
// app/api/billing/create-checkout/route.ts
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
export async function POST(req: Request) {
const { userId, priceId, successUrl, cancelUrl } = await req.json();
const { data: user } = await supabase
.from('users')
.select('email, stripe_customer_id')
.eq('id', userId)
.single();
let customerId = user?.stripe_customer_id;
if (!customerId) {
const customer = await stripe.customers.create({
email: user?.email,
metadata: { user_id: userId },
});
customerId = customer.id;
await supabase
.from('users')
.update({ stripe_customer_id: customerId })
.eq('id', userId);
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: successUrl,
cancel_url: cancelUrl,
subscription_data: {
metadata: { user_id: userId },
},
});
return Response.json({ url: session.url });
}
Frontend sends the user to session.url. Stripe runs the checkout. When the customer pays, the webhook fires.
Webhook handler: the heart of the operation
This is the file you’ll read more times than any other on this project. Watch the details.
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch (err) {
return new Response('Invalid signature', { status: 400 });
}
// Idempotency
const { data: existing } = await supabase
.from('subscription_events')
.select('id')
.eq('stripe_event_id', event.id)
.single();
if (existing) {
return new Response('Already processed', { status: 200 });
}
switch (event.type) {
case 'checkout.session.completed':
case 'customer.subscription.updated':
case 'customer.subscription.created': {
const sub = event.type === 'checkout.session.completed'
? await stripe.subscriptions.retrieve((event.data.object as any).subscription)
: event.data.object as Stripe.Subscription;
const userId = sub.metadata.user_id;
await supabase.from('subscriptions').upsert({
user_id: userId,
stripe_customer_id: sub.customer as string,
stripe_subscription_id: sub.id,
status: sub.status,
price_id: sub.items.data[0].price.id,
current_period_start: new Date(sub.current_period_start * 1000).toISOString(),
current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
cancel_at_period_end: sub.cancel_at_period_end,
updated_at: new Date().toISOString(),
}, { onConflict: 'stripe_subscription_id' });
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await supabase
.from('subscriptions')
.update({ status: 'canceled', updated_at: new Date().toISOString() })
.eq('stripe_subscription_id', sub.id);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.subscription) {
await supabase
.from('subscriptions')
.update({ status: 'past_due' })
.eq('stripe_subscription_id', invoice.subscription as string);
}
break;
}
}
await supabase.from('subscription_events').insert({
stripe_event_id: event.id,
event_type: event.type,
payload: event.data.object as any,
});
return new Response('OK', { status: 200 });
}
Critical points:
Signature verification via constructEvent. Without it, anyone can hit your webhook pretending to be Stripe and activate a subscription without paying. I’ve seen it.
Idempotency via stripe_event_id. Stripe can fire the same event more than once if you don’t return 200 fast enough. Without idempotency, you generate inconsistent data.
upsert with onConflict. If the record exists, update. If not, create. Works for the 3 events that can arrive out of order.
Checking access in the app
To unlock features for the user, run a simple query:
const { data: subscription } = await supabase
.from('subscriptions')
.select('status, current_period_end')
.eq('user_id', userId)
.in('status', ['active', 'trialing'])
.gte('current_period_end', new Date().toISOString())
.maybeSingle();
const hasActiveAccess = !!subscription;
Status active or trialing AND current period still valid. Client who canceled but is still inside the paid period keeps access until the cycle ends.
Common mistakes I’ve seen in production
Not verifying webhook signatures. Client pays R$ 49, you activate the system, and next month the client complains they weren’t charged. You discover it was a developer test that left the feature flag enabled. Stripe never came into play.
Trusting only the webhook to activate the account. If the webhook fails (Vercel down, your app down), the customer paid and has no access. Fix: on the checkout success route, hit Stripe to confirm current status and update the database.
Not handling incomplete and past_due. Client swipes the card, recurring charge fails, you don’t update anything, and they keep using the system for free. Treat past_due as blocked access after 7 days.
Forgetting to process customer.subscription.deleted. Client cancels directly in the Stripe portal and your system never knows. Always listen to that event.
Stripe Customer Portal
Instead of building a subscription management page yourself, use Stripe’s Customer Portal. Configure once, send the customer there, and they cancel, update card, download invoice without you having to code a single thing.
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: 'https://yoursite.com/account',
});
return Response.json({ url: portalSession.url });
Saves about 8-12 hours of dev work. Use it.
Testing locally
Stripe CLI is mandatory in dev. Install, run stripe listen --forward-to localhost:3000/api/webhooks/stripe, and test real events landing on your endpoint.
Useful test cards: 4242 4242 4242 4242 (success), 4000 0025 0000 3155 (3D Secure required), 4000 0000 0000 9995 (insufficient funds failure).
Implementation checklist
- Create subscriptions and subscription_events tables with indexes
- Enable RLS and create per-user read policies
- Configure webhook signing secret in .env
- Implement checkout session creation route
- Implement webhook handler with idempotency via stripe_event_id
- Implement double verification (webhook + checkout success page)
- Handle past_due and incomplete blocking access after 7 days
- Configure Customer Portal for self-service management
- Test locally with Stripe CLI before deploying
- Configure webhook failure monitoring (Sentry, Logflare)
This setup works for projects charging R$ 49 to R$ 299/month, with 1 plan and up to 2-3 tiers. If you need usage-based plans, volume discounts, complex trials, you’ll have to expand. For 90% of custom projects, this is enough.
Read also: Supabase: the missing backend for React | Integrations: how systems talk | MVP: from zero to deploy | Case AutoPars Pro
Conclusion
Billing looks like a secondary problem until you find out you’re losing customers because the system doesn’t deliver what they paid for. Or worse, you find out you’re activating accounts that didn’t pay. Both scenarios are public embarrassment for a solo dev.
The setup I showed here is what I use and what lets me sleep at night. Not the most sophisticated, but solid enough for projects up to R$ 50k/month MRR. When the client crosses that, you’ll have money to hire a consultant and architect something more complex.
Until then, simple schema, tight RLS, idempotent webhook. The rest is detail.