Skip to content

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:

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 su eventId
  2. Primer intento -- Se firma y se envía la solicitud HTTP POST a tu endpoint
  3. Evaluación de respuesta -- Se evalúa el código de estado de la respuesta
  4. Reintentos -- Si la entrega falla con un error reintentable, se reintenta 15 segundos después
  5. 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-After en 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 ​

EntregaCuándo
Intento 1Envío inicial, poco después del evento
Intentos 2–615 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
HTTP/1.1 200 OK
Content-Type: application/json

{"received": true}

Errores reintentables ​

Los siguientes casos provocan un reintento 15 segundos después:

CasoDescripción
408Request Timeout
429Too Many Requests
5xxCualquier error de servidor (500, 502, 503, 504...)
TimeoutSin respuesta dentro de 60 segundos
Error de redNo 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 (excepto 408 y 429), por ejemplo 400, 401, 403, 404, 405 o 422
  • 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 (3xx o 4xx distinto de 408 y 429)
  • 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:

HeaderDescripción
Content-Typeapplication/json
User-Agentzeltapay-webhook/1.0
Zeltapay-Event-IdIdentificador único del evento (un UUID, igual al eventId del cuerpo)
Zeltapay-Event-TypeTipo del evento
Zeltapay-TimestampTimestamp Unix (segundos) del momento en que se firmó este intento
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.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 ​

javascript
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étricaDescripción
Tasa de éxitoPorcentaje de webhooks procesados exitosamente
Latencia de respuestaTiempo que tarda tu endpoint en responder
Reintentos recibidosEntregas con Zeltapay-Delivery-Attempt mayor que 1
Tasa de duplicadosPorcentaje de eventos duplicados detectados
Errores por tipoDistribució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-Attempt mayor que 1
  • Crítica: Entregas en estado Descartado en la pestaña Webhooks de tus proyectos

Ejemplo de monitoreo ​

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

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

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

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

ProblemaCausa probableSolución
Muchos reintentosTu endpoint responde lento o con errores 5xxOptimiza tu endpoint y responde con 2xx rápidamente
Entregas descartadasErrores 4xx/3xx, o los 6 intentos se agotaronVerifica la URL del endpoint y los logs de tu servidor; concilia los eventos perdidos con la API
Eventos duplicadosReintentos después de un timeout, o un payment.success reenviadoImplementa con el eventId
Orden incorrectoLos reintentos pueden alterar el orden de llegadaUsa los campos de fecha del payload (por ejemplo createdAt o paidAt) y consulta la API si necesitas el estado actual
Pérdida de eventosEndpoint caído por más de unos minutosConcilia 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

Documentación oficial de Zelta