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 |
|---|---|
| Tiempo real | Recibe notificaciones al instante, sin necesidad de polling |
| Eficiente | Reduce la carga en tu servidor y el consumo de la API |
| Confiable | Sistema de reintentos automáticos (el intento inicial más hasta 5 reintentos) |
| 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
- 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
- Inicia sesión en tu
- Navega a Webhooks en el sidebar
- Haz clic en Agregar webhook
- Ingresa la URL de tu endpoint (ej.
https://miapp.com/webhook/zelta-pay) - Selecciona los eventos que deseas recibir
- 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
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:
| Evento | Descripción |
|---|---|
payment.success | Un pago se ha completado exitosamente |
payment.failed | Un intento de pago fue rechazado |
receipt.created | Se generó el comprobante PDF de un pago |
webhook.ping | Evento 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
{
"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
{
"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:
| Header | Descripción | Ejemplo |
|---|---|---|
Zeltapay-Event-Id | Identificador único del evento. Úsalo para idempotencia | evt_abc123def456 |
Zeltapay-Event-Type | Tipo de evento | payment.success |
Zeltapay-Timestamp | Timestamp UNIX en segundos de cuando se envió el webhook | 1710340200 |
Zeltapay-Signature | Firma HMAC-SHA256 para verificar la autenticidad | a1b2c3d4e5... |
Zeltapay-Delivery-Attempt | Nú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.
| Entrega | Descripción |
|---|---|
| Intento 1 | Envío inicial, inmediato tras el evento |
| Intentos 2–6 | Hasta 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 HTTP | Comportamiento |
|---|---|
| 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 |
| Timeout | Si 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
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'];
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:
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:
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:
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
| Problema | Causa posible | Solución |
|---|---|---|
| No recibes webhooks | URL incorrecta o no accesible | Verifica la URL en el dashboard y que tu servidor sea accesible desde internet |
| Error 401 constantemente | Webhook secret incorrecto | Regenera el webhook secret en el dashboard y actualiza tu variable de entorno |
| Webhooks duplicados | Tu servidor responde lentamente | Implementa idempotencia y responde con 200 lo más rápido posible |
| Firma no coincide | Error en la verificación | Asegú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 DLQ | Tu endpoint responde con 4xx | Revisa 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:
- Ve a Webhooks en tu
- Selecciona el webhook que deseas depurar
- Haz clic en Historial de entregas
- 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