Vérification de la signature
Vérifiez l'en-tête X-Certivant-Signature-V2 (HMAC-SHA256 sur timestamp.body, tolérance de 300 s) pour que vous puissiez faire confiance aux charges utiles des webhooks. Inclut des exemples en Node, Python, Go, Ruby et PHP.
Chaque webhook envoyé par Certivant comporte un en-tête de signature. Vérifiez-le avant de faire quoi que ce soit avec la charge utile : sinon, un inconnu qui devine l'URL de votre webhook peut vous envoyer de faux événements, et votre application les croira.
Ce que vous vérifiez
Chaque livraison porte une signature V2 dans un seul en-tête autonome :
X-Certivant-Signature-V2: t=1735689600,v1=8a2b4c...e91f
test l'heure Unix (en secondes) à laquelle Certivant a signé l'événement.v1vauthex( HMAC-SHA256( secret, "${t}.${rawBody}" ) ), oùsecretest votre secret de signaturewhsec_…etrawBodycorrespond aux octets exacts du corps de la requête.- Pendant une rotation de secret, l'en-tête peut contenir plusieurs valeurs
v1=(t=…,v1=…,v1=…). Acceptez l'événement si l'une d'elles correspond.
Votre rôle est de recalculer le HMAC sur t + "." + rawBody avec votre copie du secret, de comparer chaque v1 avec une comparaison à temps constant, et de rejeter l'événement lorsque |now - t| > 300 secondes. Si une signature correspond ET que l'horodatage se situe dans la fenêtre de tolérance, la charge utile est authentique et récente.
Tolérance de 300 secondes
Rejetez tout événement dont le t s'écarte de plus de 300 secondes (5 minutes) de votre propre horloge, dans un sens ou dans l'autre. Synchronisez vos serveurs avec NTP. L'horodatage fait partie du message signé : un attaquant ne peut donc pas le modifier sans invalider la signature. C'est ce qui rend inutile une requête interceptée pour la rejouer une fois la fenêtre refermée.
En-têtes présents sur chaque webhook
| En-tête | Valeur | Usage |
|---|---|---|
Content-Type | application/json | |
X-Certivant-Signature-V2 | t=<unix>,v1=<64-hex>[,v1=<64-hex>] | À vérifier. HMAC sur ${t}.${rawBody} |
X-Certivant-Timestamp | Secondes Unix | Même valeur que t, pour la journalisation |
X-Certivant-Event-Id | Identifiant unique | Unique par événement, stable d'une tentative à l'autre. À utiliser pour l'idempotence |
X-KYC-Signature | sha256=<64-hex> | Même HMAC sur ${X-KYC-Timestamp}.${rawBody}. Intégrations existantes ; toujours envoyé |
X-KYC-Timestamp, X-KYC-Event-Id | Secondes Unix / identifiant | Mêmes valeurs que les en-têtes X-Certivant-* |
Les événements envoyés avant que vous n'ayez configuré un secret comportent les en-têtes d'identifiant et d'horodatage, mais aucune signature.
L'ancienne signature basée uniquement sur le corps a été supprimée
Avant mai 2026, la signature ne couvrait que rawBody. Cet en-tête hérité X-KYC-Signature-Legacy, basé uniquement sur le corps et annoncé comme obsolète pour le 1er septembre 2026, n'est plus envoyé (supprimé le 24 octobre 2026), et il en va de même pour X-KYC-Signature-Deprecation. Si votre récepteur le vérifie encore, chaque événement échoue désormais à la vérification : passez à X-Certivant-Signature-V2 (ou à X-KYC-Signature avec horodatage), en suivant la procédure ci-dessous.
La procédure de vérification
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
Trois règles non négociables :
- Intégrez l'horodatage dans le message signé, et pas seulement le corps. Sans cela, un attaquant qui intercepte un webhook valide peut le rejouer indéfiniment.
- Lisez le corps brut — jamais l'objet analysé re-sérialisé. L'ordre des clés, les espaces et le format des nombres modifient tous le haché.
- Comparez avec une fonction à temps constant (
timingSafeEqual,hmac.compare_digest,hash_equals,hmac.Equal, …), jamais avec==.
Testez votre vérificateur sans écrire de code : le bouton Envoyer un événement de test du tableau de bord (KYC → Développeur → Événements webhook) envoie à votre URL configurée un véritable ping kyc.webhook.test signé, ou une charge utile exemple kyc.request.* (qui portent toutes deux "test": true).
Implémentations
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]);
}
Pièges courants
« Les signatures ne correspondent pas en production, alors qu'elles correspondaient en test »
Votre framework analyse ou modifie le corps avant que vous ne calculiez le HMAC. Le coupable le plus fréquent est le middleware d'analyse du corps dans Node (express.json() modifie req.body ; vous devez utiliser express.raw() pour la route du webhook en particulier). Dans les routes API de Next.js, définissez runtime: "nodejs" et lisez d'abord await req.text().
« Les signatures correspondent en local mais échouent via un répartiteur de charge »
Certains répartiteurs de charge (ou pare-feux applicatifs) réécrivent les corps de requête : mise en forme, suppression des sauts de ligne finaux, ou transcodage des jeux de caractères. Désactivez toute fonction de réécriture du corps sur le chemin qui mène à votre webhook.
Utiliser == au lieu d'une comparaison à temps constant
// ❌ Don't do this
return expected === candidate;
// ✅ Do this (after checking the lengths are equal)
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate));
Une comparaison de chaînes classique s'arrête au premier octet différent. Un attaquant qui chronomètre des milliers de requêtes peut retrouver le secret octet par octet. Toutes les bibliothèques HMAC fournissent une fonction d'égalité à temps constant : utilisez-la.
Rejouer d'anciens événements
La vérification ne contrôle que l'authenticité, et non la fraîcheur. Si vous journalisez les webhooks et que quelqu'un vole une ancienne charge utile avec sa signature, il peut la rejouer. Protections :
- Mémorisez
X-Certivant-Event-Idet rejetez les doublons — voir Idempotence dans Événements. - Rejetez les événements dont le
ts'écarte de plus de 300 secondes de votre horloge (les vérificateurs ci-dessus le font).
Renouveler votre secret
Le bouton Renouveler le secret du tableau de bord rend immédiatement l'ancien secret invalide pour les nouvelles livraisons. Si vous voulez une interruption nulle de votre côté, déployez un vérificateur qui accepte les deux secrets (l'argument secrets ci-dessus accepte une liste), effectuez la rotation, puis supprimez l'ancien une fois qu'aucune nouvelle tentative en cours ne le porte encore.
Tester votre vérificateur sans Certivant
Calculez vous-même une signature V2 et envoyez-la en POST vers votre point de terminaison :
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 votre gestionnaire répond 200, la vérification fonctionne. Modifiez le secret ou le corps d'un seul caractère, ou envoyez un t vieux de plus de 5 minutes, et vérifiez qu'il renvoie 401.
Configuration des webhooks
Guide pas à pas pour enregistrer un point de terminaison webhook Certivant, conserver le secret de signature, envoyer un événement de test, renouveler les secrets et configurer des remplacements par formulaire.
Événements webhook
Référence complète de chaque type d'événement webhook de Certivant — schéma de l'enveloppe, structures de charge utile par événement, modèle d'idempotence et règles d'ordre.