Skip to content

Verificación de Firmas ​

Zelta Pay firma cada webhook que envía con una firma HMAC-SHA256, permitiéndote verificar que los eventos provienen de Zelta Pay y no han sido modificados en tránsito.

Por qué verificar firmas ​

La verificación de firmas proporciona tres garantías fundamentales:

  • Autenticidad — Confirma que el webhook fue enviado por Zelta Pay y no por un tercero.
  • Integridad — Asegura que el contenido del webhook no fue alterado durante la transmisión.
  • Seguridad — Protege tu aplicación contra ataques de suplantación y reenvío de eventos falsos.

Seguridad crítica

Nunca proceses webhooks sin verificar la firma. Sin verificación, un atacante podría enviar eventos falsos a tu endpoint y provocar acciones no autorizadas en tu sistema.

Cómo funciona ​

Zelta Pay utiliza HMAC-SHA256 para firmar cada webhook. El proceso funciona en cuatro pasos:

  1. Zelta Pay genera la firma — Al enviar un webhook, Zelta Pay crea una firma usando tu secreto de webhook y el contenido del evento.
  2. La firma se incluye en el header — La firma se envía en el header Zeltapay-Signature junto con un timestamp.
  3. Tu servidor recibe el webhook — Tu endpoint recibe el evento con el header de firma.
  4. Tu servidor verifica la firma — Usando el mismo secreto, tu servidor calcula la firma esperada y la compara con la recibida.

Formato de la firma ​

El header Zeltapay-Signature tiene el siguiente formato:

Zeltapay-Signature: t=1640995200, v1=abc123def456...
ComponenteDescripción
tTimestamp Unix (en segundos) del momento en que se firmó este intento de entrega. Es el mismo valor que el header Zeltapay-Timestamp y cambia en cada reintento.
v1Firma HMAC-SHA256 codificada en hexadecimal (64 caracteres en minúscula).

La firma se calcula con el secreto de tu webhook sobre el string t=<timestamp>.<cuerpo crudo>. Cada webhook (el de la cuenta y el de cada proyecto) tiene su propio secreto: cópialo con Ver secreto en el dashboard. Al editar la URL del webhook se genera un secreto nuevo.

Algoritmo de verificación ​

Paso 1: Extraer componentes ​

Parsea el header Zeltapay-Signature para obtener el timestamp (t) y la firma (v1):

javascript
function parseSignatureHeader(header) {
  const parts = header.split(', ');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const signature = parts.find(p => p.startsWith('v1=')).slice(3);
  return { timestamp, signature };
}

Paso 2: Verificar timestamp ​

Valida que el timestamp no sea demasiado antiguo para prevenir ataques de reenvío:

javascript
const MAX_AGE_SECONDS = 300; // 5 minutos
const currentTime = Math.floor(Date.now() / 1000);
const age = currentTime - parseInt(timestamp);

if (age > MAX_AGE_SECONDS) {
  throw new Error('El timestamp del webhook ha expirado');
}

Paso 3: Crear la firma esperada ​

Construye el string a firmar con el formato t={timestamp}.{body} — el prefijo literal t=, seguido del timestamp, un punto (.) y el cuerpo raw del request — y calcula el HMAC-SHA256:

El prefijo t= forma parte del string firmado

El HMAC se calcula sobre t={timestamp}.{body}, no sobre {timestamp}.{body}. Si omites el prefijo t=, la firma que calcules no coincidirá con la de Zelta Pay.

javascript
const crypto = require('crypto');

function createExpectedSignature(timestamp, body, secret) {
  const payload = `t=${timestamp}.${body}`;
  return crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
}

Paso 4: Comparar firmas ​

Compara la firma recibida con la esperada usando una comparación de tiempo constante para prevenir ataques de timing:

javascript
const crypto = require('crypto');

function signaturesMatch(received, expected) {
  const a = Buffer.from(received, 'hex');
  const b = Buffer.from(expected, 'hex');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Implementación completa ​

A continuación se muestra la implementación completa de verificación de firmas para cada lenguaje:

javascript
const crypto = require('crypto');

function verifyWebhookSignature(payload, signatureHeader, secret) {
  // Extraer componentes
  const parts = signatureHeader.split(', ');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const signature = parts.find(p => p.startsWith('v1=')).slice(3);

  // Verificar timestamp (5 minutos de tolerancia)
  const currentTime = Math.floor(Date.now() / 1000);
  if (currentTime - parseInt(timestamp) > 300) {
    throw new Error('El timestamp del webhook ha expirado');
  }

  // Crear firma esperada
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(`t=${timestamp}.${payload}`)
    .digest('hex');

  // Comparar firmas de forma segura
  const a = Buffer.from(signature, 'hex');
  const b = Buffer.from(expectedSignature, 'hex');
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    throw new Error('La firma del webhook es inválida');
  }

  return true;
}

