Vinicius Aguiar
Arquitectura

Arquitectura de webhooks para proveedores de pago en Node.js

15 de abr. de 2026 · 10 min de lectura

Integrar pagos en un producto real va mucho más allá de seguir la documentación del proveedor. Cuando hay dinero real fluyendo por el sistema, cada falla silenciosa es un problema — cobros duplicados, estados inconsistentes o ventas que nunca fueron confirmadas. En este artículo presento la arquitectura que utilizo para recibir y procesar webhooks de proveedores como Stripe, Mercado Pago y Asaas en aplicaciones Node.js.

El problema real

Los webhooks son la forma en que los proveedores de pago notifican a tu aplicación sobre eventos — un PIX confirmado, una suscripción cancelada, una disputa abierta. El problema es que este mecanismo es inherentemente no confiable: los webhooks pueden llegar duplicados, fuera de orden, con atraso o simplemente no llegar.

Si tu aplicación no está preparada para manejar estos escenarios, vas a descubrir el problema cuando un cliente reclame que pagó pero no recibió acceso — o peor, cuando finanzas note que los números no cuadran.

El diagrama a continuación muestra el flujo completo que vamos a construir en este artículo:

Diagrama del ciclo de vida de un webhook de pago — validación, idempotencia, cola, worker, retry y reconciliaciónDiagrama del ciclo de vida de un webhook de pago — validación, idempotencia, cola, worker, retry y reconciliación

Validación de firma

La primera capa de seguridad es validar que el webhook realmente vino del proveedor. Cada proveedor implementa esto de forma diferente:

  • Stripe: envía un header Stripe-Signature con timestamp + HMAC-SHA256 del body
  • Mercado Pago: envía x-signature con hash y query params, requiere validación vía API
  • Asaas: envía un token en el header que debe compararse con el configurado en el panel

La regla es simple: nunca procese un webhook sin validar la firma. Sin esto, cualquier persona puede enviar un POST a tu endpoint y simular una confirmación de pago.

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 })
  }

  // Procesar evento validado
  await processEvent(event)
  return new Response('OK', { status: 200 })
}

Idempotencia: evitando procesamiento duplicado

Los proveedores de pago reenvían webhooks cuando no reciben un 2xx de respuesta. Esto significa que el mismo evento puede llegar múltiples veces. Si tu handler no es idempotente, puedes acreditar el saldo de un cliente dos veces o enviar dos emails de confirmación.

La solución es almacenar el ID del evento y verificar antes de procesar:

async function processEvent(event: PaymentEvent) {
  // Verificar si ya fue procesado
  const existing = await db.webhookEvents.findUnique({
    where: { eventId: event.id }
  })

  if (existing) {
    return // Ya procesado, ignorar
  }

  // Registrar evento antes de procesar
  await db.webhookEvents.create({
    data: {
      eventId: event.id,
      provider: 'stripe',
      type: event.type,
      processedAt: new Date()
    }
  })

  // Procesar con seguridad
  switch (event.type) {
    case 'payment_intent.succeeded':
      await handlePaymentSuccess(event.data)
      break
    case 'payment_intent.payment_failed':
      await handlePaymentFailure(event.data)
      break
  }
}

Procesamiento asíncrono: ack inmediato

Una regla crítica: responda 200 lo más rápido posible. Si tu handler demora en responder (porque está actualizando la base, enviando email, llamando otra API), el proveedor va a considerar que falló y reenviará — causando más carga y posibles duplicados.

El patrón correcto es ack inmediato + procesamiento en background:

export async function POST(req: Request) {
  const event = await validateAndParse(req)
  if (!event) {
    return new Response('Invalid', { status: 400 })
  }

  // Guardar en cola para procesamiento async
  await db.webhookQueue.create({
    data: {
      eventId: event.id,
      payload: JSON.stringify(event),
      status: 'pending'
    }
  })

  // Responder inmediatamente
  return new Response('OK', { status: 200 })
}

// Worker separado procesa la cola
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 fuera de orden

Un escenario real que ocurre con frecuencia: el webhook de payment.failed llega antes del payment.created. O el proveedor envía refund.completed antes de payment.succeeded. Si tu sistema depende de un orden específico, va a fallar.

Dos enfoques para manejar esto:

  • State machine: defina transiciones válidas para cada estado de pago. Si un evento intenta una transición inválida (ej: refund antes de success), encolar para reprocesamiento posterior.
  • Timestamp del proveedor: use el timestamp del evento (no el de recepción) para determinar cuál estado es más reciente. Ignore eventos más antiguos que el estado actual.
