Ir al contenido principal

Verificar la firma de webhooks

Cómo comprobar que las notificaciones de webhooks provienen realmente de FacturaDirecta usando HMAC SHA256. Incluye ejemplos en Node.js, Python y PHP.

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

Webhook-Signature

t=timestamp,v1=hmac_hex

Timestamp y firma HMAC SHA256.

Webhook-Timestamp

timestamp

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:

  1. Toma el timestamp del envío.

  2. Toma el body crudo recibido por tu servidor, sin parsear ni reserializar.

  3. Construye el texto firmado: timestamp.body_crudo.

  4. Calcula HMAC-SHA256(signing_secret, timestamp.body_crudo).

  5. Compara el resultado hexadecimal con el valor v1 de Webhook-Signature.

  6. 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 410 como 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

¿Ha quedado contestada tu pregunta?