Busque em todas as páginas da documentação
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Crie um Route Handler do Next.js que recebe eventos de webhook do Stripe, verifica a assinatura e processa eventos chave como pagamentos bem-sucedidos e alterações de assinatura.
Adicione o segredo do webhook ao seu ambiente:
# .env.local
STRIPE_WEBHOOK_SECRET=whsec_...Crie o Route Handler de webhook:
// app/api/webhooks/stripe/route.ts
import { stripe } from "@/lib/stripe";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import type Stripe from "stripe";
export async function POST(request: Request) {
const body = await request.text();
const headersList = await headers();
const signature = headersList.get("stripe-signature");
if (!signature) {
return NextResponse.json(
{ error: "Cabeçalho stripe-signature ausente" },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
const message = err instanceof Error ? err.message : "Erro desconhecido";
console.error(`Falha na verificação da assinatura do webhook: ${message}`);
return NextResponse.json({ error: message }, { status: 400 });
}
try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutCompleted(session);
break;
}
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
await handleInvoicePaid(invoice);
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionUpdated(subscription);
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionDeleted(subscription);
break;
}
default:
console.log(`Tipo de evento não tratado: ${event.type}`);
}
} catch (err) {
console.error(`Erro ao processar evento de webhook ${event.type}:`, err);
return NextResponse.json(
{ error: "Manipulador de webhook falhou" },
{ status: 500 }
);
}
return NextResponse.json({ received: true });
}Implemente as funções manipuladoras:
// lib/webhook-handlers.ts
import type Stripe from "stripe";
import { db } from "@/lib/db";
export async function handleCheckoutCompleted(
session: Stripe.Checkout.Session
) {
const userId = session.metadata?.userId;
if (!userId) return;
if (session.mode === "subscription") {
await db.user.update({
where: { id: userId },
data: {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
plan: "pro",
},
});
} else if (session.mode === "payment") {
await db.purchase.create({
data: {
userId,
sessionId: session.id,
amount: session.amount_total!,
status: "completed",
},
});
}
}
export async function handleInvoicePaid(invoice: Stripe.Invoice) {
const subscriptionId = invoice.subscription as string;
if (!subscriptionId) return;
await db.user.updateMany({
where: { stripeSubscriptionId: subscriptionId },
data: {
planStatus: "active",
currentPeriodEnd: new Date(invoice.lines.data[0]?.period.end * 1000),
},
});
}
export async function handleSubscriptionUpdated(
subscription: Stripe.Subscription
) {
await db.user.updateMany({
where: { stripeSubscriptionId: subscription.id },
data: {
planStatus: subscription.status,
plan: subscription.items.data[0]?.price.lookup_key ?? "unknown",
},
});
}
export async function handleSubscriptionDeleted(
subscription: Stripe.Subscription
) {
await db.user.updateMany({
where: { stripeSubscriptionId: subscription.id },
data: {
plan: "free",
planStatus: "canceled",
stripeSubscriptionId: null,
},
});
}Manipulador de webhook completo com idempotência:
// app/api/webhooks/stripe/route.ts
import { stripe } from "@/lib/stripe";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import type Stripe from "stripe";
async function isEventProcessed(eventId: string): Promise<boolean> {
const existing = await db.stripeEvent.findUnique({
where: { eventId },
});
return !!existing;
}
async function markEventProcessed(eventId: string): Promise<void> {
await db.stripeEvent.create({
data: { eventId, processedAt: new Date() },
});
}
export async function POST(request: Request) {
const body = await request.text();
const headersList = await headers();
const signature = headersList.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Sem assinatura" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json({ error: "Assinatura inválida" }, { status: 400 });
}
// Idempotência: pular eventos já processados
if (await isEventProcessed(event.id)) {
return NextResponse.json({ received: true, skipped: true });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
if (userId) {
await db.user.update({
where: { id: userId },
data: {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
plan: "pro",
planStatus: "active",
},
});
}
break;
}
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.subscription) {
await db.user.updateMany({
where: { stripeSubscriptionId: invoice.subscription as string },
data: { planStatus: "active" },
});
}
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await db.user.updateMany({
where: { stripeSubscriptionId: sub.id },
data: { plan: "free", planStatus: "canceled" },
});
break;
}
}
await markEventProcessed(event.id);
return NextResponse.json({ received: true });
}stripe.webhooks.constructEvent verifica o cabeçalho stripe-signature contra o corpo bruto da requisição usando seu segredo de webhook. Isso impede que atacantes enviem eventos falsos.request.text()), não como JSON. Analisá-lo primeiro corrompe a verificação da assinatura.id único (ex: evt_xxx). Armazene os IDs de eventos processados para obter idempotência e evitar processamento duplicado em retentativas.Teste local com Stripe CLI:
# Instalar Stripe CLI
brew install stripe/stripe-cli/stripe
# Fazer login no Stripe
stripe login
# Encaminhar eventos para seu servidor local
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Em outro terminal, disparar eventos de teste
stripe trigger checkout.session.completed
stripe trigger invoice.paid
stripe trigger customer.subscription.deletedA CLI imprime um segredo de assinatura de webhook (whsec_...) quando você inicia o listen. Use-o como seu STRIPE_WEBHOOK_SECRET durante o desenvolvimento local.
Manipular payment_intent.succeeded diretamente:
case "payment_intent.succeeded": {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
console.log(`Pagamento ${paymentIntent.id} bem-sucedido: $${paymentIntent.amount / 100}`);
break;
}event.data.object é tipado como Stripe.Event.Data.Object, que é uma união ampla. Converta-o para o tipo específico com base em event.type.Stripe.Event para o tipo de evento e tipos de objeto específicos como Stripe.Checkout.Session, Stripe.Invoice, Stripe.Subscription para o objeto de dados.function isCheckoutEvent(
event: Stripe.Event
): event is Stripe.DiscriminatedEvent.CheckoutSessionCompletedEvent {
return event.type === "checkout.session.completed";
}request.text(). Usar request.json() quebrará a verificação da assinatura.whsec_...) é diferente do seu Dashboard. Use o segredo da CLI durante o desenvolvimento local.request.headers.get() diretamente no App Router do Next.js. Use a função headers() de next/headers em vez disso.| Abordagem | Prós | Contras |
|---|---|---|
| Webhook de Route Handler | Controle total, processamento no lado do servidor | Deve lidar com verificação e idempotência |
| Stripe CLI (dev local) | Teste instantâneo, disparar eventos específicos | Apenas para desenvolvimento |
| Sondagem da API | Nenhuma infraestrutura de webhook necessária | Atrasado, desperdiçado, não recomendado |
| Svix (proxy de webhook) | Gerenciamento de retentativas, painel de monitoramento | Serviço extra para gerenciar |
stripe.webhooks.constructEvent verifica os bytes brutos do corpo contra o cabeçalho de assinatura. Analisar com request.json() primeiro altera o corpo (ordem das chaves, espaços em branco), o que corrompe a verificação da assinatura e faz com que a verificação falhe.
Garante que o evento foi realmente enviado pelo Stripe, e não por um atacante enviando cargas falsas para o seu endpoint. A assinatura é calculada usando seu segredo de webhook, que apenas você e o Stripe conhecem.
O Stripe pode entregar o mesmo evento várias vezes (retentativas em caso de falha). Sem idempotência, você pode processar eventos em duplicidade -- por exemplo, conceder créditos em dobro a um usuário. Armazene os IDs de eventos processados para pular duplicatas.
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Em outro terminal:
stripe trigger checkout.session.completedA CLI imprime um segredo whsec_... para usar como seu STRIPE_WEBHOOK_SECRET local.
Não. A CLI gera seu próprio segredo de assinatura quando você executa stripe listen. Use o segredo da CLI durante o desenvolvimento local e o segredo do Dashboard em produção. Eles são valores diferentes.
Retorne uma resposta 2xx em até 10 segundos. Se o seu manipulador precisar fazer trabalho lento (enviar e-mails, atualizações complexas de banco de dados), reconheça o webhook primeiro e processe assincronamente.
O Stripe retenta a entrega até 3 vezes ao longo de várias horas com backoff exponencial. Após todas as retentativas falharem, o evento é marcado como falho no seu Dashboard.
Não. No App Router do Next.js, use a função headers() de next/headers para ler os cabeçalhos da requisição. request.headers.get() direto pode não funcionar como esperado para todos os cabeçalhos.
checkout.session.completed -- compra inicial/assinatura criadainvoice.paid -- pagamento recorrente bem-sucedidocustomer.subscription.updated -- alterações de plano, alterações de statuscustomer.subscription.deleted -- assinatura totalmente canceladaevent.data.object é tipado como uma união ampla. Converta-o com base em event.type:
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
break;
}
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
break;
}function isCheckoutEvent(
event: Stripe.Event
): event is Stripe.DiscriminatedEvent.CheckoutSessionCompletedEvent {
return event.type === "checkout.session.completed";
}Revisado por Chris St. John·Última atualização: 19 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