// Uso con Express
const express = require('express');
const app = express();

app.post('/webhooks/zelta', express.raw({ type: 'application/json' }), (req, res) => {
  const signatureHeader = req.headers['zeltapay-signature'];
  const payload = req.body.toString();

  try {
    verifyWebhookSignature(payload, signatureHeader, process.env.WEBHOOK_SECRET);
    const event = JSON.parse(payload);

    // Procesar el evento
    console.log('Evento verificado:', event.type);
    res.status(200).json({ received: true });
  } catch (error) {
    console.error('Verificación fallida:', error.message);
    res.status(401).json({ error: 'Firma inválida' });
  }
});

Validación de timestamp ​

Por qué validar el timestamp ​

La validación de timestamp previene ataques de reenvío (replay attacks). Sin esta validación, un atacante que intercepte un webhook válido podría reenviarlo múltiples veces a tu endpoint.

TIP

Se recomienda una tolerancia de 5 minutos (300 segundos). Esto permite variaciones normales de latencia de red sin dejar una ventana demasiado amplia para ataques.

Verificación completa con timestamp ​

javascript
function verifyTimestamp(timestamp, toleranceSeconds = 300) {
  const currentTime = Math.floor(Date.now() / 1000);
  const webhookTime = parseInt(timestamp);
  const age = currentTime - webhookTime;

  if (age < 0) {
    throw new Error('El timestamp del webhook está en el futuro');
  }

  if (age > toleranceSeconds) {
    throw new Error(`El webhook expiró hace ${age - toleranceSeconds} segundos`);
  }

  return true;
}

Manejo de errores ​

Errores comunes de verificación ​

ErrorCausaSolución
Firma inválidaEl secreto de webhook no coincide o el payload fue modificado.Verifica que usas el secreto correcto y que el body no se parsea antes de verificar.
Timestamp expiradoLa diferencia entre el t de la firma y la hora de tu servidor supera la tolerancia. Cada intento se firma justo antes de enviarse, así que suele indicar un reloj desincronizado.Sincroniza el reloj de tu servidor. Considera aumentar la tolerancia temporalmente.
Header faltanteEl header Zeltapay-Signature no está presente.Asegúrate de que tu proxy o balanceador de carga no elimine headers personalizados.
Formato inválidoEl header no sigue el formato t=..., v1=....Verifica que estás leyendo el header correcto.

Manejo robusto de errores ​

javascript
function verifyWebhookWithErrorHandling(req, secret) {
  const signatureHeader = req.headers['zeltapay-signature'];

  if (!signatureHeader) {
    return { valid: false, error: 'MISSING_HEADER', message: 'Header Zeltapay-Signature no encontrado' };
  }

  if (!signatureHeader.includes('t=') || !signatureHeader.includes('v1=')) {
    return { valid: false, error: 'INVALID_FORMAT', message: 'Formato de firma inválido' };
  }

  try {
    const parts = signatureHeader.split(', ');
    const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
    const signature = parts.find(p => p.startsWith('v1=')).slice(3);

    // Verificar timestamp
    const currentTime = Math.floor(Date.now() / 1000);
    const age = currentTime - parseInt(timestamp);
    if (age > 300) {
      return { valid: false, error: 'EXPIRED', message: `Webhook expirado (${age}s de antigüedad)` };
    }
    if (age < 0) {
      return { valid: false, error: 'FUTURE_TIMESTAMP', message: 'Timestamp en el futuro' };
    }

    // Verificar firma
    const payload = req.body.toString();
    const expectedSignature = crypto
      .createHmac('sha256', secret)
      .update(`t=${timestamp}.${payload}`)
      .digest('hex');

    const a = Buffer.from(signature, 'hex');
    const b = Buffer.from(expectedSignature, 'hex');
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return { valid: false, error: 'INVALID_SIGNATURE', message: 'La firma no coincide' };
    }

    return { valid: true, timestamp: parseInt(timestamp) };
  } catch (error) {
    return { valid: false, error: 'VERIFICATION_ERROR', message: error.message };
  }
}

Probar la verificación ​

Probar con un ping de webhook ​

La forma más sencilla de probar tu implementación es enviar un ping desde el dashboard de Zelta Pay:

  1. Abre Webhooks en el menú lateral del dashboard.
  2. Haz clic en Enviar ping en tu webhook.
  3. Verifica en los logs de tu servidor que el evento webhook.ping se recibió y la firma se verificó correctamente.

El ping solo está disponible para el webhook de la cuenta; los webhooks de proyecto no tienen botón de ping.

Scripts de prueba manual ​

