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
Casi en tiempo realLas notificaciones normalmente llegan pocos segundos después del evento, sin necesidad de polling
EficienteReduce la carga en tu servidor y el consumo de la API
Reintentos automáticosHasta 6 intentos de entrega (el inicial más 5 reintentos), con 15 segundos entre cada uno
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 con cuerpo JSON
  • Responder con un código de estado 2xx (200, 201, 202, etc.) en menos de 60 segundos
  • Ser una URL HTTPS pública: se rechazan las URLs http://, los nombres internos (como localhost o *.local) y las direcciones IP privadas
  • No responder con una redirección (3xx): las redirecciones no se siguen y la entrega se da por fallida

2. Configura el webhook en el dashboard ​

Webhook de la cuenta:

  1. Inicia sesión en tu
  2. Abre Webhooks en el menú lateral (visible solo para los roles propietario y administrador)
  3. En la tarjeta Nuevo webhook, escribe la URL de tu endpoint (ej. https://miapp.com/webhook/zelta-pay) y haz clic en Crear
  4. Haz clic en Ver secreto para copiar el secreto de firma y guárdalo en tus variables de entorno
  5. Haz clic en Enviar ping para enviar un evento webhook.ping firmado a tu endpoint

La cuenta tiene un solo webhook y no hay selección de eventos: el webhook recibe todos los eventos que le corresponden. Ignora (respondiendo 2xx) los tipos que no manejes.

Necesitas un proveedor de pago activo

El botón Crear permanece deshabilitado hasta que la cuenta tenga al menos un proveedor de pago activo en . Si intentas crear el webhook sin uno, la solicitud se rechaza con ERR_NO_ACTIVE_PAYMENT_PROVIDER.

Webhook de un proyecto (): abre Proyectos → tu proyecto → pestaña Resumen y configura la URL en la tarjeta Webhook. Cada proyecto tiene un solo webhook, y la pestaña Webhooks del proyecto lista cada entrega con su estado.

El secreto de firma

Cada webhook (el de la cuenta y el de cada proyecto) tiene su propio secreto. Puedes verlo en cualquier momento con Ver secreto. Al Editar la URL del webhook se genera un secreto nuevo: actualiza tu variable de entorno para seguir .

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'] ?? ''; // "t=<timestamp>, v1=<firma>"
  const eventType = req.headers['zeltapay-event-type'];
  const rawBody = req.body.toString('utf8');

  // Extraer t y v1 del header Zeltapay-Signature
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(p => p.trim().split('='))
  );

  // La firma se calcula sobre `t=<timestamp>.<cuerpo crudo>`
  const expected = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(`t=${parts.t}.${rawBody}`)
    .digest('hex');

  const valid = parts.v1 && parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));

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

  const payload = JSON.parse(rawBody);

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

Para una verificación completa (con comparación de tiempo constante y validación del timestamp), consulta la guía de .

Webhook de la cuenta y webhook de proyecto ​

EventoSe entrega a
payment.success, payment.failed, receipt.created de un link creado con la API key de la cuenta o desde el dashboardWebhook de la cuenta
payment.success, payment.failed, receipt.created de un link creado con la API key de un proyecto, y receipt.created de los cargos y renovaciones de un proyectoWebhook del proyecto; si el proyecto no tiene uno activo, webhook de la cuenta
Eventos de la API de Integración (customer.created, charge.*, subscription.*, invoice.*, payment_link.rejected)Solo el webhook del proyecto (sin respaldo en el de la cuenta)
webhook.pingEl webhook al que le enviaste el ping desde el dashboard

Si el webhook de destino no existe o no está activo, el evento no se envía.

Tipos de eventos ​

Los eventos más comunes para un link de pago son:

EventoDescripción
payment.successUn pago se completó exitosamente
payment.failedUn intento de pago terminó sin pago (por ejemplo, una tarjeta rechazada)
receipt.createdSe generó el comprobante PDF de un pago
webhook.pingEvento de prueba enviado con Enviar ping

No hay eventos cuando un link se crea, vence o se cancela.

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": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
  "type": "payment.success",
  "paymentLinkId": "7312345678901234567",
  "accountId": "7298765432109876543",
  "projectId": null,
  "createdAt": "2026-03-13T14:30:00.000Z",
  "transaction": {
    "externalPaymentId": "ext_abc123",
    "orderId": "ORD-12345-xyz",
    "amount": 15000,
    "currency": "USD",
    "paidAt": "2026-03-13T14:30:00.000Z",
    "concept": "Orden #ORD-2026-0042",
    "paidTo": "Acme Inc",
    "paymentMethod": "credit_card",
    "paymentProvider": "nmi",
    "isTest": false
  },
  "customer": {
    "customerName": "Maria Garcia",
    "customerEmail": "[email protected]"
  },
  "metadata": {
    "orderId": "ORD-2026-0042",
    "type": "product-purchase"
  }
}

Cuando el cliente paga con PayPal, transaction.paymentProvider es "paypal" y transaction.paymentMethod es "paypal" (pagó con su cuenta de PayPal) o "credit_card" (pagó con tarjeta sin cuenta de PayPal). Consulta la para el resto de los payloads.

Payload de webhook.ping ​

json
{
  "eventId": "4f1c2b3a-5d6e-4f70-8a9b-0c1d2e3f4a5b",
  "type": "webhook.ping",
  "ok": true,
  "message": "Hello from Zeltapay! Your endpoint is reachable.",
  "sentAt": "2026-03-13T14:30:00.000Z",
  "description": "This is a ping payload sent to verify your webhook endpoint. If you received this with a 2xx status, everything is working."
}

Headers del webhook ​

Cada solicitud de webhook incluye los siguientes headers:

