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 generó la firma.
v1Firma HMAC-SHA256 codificada en hexadecimal.

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 expiradoEl webhook tardó más de 5 minutos en llegar.Verifica la conectividad 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. Ve a Configuración > Webhooks en tu dashboard.
  2. Selecciona el endpoint que quieres probar.
  3. Haz clic en Enviar ping de prueba.
  4. Verifica en los logs de tu servidor que el evento se recibió y la firma se verificó correctamente.

Scripts de prueba manual

javascript
const crypto = require('crypto');

// Simular un webhook firmado
const secret = 'whsec_test_secret';
const payload = JSON.stringify({
  id: 'evt_test_123',
  type: 'payment.success',
  data: { amount: 5000, currency: 'USD' }
});

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 de webhook correcto. Puedes encontrarlo en Configuración > Webhooks en tu dashboard.
  • 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.
  • 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