Verificar la firma de webhooks
Cuando FacturaDirecta envía un evento a tu endpoint, incluye una firma en las cabeceras HTTP. Verificar esta firma te permite confirmar que la notificación proviene realmente de FacturaDirecta y que el cuerpo no ha sido manipulado.
Verifica siempre la firma antes de procesar un evento. Si la firma no coincide, responde con un error y descarta la petición.
Cabeceras enviadas
Cada petición incluye estas cabeceras:
Cabecera | Formato | Descripción |
|
| Timestamp y firma HMAC SHA256. |
|
| Timestamp Unix del envío en segundos. |
Ejemplo:
Webhook-Signature: t=1715616000,v1=8f3d4e7c2b9a1f5e6d0c8b7a3e5f9d1c2b4a6e8f0d3c5b7a9e1f3d5c7b9a0e2fWebhook-Timestamp: 1715616000
Cómo se calcula la firma
FacturaDirecta usa HMAC SHA256 con un formato equivalente al de Stripe:
Toma el timestamp del envío.
Toma el body crudo recibido por tu servidor, sin parsear ni reserializar.
Construye el texto firmado:
timestamp.body_crudo.Calcula
HMAC-SHA256(signing_secret, timestamp.body_crudo).Compara el resultado hexadecimal con el valor
v1deWebhook-Signature.Rechaza la petición si el timestamp está fuera de la ventana anti-replay de 5 minutos.
El signing_secret tiene formato whsec_... y solo se muestra al crear el endpoint o al rotar el secret. Guárdalo como variable de entorno o secreto de tu infraestructura.
Node.js
const crypto = require('crypto');function verifyWebhookSignature(rawBody, signatureHeader, secret) { const parts = Object.fromEntries( signatureHeader.split(',').map((part) => part.split('=')) ); const timestamp = Number(parts.t || 0); const received = parts.v1 || ''; if (!timestamp || !received) return false; const now = Math.floor(Date.now() / 1000); if (Math.abs(now - timestamp) > 300) return false; const signedPayload = `${timestamp}.${rawBody}`; const expected = crypto .createHmac('sha256', secret) .update(signedPayload) .digest('hex'); if (received.length !== expected.length) return false; return crypto.timingSafeEqual( Buffer.from(received, 'hex'), Buffer.from(expected, 'hex') );}
Importante: en Express, configura el endpoint para conservar el body crudo. Si calculas la firma sobre un JSON parseado y vuelto a serializar, la firma puede no coincidir.
Python
import hashlibimport hmacimport timedef verify_webhook_signature(raw_body: bytes, signature_header: str, secret: str) -> bool: parts = dict(part.split('=', 1) for part in signature_header.split(',')) timestamp = int(parts.get('t', '0')) received = parts.get('v1', '') if not timestamp or not received: return False if abs(int(time.time()) - timestamp) > 300: return False signed_payload = str(timestamp).encode('utf-8') + b'.' + raw_body expected = hmac.new( secret.encode('utf-8'), signed_payload, hashlib.sha256, ).hexdigest() return hmac.compare_digest(received, expected)
PHP
function verifyWebhookSignature(string $rawBody, string $signatureHeader, string $secret): bool { $parts = []; foreach (explode(',', $signatureHeader) as $part) { [$key, $value] = explode('=', $part, 2); $parts[$key] = $value; } $timestamp = intval($parts['t'] ?? '0'); $received = $parts['v1'] ?? ''; if (!$timestamp || !$received) { return false; } if (abs(time() - $timestamp) > 300) { return false; } $signedPayload = $timestamp . '.' . $rawBody; $expected = hash_hmac('sha256', $signedPayload, $secret); return hash_equals($expected, $received);}
Buenas prácticas
Verifica siempre la firma antes de procesar el evento.
Usa comparación timing-safe (
timingSafeEqual,compare_digest,hash_equals).Responde rápido con un HTTP 2xx. Si el procesamiento es largo, encola el trabajo y responde primero.
Evita reprocesar eventos: guarda el
id(whe_...) si tu integración debe ser idempotente.Usa HTTP 410 Gone solo si quieres desactivar el endpoint. FacturaDirecta interpreta un
410como señal de baja y desactiva el endpoint inmediatamente.Rota el signing secret si sospechas que se ha expuesto. Recuerda que el secret anterior deja de funcionar al instante.
Recursos relacionados
Webhooks — payload, catálogo de eventos y referencia técnica.
Configurar webhooks — crear endpoints desde Ajustes.