Validação de webhooks
Cada webhook do Insurance-as-a-Service é assinado com a chave privada da sua integração na QI Tech. Valide a assinatura com a chave pública da QI Tech recebida na configuração dos webhooks antes de processar qualquer webhook.
O que é assinado
A assinatura chega no header AUTHORIZATION — ou em QI-AUTHORIZATION, se você configurou um header próprio chamado AUTHORIZATION. Ela é um JWT assinado com ES512 (curva P-521), cujo payload tem exatamente quatro campos:
| Campo | Descrição | Como validar |
|---|---|---|
method | Método HTTP do webhook. | Deve ser POST. |
uri | Caminho da sua URL de webhook, incluindo a query string, se houver. Sem esquema e sem host. | Deve ser igual ao caminho da URL que você configurou, por exemplo /webhooks/qitech. |
timestamp | Momento da assinatura, em UTC, no formato %Y-%m-%dT%H:%M:%S.%fZ. | Deve estar dentro de uma janela de tolerância — sugerimos 5 minutos. |
payload_md5 | MD5 do corpo do webhook serializado em JSON compacto. | Deve ser igual ao MD5 que você calcula conforme a seção abaixo. |
Como calcular o payload_md5
O MD5 é calculado sobre o corpo serializado em JSON compacto: sem espaços após , e :, com as chaves na ordem em que chegaram e com os caracteres não-ASCII em UTF-8, sem escape.
O corpo que chega na sua URL tem a mesma informação, mas não os mesmos bytes: ele vem formatado com espaços e com os acentos escapados (ç). Por isso:
- Interprete o corpo recebido como JSON.
- Serialize-o de novo em JSON compacto, mantendo a ordem das chaves e sem escapar caracteres não-ASCII.
- Calcule o MD5 em hexadecimal dos bytes UTF-8 dessa serialização.
Calcular o MD5 diretamente sobre os bytes recebidos, ou sobre uma serialização com espaços ou com acentos escapados, nunca confere.
Exemplo de validação
- Python
- Node.js
import json
from datetime import datetime, timedelta, timezone
from hashlib import md5
import jwt # PyJWT com suporte a ES512: pip install "pyjwt[crypto]"
TOLERANCIA = timedelta(minutes=5)
def validar_webhook(headers: dict, corpo_recebido: bytes, chave_publica_qi: str, uri_esperada: str) -> dict:
"""Valida um webhook e devolve o corpo interpretado. Levanta exceção se for inválido.
`headers` deve ser um dicionário com os nomes em maiúsculas.
"""
token = headers.get("QI-AUTHORIZATION") or headers["AUTHORIZATION"]
claims = jwt.decode(token, chave_publica_qi, algorithms=["ES512"])
corpo = json.loads(corpo_recebido)
corpo_assinado = json.dumps(corpo, separators=(",", ":"), ensure_ascii=False)
if claims["payload_md5"] != md5(corpo_assinado.encode("utf-8")).hexdigest():
raise ValueError("payload_md5 não confere")
if claims["method"] != "POST":
raise ValueError("method não confere")
if claims["uri"] != uri_esperada:
raise ValueError("uri não confere")
assinado_em = datetime.strptime(claims["timestamp"], "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc)
if abs(datetime.now(timezone.utc) - assinado_em) > TOLERANCIA:
raise ValueError("timestamp fora da janela de tolerância")
return corpo
const crypto = require('node:crypto');
const TOLERANCIA_MS = 5 * 60 * 1000;
// Valida um webhook e devolve o corpo interpretado. Lança erro se for inválido.
// `headers` com os nomes em minúsculas, como no Node.js; `corpoRecebido` é o corpo bruto (string).
function validarWebhook(headers, corpoRecebido, chavePublicaQi, uriEsperada) {
const token = headers['qi-authorization'] || headers['authorization'];
const [cabecalho, payload, assinatura] = token.split('.');
const { alg } = JSON.parse(Buffer.from(cabecalho, 'base64url').toString('utf8'));
if (alg !== 'ES512') {
throw new Error('algoritmo inesperado');
}
const assinaturaValida = crypto.verify(
'sha512',
Buffer.from(`${cabecalho}.${payload}`),
{ key: chavePublicaQi, dsaEncoding: 'ieee-p1363' },
Buffer.from(assinatura, 'base64url'),
);
if (!assinaturaValida) {
throw new Error('assinatura inválida');
}
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
const corpo = JSON.parse(corpoRecebido);
const corpoAssinado = JSON.stringify(corpo);
const md5 = crypto.createHash('md5').update(corpoAssinado, 'utf8').digest('hex');
if (claims.payload_md5 !== md5) {
throw new Error('payload_md5 não confere');
}
if (claims.method !== 'POST') {
throw new Error('method não confere');
}
if (claims.uri !== uriEsperada) {
throw new Error('uri não confere');
}
const assinadoEm = Date.parse(claims.timestamp);
if (Math.abs(Date.now() - assinadoEm) > TOLERANCIA_MS) {
throw new Error('timestamp fora da janela de tolerância');
}
return corpo;
}
Dados para testar
Use os dados abaixo para conferir a sua implementação. Eles foram assinados com uma chave de exemplo, para a URL https://seu-dominio.com.br/webhooks/qitech — a uri_esperada é /webhooks/qitech.
Este exemplo foi assinado em 05/10/2026. Para testá-lo, desative a verificação do timestamp.
-----BEGIN PUBLIC KEY-----
MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQA9TmX4OOkJUMWJSKrL+4znIYW/phK
0w1KnKI+F9pJiI5cjG56sfA0lZnQZ5pa3W5SDWTSUXPdwuOQECeA10Ly2fsBJ8Mo
zCiJ75Tx5/NlaZZEef8gfTHooHfNqJbf4nxerL6yUnXndZa7PfZl/GNDVbFqWK6t
yLOHXS2L7tx+UDEO90Q=
-----END PUBLIC KEY-----
eyJhbGciOiJFUzUxMiIsInR5cCI6IkpXVCJ9.eyJwYXlsb2FkX21kNSI6IjgxMzVhZjAxMDczNDNiYzJkYjBhODIxM2U1YTZjNzU0IiwidGltZXN0YW1wIjoiMjAyNi0xMC0wNVQxNTo1OToyMS42OTc3MThaIiwibWV0aG9kIjoiUE9TVCIsInVyaSI6Ii93ZWJob29rcy9xaXRlY2gifQ.AVNn4ASJQqHMBO2Q7rLrO-umRFCae9x8Elc296rXzpNRFJ4f3dHjcOM2rVnOj7h8b68NGYCh7ztCt5f7W0s04kB-AaHqm2cZqeGvdKc4ha7qM7YqhqjFYex7MnF7BCLL77BsOyOM9Hx4AiXRe2zS_dD2ReR3bHBFmc-pqT1mnjypsnE7
{"status": "cancelled", "webhook_type": "insurance.order.status_changed", "key": "5e2b3f60-7c4b-4c8e-9f1a-6d2e8b4a7c90", "event_datetime": "2026-10-05T15:59:20Z", "data": {"order_key": "5e2b3f60-7c4b-4c8e-9f1a-6d2e8b4a7c90", "requester_key": "3f1c9a2e-4b7d-4e0a-9c55-2d8e7f6a1b30", "status": "cancelled", "customer_document_number": "96969879003", "reason": "Cliente desistiu da contratação", "product_count": 1, "sequence": 2, "schema_version": 1}}
A serialização compacta desse corpo tem o MD5 8135af0107343bc2db0a8213e5a6c754, igual ao payload_md5 do token.