async function handlePaymentUpdate(event: PaymentEvent) {
  const payment = await db.payments.findUnique({
    where: { providerPaymentId: event.paymentId }
  })

  if (!payment) {
    // Pago aún no existe, encolar para retry
    await enqueueForRetry(event)
    return
  }

  // Ignorar eventos más antiguos que el estado actual
  if (event.timestamp <= payment.lastEventTimestamp) {
    return
  }

  // Validar transición de estado
  const validTransitions: Record<string, string[]> = {
    pending: ['confirmed', 'failed', 'cancelled'],
    confirmed: ['refunded', 'disputed'],
    failed: ['pending'] // retry del proveedor
  }

  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: diferencias entre proveedores

PIX es el medio de pago más utilizado en Brasil, pero cada proveedor implementa la confirmación de forma diferente:

  • Stripe: no ofrece PIX nativamente en Brasil (usa transferencias bancarias como alternativa)
  • Mercado Pago: la confirmación de PIX generalmente llega en segundos vía webhook, pero puede atrasar hasta minutos en horarios pico
  • Asaas: la confirmación puede tomar de segundos a minutos, y el webhook de confirmación a veces llega antes del webhook de creación

En la práctica, esto significa que no puedes confiar en un orden específico de eventos para PIX. El sistema necesita ser resiliente a confirmaciones que llegan antes de la creación, atrasos largos y casos donde el webhook simplemente no llega.

Reconciliación: cuando el estado diverge

Incluso con todas las protecciones anteriores, habrá momentos en que el estado local de tu aplicación y el estado en el proveedor van a divergir. Un webhook que nunca llegó, un bug en el handler, un deploy que tumbó el worker por algunos minutos.

La solución es un job de reconciliación que corre periódicamente:

async function reconcilePayments() {
  // Buscar pagos pendientes hace más 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 estado directamente en el proveedor
    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 auditoría
      await db.reconciliationLog.create({
        data: {
          paymentId: payment.id,
          previousStatus: payment.status,
          newStatus: providerStatus,
          reason: 'reconciliation_job'
        }
      })
    }
  }
}

Este job es la red de seguridad final. Garantiza que incluso cuando todo falla — webhooks perdidos, bugs en el handler, proveedor caído — el sistema eventualmente converge al estado correcto.

Dead letter queue: qué hacer cuando falla

Cuando el procesamiento de un webhook falla repetidamente (3-5 intentos), debe ir a una dead letter queue — una tabla separada para eventos que necesitan intervención manual o investigación.

async function processWithRetry(item: WebhookQueueItem) {
  const MAX_RETRIES = 5

  if (item.retryCount >= MAX_RETRIES) {
    // Mover a 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 al equipo
    await notify(`Webhook ${item.eventId} falló ${MAX_RETRIES}x y fue movido a DLQ`)
    return
  }

  // Intentar procesar con 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
      }
    })
  }
}

Degradación graciosa

Cuando un proveedor de pagos está caído, tu aplicación no puede simplemente fallar. El usuario necesita saber que el pago está siendo procesado, aunque la confirmación demore. Algunas prácticas:

  • Estado intermedio: use un estado como awaiting_confirmation que el usuario ve mientras espera la confirmación del proveedor
  • Timeouts configurables: si la confirmación no llega en X minutos, marque como requires_review en lugar de fallar silenciosamente
  • Fallback entre proveedores: si un proveedor está caído, ofrezca otro medio de pago como alternativa
  • Comunicación clara: notifique al usuario sobre el estado real en lugar de mostrar un loading eterno

Resumen de la arquitectura

La arquitectura completa para webhooks de pago en producción se resume en estas capas:

  1. Validación de firma — rechazar cualquier webhook no autenticado
  2. Idempotencia — almacenar IDs de eventos e ignorar duplicados
  3. Ack inmediato — responder 200 y procesar en background
  4. State machine — validar transiciones de estado y manejar eventos fuera de orden
  5. Reconciliación — job periódico que sincroniza con el proveedor
  6. Dead letter queue — eventos que fallaron repetidamente van a investigación
  7. Degradación graciosa — el sistema sigue funcionando cuando el proveedor falla

Cada capa es una red de seguridad para la anterior. Ninguna de ellas sola resuelve el problema — es la combinación lo que hace al sistema lo suficientemente confiable para manejar dinero real en producción.