跳到主要内容

Validação de webhooks

Veja também

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:

CampoDescriçãoComo validar
methodMétodo HTTP do webhook.Deve ser POST.
uriCaminho 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.
timestampMomento 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_md5MD5 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:

  1. Interprete o corpo recebido como JSON.
  2. Serialize-o de novo em JSON compacto, mantendo a ordem das chaves e sem escapar caracteres não-ASCII.
  3. Calcule o MD5 em hexadecimal dos bytes UTF-8 dessa serialização.
Atençã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​

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

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.

Informação

Este exemplo foi assinado em 05/10/2026. Para testá-lo, desative a verificação do timestamp.

Chave pública de exemplo
-----BEGIN PUBLIC KEY-----
MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQA9TmX4OOkJUMWJSKrL+4znIYW/phK
0w1KnKI+F9pJiI5cjG56sfA0lZnQZ5pa3W5SDWTSUXPdwuOQECeA10Ly2fsBJ8Mo
zCiJ75Tx5/NlaZZEef8gfTHooHfNqJbf4nxerL6yUnXndZa7PfZl/GNDVbFqWK6t
yLOHXS2L7tx+UDEO90Q=
-----END PUBLIC KEY-----
Header AUTHORIZATION
eyJhbGciOiJFUzUxMiIsInR5cCI6IkpXVCJ9.eyJwYXlsb2FkX21kNSI6IjgxMzVhZjAxMDczNDNiYzJkYjBhODIxM2U1YTZjNzU0IiwidGltZXN0YW1wIjoiMjAyNi0xMC0wNVQxNTo1OToyMS42OTc3MThaIiwibWV0aG9kIjoiUE9TVCIsInVyaSI6Ii93ZWJob29rcy9xaXRlY2gifQ.AVNn4ASJQqHMBO2Q7rLrO-umRFCae9x8Elc296rXzpNRFJ4f3dHjcOM2rVnOj7h8b68NGYCh7ztCt5f7W0s04kB-AaHqm2cZqeGvdKc4ha7qM7YqhqjFYex7MnF7BCLL77BsOyOM9Hx4AiXRe2zS_dD2ReR3bHBFmc-pqT1mnjypsnE7
Corpo como recebido
{"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.