Verificación de firma
Verifique la cabecera X-Certivant-Signature-V2 (HMAC-SHA256 sobre timestamp.body, con tolerancia de 300 s) para confiar en los payloads de webhook. Incluye ejemplos en Node, Python, Go, Ruby y PHP.
Cada webhook que envía Certivant incluye una cabecera de firma. Verifíquela antes de hacer nada con el payload: de lo contrario, un desconocido que adivine su URL de webhook podría enviarle eventos falsos, y su aplicación los creería.
Qué se verifica
Cada entrega lleva una firma V2 en una única cabecera autocontenida:
X-Certivant-Signature-V2: t=1735689600,v1=8a2b4c...e91f
tes la hora Unix (en segundos) en la que Certivant firmó el evento.v1eshex( HMAC-SHA256( secret, "${t}.${rawBody}" ) ), dondesecretes su secreto de firmawhsec_…yrawBodyson los bytes exactos del cuerpo de la petición.- Durante la rotación de un secreto, la cabecera puede llevar varios valores
v1=(t=…,v1=…,v1=…). Acepte el evento si alguno coincide.
Su trabajo es recalcular el HMAC sobre t + "." + rawBody con su copia del secreto, comparar cada v1 mediante una comparación en tiempo constante y rechazar el evento cuando |ahora - t| > 300 segundos. Si una firma coincide Y la marca de tiempo está dentro de la ventana de tolerancia, el payload es auténtico y reciente.
Tolerancia de 300 segundos
Rechace cualquier evento cuyo t se diferencie en más de 300 segundos (5 minutos) de su propio reloj, en cualquier sentido. Mantenga sus servidores sincronizados con NTP. La marca de tiempo forma parte del mensaje firmado, así que un atacante no puede cambiarla sin invalidar la firma: esto es lo que hace inútil una petición capturada para reproducirla cuando se cierra la ventana.
Cabeceras en cada webhook
| Cabecera | Valor | Uso |
|---|---|---|
Content-Type | application/json | |
X-Certivant-Signature-V2 | t=<unix>,v1=<64-hex>[,v1=<64-hex>] | Verifique esta. HMAC sobre ${t}.${rawBody} |
X-Certivant-Timestamp | Segundos Unix | El mismo valor que t, para los registros |
X-Certivant-Event-Id | Id único | Único por evento, estable entre reintentos. Úselo para la idempotencia |
X-KYC-Signature | sha256=<64-hex> | El mismo HMAC sobre ${X-KYC-Timestamp}.${rawBody}. Integraciones existentes; sigue enviándose |
X-KYC-Timestamp, X-KYC-Event-Id | Segundos Unix / id | Los mismos valores que las cabeceras X-Certivant-* |
Los eventos enviados antes de que haya configurado un secreto llevan las cabeceras de id y marca de tiempo, pero no firma.
La firma heredada solo con el cuerpo se ha eliminado
Antes de mayo de 2026, la firma cubría solo rawBody. Esa cabecera heredada X-KYC-Signature-Legacy, anunciada como obsoleta para el 2026-09-01, ya no se envía (se eliminó el 24 October 2026), y tampoco X-KYC-Signature-Deprecation. Si su receptor todavía la comprueba, cada evento fallará la verificación: cámbiese a X-Certivant-Signature-V2 (o a la X-KYC-Signature con marca de tiempo) siguiendo la receta de más abajo.
La receta de verificación
function verify(rawBody, signatureV2Header, secrets):
parts = split(signatureV2Header, ",")
t = value of the part "t=..."
sigs = values of every part "v1=..."
if t is missing or sigs is empty: return false
if abs(now() - int(t)) > 300: return false # replay protection
signed = t + "." + rawBody
for secret in secrets: # >1 only while rotating
expected = hex(hmac_sha256(secret, signed))
for sig in sigs:
if constant_time_equal(expected, sig): return true
return false
Tres reglas innegociables:
- Incluya la marca de tiempo en el mensaje firmado, no solo el cuerpo. Sin esto, un atacante que capture un webhook válido podría reproducirlo para siempre.
- Lea el cuerpo sin procesar, nunca el objeto analizado y vuelto a serializar. El orden de las claves, los espacios en blanco y el formato de los números cambian el hash.
- Compare con una función de tiempo constante (
timingSafeEqual,hmac.compare_digest,hash_equals,hmac.Equal, …), nunca con==.
Pruebe su verificador sin escribir código: el botón Enviar un evento de prueba del panel (KYC → Desarrollador → Eventos de webhook) entrega a su URL configurada un ping kyc.webhook.test real y firmado, o un payload de ejemplo kyc.request.* (ambos llevan "test": true).
Implementaciones
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300; // reject events more than 5 minutes off
/**
* Verify an X-Certivant-Signature-V2 header ("t=<unix>,v1=<hex>[,v1=<hex>]").
* `secrets` may hold two values while you rotate (old + new).
*/
export function verifyCertivantSignature(
rawBody: string,
signatureHeader: string | undefined | null,
secrets: string | string[],
toleranceSeconds = TOLERANCE_SECONDS,
): boolean {
if (!signatureHeader) return false;
// 1. Parse "t=...,v1=...,v1=..."
let t = "";
const candidates: string[] = [];
for (const part of signatureHeader.split(",")) {
const i = part.indexOf("=");
if (i < 0) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === "t") t = value;
else if (key === "v1") candidates.push(value);
}
if (!/^\d+$/.test(t) || candidates.length === 0) return false;
// 2. Reject stale / future-dated events (replay protection)
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
// 3. Recompute HMAC over `${t}.${rawBody}` — the timestamp is part of the
// signed message — and compare in constant time against every v1.
for (const secret of Array.isArray(secrets) ? secrets : [secrets]) {
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
for (const candidate of candidates) {
const a = Buffer.from(expected);
const b = Buffer.from(candidate);
if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true;
}
}
return false;
}
// Hono usage
app.post("/webhooks/certivant", async (c) => {
const rawBody = await c.req.text(); // <-- raw, not c.req.json()
const sig = c.req.header("X-Certivant-Signature-V2");
if (!verifyCertivantSignature(rawBody, sig, process.env.CERTIVANT_WEBHOOK_SECRET!)) {
return c.json({ error: "Invalid signature" }, 401);
}
const payload = JSON.parse(rawBody);
// De-duplicate on c.req.header("X-Certivant-Event-Id"), then handle payload
return c.json({ received: true });
});
// Express usage — needs express.raw() to keep the raw body
import express from "express";
const app = express();
app.post(
"/webhooks/certivant",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body.toString(); // Buffer -> string
const sig = req.header("X-Certivant-Signature-V2");
if (!verifyCertivantSignature(rawBody, sig, process.env.CERTIVANT_WEBHOOK_SECRET!)) {
return res.status(401).json({ error: "Invalid signature" });
}
const payload = JSON.parse(rawBody);
res.json({ received: true });
},
);
import hashlib
import hmac
import re
import time
TOLERANCE_SECONDS = 300 # reject events more than 5 minutes off
def verify_certivant_signature(
raw_body: bytes,
signature_header: str | None,
secrets: str | list[str],
tolerance_seconds: int = TOLERANCE_SECONDS,
) -> bool:
"""Verify X-Certivant-Signature-V2 ("t=<unix>,v1=<hex>[,v1=<hex>]").
`secrets` may hold two values while you rotate (old + new)."""
if not signature_header:
return False
# 1. Parse "t=...,v1=...,v1=..."
t = ""
candidates: list[str] = []
for part in signature_header.split(","):
key, sep, value = part.partition("=")
if not sep:
continue
key, value = key.strip(), value.strip()
if key == "t":
t = value
elif key == "v1":
candidates.append(value)
if not re.fullmatch(r"\d+", t) or not candidates:
return False
# 2. Reject stale / future-dated events (replay protection)
if abs(time.time() - int(t)) > tolerance_seconds:
return False
# 3. Recompute HMAC over `${t}.${rawBody}`. We feed bytes so the body
# is never accidentally re-encoded; compare in constant time.
signed = t.encode("ascii") + b"." + raw_body
for secret in [secrets] if isinstance(secrets, str) else secrets:
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
if any(hmac.compare_digest(expected, c) for c in candidates):
return True
return False
# FastAPI
from fastapi import FastAPI, HTTPException, Request
import os
app = FastAPI()
@app.post("/webhooks/certivant")
async def handle(request: Request):
raw_body = await request.body() # bytes, untouched
sig = request.headers.get("x-certivant-signature-v2")
if not verify_certivant_signature(raw_body, sig, os.environ["CERTIVANT_WEBHOOK_SECRET"]):
raise HTTPException(status_code=401, detail="Invalid signature")
payload = await request.json()
return {"received": True}
# Flask
from flask import Flask, abort, request
app = Flask(__name__)
@app.post("/webhooks/certivant")
def handle():
raw_body = request.get_data()
sig = request.headers.get("X-Certivant-Signature-V2")
if not verify_certivant_signature(raw_body, sig, os.environ["CERTIVANT_WEBHOOK_SECRET"]):
abort(401)
payload = request.get_json()
return {"received": True}
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const toleranceSeconds = 300
// Verifies X-Certivant-Signature-V2 ("t=<unix>,v1=<hex>[,v1=<hex>]").
// Pass two secrets while rotating (old + new).
func verifyCertivantSignature(rawBody []byte, header string, secrets ...string) bool {
var t string
var candidates []string
for _, part := range strings.Split(header, ",") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
t = kv[1]
case "v1":
candidates = append(candidates, kv[1])
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil || len(candidates) == 0 {
return false
}
if math.Abs(float64(time.Now().Unix()-ts)) > toleranceSeconds {
return false // replay protection
}
for _, secret := range secrets {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
for _, c := range candidates {
if hmac.Equal([]byte(expected), []byte(c)) {
return true
}
}
}
return false
}
func Handler(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read body", http.StatusBadRequest)
return
}
sig := r.Header.Get("X-Certivant-Signature-V2")
if !verifyCertivantSignature(rawBody, sig, os.Getenv("CERTIVANT_WEBHOOK_SECRET")) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// unmarshal rawBody into your struct and handle...
w.WriteHeader(http.StatusOK)
}
require "openssl"
TOLERANCE_SECONDS = 300
# Verifies X-Certivant-Signature-V2 ("t=<unix>,v1=<hex>[,v1=<hex>]").
# `secrets` may hold two values while you rotate (old + new).
def verify_certivant_signature(raw_body, signature_header, secrets)
return false if signature_header.nil? || signature_header.empty?
parts = signature_header.split(",").map { |p| p.strip.split("=", 2) }
t = parts.find { |k, _| k == "t" }&.last.to_s
candidates = parts.select { |k, _| k == "v1" }.map(&:last)
return false unless t.match?(/\A\d+\z/) && !candidates.empty?
return false if (Time.now.to_i - t.to_i).abs > TOLERANCE_SECONDS # replay protection
Array(secrets).any? do |secret|
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
candidates.any? { |c| Rack::Utils.secure_compare(expected, c) }
end
end
# Rails
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token, only: [:certivant]
def certivant
raw_body = request.raw_post
sig = request.headers["X-Certivant-Signature-V2"]
unless verify_certivant_signature(raw_body, sig, ENV["CERTIVANT_WEBHOOK_SECRET"])
head :unauthorized and return
end
payload = JSON.parse(raw_body)
# ... handle
render json: { received: true }
end
end
<?php
const CERTIVANT_TOLERANCE_SECONDS = 300;
// Verifies X-Certivant-Signature-V2 ("t=<unix>,v1=<hex>[,v1=<hex>]").
// $secrets may hold two values while you rotate (old + new).
function verify_certivant_signature(string $rawBody, ?string $signatureHeader, string|array $secrets): bool {
if ($signatureHeader === null || $signatureHeader === "") return false;
$t = "";
$candidates = [];
foreach (explode(",", $signatureHeader) as $part) {
$kv = explode("=", trim($part), 2);
if (count($kv) !== 2) continue;
if ($kv[0] === "t") $t = $kv[1];
elseif ($kv[0] === "v1") $candidates[] = $kv[1];
}
if (!ctype_digit($t) || count($candidates) === 0) return false;
if (abs(time() - (int) $t) > CERTIVANT_TOLERANCE_SECONDS) return false; // replay protection
foreach ((array) $secrets as $secret) {
$expected = hash_hmac("sha256", $t . "." . $rawBody, $secret);
foreach ($candidates as $candidate) {
if (hash_equals($expected, $candidate)) return true;
}
}
return false;
}
// Plain PHP
$rawBody = file_get_contents("php://input");
$sig = $_SERVER["HTTP_X_CERTIVANT_SIGNATURE_V2"] ?? null;
if (!verify_certivant_signature($rawBody, $sig, getenv("CERTIVANT_WEBHOOK_SECRET"))) {
http_response_code(401);
echo json_encode(["error" => "Invalid signature"]);
exit;
}
$payload = json_decode($rawBody, true);
// ... handle
echo json_encode(["received" => true]);
// Laravel
public function certivant(\Illuminate\Http\Request $request) {
$rawBody = $request->getContent();
$sig = $request->header("X-Certivant-Signature-V2");
if (!verify_certivant_signature($rawBody, $sig, env("CERTIVANT_WEBHOOK_SECRET"))) {
abort(401);
}
$payload = json_decode($rawBody, true);
return response()->json(["received" => true]);
}
Errores comunes
«Las firmas no coinciden en producción, pero sí en pruebas»
Su framework analiza o modifica el cuerpo antes de calcular el HMAC. El culpable más habitual es el middleware de análisis del cuerpo en Node (express.json() modifica req.body; para la ruta del webhook debe usar express.raw()). En las rutas de API de Next.js, configure runtime: "nodejs" y lea primero await req.text().
«Las firmas coinciden en local, pero fallan cuando pasan por un balanceador de carga»
Algunos balanceadores de carga (o WAF) reescriben los cuerpos de las peticiones: los formatean, eliminan los saltos de línea finales o transcodifican el juego de caracteres. Desactive cualquier función de reescritura del cuerpo en el camino hasta su webhook.
Usar == en lugar de una comparación en tiempo constante
// ❌ Don't do this
return expected === candidate;
// ✅ Do this (after checking the lengths are equal)
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate));
La comparación de cadenas normal se interrumpe en el primer byte que no coincide. Un atacante que mida el tiempo de miles de peticiones podría recuperar el secreto byte a byte. Todas las bibliotecas HMAC incluyen una función de igualdad en tiempo constante: úsela.
Reproducir eventos antiguos
La verificación solo comprueba la autenticidad, no la actualidad. Si registra los webhooks y alguien roba un payload antiguo junto con su firma, puede reproducirlo. Defensas:
- Registre
X-Certivant-Event-Idy rechace los duplicados; consulte Idempotencia en Eventos. - Rechace los eventos cuyo
tse diferencie más de 300 segundos de su reloj (los verificadores anteriores lo hacen).
Rotar su secreto
El botón Rotar secreto del panel invalida de inmediato el secreto antiguo para las nuevas entregas. Si quiere cero tiempo de inactividad en su lado, despliegue un verificador que acepte ambos secretos (el argumento secrets de los ejemplos anteriores admite una lista), rote y luego elimine el antiguo cuando ningún reintento en vuelo lo siga usando.
Probar su verificador sin Certivant
Calcule usted mismo una firma V2 y envíela por POST a su endpoint:
SECRET=whsec_test
BODY='{"event":"kyc.request.approved","requestId":"req_test"}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
curl -X POST http://localhost:3000/webhooks/certivant \
-H "Content-Type: application/json" \
-H "X-Certivant-Signature-V2: t=$T,v1=$SIG" \
-d "$BODY"
Si su manejador responde 200, la verificación funciona. Modifique el secreto o el cuerpo en un solo carácter, o envíe un t con más de 5 minutos de antigüedad, y compruebe que devuelve 401.
Configuración de webhooks
Guía paso a paso para registrar un endpoint de webhook de Certivant, guardar el secreto de firma, enviar un evento de prueba, rotar secretos y configurar excepciones por formulario.
Eventos de webhook
Referencia completa de cada tipo de evento de webhook de Certivant: esquema del sobre, formas del payload por evento, patrón de idempotencia y reglas de orden.