Receber webhooks da Zabit
A entrega é at-least-once e sem garantia de ordem (veja a seção 4). Para saber que um KYC terminou,
use o webhook como aviso e confira o estado com GET /kyc/v1/dossiers/{id}.
A Zabit entrega eventos de domínio no seu endpoint HTTPS seguindo Standard Webhooks. Este guia é o contrato do lado do receptor: o que chega, como verificar que veio mesmo de nós, e o que o seu endpoint precisa garantir para não perder nem duplicar fato.
Tipos de evento
Você escolhe quais tipos assinar em Configurações › Integrações. Estes são os que a Zabit emite:
Tipo (type no envelope: zabit.<tipo>.v1) | Quando é emitido |
|---|---|
onboarding.kyc.created | Dossiê KYC aberto para um sujeito. |
onboarding.kyc.completed | Dossiê KYC concluído, com a conclusão de risco do sujeito. |
onboarding.kyc.requeried | Reconsulta de um dossiê KYC disparada (foto nova do mesmo sujeito). |
onboarding.decision.applied | Decisão do fluxo aplicada ao dossiê (por um analista ou automática, por risco). |
onboarding.certificate.issued | Certidão emitida para o dossiê. |
onboarding.import.completed | Lote de importação concluído (nenhuma linha em andamento). |
case.archived | Caso arquivado (encerramento do ciclo de análise). |
case.action.approved | Ação do caso aprovada na alçada. |
case.workflow.transitioned | Transição de estado do fluxo do caso. |
A tela de Integrações pode listar tipos além destes: assinar um tipo que ainda não é emitido não
gera entregas. Novos tipos chegam sem aviso prévio e são anunciados nesta documentação:
ignore, em vez de falhar, um type que o seu código não trata. O envelope abaixo é comum a todos os
tipos, e o exemplo de data é o de onboarding.kyc.completed.
1. O que o seu endpoint precisa ser
| Requisito | Por quê |
|---|---|
| HTTPS, em IP público | Todo DNS é resolvido e validado a cada tentativa, e a conexão vai no IP literal validado (defesa anti-DNS-rebinding). IP privado, loopback ou link-local (10/8, 127/8, 169.254/16, …) é recusado no cadastro e na entrega. |
| Sem redirecionar | 3xx nunca é seguido — é tratado como falha da tentativa. Publique a URL final. |
| Responder em ≤ 10 s | Timeout de entrega. Faça o trabalho pesado depois de responder. |
2xx = recebi | Qualquer outra coisa (ou timeout) conta como falha e entra no retry. |
Responda 2xx assim que tiver persistido a mensagem. Processar antes de responder é como se
perde entrega no timeout.
2. O que chega
POST com content-type: application/json. O corpo é o envelope (perfil CloudEvents 1.0):
{
"specversion": "1.0",
"id": "0192f3c1-8a7e-7c2b-9f10-3d5b6e2a4c88",
"type": "zabit.onboarding.kyc.completed.v1",
"source": "zabit://onboarding",
"subject": "c9166a7a-c3c0-459e-b62c-a8606d2d7795",
"time": "2026-08-28T03:12:44.918273+00:00",
"workspaceid": "07459343-eb3c-4361-939d-23b86e72b2b6",
"dataschema": "events/onboarding.v1.asyncapi.yaml#/components/schemas/KycCompletedEvent",
"data": {
"dossier_id": "c9166a7a-c3c0-459e-b62c-a8606d2d7795",
"party_id": "e20f3fdc-d554-4795-b2f6-561c22cb5264",
"document_type": "PF",
"subtype": "kyc",
"status": "vigente",
"risk_level": "LOW",
"risk_level_rank": 1
}
}
e três cabeçalhos, fora do corpo:
| Cabeçalho | Conteúdo |
|---|---|
webhook-id | Id da mensagem (== id do envelope). É a sua chave de idempotência. |
webhook-timestamp | Epoch em segundos do momento do envio. |
webhook-signature | v1,<base64(HMAC-SHA256(secret, "<id>.<timestamp>.<body>"))>. Pode trazer mais de uma assinatura separada por espaço. |
Roteie por type — é o discriminador que determina a forma de data.
O payload é THIN, por contrato
data carrega ids opacos, códigos, enums e números. Nunca CPF/CNPJ, nome, e-mail, telefone,
endereço, nem texto livre escrito por analista. Não é economia de bytes: o destino é uma URL que
você controla, e o que não sai não vaza. Precisa do nome do sujeito, do parecer ou do PDF?
Busque pela API autenticada com o id que recebeu.
Um detalhe que muda a modelagem do seu lado: dossier_id identifica a foto do dossiê, não o
sujeito. Duas consultas do mesmo CPF são duas fotos com ids diferentes — quem costura as duas é o
party_id.
3. Verificar a assinatura (obrigatório)
Três regras que não são opcionais:
- Use o corpo BRUTO. Os bytes exatos que chegaram. Reserializar o JSON (mesmo "sem mudar nada") reordena chaves ou muda espaçamento e quebra a assinatura.
- Compare em tempo constante.
==em string vaza timing e é o ataque padrão contra HMAC. - Rejeite timestamp velho. Fora de ±5 min → recuse. É o que impede o replay de uma requisição capturada.
O segredo vem no formato whsec_<base64url>. O prefixo whsec_ não faz parte da chave: a
chave HMAC é o que vem depois, decodificado de base64url.
import base64, hashlib, hmac, time
def verificar(*, corpo_bruto: bytes, headers, segredos: list[str], tolerancia_s: int = 300) -> bool:
msg_id = headers["webhook-id"]
ts = headers["webhook-timestamp"]
try:
if abs(int(time.time()) - int(ts)) > tolerancia_s:
return False # replay
except ValueError:
return False
assinado = f"{msg_id}.{ts}.".encode() + corpo_bruto # corpo BRUTO, nunca reserializado
esperadas = []
for s in segredos: # 2 segredos durante a janela de rotação
b64 = s.removeprefix("whsec_")
chave = base64.urlsafe_b64decode(b64 + "=" * (-len(b64) % 4))
esperadas.append(base64.b64encode(
hmac.new(chave, assinado, hashlib.sha256).digest()).decode())
recebidas = [p.split(",", 1)[1]
for p in headers["webhook-signature"].split() if p.startswith("v1,")]
return any(hmac.compare_digest(e, r) for e in esperadas for r in recebidas)
import crypto from 'node:crypto'
export function verificar({ corpoBruto, headers, segredos, toleranciaS = 300 }) {
const id = headers['webhook-id']
const ts = headers['webhook-timestamp']
if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > toleranciaS) return false
const assinado = Buffer.concat([Buffer.from(`${id}.${ts}.`), corpoBruto])
const esperadas = segredos.map((s) => {
const chave = Buffer.from(s.replace(/^whsec_/, ''), 'base64url')
return crypto.createHmac('sha256', chave).update(assinado).digest('base64')
})
const recebidas = headers['webhook-signature'].split(' ')
.filter((p) => p.startsWith('v1,')).map((p) => p.slice(3))
return esperadas.some((e) => recebidas.some((r) => {
const a = Buffer.from(e), b = Buffer.from(r)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}))
}
Express com express.json(), FastAPI com um modelo Pydantic no parâmetro, Rails com params — todos
já parsearam quando o seu handler roda. Guarde os bytes originais antes (express.raw(),
await request.body() no FastAPI, request.raw_post), ou a verificação vai falhar por um motivo
que não é o seu código.
Rotação de segredo
Ao girar o segredo, os dois ficam válidos por uma janela e a assinatura chega com duas entradas
v1,.... Aceite se qualquer uma bater — é isto que permite trocar a chave no seu lado sem
janela de indisponibilidade. Depois de trocar, o antigo cai sozinho.
4. Idempotência, retry e ordem
Deduplique por webhook-id. A entrega é at-least-once: a mesma mensagem pode chegar mais de
uma vez, e um replay manual reenvia o mesmo id de propósito — replay não é um fato novo.
Guarde o id visto e ignore o repetido.
Duas ressalvas que valem escrever no seu código:
- Fan-out não é duplicata. Um evento entregue a duas assinaturas suas são duas mensagens com
ids diferentes. Deduplicar por
webhook-idnão funde as duas — e não deve. subjectrepete. Reconsulta, decisão e conclusão do mesmo dossiê compartilham osubject. A chave de deduplicação é owebhook-id, nunca osubject.
Se o seu endpoint falhar, tentamos de novo nesta agenda:
| Tentativa | Espera desde a falha anterior |
|---|---|
| 1 | imediata |
| 2 | 5 s |
| 3 | 5 min |
| 4 | 30 min |
| 5 | 2 h |
| 6 | 5 h |
| 7 | 10 h |
| 8 | 10 h |
Falhou a 8ª → a mensagem vai para a DLQ (dead) e só sai de lá por replay manual, pela tela de
Integrações. Três dead consecutivas desligam o endpoint (auto_disabled) e avisam os
administradores do workspace — ~3 a 4 dias de endpoint surdo, não um pico transitório.
A ordem não é garantida. Ordene por time (o instante do FATO, preservado entre tentativas e
replays), nunca pela ordem de chegada. Não presuma que kyc.created chega antes de
kyc.completed.
5. Testar antes de ligar a assinatura
Cadastre o endpoint em Configurações › Integrações, guarde o segredo (ele aparece uma única
vez) e dispare o evento de teste. Ele chega assinado e com o mesmo formato de envelope, com
type: zabit.ping.v1 — sirva para provar cadeia, TLS e verificação de assinatura ponta a ponta.
O ping não passa por assinatura: ele sempre chega ao endpoint testado, independente do que
você marcou na árvore de eventos. Por isso ele não aparece na lista de tipos assináveis — e por
isso o seu handler deve ignorá-lo, não tratá-lo como fato de domínio.
6. Checklist
- Guardo o corpo bruto antes de parsear.
- Verifico a assinatura em tempo constante, aceitando os dois segredos.
- Rejeito
webhook-timestampfora de ±5 min. - Deduplico por
webhook-id(não porsubject). - Respondo
2xxdepois de persistir, e processo depois. - Ordeno por
time, não por chegada. - Meu endpoint é HTTPS público, sem redirect, responde em menos de 10 s.