Verificação de assinatura
Verifique o cabeçalho X-Certivant-Signature-V2 (HMAC-SHA256 sobre timestamp.corpo, com tolerância de 300 s) para confiar nos payloads de webhook. Inclui exemplos em Node, Python, Go, Ruby e PHP.
Todo webhook que a Certivant envia inclui um cabeçalho de assinatura. Verifique-o antes de fazer qualquer coisa com o payload — caso contrário, um estranho que adivinhe a sua URL de webhook pode disparar eventos falsos contra você, e a sua aplicação vai acreditar neles.
O que você está verificando
Cada entrega traz uma assinatura V2 em um único cabeçalho autocontido:
X-Certivant-Signature-V2: t=1735689600,v1=8a2b4c...e91f
té o horário Unix (em segundos) em que a Certivant assinou o evento.v1éhex( HMAC-SHA256( secret, "${t}.${rawBody}" ) ), em quesecreté o seu segredo de assinaturawhsec_…erawBodysão os bytes exatos do corpo da requisição.- Durante a rotação de um segredo, o cabeçalho pode trazer vários valores
v1=(t=…,v1=…,v1=…). Aceite o evento se qualquer um deles corresponder.
Sua tarefa é recalcular o HMAC sobre t + "." + rawBody com a sua cópia do segredo, comparar com cada v1 usando uma comparação em tempo constante e rejeitar o evento quando |agora - t| > 300 segundos. Se a assinatura corresponder E o timestamp estiver dentro da janela de tolerância, o payload é autêntico e recente.
Tolerância de 300 segundos
Rejeite qualquer evento cujo t esteja a mais de 300 segundos (5 minutos) do seu relógio, em qualquer direção. Mantenha os seus servidores sincronizados via NTP. O timestamp faz parte da mensagem assinada, então um atacante não consegue alterá-lo sem invalidar a assinatura — é isso que torna inútil uma requisição capturada para reenvio depois que a janela se fecha.
Cabeçalhos em todo webhook
| Cabeçalho | Valor | Uso |
|---|---|---|
Content-Type | application/json | |
X-Certivant-Signature-V2 | t=<unix>,v1=<64-hex>[,v1=<64-hex>] | Verifique este cabeçalho. HMAC sobre ${t}.${rawBody} |
X-Certivant-Timestamp | Unix em segundos | Mesmo valor de t, para registros em log |
X-Certivant-Event-Id | ID único | Único por evento, estável entre as novas tentativas. Use para idempotência |
X-KYC-Signature | sha256=<64-hex> | Mesmo HMAC sobre ${X-KYC-Timestamp}.${rawBody}. Integrações existentes; continua sendo enviado |
X-KYC-Timestamp, X-KYC-Event-Id | Unix em segundos / ID | Mesmos valores dos cabeçalhos X-Certivant-* |
Eventos enviados antes de você configurar um segredo trazem os cabeçalhos de ID e timestamp, mas não trazem assinatura.
A assinatura legada apenas do corpo foi removida
Antes de maio de 2026, a assinatura cobria apenas o rawBody. O cabeçalho legado X-KYC-Signature-Legacy, apenas do corpo, anunciado como obsoleto para 2026-09-01, não é mais enviado (removido em 24 de outubro de 2026), e o mesmo vale para X-KYC-Signature-Deprecation. Se o seu receptor ainda o verifica, todos os eventos agora falham na verificação: mude para X-Certivant-Signature-V2 (ou para o X-KYC-Signature com timestamp), usando a receita abaixo.
A receita de verificação
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
Três regras inegociáveis:
- Vincule o timestamp à mensagem assinada, não apenas ao corpo. Sem isso, um atacante que capturar um webhook válido pode reenviá-lo para sempre.
- Leia o corpo bruto — nunca o objeto interpretado e serializado novamente. A ordem das chaves, os espaços em branco e a formatação dos números alteram o hash.
- Compare com uma função de tempo constante (
timingSafeEqual,hmac.compare_digest,hash_equals,hmac.Equal, …), nunca com==.
Teste o seu verificador sem escrever código: o botão Enviar evento de teste do painel (KYC → Desenvolvedor → Eventos de webhook) entrega um ping kyc.webhook.test real e assinado, ou um payload de exemplo kyc.request.* (ambos trazem "test": true), para a sua URL configurada.
Implementações
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]);
}
Armadilhas comuns
"As assinaturas não batem em produção, mas batiam nos testes"
O seu framework está interpretando ou modificando o corpo antes de você calcular o HMAC. O culpado mais comum é o middleware body-parser no Node (o express.json() do Express altera req.body; você deve usar express.raw() especificamente na rota do webhook). Nas rotas de API do Next.js, defina runtime: "nodejs" e leia await req.text() primeiro.
"As assinaturas batem localmente, mas falham quando passam por um load balancer"
Alguns load balancers (ou WAFs) reescrevem os corpos das requisições — formatando o JSON, removendo quebras de linha finais ou convertendo charsets. Desative qualquer recurso de reescrita de corpo no caminho até o seu webhook.
Usar == em vez de comparação em tempo 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));
A comparação simples de strings interrompe a execução no primeiro byte diferente. Um atacante que meça o tempo de milhares de requisições pode recuperar o segredo byte a byte. Todas as bibliotecas de HMAC trazem uma função de igualdade em tempo constante — use-a.
Reenvio de eventos antigos
A verificação confere apenas a autenticidade, não a atualidade. Se você registra webhooks em log e alguém rouba um payload e uma assinatura antigos, essa pessoa pode reenviá-los. Defesas:
- Registre o
X-Certivant-Event-Ide rejeite duplicatas — veja Idempotência em Eventos. - Rejeite eventos cujo
testeja a mais de 300 segundos do seu relógio (os verificadores acima fazem isso).
Rotacionar o seu segredo
O botão Rotacionar segredo do painel torna o segredo antigo inválido imediatamente para as novas entregas. Se você quer zero de indisponibilidade do seu lado, implante um verificador que aceite ambos os segredos (o argumento secrets acima recebe uma lista), rotacione e, depois, descarte o antigo quando nenhuma nova tentativa em andamento ainda o carregar.
Testar o seu verificador sem a Certivant
Calcule você mesmo uma assinatura V2 e faça um POST para o seu 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"
Se o seu manipulador responder 200, a verificação funciona. Altere o segredo ou o corpo em um único caractere, ou envie um t com mais de 5 minutos, e confirme que a resposta é 401.
Configuração de webhooks
Guia passo a passo para registrar um endpoint de webhook da Certivant, guardar o segredo de assinatura, enviar um evento de teste, rotacionar segredos e configurar substituições por formulário.
Eventos de webhook
Referência completa de cada tipo de evento de webhook da Certivant — esquema do envelope, formatos de payload por evento, padrão de idempotência e regras de ordenação.