Entrega de Webhooks
Zelta Pay implementa un sistema robusto de entrega de webhooks con reintentos automáticos, backoff exponencial y una Dead Letter Queue (DLQ) para garantizar que ningún evento se pierda.
Proceso de entrega
Cuando ocurre un evento, Zelta Pay sigue estos pasos:
- Generación -- Se genera el evento con un ID único y se firma criptográficamente
- Primer intento -- Se envía la solicitud HTTP
POSTa tu endpoint inmediatamente - Evaluación de respuesta -- Se evalúa el código de estado de la respuesta
- Reintentos -- Si la entrega falla, se reintenta con backoff exponencial
- DLQ -- Si todos los intentos fallan, el evento se envía a la Dead Letter Queue
Cronología de entrega
- Entrega inmediata -- El primer intento se realiza en milisegundos después del evento
- Hasta 6 intentos -- 1 intento inicial + hasta 5 reintentos
- Período total de ~24 horas -- Los reintentos se distribuyen a lo largo de aproximadamente 24 horas
- DLQ -- Los eventos que agotan todos los intentos se almacenan por 30 días
Calendario de reintentos
Cada reintento usa backoff exponencial con jitter (variación aleatoria) para evitar sobrecarga:
| Intento | Retraso base | Rango con jitter | Retraso máximo |
|---|---|---|---|
| 1 | Inmediato | - | - |
| 2 | 1 minuto | 30s - 1m 30s | 2 minutos |
| 3 | 5 minutos | 2m 30s - 7m 30s | 10 minutos |
| 4 | 30 minutos | 15m - 45m | 1 hora |
| 5 | 2 horas | 1h - 3h | 4 horas |
| 6 | 8 horas | 4h - 12h | 16 horas |
Implementación del jitter
El jitter agrega variación aleatoria al retraso base para evitar que múltiples webhooks fallidos se reintenten al mismo tiempo:
function calculateRetryDelay(attempt) {
const baseDelays = [0, 60, 300, 1800, 7200, 28800]; // en segundos
const baseDelay = baseDelays[attempt - 1] || 28800;
// Jitter: +/- 50% del delay base
const jitter = baseDelay * 0.5;
const delay = baseDelay + (Math.random() * jitter * 2 - jitter);
return Math.max(0, Math.round(delay));
}Manejo de respuestas
El comportamiento de Zelta Pay depende del código de estado que tu endpoint retorne:
Éxito (2xx)
Cualquier respuesta con código 2xx se considera exitosa. El evento se marca como entregado y no se reintenta.
HTTP/1.1 200 OK
Content-Type: application/json
{"received": true}Errores reintentables
Los siguientes códigos provocan reintentos automáticos:
| Código | Descripción | Comportamiento |
|---|---|---|
408 | Request Timeout | Se reintenta |
429 | Too Many Requests | Se reintenta |
500 | Internal Server Error | Se reintenta |
502 | Bad Gateway | Se reintenta |
503 | Service Unavailable | Se reintenta |
504 | Gateway Timeout | Se reintenta |
| Timeout | Sin respuesta dentro del tiempo límite | Se reintenta |
Errores no reintentables (4xx)
Los errores 4xx (excepto 408 y 429) se consideran permanentes. El evento se envía directamente al DLQ sin reintentos:
| Código | Descripción | Comportamiento |
|---|---|---|
400 | Bad Request | Directo al DLQ |
401 | Unauthorized | Directo al DLQ |
403 | Forbidden | Directo al DLQ |
404 | Not Found | Directo al DLQ |
405 | Method Not Allowed | Directo al DLQ |
422 | Unprocessable Entity | Directo al DLQ |
Dead Letter Queue (DLQ)
¿Qué es el DLQ?
La Dead Letter Queue es un almacén donde se guardan los eventos de webhook que no pudieron ser entregados exitosamente. Te permite revisar, diagnosticar y reintentar estos eventos manualmente.
¿Cuándo un evento va al DLQ?
- Después de agotar los 6 intentos de entrega con errores reintentables
- Inmediatamente al recibir un error
4xxno reintentable
Gestión del DLQ
| Característica | Detalle |
|---|---|
| Retención | Los eventos se almacenan por 30 días |
| Acceso | Disponible desde el |
| Reintento manual | Puedes reintentar eventos individuales desde el dashboard |
| Reintento masivo | Puedes reintentar múltiples eventos a la vez |
Revisa tu DLQ regularmente
Eventos en el DLQ pueden indicar problemas con tu endpoint (URL incorrecta, errores en tu código, servidor caído). Revisa el DLQ periódicamente desde el para identificar y resolver problemas.
Headers de entrega
Headers estándar
Presentes en todas las entregas:
| Header | Descripción |
|---|---|
Content-Type | application/json |
User-Agent | ZeltaPay-Webhooks/1.0 |
Zeltapay-Event-Id | Identificador único del evento |
Zeltapay-Event-Type | Tipo del evento |
Zeltapay-Timestamp | Timestamp Unix del evento |
Zeltapay-Signature | Firma HMAC-SHA256 |
Headers específicos de entrega
Presentes para monitorear el estado de la entrega:
| Header | Descripción | Ejemplo |
|---|---|---|
Zeltapay-Delivery-Attempt | Número de intento actual (1-6) | 3 |
Ejemplos de implementación
Handler básico
:: tab Node.js (Express)
import express from 'express';
const app = express();
app.use(express.json());
app.post('/webhooks/zelta-pay', async (req, res) => {
const deliveryAttempt = req.headers['zeltapay-delivery-attempt'];
const eventId = req.headers['zeltapay-event-id'];
console.log(`Webhook recibido - Evento: ${eventId}, Intento: ${deliveryAttempt}`);
try {
await processEvent(req.body);
res.status(200).json({ received: true });
} catch (error) {
console.error(`Error procesando evento ${eventId}:`, error);
// Responder 200 para evitar reintentos si el error es de lógica de negocio
// Responder 500 solo si quieres que se reintente
res.status(200).json({ received: true, error: error.message });
}
});::
:: tab Cloudflare Workers
export default {
async fetch(request, env, ctx) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const deliveryAttempt = request.headers.get('zeltapay-delivery-attempt');
const eventId = request.headers.get('zeltapay-event-id');
console.log(`Webhook recibido - Evento: ${eventId}, Intento: ${deliveryAttempt}`);
try {
const event = await request.json();
// Procesar de forma asíncrona con waitUntil
ctx.waitUntil(processEvent(event, env));
return Response.json({ received: true }, { status: 200 });
} catch (error) {
console.error(`Error procesando evento ${eventId}:`, error);
return Response.json({ received: true, error: error.message }, { status: 200 });
}
}
};::
:: tab Hono
import { Hono } from 'hono';
const app = new Hono();
app.post('/webhooks/zelta-pay', async (c) => {
const deliveryAttempt = c.req.header('zeltapay-delivery-attempt');
const eventId = c.req.header('zeltapay-event-id');
console.log(`Webhook recibido - Evento: ${eventId}, Intento: ${deliveryAttempt}`);
try {
const event = await c.req.json();
// Procesar de forma asíncrona con executionCtx.waitUntil
c.executionCtx.waitUntil(processEvent(event, c.env));
return c.json({ received: true }, 200);
} catch (error) {
console.error(`Error procesando evento ${eventId}:`, error);
return c.json({ received: true, error: error.message }, 200);
}
});
export default app;::
Handler avanzado con idempotencia
import express from 'express';
const app = express();
app.use(express.json());
app.post('/webhooks/zelta-pay', async (req, res) => {
const eventId = req.headers['zeltapay-event-id'];
const deliveryAttempt = req.headers['zeltapay-delivery-attempt'];
try {
// Verificar idempotencia
const existing = await db.query(
'SELECT event_id FROM webhook_events WHERE event_id = $1',
[eventId]
);
if (existing.rows.length > 0) {
console.log(`Evento duplicado: ${eventId} (intento ${deliveryAttempt})`);
return res.status(200).json({ received: true, duplicate: true });
}
// Procesar dentro de una transacción
await db.transaction(async (tx) => {
// Registrar el evento
await tx.query(
'INSERT INTO webhook_events (event_id, event_type, delivery_attempt, payload) VALUES ($1, $2, $3, $4)',
[eventId, req.body.type, deliveryAttempt, JSON.stringify(req.body)]
);
// Procesar la lógica de negocio
await processEvent(tx, req.body);
});
res.status(200).json({ received: true });
} catch (error) {
console.error(`Error procesando evento ${eventId}:`, error);
// Si es un error de base de datos, permitir reintento
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
return res.status(503).json({ error: 'Database unavailable' });
}
// Para otros errores, responder 200 para evitar reintentos
res.status(200).json({ received: true, error: error.message });
}
});Monitoreo y alertas
Métricas a rastrear
Monitorea las siguientes métricas para asegurar la salud de tu integración de webhooks:
| Métrica | Descripción |
|---|---|
| Tasa de éxito | Porcentaje de webhooks procesados exitosamente |
| Latencia de respuesta | Tiempo que tarda tu endpoint en responder |
| Eventos en DLQ | Cantidad de eventos en la Dead Letter Queue |
| Tasa de duplicados | Porcentaje de eventos duplicados detectados |
| Errores por tipo | Distribución de errores (timeout, 5xx, lógica, etc.) |
Reglas de alertas recomendadas
- Crítica: Tasa de éxito < 95% en los últimos 15 minutos
- Advertencia: Latencia de respuesta > 5 segundos
- Crítica: Más de 10 eventos en DLQ en la última hora
- Informativa: Primer evento en DLQ del día
Ejemplo de monitoreo
:: tab Node.js
const metrics = {
received: 0,
processed: 0,
failed: 0,
duplicates: 0,
totalLatencyMs: 0
};
app.post('/webhooks/zelta-pay', async (req, res) => {
const startTime = Date.now();
metrics.received++;
try {
const eventId = req.headers['zeltapay-event-id'];
const isDuplicate = await checkIfProcessed(eventId);
if (isDuplicate) {
metrics.duplicates++;
return res.status(200).json({ received: true, duplicate: true });
}
await processEvent(req.body);
metrics.processed++;
res.status(200).json({ received: true });
} catch (error) {
metrics.failed++;
console.error('Error procesando webhook:', error);
res.status(200).json({ received: true, error: error.message });
} finally {
metrics.totalLatencyMs += Date.now() - startTime;
}
});
// Endpoint de métricas
app.get('/webhooks/metrics', (req, res) => {
res.json({
...metrics,
averageLatencyMs: metrics.received > 0
? Math.round(metrics.totalLatencyMs / metrics.received)
: 0,
successRate: metrics.received > 0
? ((metrics.processed / metrics.received) * 100).toFixed(2) + '%'
: 'N/A'
});
});::
:: tab Cloudflare Workers
export default {
async fetch(request, env, ctx) {
if (request.method === 'GET' && new URL(request.url).pathname === '/webhooks/metrics') {
const metrics = JSON.parse(await env.WEBHOOK_KV.get('metrics') || '{}');
return Response.json(metrics);
}
const startTime = Date.now();
try {
const event = await request.json();
ctx.waitUntil(processEvent(event, env));
// Actualizar métricas
ctx.waitUntil(updateMetrics(env, 'processed', Date.now() - startTime));
return Response.json({ received: true }, { status: 200 });
} catch (error) {
ctx.waitUntil(updateMetrics(env, 'failed', Date.now() - startTime));
return Response.json({ received: true, error: error.message }, { status: 200 });
}
}
};
async function updateMetrics(env, status, latencyMs) {
const metrics = JSON.parse(await env.WEBHOOK_KV.get('metrics') || '{"received":0,"processed":0,"failed":0}');
metrics.received++;
metrics[status]++;
await env.WEBHOOK_KV.put('metrics', JSON.stringify(metrics));
}::
Buenas prácticas
Responde rápido
Tu endpoint debe responder con 200 en menos de 5 segundos. Usa procesamiento asíncrono para lógica que toma más tiempo:
// Bueno: responder inmediato, procesar después
app.post('/webhooks/zelta-pay', (req, res) => {
res.status(200).json({ received: true });
processEventAsync(req.body).catch(console.error);
});Maneja errores correctamente
- Responde
200si tu servidor recibió el evento pero falló la lógica de negocio - Responde
503solo si tu servidor realmente no puede procesar (ej. base de datos caída) y quieres que se reintente - Nunca respondas
4xxa menos que el endpoint sea incorrecto
Implementa un circuit breaker
Si tu servicio downstream está caído, evita procesar webhooks que sabes que fallarán:
let circuitOpen = false;
let lastFailure = 0;
const CIRCUIT_TIMEOUT = 60000; // 1 minuto
app.post('/webhooks/zelta-pay', async (req, res) => {
// Verificar circuit breaker
if (circuitOpen && Date.now() - lastFailure < CIRCUIT_TIMEOUT) {
// Responder 503 para que se reintente después
return res.status(503).json({ error: 'Service temporarily unavailable' });
}
try {
await processEvent(req.body);
circuitOpen = false;
res.status(200).json({ received: true });
} catch (error) {
if (isDownstreamError(error)) {
circuitOpen = true;
lastFailure = Date.now();
return res.status(503).json({ error: 'Downstream service unavailable' });
}
res.status(200).json({ received: true, error: error.message });
}
});Usa colas de mensajes
Para alta confiabilidad, encola los eventos y procésalos de forma asíncrona:
app.post('/webhooks/zelta-pay', async (req, res) => {
const eventId = req.headers['zeltapay-event-id'];
// Encolar para procesamiento asíncrono
await messageQueue.send({
eventId,
payload: req.body,
receivedAt: new Date().toISOString()
});
res.status(200).json({ received: true });
});Solución de problemas
| Problema | Causa probable | Solución |
|---|---|---|
| Muchos reintentos | Tu endpoint responde lento o con errores 5xx | Optimiza tu endpoint y responde con 200 rápidamente |
| Eventos en DLQ | Errores 4xx o todos los reintentos agotados | Verifica la URL del endpoint y los logs de tu servidor |
| Eventos duplicados | Reintentos exitosos después de timeout | Implementa |
| Orden incorrecto | Los reintentos pueden alterar el orden | Usa el campo timestamp del evento para verificar el orden |
| Pérdida de eventos | Endpoint caído por período prolongado | Revisa el DLQ en el y reintenta |
Siguientes pasos
- -- Guía general de webhooks
- -- Referencia completa de eventos y payloads
- -- Evitar procesamiento duplicado
- -- Verificar autenticidad de los webhooks