Webhooks
Verificar firmas
Cada POST de webhook lleva un header Fiscal-Web-Signature. Verificar la firma antes de confiar en el payload es obligatorio en producción.
Cómo se construye la firma
El header tiene la forma t=<timestamp>,v1=<hex>. El timestamp es UNIX seconds. La firma v1 es HMAC-SHA256(signing_secret, `<timestamp>.<raw_body>`).
Cuando recibas un POST:
- Parsea el header y separa
tyv1. - Rechaza si
tes más viejo que 5 minutos (mitiga replays). - Recalcula la firma usando el raw body (¡antes de parsear el JSON!).
- Compara con
timingSafeEqual. Igual = procesa.
Usa el body crudo
Node.js
import crypto from "node:crypto";
function verifyFiscalWebSignature(payload, signatureHeader, secret) {
// signatureHeader = "t=1684164724,v1=abcd1234..."
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => kv.split("=")),
);
const timestamp = parts.t;
const v1 = parts.v1;
// 1. Rechaza payloads con >5min de antigüedad (mitiga replay)
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (age > 300) return false;
// 2. Recalcula HMAC-SHA256 sobre "${timestamp}.${payload}"
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${payload}`)
.digest("hex");
// 3. Compara con timing-safe equal
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}Python
import hmac, hashlib, time
def verify_fiscal_web_signature(payload: str, signature_header: str, secret: str) -> bool:
parts = dict(kv.split("=") for kv in signature_header.split(","))
timestamp = parts["t"]
v1 = parts["v1"]
age = int(time.time()) - int(timestamp)
if age > 300:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.{payload}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, v1)PHP
Si usas Laravel/Symfony, busca primero el helper hash_hmac() con hash_equals() para comparación timing-safe. Si usas el SDK oficial fiscal-web/php, llama $fw->webhooks->verify($payload, $header) y delega ahí.
Rotación de secret
Si sospechas que tu whsec_* se filtró, rota desde /app/webhooks. Durante 24h aceptamos firmas con el viejo Y el nuevo secret; después, solo el nuevo. Tu backend debe aceptar ambos durante la ventana de transición.