Webhooks
Los webhooks te permiten recibir notificaciones automáticas en tu servidor cuando ocurren eventos en Zelta Pay, como un pago completado o un link expirado. 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 con hasta 6 intentos durante 24 horas |
| 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
:: tab Node.js (Express)
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 signature = req.headers['zeltapay-signature'];
const timestamp = req.headers['zeltapay-timestamp'];
const eventType = req.headers['zeltapay-event-type'];
const rawBody = req.body.toString();
// Verificar firma
const expectedSignature = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(`${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.paymentLink.id);
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);::
:: tab Cloudflare Workers
export default {
async fetch(request, env, ctx) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const signature = request.headers.get('zeltapay-signature');
const timestamp = request.headers.get('zeltapay-timestamp');
const eventType = request.headers.get('zeltapay-event-type');
const rawBody = await request.text();
// Verificar firma
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(env.WEBHOOK_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signatureBuffer = await crypto.subtle.sign(
'HMAC',
key,
encoder.encode(`${timestamp}.${rawBody}`)
);
const expectedSignature = Array.from(new Uint8Array(signatureBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
if (signature !== expectedSignature) {
return new Response(JSON.stringify({ error: 'Firma inválida' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const payload = JSON.parse(rawBody);
// Procesar de forma asíncrona para responder rápido
ctx.waitUntil(handleWebhookEvent(eventType, payload, env));
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
};
async function handleWebhookEvent(eventType, payload, env) {
switch (eventType) {
case 'payment.success':
console.log('Pago completado:', payload.paymentLink.id);
// Tu lógica de negocio aquí
break;
case 'webhook.ping':
console.log('Ping recibido');
break;
}
}::
:: tab Hono
import { Hono } from 'hono';
type Bindings = {
WEBHOOK_SECRET: string;
};
const app = new Hono<{ Bindings: Bindings }>();
app.post('/webhook/zelta-pay', async (c) => {
const signature = c.req.header('zeltapay-signature');
const timestamp = c.req.header('zeltapay-timestamp');
const eventType = c.req.header('zeltapay-event-type');
const rawBody = await c.req.text();
// Verificar firma
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(c.env.WEBHOOK_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signatureBuffer = await crypto.subtle.sign(
'HMAC',
key,
encoder.encode(`${timestamp}.${rawBody}`)
);
const expectedSignature = Array.from(new Uint8Array(signatureBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
if (signature !== expectedSignature) {
return c.json({ error: 'Firma inválida' }, 401);
}
const payload = JSON.parse(rawBody);
// Procesar de forma asíncrona con waitUntil
c.executionCtx.waitUntil(handleWebhookEvent(eventType, payload));
return c.json({ received: true });
});
async function handleWebhookEvent(eventType: string, payload: any) {
switch (eventType) {
case 'payment.success':
console.log('Pago completado:', payload.paymentLink.id);
break;
case 'webhook.ping':
console.log('Ping recibido');
break;
}
}
export default app;::
Tipos de eventos
| Evento | Descripción |
|---|---|
payment.success | Un pago se ha completado exitosamente |
webhook.ping | Evento de prueba para verificar la conectividad del endpoint |
Para ver la estructura completa de cada evento, consulta la .
Payload de payment.success
{
"type": "payment.success",
"eventId": "evt_abc123def456",
"timestamp": "2026-03-13T14:30:00.000Z",
"paymentLink": {
"id": "pl_1234567890abcdef",
"paymentLinkUrl": "https://pay.zelta.dev/abc123",
"customerName": "Maria Garcia",
"customerEmail": "[email protected]",
"concept": "Orden #ORD-2026-0042",
"amount": 15000,
"status": "completed",
"createdAt": "2026-03-13T10:30:00.000Z",
"metadata": {
"orderId": "ORD-2026-0042",
"type": "product-purchase"
}
},
"transaction": {
"id": "txn_xyz789",
"amount": 15000,
"paymentMethod": "card",
"completedAt": "2026-03-13T14:30:00.000Z"
}
}Payload de webhook.ping
{
"type": "webhook.ping",
"eventId": "evt_ping_001",
"timestamp": "2026-03-13T14:30:00.000Z",
"message": "Webhook endpoint is reachable"
}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, Zelta Pay reintentará la entrega automáticamente:
| Intento | Tiempo después del evento | Backoff |
|---|---|---|
| 1 | Inmediato | -- |
| 2 | ~1 minuto | Exponencial |
| 3 | ~5 minutos | Exponencial |
| 4 | ~30 minutos | Exponencial |
| 5 | ~2 horas | Exponencial |
| 6 | ~8 horas | Exponencial |
| 7 (final) | ~24 horas | Exponencial |
En total, se realizan hasta 6 reintentos a lo largo de 24 horas con backoff exponencial.
Comportamiento según código de respuesta
| Código HTTP | Comportamiento |
|---|---|
| 2xx (200, 201, 202...) | Entrega exitosa. No se reintenta |
| 4xx (400, 401, 404...) | Error del cliente. Se envía a la Dead Letter Queue (DLQ) sin reintentar |
| 5xx (500, 502, 503...) | Error del servidor. Se reintenta según la política de reintentos |
| Timeout | Si no se recibe respuesta en 30 segundos, se trata como error 5xx 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:
:: tab Node.js (Express)
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);
});
});::
:: tab Cloudflare Workers
export default {
async fetch(request, env, ctx) {
const rawBody = await request.text();
const payload = JSON.parse(rawBody);
// ctx.waitUntil permite procesar después de enviar la respuesta
ctx.waitUntil(processWebhookAsync(payload, env));
// Responder inmediatamente
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
};::
:: tab Hono
app.post('/webhook/zelta-pay', async (c) => {
const rawBody = await c.req.text();
const payload = JSON.parse(rawBody);
// Procesar de forma asíncrona con waitUntil
c.executionCtx.waitUntil(processWebhookAsync(payload));
// Responder inmediatamente
return c.json({ received: true });
});::
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 el formato {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