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ónValidació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-Signaturecon timestamp + HMAC-SHA256 del body - Mercado Pago: envía
x-signaturecon 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_confirmationque el usuario ve mientras espera la confirmación del proveedor - Timeouts configurables: si la confirmación no llega en X minutos, marque como
requires_reviewen 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:
- Validación de firma — rechazar cualquier webhook no autenticado
- Idempotencia — almacenar IDs de eventos e ignorar duplicados
- Ack inmediato — responder 200 y procesar en background
- State machine — validar transiciones de estado y manejar eventos fuera de orden
- Reconciliación — job periódico que sincroniza con el proveedor
- Dead letter queue — eventos que fallaron repetidamente van a investigación
- 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.
