Skip to content

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:

  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 con backoff exponencial
  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 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:

IntentoRetraso baseRango con jitterRetraso máximo
1Inmediato--
21 minuto30s - 1m 30s2 minutos
35 minutos2m 30s - 7m 30s10 minutos
430 minutos15m - 45m1 hora
52 horas1h - 3h4 horas
68 horas4h - 12h16 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:

javascript
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ó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 (4xx)

Los errores 4xx (excepto 408 y 429) se consideran permanentes. El evento se envía directamente al DLQ sin reintentos:

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-Webhooks/1.0
Zeltapay-Event-IdIdentificador único del evento
Zeltapay-Event-TypeTipo del evento
Zeltapay-TimestampTimestamp Unix del evento
Zeltapay-SignatureFirma HMAC-SHA256

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

:: tab Node.js (Express)

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

::

:: tab Cloudflare Workers

javascript
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

javascript
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

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

:: tab Node.js

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

::

:: tab Cloudflare Workers

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

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