Tutoriales • Octubre 1, 2026

Cómo verificar la firma de los webhooks de ValidaFirma

Diego Serrano Bustos

Diego Serrano Bustos

Gerente General Validafirma.cl

Cómo verificar la firma de los webhooks de ValidaFirma

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.

AntesAhora
Headers X-ValidaFirma-Signature y X-ValidaFirma-Timestamp, imposibles de verificar desde fueraUn solo header, X-Webhook-Signature: t=<unix>,v1=<hex>
X-ValidaFirma-Version: 1.0X-ValidaFirma-Version: 2.0
Sin secreto visibleUn 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:

  1. 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.
  2. Entra a API Keys y busca la sección Secreto de webhooks. Dice Modo de prueba o Modo real.
  3. 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:

  1. Lee t (la hora del envío, en segundos Unix) y los v1 del header X-Webhook-Signature.
  2. Rechaza el aviso si t se aleja más de 300 segundos de tu reloj. Así nadie puede reenviarte un aviso viejo.
  3. Calcula el HMAC-SHA256, con tu secreto, del texto <t>.<cuerpo>: el valor de t, un punto y el cuerpo exactamente como llegó.
  4. 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 t nuevo 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-Delivery para no procesarlo dos veces.

Eventos que te pueden llegar

EventoCuándo
documento.completadoFirmó el último firmante
documento.rechazadoUn firmante rechazó el documento
documento.canceladoCancelaste el documento
documento.expiradoVenció el plazo sin que firmaran todos
firmante.bloqueadoUn firmante falló 5 veces la verificación de identidad
firmante.identidad_no_coincideLa cédula presentada no corresponde al RUT declarado
firmante.identidad_aprobadaSe aprobó la identidad de un firmante de canal enlace
firmante.invitacion_no_entregadaNo llegaron ni el WhatsApp ni el SMS de la invitación
firmante.validacion_contacto_no_logradaEl 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 v1 en 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 vesPor qué pasaQué hacer
La firma nunca calzaEstás firmando el JSON vuelto a serializar, no el cuerpo crudoLee 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ónSigues usando whsec_test_Usa whsec_live_ con la llave vf_live_
Todos los avisos fallan justo después de rotarElegiste Deja de firmar ahora y tu sistema tiene el secreto anteriorCarga el secreto nuevo
Rechazas avisos válidos por la horaEl reloj de tu servidor está desfasadoSincroniza la hora con NTP
No te llega ningún webhook y tu URL redirigeLas redirecciones no se siguenConfigura la URL final, sin redirección
Buscas el header X-ValidaFirma-SignatureYa no se envíaVerifica X-Webhook-Signature como explica esta guía