Vinicius Aguiar
Arquitetura

Arquitetura de webhooks para provedores de pagamento em Node.js

15 de abril de 2026 · 10 min de leitura

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çãoDiagrama do lifecycle de um webhook de pagamento — validação, idempotência, fila, worker, retry e reconciliação

Validaçã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-Signature com timestamp + HMAC-SHA256 do body
  • Mercado Pago: envia x-signature com 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_confirmation que 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_review ao 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:

  1. Validação de assinatura — rejeitar qualquer webhook não autenticado
  2. Idempotência — armazenar IDs de eventos e ignorar duplicatas
  3. Ack imediato — responder 200 e processar em background
  4. State machine — validar transições de estado e lidar com eventos fora de ordem
  5. Reconciliação — job periódico que sincroniza com o provedor
  6. Dead letter queue — eventos que falharam repetidamente vão para investigação
  7. 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.