Cómo verificar la firma de los webhooks de ValidaFirma
Diego Serrano Bustos
Gerente General Validafirma.cl
Cada vez que un documento cambia de estado, ValidaFirma le avisa a tu sistema con un webhook: un POST a la URL que indicaste al crear el documento. Como esa URL es pública, cualquiera podría mandarle un aviso falso. Por eso firmamos cada webhook con un secreto propio de tu cuenta, y tu sistema puede comprobar que el aviso viene de ValidaFirma y que nadie lo cambió en el camino.
En esta guía vas a ver dónde está tu secreto, cómo verificar la firma (con ejemplos en Node.js, Python y PHP), cómo responder y qué hacer si el secreto se filtra.
¿Quieres probarlo sin costo? En Cómo probar tu integración en modo de prueba creas un documento, lo firmas sin que nadie haga nada y recibes el webhook, sin gastar créditos ni avisar a nadie.
Si ya recibías webhooks de ValidaFirma: qué cambió
Desde el 1 de octubre de 2026 los webhooks se firman con un secreto que ves en el panel. No es tu llave de API.
| Antes | Ahora |
|---|---|
Headers X-ValidaFirma-Signature y X-ValidaFirma-Timestamp, imposibles de verificar desde fuera | Un solo header, X-Webhook-Signature: t=<unix>,v1=<hex> |
X-ValidaFirma-Version: 1.0 | X-ValidaFirma-Version: 2.0 |
| Sin secreto visible | Un secreto por modo: whsec_test_… para prueba y whsec_live_… para producción, en API Keys → Secreto de webhooks |
Si tu código leía X-ValidaFirma-Signature, ese header ya no llega: cámbialo por la verificación que explicamos más abajo. El formato es el mismo que usa la plataforma rCAPI de REDCUMBRE, así que si ya integraste rCAPI, la misma función te sirve para los dos.
Dónde está tu secreto
Cada cuenta tiene dos secretos: uno para los documentos de prueba (whsec_test_…) y otro para los reales (whsec_live_…). Un documento de prueba siempre se firma con el de prueba, y uno real con el real.
El panel te muestra el secreto del modo en que está el panel:
- Abre el menú de usuario (arriba a la derecha). Enciende Modo de prueba para ver el secreto de prueba, o apágalo para ver el real.
- Entra a API Keys y busca la sección Secreto de webhooks. Dice Modo de prueba o Modo real.
- Haz clic en Revelar y después en Copiar.
Guarda el secreto en tu sistema como cualquier otra credencial (una variable de entorno o un gestor de secretos), nunca en el código.
Ten en cuenta que:
- El secreto se ve sólo desde el panel, con tu sesión. Ninguna llave de API puede leerlo: si alguien obtiene tu llave, no obtiene tu secreto.
- En una cuenta de empresa, el secreto lo ve y lo rota sólo quien administra la cuenta. Quien sólo opera o consulta no ve la sección.
- El interruptor del menú cambia sólo lo que ves en el panel. No cambia el modo de tus llaves: una llave
vf_live_sigue siendo real aunque el panel esté en modo de prueba.
Cómo llega un webhook
Cada webhook llega como un POST con cuerpo JSON y estos headers:
X-Webhook-Signature: t=1790869332,v1=c621f520ba0cc90735ebd33e4bb49fb0243658448cc5a7e1550a502c762ad240
X-ValidaFirma-Event: documento.completado
X-ValidaFirma-Version: 2.0
X-ValidaFirma-Delivery: 71
X-Webhook-ID: 71
X-Webhook-Source: validafirma.cl
Content-Type: application/json
Y el cuerpo trae el evento, el documento y sus firmantes:
{
"evento": "documento.completado",
"timestamp": "2026-10-01T15:42:12.930Z",
"es_prueba": false,
"sandbox": false,
"documento": { "id": "…", "codigo_verificacion": "…", "estado": "firmado", "url_descarga": "…", "url_validacion": "…" },
"firmantes": [ … ]
}
Cómo verificar la firma
Para comprobar que el aviso viene de ValidaFirma y que nadie lo cambió en el camino:
- Lee
t(la hora del envío, en segundos Unix) y losv1del headerX-Webhook-Signature. - Rechaza el aviso si
tse aleja más de 300 segundos de tu reloj. Así nadie puede reenviarte un aviso viejo. - Calcula el HMAC-SHA256, con tu secreto, del texto
<t>.<cuerpo>: el valor det, un punto y el cuerpo exactamente como llegó. - Compara el resultado, en hexadecimal, con cada
v1, usando una comparación de tiempo constante. Si calza cualquiera de ellos, el aviso es válido.
Lo que más falla es el cuerpo. Tienes que firmar los bytes que recibiste, antes de convertirlos a JSON. Si conviertes el cuerpo a objeto y lo vuelves a serializar, cambian los espacios o el orden y la firma no calza.
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
function esDeValidaFirma(cabecera, cuerpoCrudo, secreto, tolerancia = 300) {
const partes = cabecera.split(',').map((p) => p.trim().split('='));
const t = (partes.find(([k]) => k === 't') || [])[1];
const firmas = partes.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > tolerancia) return false;
const esperada = crypto.createHmac('sha256', secreto).update(`${t}.`).update(cuerpoCrudo).digest();
return firmas.some((v1) => {
const recibida = Buffer.from(v1, 'hex');
return recibida.length === esperada.length && crypto.timingSafeEqual(recibida, esperada);
});
}
const app = express();
// express.raw entrega el cuerpo como Buffer, sin convertirlo a JSON.
app.post('/webhooks/validafirma', express.raw({ type: 'application/json' }), (req, res) => {
const cabecera = req.get('X-Webhook-Signature') || '';
if (!esDeValidaFirma(cabecera, req.body, process.env.VALIDAFIRMA_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const evento = JSON.parse(req.body);
// … procesa el evento
res.status(200).end();
});
Python (Flask)
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
def es_de_validafirma(cabecera: str, cuerpo_crudo: bytes, secreto: str, tolerancia: int = 300) -> bool:
partes = [p.strip().split("=", 1) for p in cabecera.split(",") if "=" in p]
t = next((v for k, v in partes if k == "t"), None)
firmas = [v for k, v in partes if k == "v1"]
if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerancia:
return False
esperada = hmac.new(secreto.encode(), f"{t}.".encode() + cuerpo_crudo, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(esperada, v1) for v1 in firmas)
app = Flask(__name__)
@app.post("/webhooks/validafirma")
def recibir():
cuerpo = request.get_data() # los bytes tal como llegaron
if not es_de_validafirma(request.headers.get("X-Webhook-Signature", ""), cuerpo,
os.environ["VALIDAFIRMA_WEBHOOK_SECRET"]):
return "", 401
evento = json.loads(cuerpo)
# … procesa el evento
return "", 200
PHP
<?php
function esDeValidaFirma(string $cabecera, string $cuerpoCrudo, string $secreto, int $tolerancia = 300): bool {
$t = null;
$firmas = [];
foreach (explode(',', $cabecera) as $parte) {
[$clave, $valor] = array_pad(explode('=', trim($parte), 2), 2, '');
if ($clave === 't') { $t = $valor; }
if ($clave === 'v1') { $firmas[] = $valor; }
}
if ($t === null || abs(time() - (int) $t) > $tolerancia) { return false; }
$esperada = hash_hmac('sha256', $t . '.' . $cuerpoCrudo, $secreto);
foreach ($firmas as $v1) {
if (hash_equals($esperada, $v1)) { return true; }
}
return false;
}
$cuerpo = file_get_contents('php://input'); // los bytes tal como llegaron
$cabecera = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (!esDeValidaFirma($cabecera, $cuerpo, getenv('VALIDAFIRMA_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
$evento = json_decode($cuerpo, true);
// … procesa el evento
http_response_code(200);
Avisos de prueba y avisos reales
Para saber si un aviso es de prueba, lee es_prueba (o sandbox, que vale lo mismo) en la raíz del JSON.
Si usas el secreto equivocado, la verificación falla: un aviso de un documento real no calza con whsec_test_, y uno de prueba no calza con whsec_live_. Si recibes avisos de los dos modos en el mismo sistema, lee es_prueba para elegir el secreto antes de verificar.
Cómo responder y qué pasa si tu sistema falla
- Responde con un código 2xx antes de 30 segundos. Si necesitas hacer algo largo, guarda el evento, responde y procésalo después.
- Si respondes con error o no respondes a tiempo, reintentamos hasta 5 veces, con esperas que van creciendo. Cada reintento lleva un
tnuevo y una firma nueva, así que la verificación con tolerancia de 300 segundos sigue funcionando. - Una redirección (
301,302…) no se sigue: cuenta como entrega fallida y no se reintenta. Configura la URL final. - Puede llegarte el mismo evento más de una vez. Usa
X-ValidaFirma-Deliverypara no procesarlo dos veces.
Eventos que te pueden llegar
| Evento | Cuándo |
|---|---|
documento.completado | Firmó el último firmante |
documento.rechazado | Un firmante rechazó el documento |
documento.cancelado | Cancelaste el documento |
documento.expirado | Venció el plazo sin que firmaran todos |
firmante.bloqueado | Un firmante falló 5 veces la verificación de identidad |
firmante.identidad_no_coincide | La cédula presentada no corresponde al RUT declarado |
firmante.identidad_aprobada | Se aprobó la identidad de un firmante de canal enlace |
firmante.invitacion_no_entregada | No llegaron ni el WhatsApp ni el SMS de la invitación |
firmante.validacion_contacto_no_lograda | El firmante no logró validar su correo o teléfono |
Si tu secreto se filtró, o quieres cambiarlo
En API Keys → Secreto de webhooks, haz clic en Rotar. Te preguntamos qué hacer con el secreto anterior:
- Deja de firmar ahora (la opción por defecto): úsala si el secreto se filtró. Desde ese momento sólo vale el nuevo, y hasta que lo cargues en tu sistema tu verificación va a rechazar los avisos.
- Sigue firmando 24 horas: úsala para un cambio planificado. Durante 24 horas cada webhook lleva dos
v1en el header, uno por cada secreto, y los ejemplos de esta guía aceptan el aviso si calza cualquiera. Así alcanzas a actualizar tu sistema sin perder avisos.
Cada modo se rota por separado: rotar el secreto de prueba no toca el real.
Errores frecuentes
| Lo que ves | Por qué pasa | Qué hacer |
|---|---|---|
| La firma nunca calza | Estás firmando el JSON vuelto a serializar, no el cuerpo crudo | Lee el cuerpo como bytes o texto antes de convertirlo (express.raw, request.get_data(), php://input) |
| La firma calza en prueba pero no en producción | Sigues usando whsec_test_ | Usa whsec_live_ con la llave vf_live_ |
| Todos los avisos fallan justo después de rotar | Elegiste Deja de firmar ahora y tu sistema tiene el secreto anterior | Carga el secreto nuevo |
| Rechazas avisos válidos por la hora | El reloj de tu servidor está desfasado | Sincroniza la hora con NTP |
| No te llega ningún webhook y tu URL redirige | Las redirecciones no se siguen | Configura la URL final, sin redirección |
Buscas el header X-ValidaFirma-Signature | Ya no se envía | Verifica X-Webhook-Signature como explica esta guía |