Webhooks do Stripe no Next.js (App Router) com Supabase: 6 erros que quebram pagamentos
O checkout funciona no teste, o cliente paga, e mesmo assim a assinatura não aparece no app. Quase sempre o culpado é o webhook. Se você usa Next.js (App Router) na Vercel com Supabase como banco, estes são os seis erros mais comuns e como evitá-los.
1. Ler o corpo como JSON antes de verificar a assinatura
A verificação do Stripe precisa do corpo bruto, exatamente como chegou. Se você chamar req.json() primeiro, a assinatura não bate e o evento é rejeitado.
// app/api/stripe/webhook/route.ts
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const body = await req.text() // corpo bruto
const sig = req.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return new Response('assinatura inválida', { status: 400 })
}
// ... tratar o evento
return new Response('ok')
}
2. Usar o segredo do webhook errado
O STRIPE_WEBHOOK_SECRET do stripe listen local é diferente do segredo do endpoint cadastrado no painel, e o de modo teste é diferente do de produção. Na Vercel, configure cada um no ambiente certo (Preview e Production) e faça um novo deploy depois de mudar variáveis.
3. Gravar no Supabase com a chave anon
O webhook não tem usuário logado, então as políticas de RLS bloqueiam a escrita feita com a chave anon. Use um cliente com a service role key, criado só no servidor e nunca exposto ao navegador:
import { createClient } from '@supabase/supabase-js'
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // sem NEXT_PUBLIC_
)
4. Não saber a qual usuário o pagamento pertence
Sem um vínculo, o webhook recebe um customer que o seu banco não conhece. Ao criar a sessão de checkout, passe o ID do usuário do Supabase em client_reference_id ou em metadata, e guarde o customer do Stripe na tabela de perfis. No webhook, procure pelo ID que você mesmo enviou.
5. Processar o mesmo evento duas vezes
O Stripe pode reenviar um evento (timeout, erro 500, reenvio manual). Se cada entrega cria um crédito novo, o cliente ganha em dobro. Guarde o event.id numa tabela com chave única e ignore o que já foi processado:
create table stripe_events (
id text primary key,
created_at timestamptz default now()
);
Faça o insert primeiro; se der conflito, responda 200 e saia.
6. Demorar para responder
Se o handler faz muita coisa antes de responder (e-mails, chamadas externas), ele pode estourar o tempo da função e o Stripe registra falha. Responda rápido, grave o essencial e deixe o trabalho pesado para depois. E escute só os eventos que você usa, como checkout.session.completed, customer.subscription.updated e customer.subscription.deleted.
Checklist rápido
req.text()econstructEventantes de qualquer coisa- Segredo de webhook certo para cada ambiente
- Service role key só no servidor
- ID do usuário em
client_reference_idoumetadata - Tabela de
event.idpara idempotência - Resposta rápida e só os eventos necessários
Transparência: eu faço parte do time do Massvai (massvai.com), um construtor de apps com IA que gera apps full-stack em Next.js, Supabase e Vercel. Estes erros são os que mais aparecem quando um app ganha pagamentos, mas valem para qualquer projeto com essa stack.