Stripe + Supabase: cobrança recorrente sem virar SaaS de assinatura
Já recusei mais cliente por causa de billing mal feito do que por preço alto. Cliente vai pagar e o sistema deixa ele entrar de graça. Cliente cancela e continua acessando. Cliente paga em duplicidade e ninguém percebe. Cobrança recorrente parece simples até você precisar implementar.
Esse post é o setup que uso há dois anos em projetos pequenos e que ainda não me deu dor de cabeça. Não é arquitetura de SaaS de R$ 2 milhões. É o mínimo viável que funciona pra cobrar mensalidade de cliente real.
O cenário
Você está construindo um sistema sob medida onde o cliente paga mensalidade. Pode ser sistema de gestão pra clínica, painel de relatórios pra agência de marketing, área de membros pra produtor de conteúdo. O ticket é R$ 49 a R$ 299/mês, sem grandes complicações de planos. Um plano, recorrência mensal ou anual.
Stack: React + Supabase + Stripe. Deploy na Vercel. Nada exótico. A ideia é entregar billing funcionando em 1-2 dias, não em 1-2 semanas.
Por que Stripe e não Asaas ou Mercado Pago
Stripe é mundial, documentação impecável, SDKs maduros e webhook system robusto. Pra cliente brasileiro, Asaas é mais barato (taxa menor) e aceita PIX nativo, o que é vantagem real. Mercado Pago tem capilaridade no Brasil mas dor de cabeça operacional.
Pra esse setup, vou de Stripe. Se o cliente exige PIX, troco pra Asaas (a estrutura é a mesma, só muda o SDK). O importante é o schema do banco e a lógica de webhook, não o gateway específico.
Schema mínimo no Supabase
Crie 3 tabelas: users (já existe se usa Supabase Auth), subscriptions e 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);
A tabela subscription_events é o seguro contra 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);
stripe_event_id único garante idempotência. Se o webhook do Stripe disparar 2x (e dispara), você não processa duas vezes.
Row Level Security: o erro que ninguém vê
Sem RLS, qualquer usuário autenticado consegue listar subscriptions de qualquer outro usuário via API do Supabase. Não é hipotético. Já vi sistema em produção vazando isso.
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 e updates só via service_role (webhook server)
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);
Cliente lê só o que é dele. Escritas só acontecem via webhook do servidor (que usa service_role e bypassa RLS).
Criando customer e subscription
Quando o usuário se cadastra, crie o customer no Stripe na hora. Você precisa do stripe_customer_id antes da primeira cobrança.
// 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 manda o usuário pra session.url. Stripe faz o checkout. Quando o cliente paga, o webhook dispara.
Webhook handler: o coração da operação
Esse é o arquivo que você vai ler mais vezes na vida desse projeto. Cuidado com os detalhes.
// 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 });
}
// Idempotência
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 });
}
Pontos críticos:
A verificação de assinatura via constructEvent. Sem isso, qualquer pessoa pode bater no seu webhook fingindo ser Stripe e ativar assinatura sem pagar. Já vi.
Idempotência via stripe_event_id. Stripe pode disparar o mesmo evento mais de uma vez se você não retornar 200 rápido. Sem idempotência, você gera dados inconsistentes.
upsert com onConflict. Se o registro já existe, atualiza. Se não, cria. Funciona pros 3 eventos que podem chegar fora de ordem.
Verificando acesso na aplicação
Pra liberar feature pro usuário, faz query simples:
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 ou trialing E período atual ainda válido. Cliente que cancelou e ainda está dentro do período já pago tem acesso até o fim do ciclo.
Erros comuns que vi em produção
Não validar a assinatura do webhook. Cliente paga R$ 49, você ativa o sistema, mês seguinte cliente reclama que não foi cobrado. Você descobre que era um teste do desenvolvedor que esqueceu de remover a feature flag. Stripe nunca entrou no jogo.
Confiar só no webhook pra ativar a conta. Se o webhook falha (Vercel down, sua app caída), o cliente paga e fica sem acesso. Solução: na rota de checkout success, faz uma chamada ao Stripe pra confirmar o status atual e atualiza o banco.
Não tratar incomplete e past_due. Cliente passa o cartão, falha na cobrança recorrente, você não atualiza nada e ele fica usando o sistema de graça. Trate past_due como acesso bloqueado depois de 7 dias.
Esquecer de processar customer.subscription.deleted. Cliente cancela direto no portal do Stripe e seu sistema nunca sabe. Sempre escute esse evento.
Stripe Customer Portal
Em vez de construir página de gerenciamento de assinatura você mesmo, use o Customer Portal da Stripe. Configura uma vez, manda o cliente pra lá e ele cancela, atualiza cartão, baixa fatura sem você precisar codar nada.
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: 'https://seusite.com/conta',
});
return Response.json({ url: portalSession.url });
Economiza umas 8-12 horas de desenvolvimento. Use.
Testando localmente
Stripe CLI é obrigatório no dev. Instala, roda stripe listen --forward-to localhost:3000/api/webhooks/stripe e testa eventos de verdade chegando.
Cartões de teste úteis: 4242 4242 4242 4242 (sucesso), 4000 0025 0000 3155 (3D Secure obrigatório), 4000 0000 0000 9995 (falha por fundos insuficientes).
Checklist de implementação
- Criar tabelas subscriptions e subscription_events com índices
- Habilitar RLS e criar policies de leitura por usuário
- Configurar webhook signing secret no .env
- Implementar rota de criação de checkout session
- Implementar webhook handler com idempotência via stripe_event_id
- Implementar verificação dupla (webhook + checkout success)
- Tratar past_due e incomplete bloqueando acesso após 7 dias
- Configurar Customer Portal pro cliente gerenciar sozinho
- Testar localmente com Stripe CLI antes de deploy
- Configurar monitoramento de webhook failures (Sentry, Logflare)
Esse setup funciona pra projeto que cobra de R$ 49 a R$ 299/mês, com 1 plano e até 2-3 níveis. Se você precisa de planos por uso, descontos por volume, trial complexo, vai precisar expandir. Pra 90% dos projetos sob medida, esse é o suficiente.
Leia também: Supabase: o backend que faltava pro React | Integrações: como sistemas conversam | MVP: do zero ao deploy | Case AutoPars Pro
Conclusão
Billing parece o problema secundário até você descobrir que tá perdendo cliente porque o sistema não entrega o que ele pagou. Ou pior, descobre que tá ativando conta de quem não pagou. Os dois cenários são vergonha pública pra dev solo.
O setup que mostrei aqui é o que uso e que dorme tranquilo de noite. Não é o mais sofisticado, mas é robusto o suficiente pra projeto até R$ 50 mil/mês de MRR. Quando o cliente passar disso, você vai ter dinheiro pra contratar consultoria e arquitetar coisa mais complexa.
Até lá, schema simples, RLS apertada, webhook idempotente. O resto é detalhe.