Voltar ao blog
Tutorial

Stripe + Supabase: cobrança recorrente sem virar SaaS de assinatura

Por Flávio Emanuel · · 10 min de leitura

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.

Próximo passo

Precisa de um dev que entrega de verdade?

Seja pra um projeto pontual, reforço no time, ou parceria de longo prazo. Vamos conversar.

Falar no WhatsApp

Respondo em até 2h durante horário comercial.