javascript
const crypto = require('crypto');

// Simular un webhook firmado
const secret = 'tu-secreto-de-webhook';
const payload = JSON.stringify({
  eventId: '9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f',
  type: 'webhook.ping',
  ok: true,
  message: 'Hello from Zeltapay! Your endpoint is reachable.',
  sentAt: new Date().toISOString()
});

const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = crypto
  .createHmac('sha256', secret)
  .update(`t=${timestamp}.${payload}`)
  .digest('hex');

const signatureHeader = `t=${timestamp}, v1=${signature}`;

console.log('Header:', signatureHeader);
console.log('Payload:', payload);

// Verificar
try {
  verifyWebhookSignature(payload, signatureHeader, secret);
  console.log('Verificación exitosa');
} catch (error) {
  console.error('Verificación fallida:', error.message);
}

Buenas prácticas de seguridad ​

Usa comparación de tiempo constante ​

Siempre usa funciones de comparación de tiempo constante (timingSafeEqual, hmac.compare_digest, hash_equals) en lugar de === o ==. Las comparaciones directas pueden filtrar información sobre la firma a través de diferencias en el tiempo de respuesta.

Valida el formato de entrada ​

Antes de procesar el header de firma, verifica que tenga el formato esperado. Un header malformado podría causar errores inesperados en tu aplicación.

Maneja casos extremos ​

  • Body vacío — Rechaza webhooks sin body.
  • Header duplicado — Usa solo la primera ocurrencia del header.
  • Encoding — Asegúrate de usar el body raw (sin parsear) para la verificación.

WARNING

No parsees el JSON del body antes de verificar la firma. La verificación debe hacerse sobre el string raw del body exactamente como llegó. Cualquier diferencia en formato, espacios o encoding causará que la firma no coincida.

Registra eventos de seguridad ​

Registra los intentos fallidos de verificación para detectar posibles ataques:

javascript
function logSecurityEvent(event) {
  console.log(JSON.stringify({
    type: 'webhook_security',
    event: event.type,
    ip: event.ip,
    timestamp: new Date().toISOString(),
    error: event.error || null
  }));
}

Solución de problemas ​

Problemas comunes ​

La firma no coincide ​

  • Verifica el secreto — Asegúrate de usar el secreto del webhook que recibe el evento. Cópialo con Ver secreto en Webhooks (webhook de la cuenta) o en la tarjeta Webhook de tu proyecto. Recuerda que editar la URL genera un secreto nuevo.
  • Usa el body raw — La verificación debe hacerse sobre el body sin parsear. Si usas Express, asegúrate de usar express.raw() en lugar de express.json().
  • Revisa el encoding — El body debe ser tratado como UTF-8.

La validación de timestamp falla ​

  • Sincroniza el reloj — Asegúrate de que el reloj de tu servidor esté sincronizado con NTP. Como cada intento se firma justo antes de enviarse, una diferencia grande casi siempre indica un reloj desincronizado.
  • Ajusta la tolerancia — Si tu servidor tiene latencia alta, considera aumentar la tolerancia de timestamp temporalmente.

El header no se encuentra ​

  • Revisa tu proxy — Algunos proxies y balanceadores de carga eliminan headers personalizados. Configúralos para pasar el header Zeltapay-Signature.
  • Verifica el nombre — El header es Zeltapay-Signature (sensible a mayúsculas en algunos frameworks).

Modo debug ​

Agrega un modo debug temporal para diagnosticar problemas de verificación:

javascript
function debugVerification(payload, signatureHeader, secret) {
  console.log('=== Debug de verificación ===');
  console.log('Header recibido:', signatureHeader);
  console.log('Longitud del payload:', payload.length);
  console.log('Primeros 100 chars del payload:', payload.substring(0, 100));

  const parts = signatureHeader.split(', ');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const receivedSignature = parts.find(p => p.startsWith('v1=')).slice(3);

  console.log('Timestamp:', timestamp);
  console.log('Firma recibida:', receivedSignature);

  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(`t=${timestamp}.${payload}`)
    .digest('hex');

  console.log('Firma esperada:', expectedSignature);
  console.log('Coinciden:', receivedSignature === expectedSignature);
  console.log('============================');
}

DANGER

Elimina el modo debug antes de pasar a producción. Los logs de debug pueden exponer información sensible como firmas y payloads completos.

Siguientes pasos ​

  • — Aprende a configurar y recibir webhooks.
  • — Conoce cómo Zelta Pay entrega los webhooks y el sistema de reintentos.
  • — Implementa procesamiento idempotente de eventos.
  • — Consulta todos los tipos de eventos disponibles.
  • — Explora ejemplos prácticos de integración.

Documentación oficial de Zelta