1

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() e constructEvent antes de qualquer coisa
  • Segredo de webhook certo para cada ambiente
  • Service role key só no servidor
  • ID do usuário em client_reference_id ou metadata
  • Tabela de event.id para 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.

Carregando publicação patrocinada...