How do I handle Stripe and Lemon Squeezy webhooks idempotently in Next.js?
It depends on your billing providers, but the core architecture is identical: verify the raw payload signature before parsing JSON, normalize provider events into a shared internal contract, and enforce idempotency at the database boundary using the provider's event or order ID. Never grant product entitlements from a client-side checkout redirect; let the verified webhook transactionally update subscription state and entitlements once. supastack prewires this pipeline for Stripe, Lemon Squeezy, and mobile RevenueCat, though single-plan apps can start with a simpler handler.
Verifica o payload bruto antes de tocar no JSON
Cada fornecedor de pagamentos assina a sequência exata de bytes que envia pela rede. Se um handler de rota analisar await request.json() e voltar a serializar o objeto antes de verificar a assinatura, diferenças na ordem das chaves ou nos espaços em branco invalidarão webhooks legítimos — ou tentarão a equipa a ignorar a verificação em staging.
Lê o corpo bruto uma única vez com await request.text() no teu handler POST do App Router do Next.js, valida a assinatura criptográfica com o segredo exclusivo do servidor e rejeita qualquer falha com HTTP 400 antes de executar uma única query. O guia do módulo de faturação mostra como o supastack isola a verificação de assinaturas atrás de um único ponto de entrada BFF.
export async function POST(request: Request) {
const rawBody = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
const event = stripe.webhooks.constructEvent(
rawBody,
signature,
env.STRIPE_WEBHOOK_SECRET,
);
await handleNormalizedBillingEvent(event);
return Response.json({ received: true });
}- Lê o corpo bruto do pedido com
request.text()— nunca voltando a serializar JSON já analisado. - Mantém
STRIPE_WEBHOOK_SECRETe os segredos de assinatura da Lemon Squeezy apenas na configuração do servidor. - Usa comparação em tempo constante (
crypto.timingSafeEqual) ao validar assinaturas HMAC manualmente.
Deduplica na fronteira da base de dados, não em memória
A Stripe e a Lemon Squeezy reenviam webhooks sempre que um deploy reinicia, uma função serverless atinge timeout ou um salto de rede perde a resposta. Conjuntos em memória ou chaves de cache efémeras desaparecem entre instâncias e não impedem que duas tentativas simultâneas emitam licenças duplicadas ou enviem dois recibos por email.
Regista o ID de evento do fornecedor ou o estado de liquidação da encomenda dentro da mesma transação Postgres que altera o acesso da subscrição. Um INSERT ... ON CONFLICT DO NOTHING atómico ou uma atualização condicional em status != 'paid' transforma uma repetição concorrente numa operação neutra. Ao desenhar os planos que esses webhooks ativam, o gerador de páginas de preços SaaS ajuda a modelar escalões e regras por lugar antecipadamente.
const settled = await db.transaction(async (tx) => {
const [inserted] = await tx
.insert(billingWebhookEvents)
.values({ providerEventId: event.id, provider: "stripe" })
.onConflictDoNothing({ target: billingWebhookEvents.providerEventId })
.returning({ id: billingWebhookEvents.id });
if (!inserted) return false; // Already processed
await applyWorkspaceEntitlement(tx, event);
return true;
});Lida com eventos fora de ordem e subscrições sobrepostas
Não há garantia de que os webhooks cheguem pela ordem em que o utilizador clicou. customer.subscription.updated pode chegar antes de checkout.session.completed, e um cliente que abra acidentalmente dois separadores de checkout pode deter brevemente duas subscrições no mesmo workspace.
Associa cancelamentos e falhas de pagamento ao ID de subscrição específico que originou o evento. Antes de revogar o direito pago de um workspace, confirma se outra subscrição ativa ainda cobre esse workspace — caso contrário, cancelar uma subscrição duplicada remove silenciosamente o acesso pelo qual o cliente continua a pagar.
- Indexa o estado da subscrição por
providerSubscriptionId, e não apenas porworkspaceId. - Mantém ativo o direito pago de um workspace enquanto pelo menos uma subscrição estiver
activeoutrialing. - Confirma que o montante pago e a moeda correspondem ao SKU esperado antes de cumprir encomendas de pagamento único.
Perguntas frequentes sobre webhooks de faturação SaaS
Estas questões surgem sempre que uma equipa passa a faturação dos testes em sandbox para o tráfego real de produção.
- Posso desbloquear funcionalidades pagas no redirecionamento de sucesso do checkout? — Não. Os URLs de redirecionamento podem ser guardados nos favoritos, partilhados ou visitados antes da liquidação de métodos de pagamento assíncronos. Usa a página de sucesso para consultar o estado no servidor enquanto o webhook validado concede o acesso.
- Que código de estado deve devolver um webhook duplicado? — Devolve
200 OKassim que a base de dados confirmar que o evento já foi processado. Devolver4xxou5xxindica ao fornecedor que deve continuar a tentar. - Como testo novas tentativas de webhooks localmente? — Escreve testes unitários determinísticos que enviem exatamente o mesmo payload assinado duas vezes e confirma que apenas uma licença, direito ou email transacional é produzido.
- Como se encaixam as compras in-app móveis no mesmo modelo? — Encaminha as notificações de servidor do RevenueCat pela mesma tabela idempotente de direitos para que as compras web e móveis nunca se substituam mutuamente — consulta a checklist de lançamento para as verificações pré-lançamento.