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:
- Zelta Pay genera la firma — Al enviar un webhook, Zelta Pay crea una firma usando tu secreto de webhook y el contenido del evento.
- La firma se incluye en el header — La firma se envía en el header
Zeltapay-Signaturejunto con un timestamp. - Tu servidor recibe el webhook — Tu endpoint recibe el evento con el header de firma.
- 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...| Componente | Descripción |
|---|---|
t | Timestamp Unix (en segundos) del momento en que se generó la firma. |
v1 | Firma 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):
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:
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.
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:
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:
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
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
| Error | Causa | Solución |
|---|---|---|
| Firma inválida | El 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 expirado | El webhook tardó más de 5 minutos en llegar. | Verifica la conectividad de tu servidor. Considera aumentar la tolerancia temporalmente. |
| Header faltante | El header Zeltapay-Signature no está presente. | Asegúrate de que tu proxy o balanceador de carga no elimine headers personalizados. |
| Formato inválido | El header no sigue el formato t=..., v1=.... | Verifica que estás leyendo el header correcto. |
Manejo robusto de errores
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:
- Ve a Configuración > Webhooks en tu dashboard.
- Selecciona el endpoint que quieres probar.
- Haz clic en Enviar ping de prueba.
- Verifica en los logs de tu servidor que el evento se recibió y la firma se verificó correctamente.
Scripts de prueba manual
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:
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 deexpress.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:
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.