HeaderDescripciónEjemplo
Zeltapay-Event-IdIdentificador único del evento (un UUID, igual al eventId del cuerpo). Úsalo para idempotencia9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f
Zeltapay-Event-TypeTipo de eventopayment.success
Zeltapay-TimestampTimestamp UNIX en segundos del momento en que se firmó este intento de entrega1710340200
Zeltapay-SignatureFirma HMAC-SHA256 con el formato t=<timestamp>, v1=<firma hex>t=1710340200, v1=5f2b...
Zeltapay-Delivery-AttemptNúmero de intento de entrega (1 = primer intento)1

Además llegan Content-Type: application/json y User-Agent: zeltapay-webhook/1.0.

Usa los headers para seguridad

Siempre verifica la firma (Zeltapay-Signature) y valida su 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 en 60 segundos), Zelta Pay reintenta la entrega automáticamente: el intento inicial más hasta 5 reintentos (6 entregas en total), con un retraso fijo de 15 segundos entre intentos. Todos los intentos ocurren en pocos minutos, así que los reintentos no cubren una caída larga de tu endpoint.

EntregaDescripción
Intento 1Envío inicial, normalmente pocos segundos después del evento
Intentos 2–6Hasta 5 reintentos, cada uno 15 segundos después del intento fallido anterior

Puedes leer el número de intento en el header Zeltapay-Delivery-Attempt. Si la entrega sigue fallando después del último intento, se descarta de forma definitiva. Consulta la guía de para los detalles completos.

Comportamiento según código de respuesta ​

RespuestaComportamiento
2xx (200, 201, 202...)Entrega exitosa. No se reintenta
408, 429, 5xx (500, 502, 503...)Se reintenta según la política de reintentos
Timeout (sin respuesta en 60 segundos) o error de redSe reintenta según la política de reintentos
3xx y demás 4xxNo se reintenta: la entrega se descarta de inmediato. Las redirecciones nunca se siguen

No hay reenvío manual

Las entregas descartadas no se pueden reenviar desde el dashboard. Si tu endpoint estuvo caído, corrige el problema y concilia los eventos perdidos consultando la API (por ejemplo, con ).

Idempotencia ​

Un mismo evento puede llegar más de una vez: por ejemplo, si tu servidor procesó el evento pero su respuesta 2xx no llegó a tiempo, se reintenta. Usa el eventId (o el header Zeltapay-Event-Id) para procesar cada evento una sola vez:

  • Los reintentos automáticos de una entrega conservan el mismo eventId
  • payment.success tiene un eventId estable por transacción: si Zelta Pay lo vuelve a enviar para el mismo pago, llega con el mismo eventId

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'];
  const rawBody = req.body.toString('utf8');

  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'], rawBody]
    );
  } 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
  await processEvent(JSON.parse(rawBody));

  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 2xx lo antes posible (cada intento espera como máximo 60 segundos). Realiza el procesamiento pesado de forma asíncrona:

javascript
app.post('/webhook/zelta-pay', express.raw({ type: 'application/json' }), (req, res) => {
  // (verifica la firma antes de responder)
  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);
  });
});

Procesamiento en segundo plano

Si respondes antes de procesar, un fallo posterior en tu lógica ya no provoca un reintento. Guarda el evento (por ejemplo, en una cola o una tabla) antes de responder para no perderlo.

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 signatureHeader = req.headers['zeltapay-signature'];
    const rawBody = req.body.toString('utf8');

    // 1. Verificar firma y timestamp (ver "Verificación de firmas")
    if (!verifyWebhookSignature(rawBody, signatureHeader, process.env.WEBHOOK_SECRET)) {
      console.error('Webhook con firma inválida recibido');
      return res.status(401).json({ error: 'Firma inválida' });
    }

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

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

Recuerda que un 4xx distinto de 408 y 429 descarta la entrega sin reintentos: responde 401 solo cuando la firma realmente no es válida.

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 = Number(req.headers['zeltapay-delivery-attempt']);

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

  if (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, no pública o no HTTPSVerifica la URL en el dashboard y que tu servidor sea accesible desde internet por HTTPS. Usa Enviar ping para probar
No recibes los eventos de un proyectoEl proyecto no tiene webhook, o esperas en el webhook de la cuenta un evento de la API de IntegraciónConfigura el webhook en la tarjeta Webhook del proyecto (ver )
Error 401 constantementeSecreto incorrecto o regeneradoCopia el secreto vigente con Ver secreto (editar la URL genera uno nuevo) y actualiza tu variable de entorno
Webhooks duplicadosTu servidor responde lentamente o falla después de procesarImplementa idempotencia con el eventId y responde con 2xx lo más rápido posible
Firma no coincideError en la verificaciónUsa el cuerpo crudo (sin parsear) y firma el string con el prefijo t=, es decir t=<timestamp>.<cuerpo>
Entregas descartadasTu endpoint responde 4xx o 3xx, o fallaron los 6 intentosRevisa los logs de tu servidor y concilia los eventos perdidos con la API

Trazas de entrega ​

La pestaña Webhooks de cada proyecto (Proyectos → tu proyecto → Webhooks) lista las entregas del webhook del proyecto con su estado (Pendiente, Entregado, Fallido o Descartado), el código HTTP de tu respuesta, el número de intentos y el payload enviado (Ver payload). El webhook de la cuenta no tiene una lista de entregas en el dashboard.

Usa webhook.ping para probar

Envía un evento webhook.ping con Enviar ping para verificar tu endpoint antes de recibir eventos reales. El mensaje de confirmación del dashboard indica que el ping se envió, no que tu endpoint respondió: revisa los logs de tu servidor para confirmar que llegó y respondió con 2xx.

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 entregas descartadas
  • -- Patrones 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