Skip to content

Webhooks

Los webhooks te permiten recibir notificaciones automáticas en tu servidor cuando ocurren eventos en Zelta Pay, como un pago completado o un intento de pago rechazado. En lugar de consultar la API repetidamente, Zelta Pay envía una solicitud HTTP POST a la URL que configures.

Beneficios

BeneficioDescripción
Tiempo realRecibe notificaciones al instante, sin necesidad de polling
EficienteReduce la carga en tu servidor y el consumo de la API
ConfiableSistema de reintentos automáticos (el intento inicial más hasta 5 reintentos)
SeguroCada webhook incluye una firma HMAC-SHA256 para verificar su autenticidad

Inicio rápido

1. Crea tu endpoint

Tu endpoint debe cumplir estos requisitos:

  • Aceptar solicitudes HTTP POST
  • Responder con un código de estado 2xx (200, 201, 202, etc.)
  • Estar disponible vía HTTPS (obligatorio en producción)

2. Configura el webhook en el dashboard

  1. Inicia sesión en tu
  2. Navega a Webhooks en el sidebar
  3. Haz clic en Agregar webhook
  4. Ingresa la URL de tu endpoint (ej. https://miapp.com/webhook/zelta-pay)
  5. Selecciona los eventos que deseas recibir
  6. Guarda la configuración y copia tu webhook secret

Guarda tu webhook secret

El webhook secret se muestra una sola vez al crear el webhook. Guárdalo de forma segura en tus variables de entorno. Lo necesitarás para de los webhooks.

3. Implementa el handler

javascript
import express from 'express';
import crypto from 'crypto';

const app = express();

app.post('/webhook/zelta-pay', express.raw({ type: 'application/json' }), async (req, res) => {
  const signatureHeader = req.headers['zeltapay-signature']; // formato: "t=..., v1=..."
  const timestamp = req.headers['zeltapay-timestamp'];
  const eventType = req.headers['zeltapay-event-type'];
  const rawBody = req.body.toString();

  // Extraer la firma v1 del header Zeltapay-Signature
  const signature = signatureHeader
    .split(',')
    .map(p => p.trim())
    .find(p => p.startsWith('v1='))
    ?.slice(3);

  // Verificar firma: el HMAC se calcula sobre `t={timestamp}.{body}`
  const expectedSignature = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(`t=${timestamp}.${rawBody}`)
    .digest('hex');

  if (signature !== expectedSignature) {
    return res.status(401).json({ error: 'Firma inválida' });
  }

  const payload = JSON.parse(rawBody);

  // Procesar según tipo de evento
  switch (eventType) {
    case 'payment.success':
      console.log('Pago completado:', payload.paymentLinkId);
      await handlePaymentSuccess(payload);
      break;

    case 'webhook.ping':
      console.log('Ping recibido');
      break;

    default:
      console.log('Evento no manejado:', eventType);
  }

  res.status(200).json({ received: true });
});

app.listen(3000);

Tipos de eventos

Zelta Pay emite muchos tipos de evento. Los más comunes para un link de pago son:

EventoDescripción
payment.successUn pago se ha completado exitosamente
payment.failedUn intento de pago fue rechazado
receipt.createdSe generó el comprobante PDF de un pago
webhook.pingEvento de prueba para verificar la conectividad del endpoint

Si además usas la (proyectos, clientes, cargos y suscripciones), recibirás también eventos como charge.succeeded, charge.failed, subscription.created, invoice.paid y customer.created, entre otros.

Para ver la lista completa de eventos y la estructura de cada payload, consulta la .

Payload de payment.success

json
{
  "eventId": "evt_abc123def456",
  "type": "payment.success",
  "paymentLinkId": "pl_1234567890abcdef",
  "accountId": "acc_1234567890abcdef",
  "projectId": null,
  "createdAt": "2026-03-13T14:30:00.000Z",
  "transaction": {
    "externalPaymentId": "ext_abc123",
    "orderId": "ORD-2026-0042",
    "amount": 15000,
    "currency": "USD",
    "paidAt": "2026-03-13T14:30:00.000Z",
    "paymentMethod": "credit_card",
    "paymentProvider": "nmi",
    "concept": "Orden #ORD-2026-0042",
    "paidTo": "Acme Inc",
    "isTest": false
  },
  "customer": {
    "customerName": "Maria Garcia",
    "customerEmail": "maria@ejemplo.com"
  },
  "metadata": {
    "orderId": "ORD-2026-0042",
    "type": "product-purchase"
  }
}

Consulta la para el resto de los payloads.

Payload de webhook.ping

json
{
  "eventId": "evt_ping_001",
  "type": "webhook.ping",
  "ok": true,
  "message": "Hello from Zeltapay! Your endpoint is reachable.",
  "sentAt": "2026-03-13T14:30:00.000Z"
}

Headers del webhook

Cada solicitud de webhook incluye los siguientes headers:

HeaderDescripciónEjemplo
Zeltapay-Event-IdIdentificador único del evento. Úsalo para idempotenciaevt_abc123def456
Zeltapay-Event-TypeTipo de eventopayment.success
Zeltapay-TimestampTimestamp UNIX en segundos de cuando se envió el webhook1710340200
Zeltapay-SignatureFirma HMAC-SHA256 para verificar la autenticidada1b2c3d4e5...
Zeltapay-Delivery-AttemptNúmero de intento de entrega (1 = primer intento)1

Usa los headers para seguridad

Siempre verifica la firma (Zeltapay-Signature) y valida el timestamp (Zeltapay-Timestamp) antes de procesar un webhook. Consulta la guía de para una implementación completa.

Entrega de webhooks

Política de reintentos

Si tu endpoint no responde con un código 2xx (o no responde a tiempo), Zelta Pay reintentará la entrega automáticamente. En producción se realizan el intento inicial más hasta 5 reintentos (6 entregas en total), con un retraso base de ~15 segundos entre intentos.

EntregaDescripción
Intento 1Envío inicial, inmediato tras el evento
Intentos 2–6Hasta 5 reintentos automáticos si el intento previo no recibió un 2xx

Puedes leer el número de intento en el header Zeltapay-Delivery-Attempt de cada solicitud. Si tras el último reintento la entrega sigue fallando, el evento se envía a la Dead Letter Queue (DLQ). Consulta la guía de para los detalles completos.

Comportamiento según código de respuesta

Código HTTPComportamiento
2xx (200, 201, 202...)Entrega exitosa. No se reintenta
3xx / 4xx (excepto 408 y 429)No se reintenta. Se envía directamente a la Dead Letter Queue (DLQ). Los redireccionamientos (3xx) nunca se siguen
408, 429, 5xx (500, 502, 503...)Se reintenta según la política de reintentos
TimeoutSi no se recibe respuesta en 60 segundos, se trata como reintentable y se reintenta

Dead Letter Queue

Los webhooks que fallan después de todos los reintentos o que reciben un código 4xx se envían a la Dead Letter Queue. Puedes revisar y reenviar webhooks fallidos desde tu en Webhooks > DLQ.

Idempotencia

Los webhooks pueden entregarse más de una vez (por ejemplo, si tu servidor respondió lentamente y el sistema lo interpretó como un fallo). Usa el header Zeltapay-Event-Id para garantizar que procesas cada evento solo una vez.

Implementación básica

javascript
const processedEvents = new Set();

async function handleWebhook(req, res) {
  const eventId = req.headers['zeltapay-event-id'];

  // Verificar si ya procesamos este evento
  if (processedEvents.has(eventId)) {
    return res.status(200).json({ received: true, duplicate: true });
  }

  // Procesar el evento
  const payload = JSON.parse(req.body);
  await processEvent(payload);

  // Marcar como procesado
  processedEvents.add(eventId);

  res.status(200).json({ received: true });
}

Almacenamiento en memoria

El ejemplo anterior usa un Set en memoria, que se pierde al reiniciar el servidor. En producción, usa una base de datos para almacenar los IDs de eventos procesados.

Implementación con base de datos

Para mayor confiabilidad, usa restricciones únicas en tu base de datos:

sql
CREATE TABLE webhook_events (
  event_id VARCHAR(255) PRIMARY KEY,
  event_type VARCHAR(100) NOT NULL,
  processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  payload JSONB NOT NULL
);
javascript
async function handleWebhookWithDb(req, res) {
  const eventId = req.headers['zeltapay-event-id'];

  try {
    // Intentar insertar el evento (la restricción UNIQUE previene duplicados)
    await db.query(
      'INSERT INTO webhook_events (event_id, event_type, payload) VALUES ($1, $2, $3)',
      [eventId, req.headers['zeltapay-event-type'], JSON.stringify(req.body)]
    );
  } catch (error) {
    // Si ya existe, es un duplicado (23505 = unique_violation en PostgreSQL)
    if (error.code === '23505') {
      return res.status(200).json({ received: true, duplicate: true });
    }
    throw error;
  }

  // Procesar el evento
  const payload = JSON.parse(req.body);
  await processEvent(payload);

  res.status(200).json({ received: true });
}

Para implementaciones más robustas, consulta la .

Buenas prácticas

Responde rápido

Tu endpoint debe responder con un 200 lo antes posible. Realiza el procesamiento pesado de forma asíncrona:

javascript
app.post('/webhook/zelta-pay', express.raw({ type: 'application/json' }), (req, res) => {
  // Responder inmediatamente
  res.status(200).json({ received: true });

  // Procesar de forma asíncrona (no bloquea la respuesta)
  const payload = JSON.parse(req.body.toString());
  processWebhookAsync(payload).catch(err => {
    console.error('Error procesando webhook:', err);
  });
});

ctx.waitUntil en Cloudflare Workers y Hono

En entornos serverless como Cloudflare Workers, usa ctx.waitUntil() para ejecutar tareas después de enviar la respuesta. Esto garantiza que el procesamiento se complete incluso después de responder al webhook.

Manejo de errores

Implementa un handler robusto que valide firma, timestamp e idempotencia:

javascript
app.post('/webhook/zelta-pay', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    const signature = req.headers['zeltapay-signature'];
    const timestamp = req.headers['zeltapay-timestamp'];
    const rawBody = req.body.toString();

    // 1. Verificar firma
    if (!verifySignature(rawBody, timestamp, signature)) {
      console.error('Webhook con firma inválida recibido');
      return res.status(401).json({ error: 'Firma inválida' });
    }

    // 2. Verificar timestamp (rechazar webhooks de más de 5 minutos)
    const webhookAge = Math.abs(Date.now() / 1000 - parseInt(timestamp));
    if (webhookAge > 300) {
      console.error('Webhook con timestamp expirado');
      return res.status(400).json({ error: 'Timestamp expirado' });
    }

    // 3. Verificar idempotencia
    const eventId = req.headers['zeltapay-event-id'];
    if (await isEventProcessed(eventId)) {
      return res.status(200).json({ received: true, duplicate: true });
    }

    // 4. Procesar evento
    const payload = JSON.parse(rawBody);
    await processEvent(payload);
    await markEventAsProcessed(eventId);

    res.status(200).json({ received: true });
  } catch (error) {
    console.error('Error procesando webhook:', error);
    // Responder 500 para que se reintente
    res.status(500).json({ error: 'Error interno' });
  }
});

