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 link expirado. 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 con hasta 6 intentos durante 24 horas
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

:: tab Node.js (Express)

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

javascript
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

typescript
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

EventoDescripción
payment.successUn pago se ha completado exitosamente
webhook.pingEvento de prueba para verificar la conectividad del endpoint

Para ver la estructura completa de cada evento, consulta la .

Payload de payment.success

json
{
  "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

json
{
  "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:

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, Zelta Pay reintentará la entrega automáticamente:

IntentoTiempo después del eventoBackoff
1Inmediato--
2~1 minutoExponencial
3~5 minutosExponencial
4~30 minutosExponencial
5~2 horasExponencial
6~8 horasExponencial
7 (final)~24 horasExponencial

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 HTTPComportamiento
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
TimeoutSi 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

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:

:: tab Node.js (Express)

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

::

:: tab Cloudflare Workers

javascript
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

typescript
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:

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 el formato {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