Entrega de Webhooks
Zelta Pay entrega cada evento de webhook con reintentos automáticos para que un error pasajero de tu endpoint no te haga perder eventos. Esta guía explica cómo se entrega cada evento, cuándo se reintenta y qué pasa cuando la entrega falla definitivamente.
En el dashboard puedes revisar las trazas de entrega del webhook de cada proyecto, con su estado, código de respuesta HTTP y número de intentos:

Proceso de entrega
Cuando ocurre un evento, Zelta Pay sigue estos pasos:
- Generación -- Se genera el evento con su
eventId - Primer intento -- Se firma y se envía la solicitud HTTP
POSTa tu endpoint - Evaluación de respuesta -- Se evalúa el código de estado de la respuesta
- Reintentos -- Si la entrega falla con un error reintentable, se reintenta 15 segundos después
- Descarte -- Si se agotan los intentos, o la respuesta no es reintentable, la entrega se descarta de forma definitiva
Cronología de entrega
- Primer intento -- Normalmente pocos segundos después del evento; no hay una garantía de latencia
- Timeout -- Cada intento espera tu respuesta como máximo 60 segundos
- Hasta 6 intentos -- 1 intento inicial + hasta 5 reintentos automáticos
- Retraso fijo -- Cada reintento se hace 15 segundos después del intento fallido anterior, sin backoff exponencial. Un header
Retry-Afteren tu respuesta no se toma en cuenta
Todos los intentos ocurren en pocos minutos (75 segundos de espera entre reintentos más hasta 60 segundos por intento). No cuentes con los reintentos para cubrir una caída larga de tu endpoint: concilia los eventos perdidos con la API.
Calendario de reintentos
| Entrega | Cuándo |
|---|---|
| Intento 1 | Envío inicial, poco después del evento |
| Intentos 2–6 | 15 segundos después de que falló el intento anterior |
Puedes leer el número de intento actual en el header Zeltapay-Delivery-Attempt de cada solicitud. Cada intento se firma de nuevo, así que Zeltapay-Timestamp y el t de la firma cambian en cada reintento; el eventId y el cuerpo se mantienen.
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 casos provocan un reintento 15 segundos después:
| Caso | Descripción |
|---|---|
408 | Request Timeout |
429 | Too Many Requests |
5xx | Cualquier error de servidor (500, 502, 503, 504...) |
| Timeout | Sin respuesta dentro de 60 segundos |
| Error de red | No se pudo conectar con tu endpoint |
Errores no reintentables (3xx / 4xx)
Cualquier otra respuesta que no sea 2xx descarta la entrega de inmediato, sin reintentos:
- Los errores
4xx(excepto408y429), por ejemplo400,401,403,404,405o422 - Las redirecciones
3xx: nunca se siguen (por seguridad, para que la solicitud firmada no se reenvíe a otro host) y cuentan como fallo
Cuidado con tus respuestas 4xx
Un 4xx descarta el evento para siempre. Responde 401 solo cuando la firma realmente no es válida, y usa 5xx (o 503) si tu servidor tiene un problema temporal y quieres que el evento se reintente.
Entregas que no se envían
- Si el webhook se eliminó o se desactivó después de generarse el evento, la entrega se cancela sin más intentos
- Si la URL del webhook no es un endpoint HTTPS público, la entrega se descarta sin enviarse
Entregas descartadas
Una entrega se descarta de forma definitiva cuando:
- Se agotan los 6 intentos con errores reintentables
- Tu endpoint responde un código no reintentable (
3xxo4xxdistinto de408y429) - La URL del webhook no es un endpoint HTTPS público
Dónde verlas
- Webhooks de proyecto: la pestaña Webhooks del proyecto (Proyectos → tu proyecto → Webhooks) lista cada entrega con su estado — Pendiente, Entregado, Fallido o Descartado —, el código HTTP de tu respuesta, el número de intentos y el payload (Ver payload).
- Webhook de la cuenta: el dashboard no tiene una lista de entregas.
No hay reenvío manual ni masivo. Si una entrega se descarta, corrige tu endpoint y concilia los eventos perdidos consultando la API (por ejemplo, o ).
Headers de entrega
Headers estándar
Presentes en todas las entregas:
| Header | Descripción |
|---|---|
Content-Type | application/json |
User-Agent | zeltapay-webhook/1.0 |
Zeltapay-Event-Id | Identificador único del evento (un UUID, igual al eventId del cuerpo) |
Zeltapay-Event-Type | Tipo del evento |
Zeltapay-Timestamp | Timestamp Unix (segundos) del momento en que se firmó este intento |
Zeltapay-Signature | Firma HMAC-SHA256 con formato t=<timestamp>, v1=<hmac> |
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
import express from 'express';
const app = express();
app.post('/webhooks/zelta-pay', express.raw({ type: 'application/json' }), 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}`);
// (verifica la firma con el cuerpo crudo antes de continuar)
try {
await processEvent(JSON.parse(req.body.toString('utf8')));
res.status(200).json({ received: true });
} catch (error) {
console.error(`Error procesando evento ${eventId}:`, error);
// Responder 200 si no quieres que se reintente (error de lógica de negocio)
// Responder 500 solo si quieres que se reintente
res.status(200).json({ received: true, error: error.message });
}
});Handler avanzado con idempotencia
import express from 'express';
const app = express();
app.post('/webhooks/zelta-pay', express.raw({ type: 'application/json' }), async (req, res) => {
const eventId = req.headers['zeltapay-event-id'];
const deliveryAttempt = req.headers['zeltapay-delivery-attempt'];
const event = JSON.parse(req.body.toString('utf8'));
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, event.type, deliveryAttempt, JSON.stringify(event)]
);
// Procesar la lógica de negocio
await processEvent(tx, event);
});
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 en tu endpoint 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 |
| Reintentos recibidos | Entregas con Zeltapay-Delivery-Attempt mayor que 1 |
| Tasa de duplicados | Porcentaje de eventos duplicados detectados |
| Errores por tipo | Distribución de errores (timeout, 5xx, lógica, etc.) |
Reglas de alertas sugeridas
- Crítica: Tasa de éxito < 95% en los últimos 15 minutos
- Advertencia: Latencia de respuesta cercana al timeout de 60 segundos
- Advertencia: Muchas entregas con
Zeltapay-Delivery-Attemptmayor que 1 - Crítica: Entregas en estado Descartado en la pestaña Webhooks de tus proyectos
Ejemplo de monitoreo
const metrics = {
received: 0,
processed: 0,
failed: 0,
duplicates: 0,
retries: 0,
totalLatencyMs: 0
};
app.post('/webhooks/zelta-pay', express.raw({ type: 'application/json' }), async (req, res) => {
const startTime = Date.now();
metrics.received++;
if (Number(req.headers['zeltapay-delivery-attempt']) > 1) metrics.retries++;
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(JSON.parse(req.body.toString('utf8')));
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'
});
});Buenas prácticas
Responde rápido
Tu endpoint debe responder con un 2xx lo antes posible, y siempre antes de los 60 segundos del timeout. Usa procesamiento asíncrono para la lógica que toma más tiempo:
// Bueno: guardar el evento, responder y procesar después
app.post('/webhooks/zelta-pay', express.raw({ type: 'application/json' }), async (req, res) => {
const event = JSON.parse(req.body.toString('utf8'));
await saveEventForLater(event); // p. ej. en una tabla o cola propia
res.status(200).json({ received: true });
processEventAsync(event).catch(console.error);
});Maneja errores correctamente
- Responde
200si tu servidor recibió el evento pero falló la lógica de negocio y no quieres que se reintente - Responde
503(o cualquier5xx) solo si tu servidor realmente no puede procesar en este momento (ej. base de datos caída) y quieres que se reintente - Evita los
4xxsalvo para firmas inválidas: descartan el evento sin reintentos
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', express.raw({ type: 'application/json' }), 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(JSON.parse(req.body.toString('utf8')));
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 });
}
});Los reintentos duran pocos minutos
Como los 6 intentos ocurren en pocos minutos, un circuit breaker abierto durante más tiempo hará que se descarten entregas. Úsalo para caídas cortas y concilia con la API después de una caída larga.
Usa colas de mensajes
Para alta confiabilidad, encola los eventos y procésalos de forma asíncrona:
app.post('/webhooks/zelta-pay', express.raw({ type: 'application/json' }), async (req, res) => {
const eventId = req.headers['zeltapay-event-id'];
// Encolar para procesamiento asíncrono
await messageQueue.send({
eventId,
payload: req.body.toString('utf8'),
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 2xx rápidamente |
| Entregas descartadas | Errores 4xx/3xx, o los 6 intentos se agotaron | Verifica la URL del endpoint y los logs de tu servidor; concilia los eventos perdidos con la API |
| Eventos duplicados | Reintentos después de un timeout, o un payment.success reenviado | Implementa con el eventId |
| Orden incorrecto | Los reintentos pueden alterar el orden de llegada | Usa los campos de fecha del payload (por ejemplo createdAt o paidAt) y consulta la API si necesitas el estado actual |
| Pérdida de eventos | Endpoint caído por más de unos minutos | Concilia los recursos afectados con la API: las entregas descartadas no se pueden reenviar |
Siguientes pasos
- -- Guía general de webhooks
- -- Referencia completa de eventos y payloads
- -- Evitar procesamiento duplicado
- -- Verificar autenticidad de los webhooks