Monitoreo

Registra métricas de tus webhooks para detectar problemas:

javascript
async function processWebhookWithMonitoring(req) {
  const startTime = Date.now();
  const eventType = req.headers['zeltapay-event-type'];
  const deliveryAttempt = req.headers['zeltapay-delivery-attempt'];

  console.log(JSON.stringify({
    type: 'webhook_received',
    eventType,
    deliveryAttempt: parseInt(deliveryAttempt),
    eventId: req.headers['zeltapay-event-id'],
    timestamp: new Date().toISOString()
  }));

  if (parseInt(deliveryAttempt) > 1) {
    console.warn(`Webhook reintentado (intento ${deliveryAttempt}):`, eventType);
  }

  try {
    await processEvent(JSON.parse(req.body.toString()));

    console.log(JSON.stringify({
      type: 'webhook_processed',
      eventType,
      durationMs: Date.now() - startTime,
      success: true
    }));
  } catch (error) {
    console.error(JSON.stringify({
      type: 'webhook_error',
      eventType,
      durationMs: Date.now() - startTime,
      error: error.message
    }));

    throw error;
  }
}

Solución de problemas

Problemas comunes

ProblemaCausa posibleSolución
No recibes webhooksURL incorrecta o no accesibleVerifica la URL en el dashboard y que tu servidor sea accesible desde internet
Error 401 constantementeWebhook secret incorrectoRegenera el webhook secret en el dashboard y actualiza tu variable de entorno
Webhooks duplicadosTu servidor responde lentamenteImplementa idempotencia y responde con 200 lo más rápido posible
Firma no coincideError en la verificaciónAsegúrate de usar el body raw (sin parsear) y de firmar el string con el prefijo t=, es decir t={timestamp}.{body}
Webhooks llegan a la DLQTu endpoint responde con 4xxRevisa los logs de tu servidor para identificar el error

Modo de depuración

Activa el modo de depuración en tu dashboard para ver los detalles de cada entrega:

  1. Ve a Webhooks en tu
  2. Selecciona el webhook que deseas depurar
  3. Haz clic en Historial de entregas
  4. Revisa el payload enviado, los headers, el código de respuesta y el tiempo de respuesta

Usa webhook.ping para probar

Envía un evento webhook.ping desde el dashboard para verificar que tu endpoint está funcionando correctamente antes de recibir eventos reales.

Guías detalladas

Para información más detallada sobre temas específicos de webhooks, consulta las siguientes guías:

  • -- Referencia completa de todos los tipos de eventos y payloads
  • -- Implementación paso a paso de la verificación HMAC-SHA256
  • -- Detalles sobre reintentos, timeouts y Dead Letter Queue
  • -- Patrones avanzados para garantizar procesamiento único

Siguientes pasos

  • -- Crea links de pago programáticamente
  • -- Ejemplos prácticos de integración con webhooks
  • -- API keys y rate limiting
  • -- Documentación completa de endpoints

Documentación oficial de Zelta