Integrar pagamentos em um produto real vai muito além de seguir a documentação do provedor. Quando há dinheiro real fluindo pelo sistema, cada falha silenciosa é um problema — cobranças duplicadas, status inconsistente, ou vendas que nunca foram confirmadas. Neste artigo, apresento a arquitetura que utilizo para receber e processar webhooks de provedores como Stripe, Mercado Pago e Asaas em aplicações Node.js.
O problema real
Webhooks são a forma como provedores de pagamento notificam sua aplicação sobre eventos — um PIX confirmado, uma assinatura cancelada, uma disputa aberta. O problema é que esse mecanismo é inerentemente não confiável: webhooks podem chegar duplicados, fora de ordem, com atraso, ou simplesmente não chegar.
Se a sua aplicação não estiver preparada para lidar com esses cenários, você vai descobrir o problema quando um cliente reclamar que pagou mas não recebeu acesso — ou pior, quando o financeiro notar que os números não batem.
O diagrama abaixo mostra o fluxo completo que vamos construir neste artigo:
Diagrama do lifecycle de um webhook de pagamento — validação, idempotência, fila, worker, retry e reconciliaçãoValidação de assinatura
A primeira camada de segurança é validar que o webhook realmente veio do provedor. Cada provedor implementa isso de forma diferente:
- Stripe: envia um header
Stripe-Signaturecom timestamp + HMAC-SHA256 do body - Mercado Pago: envia
x-signaturecom hash e query params, exige validação via API - Asaas: envia um token no header que deve ser comparado com o configurado no painel
A regra é simples: nunca processe um webhook sem validar a assinatura. Sem isso, qualquer pessoa pode enviar um POST para seu endpoint e simular uma confirmação de pagamento.
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const body = await req.text()
const signature = req.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return new Response('Invalid signature', { status: 400 })
}
// Processar evento validado
await processEvent(event)
return new Response('OK', { status: 200 })
}Idempotência: evitando processamento duplicado
Provedores de pagamento reenviam webhooks quando não recebem um 2xx de resposta. Isso significa que o mesmo evento pode chegar múltiplas vezes. Se o seu handler não for idempotente, você pode creditar o saldo de um cliente duas vezes ou enviar dois emails de confirmação.
A solução é armazenar o ID do evento e verificar antes de processar:
async function processEvent(event: PaymentEvent) {
// Verificar se já foi processado
const existing = await db.webhookEvents.findUnique({
where: { eventId: event.id }
})
if (existing) {
return // Já processado, ignorar
}
// Registrar evento antes de processar
await db.webhookEvents.create({
data: {
eventId: event.id,
provider: 'stripe',
type: event.type,
processedAt: new Date()
}
})
// Processar com segurança
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data)
break
case 'payment_intent.payment_failed':
await handlePaymentFailure(event.data)
break
}
}Processamento assíncrono: ack imediato
Uma regra crítica: responda 200 o mais rápido possível. Se o seu handler demora para responder (porque está atualizando banco, enviando email, chamando outra API), o provedor vai considerar que falhou e reenviar — causando mais carga e possíveis duplicatas.
O padrão correto é ack imediato + processamento em background:
export async function POST(req: Request) {
const event = await validateAndParse(req)
if (!event) {
return new Response('Invalid', { status: 400 })
}
// Salvar na fila para processamento async
await db.webhookQueue.create({
data: {
eventId: event.id,
payload: JSON.stringify(event),
status: 'pending'
}
})
// Responder imediatamente
return new Response('OK', { status: 200 })
}
// Worker separado processa a fila
async function processQueue() {
const pending = await db.webhookQueue.findMany({
where: { status: 'pending' },
orderBy: { createdAt: 'asc' }
})
for (const item of pending) {
try {
await processEvent(JSON.parse(item.payload))
await db.webhookQueue.update({
where: { id: item.id },
data: { status: 'processed' }
})
} catch (err) {
await db.webhookQueue.update({
where: { id: item.id },
data: {
status: 'failed',
retryCount: { increment: 1 },
lastError: err.message
}
})
}
}
}Webhooks fora de ordem
Um cenário real que acontece com frequência: o webhook de payment.failed chega antes do payment.created. Ou o provedor envia refund.completed antes de payment.succeeded. Se o seu sistema depende de uma ordem específica, ele vai quebrar.
Duas abordagens para lidar com isso:
- State machine: defina transições válidas para cada status de pagamento. Se um evento tenta uma transição inválida (ex: refund antes de success), enfileire para reprocessamento posterior.
- Timestamp do provedor: use o timestamp do evento (não o de recebimento) para determinar qual estado é mais recente. Ignore eventos mais antigos que o estado atual.
async function handlePaymentUpdate(event: PaymentEvent) {
const payment = await db.payments.findUnique({
where: { providerPaymentId: event.paymentId }
})
if (!payment) {
// Pagamento ainda não existe, enfileirar para retry
await enqueueForRetry(event)
return
}
// Ignorar eventos mais antigos que o estado atual
if (event.timestamp <= payment.lastEventTimestamp) {
return
}
// Validar transição de estado
const validTransitions: Record<string, string[]> = {
pending: ['confirmed', 'failed', 'cancelled'],
confirmed: ['refunded', 'disputed'],
failed: ['pending'] // retry do provedor
}
if (!validTransitions[payment.status]?.includes(event.newStatus)) {
await enqueueForRetry(event)
return
}
await db.payments.update({
where: { id: payment.id },
data: {
status: event.newStatus,
lastEventTimestamp: event.timestamp
}
})
}PIX: diferenças entre provedores
PIX é o meio de pagamento mais utilizado no Brasil, mas cada provedor implementa a confirmação de forma diferente:
- Stripe: não oferece PIX nativamente no Brasil (usa transferências bancárias como alternativa)
- Mercado Pago: confirmação de PIX geralmente chega em segundos via webhook, mas pode atrasar até minutos em horários de pico
- Asaas: confirmação pode levar de segundos a minutos, e o webhook de confirmação às vezes chega antes do webhook de criação
Na prática, isso significa que você não pode confiar em uma ordem específica de eventos para PIX. O sistema precisa ser resiliente a confirmações que chegam antes da criação, atrasos longos, e casos onde o webhook simplesmente não chega.
Reconciliação: quando o estado diverge
Mesmo com todas as proteções acima, haverá momentos em que o estado local da sua aplicação e o estado no provedor vão divergir. Um webhook que nunca chegou, um bug no handler, um deploy que derrubou o worker por alguns minutos.
A solução é um job de reconciliação que roda periodicamente:
async function reconcilePayments() {
// Buscar pagamentos pendentes há mais de 30 minutos
const stalePayments = await db.payments.findMany({
where: {
status: 'pending',
createdAt: {
lt: new Date(Date.now() - 30 * 60 * 1000)
}
}
})
for (const payment of stalePayments) {
// Consultar status diretamente no provedor
const providerStatus = await getProviderStatus(
payment.provider,
payment.providerPaymentId
)
if (providerStatus !== payment.status) {
await db.payments.update({
where: { id: payment.id },
data: { status: providerStatus }
})
// Log para auditoria
await db.reconciliationLog.create({
data: {
paymentId: payment.id,
previousStatus: payment.status,
newStatus: providerStatus,
reason: 'reconciliation_job'
}
})
}
}
}Esse job é a rede de segurança final. Ele garante que mesmo quando tudo falha — webhooks perdidos, bugs no handler, provider fora do ar — o sistema eventualmente converge para o estado correto.
Dead letter queue: o que fazer quando falha
Quando o processamento de um webhook falha repetidamente (3-5 tentativas), ele deve ir para uma dead letter queue — uma tabela separada para eventos que precisam de intervenção manual ou investigação.
async function processWithRetry(item: WebhookQueueItem) {
const MAX_RETRIES = 5
if (item.retryCount >= MAX_RETRIES) {
// Mover para dead letter queue
await db.deadLetterQueue.create({
data: {
eventId: item.eventId,
payload: item.payload,
lastError: item.lastError,
failedAt: new Date()
}
})
await db.webhookQueue.delete({
where: { id: item.id }
})
// Alertar o time
await notify(`Webhook ${item.eventId} falhou ${MAX_RETRIES}x e foi movido para DLQ`)
return
}
// Tentar processar com backoff exponencial
try {
await processEvent(JSON.parse(item.payload))
} catch (err) {
const nextRetry = new Date(
Date.now() + Math.pow(2, item.retryCount) * 1000
)
await db.webhookQueue.update({
where: { id: item.id },
data: {
retryCount: { increment: 1 },
lastError: err.message,
nextRetryAt: nextRetry
}
})
}
}Degradação graciosa
Quando um provedor de pagamento está fora do ar, sua aplicação não pode simplesmente quebrar. O usuário precisa saber que o pagamento está sendo processado, mesmo que a confirmação demore. Algumas práticas:
- Status intermediário: use um estado como
awaiting_confirmationque o usuário vê enquanto espera a confirmação do provedor - Timeouts configuráveis: se a confirmação não chegar em X minutos, marque como
requires_reviewao invés de falhar silenciosamente - Fallback entre provedores: se um provedor está fora, ofereça outro meio de pagamento como alternativa
- Comunicação clara: notifique o usuário sobre o status real ao invés de mostrar um loading eterno
Resumo da arquitetura
A arquitetura completa para webhooks de pagamento em produção se resume a estas camadas:
- Validação de assinatura — rejeitar qualquer webhook não autenticado
- Idempotência — armazenar IDs de eventos e ignorar duplicatas
- Ack imediato — responder 200 e processar em background
- State machine — validar transições de estado e lidar com eventos fora de ordem
- Reconciliação — job periódico que sincroniza com o provedor
- Dead letter queue — eventos que falharam repetidamente vão para investigação
- Degradação graciosa — o sistema continua funcionando quando o provedor falha
Cada camada é uma rede de segurança para a anterior. Nenhuma delas sozinha resolve o problema — é a combinação que torna o sistema confiável o suficiente para lidar com dinheiro real em produção.
