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
| Beneficio | Descripción |
|---|---|
| Casi en tiempo real | Las notificaciones normalmente llegan pocos segundos después del evento, sin necesidad de polling |
| Eficiente | Reduce la carga en tu servidor y el consumo de la API |
| Reintentos automáticos | Hasta 6 intentos de entrega (el inicial más 5 reintentos), con 15 segundos entre cada uno |
| Seguro | Cada 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 (comolocalhosto*.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:
- Inicia sesión en tu
- Abre Webhooks en el menú lateral (visible solo para los roles propietario y administrador)
- En la tarjeta Nuevo webhook, escribe la URL de tu endpoint (ej.
https://miapp.com/webhook/zelta-pay) y haz clic en Crear - Haz clic en Ver secreto para copiar el secreto de firma y guárdalo en tus variables de entorno
- Haz clic en Enviar ping para enviar un evento
webhook.pingfirmado 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
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
| Evento | Se entrega a |
|---|---|
payment.success, payment.failed, receipt.created de un link creado con la API key de la cuenta o desde el dashboard | Webhook 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 proyecto | Webhook 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.ping | El 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:
| Evento | Descripción |
|---|---|
payment.success | Un pago se completó exitosamente |
payment.failed | Un intento de pago terminó sin pago (por ejemplo, una tarjeta rechazada) |
receipt.created | Se generó el comprobante PDF de un pago |
webhook.ping | Evento 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
{
"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
{
"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:
| Header | Descripción | Ejemplo |
|---|---|---|
Zeltapay-Event-Id | Identificador único del evento (un UUID, igual al eventId del cuerpo). Úsalo para idempotencia | 9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f |
Zeltapay-Event-Type | Tipo de evento | payment.success |
Zeltapay-Timestamp | Timestamp UNIX en segundos del momento en que se firmó este intento de entrega | 1710340200 |
Zeltapay-Signature | Firma HMAC-SHA256 con el formato t=<timestamp>, v1=<firma hex> | t=1710340200, v1=5f2b... |
Zeltapay-Delivery-Attempt | Nú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.
| Entrega | Descripción |
|---|---|
| Intento 1 | Envío inicial, normalmente pocos segundos después del evento |
| Intentos 2–6 | Hasta 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
| Respuesta | Comportamiento |
|---|---|
| 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 red | Se reintenta según la política de reintentos |
| 3xx y demás 4xx | No 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.successtiene uneventIdestable por transacción: si Zelta Pay lo vuelve a enviar para el mismo pago, llega con el mismoeventId
Implementación básica
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:
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
);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:
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:
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:
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
| Problema | Causa posible | Solución |
|---|---|---|
| No recibes webhooks | URL incorrecta, no pública o no HTTPS | Verifica 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 proyecto | El proyecto no tiene webhook, o esperas en el webhook de la cuenta un evento de la API de Integración | Configura el webhook en la tarjeta Webhook del proyecto (ver ) |
| Error 401 constantemente | Secreto incorrecto o regenerado | Copia el secreto vigente con Ver secreto (editar la URL genera uno nuevo) y actualiza tu variable de entorno |
| Webhooks duplicados | Tu servidor responde lentamente o falla después de procesar | Implementa idempotencia con el eventId y responde con 2xx lo más rápido posible |
| Firma no coincide | Error en la verificación | Usa el cuerpo crudo (sin parsear) y firma el string con el prefijo t=, es decir t=<timestamp>.<cuerpo> |
| Entregas descartadas | Tu endpoint responde 4xx o 3xx, o fallaron los 6 intentos | Revisa 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