Clav
Webhooks

Verificar a assinatura

Como confirmar que uma requisição de webhook veio da Clav e não foi alterada nem reenviada por terceiros.

Por que verificar

A URL do seu webhook não é secreta, então qualquer pessoa pode mandar um POST para ela. Sem conferir a assinatura, o seu servidor não tem como distinguir um evento da Clav de uma requisição forjada. Recuse toda requisição cuja assinatura não confira.

Formato do cabeçalho

x-clav-signature: t=1758024251,v1=5f2b0c...e91a
  • t é o momento do envio, em segundos desde 1970 (Unix).
  • v1 é o HMAC-SHA256, em hexadecimal, calculado com o segredo do webhook.

O texto assinado é o timestamp, um ponto e o corpo bruto da requisição:

<t>.<corpo bruto>

O timestamp entra na assinatura para que ninguém consiga reaproveitar uma requisição antiga trocando só o t.

Passo a passo

Leia o corpo bruto

Pegue o corpo exatamente como chegou, antes de qualquer parse. Se o seu framework converter o JSON e você serializar de novo, espaços e ordem dos campos podem mudar e a assinatura não vai bater.

Separe t e v1

Divida o cabeçalho x-clav-signature pelas vírgulas e cada parte pelo sinal de igual.

Recuse timestamps antigos

Compare t com o relógio do seu servidor. Recomendamos recusar requisições com mais de 5 minutos de diferença, o que barra reenvios de requisições capturadas.

Calcule e compare

Calcule o HMAC-SHA256 de t + "." + corpo com o segredo e compare com v1 usando uma comparação de tempo constante.

Exemplo em Node.js

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

export function isValidClavSignature(input: {
	rawBody: string;
	header: string | null;
	secret: string;
}): boolean {
	if (!input.header) return false;

	const parts = new Map(
		input.header.split(",").map((part) => {
			const [key, ...rest] = part.split("=");
			return [key?.trim(), rest.join("=")] as const;
		}),
	);

	const timestamp = Number(parts.get("t"));
	const received = parts.get("v1");
	if (!Number.isInteger(timestamp) || !received) return false;

	const age = Math.abs(Date.now() / 1000 - timestamp);
	if (age > TOLERANCE_SECONDS) return false;

	const expected = createHmac("sha256", input.secret)
		.update(timestamp + "." + input.rawBody)
		.digest("hex");

	const a = Buffer.from(expected, "hex");
	const b = Buffer.from(received, "hex");
	return a.length === b.length && timingSafeEqual(a, b);
}

Exemplo em Python

import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300

def is_valid_clav_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
    if not header:
        return False

    parts = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part)
    timestamp = parts.get("t", "")
    received = parts.get("v1")
    if not timestamp.isdigit() or not received:
        return False

    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)

Use o segredo como texto

O segredo é exibido como uma string base64url. Use essa string diretamente como chave do HMAC, sem decodificar.

Problemas comuns

A assinatura nunca confere

Quase sempre o corpo foi alterado antes da verificação. Confirme que você está usando o corpo bruto, e não o resultado de JSON.stringify sobre o objeto já convertido. Confira também se o segredo é o do webhook certo, já que cada webhook tem o seu.

Algumas requisições são recusadas por timestamp

O relógio do seu servidor provavelmente está adiantado ou atrasado. Sincronize com NTP. Lembre que cada nova tentativa de entrega é assinada de novo, com um timestamp novo.