Skip to content

Entrega de Webhooks

Zelta Pay implementa un sistema robusto de entrega de webhooks con reintentos automáticos y una Dead Letter Queue (DLQ) para garantizar que ningún evento se pierda.

En el dashboard puedes revisar las trazas de entrega de cada evento, con su estado, código de respuesta HTTP y número de intentos:

Trazas de entrega de webhooks con estado y código HTTP

Proceso de entrega

Cuando ocurre un evento, Zelta Pay sigue estos pasos:

  1. Generación -- Se genera el evento con un ID único y se firma criptográficamente
  2. Primer intento -- Se envía la solicitud HTTP POST a tu endpoint inmediatamente
  3. Evaluación de respuesta -- Se evalúa el código de estado de la respuesta
  4. Reintentos -- Si la entrega falla, se reintenta automáticamente tras un breve retraso
  5. 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 entregas -- 1 intento inicial + hasta 5 reintentos automáticos
  • Retraso entre intentos -- En producción cada reintento espera un retraso base de aproximadamente 15 segundos antes de volver a intentar
  • DLQ -- Los eventos que agotan todos los intentos se almacenan por 30 días

Calendario de reintentos

Si un intento no recibe una respuesta 2xx (o no responde a tiempo), Zelta Pay reintenta la entrega. En producción se realizan el intento inicial más hasta 5 reintentos, con un retraso base de ~15 segundos entre cada uno:

EntregaDescripción
Intento 1Envío inicial, inmediato tras el evento
Intentos 2–6Hasta 5 reintentos automáticos, cada uno tras un retraso base de ~15 s

Puedes leer el número de intento actual en el header Zeltapay-Delivery-Attempt de cada solicitud.

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ódigoDescripciónComportamiento
408Request TimeoutSe reintenta
429Too Many RequestsSe reintenta
500Internal Server ErrorSe reintenta
502Bad GatewaySe reintenta
503Service UnavailableSe reintenta
504Gateway TimeoutSe reintenta
TimeoutSin respuesta dentro del tiempo límiteSe reintenta

Errores no reintentables (3xx / 4xx)

Los errores 4xx (excepto 408 y 429) se consideran permanentes y el evento se envía directamente al DLQ sin reintentos. Las respuestas de redirección (3xx) nunca se siguen —por seguridad, para evitar que un endpoint reenvíe la solicitud firmada a otro host— y también se envían directamente al DLQ:

CódigoDescripciónComportamiento
400Bad RequestDirecto al DLQ
401UnauthorizedDirecto al DLQ
403ForbiddenDirecto al DLQ
404Not FoundDirecto al DLQ
405Method Not AllowedDirecto al DLQ
422Unprocessable EntityDirecto 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 4xx no reintentable

Gestión del DLQ

CaracterísticaDetalle
RetenciónLos eventos se almacenan por 30 días
AccesoDisponible desde el
Reintento manualPuedes reintentar eventos individuales desde el dashboard
Reintento masivoPuedes 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:

HeaderDescripción
Content-Typeapplication/json
User-Agentzeltapay-webhook/1.0
Zeltapay-Event-IdIdentificador único del evento
Zeltapay-Event-TypeTipo del evento
Zeltapay-TimestampTimestamp Unix (segundos) del evento
Zeltapay-SignatureFirma HMAC-SHA256 con formato t=<timestamp>, v1=<hmac>

Headers específicos de entrega

Presentes para monitorear el estado de la entrega:

HeaderDescripciónEjemplo
Zeltapay-Delivery-AttemptNúmero de intento actual (1-6)3

Ejemplos de implementación

Handler básico

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

Handler avanzado con idempotencia

javascript
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étricaDescripción
Tasa de éxitoPorcentaje de webhooks procesados exitosamente
Latencia de respuestaTiempo que tarda tu endpoint en responder
Eventos en DLQCantidad de eventos en la Dead Letter Queue
Tasa de duplicadosPorcentaje de eventos duplicados detectados
Errores por tipoDistribució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

javascript
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'
  });
});

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:

javascript
// 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 200 si tu servidor recibió el evento pero falló la lógica de negocio
  • Responde 503 solo si tu servidor realmente no puede procesar (ej. base de datos caída) y quieres que se reintente
  • Nunca respondas 4xx a 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:

javascript
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:

javascript
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

ProblemaCausa probableSolución
Muchos reintentosTu endpoint responde lento o con errores 5xxOptimiza tu endpoint y responde con 200 rápidamente
Eventos en DLQErrores 4xx o todos los reintentos agotadosVerifica la URL del endpoint y los logs de tu servidor
Eventos duplicadosReintentos exitosos después de timeoutImplementa
Orden incorrectoLos reintentos pueden alterar el ordenUsa el campo timestamp del evento para verificar el orden
Pérdida de eventosEndpoint caído por período prolongadoRevisa 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

Documentación oficial de Zelta