Pular para o conteúdo principal

Receber webhooks da Zabit

Não use o webhook como único canal

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.createdDossiê KYC aberto para um sujeito.
onboarding.kyc.completedDossiê KYC concluído, com a conclusão de risco do sujeito.
onboarding.kyc.requeriedReconsulta de um dossiê KYC disparada (foto nova do mesmo sujeito).
onboarding.decision.appliedDecisão do fluxo aplicada ao dossiê (por um analista ou automática, por risco).
onboarding.certificate.issuedCertidão emitida para o dossiê.
onboarding.import.completedLote de importação concluído (nenhuma linha em andamento).
case.archivedCaso arquivado (encerramento do ciclo de análise).
case.action.approvedAção do caso aprovada na alçada.
case.workflow.transitionedTransiçã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​

RequisitoPor quê
HTTPS, em IP públicoTodo 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 redirecionar3xx nunca é seguido — é tratado como falha da tentativa. Publique a URL final.
Responder em ≤ 10 sTimeout de entrega. Faça o trabalho pesado depois de responder.
2xx = recebiQualquer 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çalhoConteúdo
webhook-idId da mensagem (== id do envelope). É a sua chave de idempotência.
webhook-timestampEpoch em segundos do momento do envio.
webhook-signaturev1,<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:

  1. 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.
  2. Compare em tempo constante. == em string vaza timing e é o ataque padrão contra HMAC.
  3. 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)
}))
}
Frameworks web consomem o corpo bruto

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-id não funde as duas — e não deve.
  • subject repete. Reconsulta, decisão e conclusão do mesmo dossiê compartilham o subject. A chave de deduplicação é o webhook-id, nunca o subject.

Se o seu endpoint falhar, tentamos de novo nesta agenda:

TentativaEspera desde a falha anterior
1imediata
25 s
35 min
430 min
52 h
65 h
710 h
810 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-timestamp fora de ±5 min.
  • Deduplico por webhook-id (não por subject).
  • Respondo 2xx depois 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.