Webhooks & eventos
A verdade é o webhook: o resultado oficial vai ao seu backend por evento assinado. Os principais sãoverification.completed e verification.blocked. O ciclo de até 3 tentativas cobre soluços momentâneos do seu endpoint, sem prender a resposta ao usuário.
Existe um terceiro evento terminal, verification.failed: ele sai quando a análise não foi concluída do nosso lado (uma etapa do processamento se perdeu e não será retomada). O corpo vem com status: "failed" e decision_reason: "analysis_incomplete", e essa verificação não é cobrada. Trate como inconclusiva, nunca como reprovação: o titular não fez nada de errado, e o caminho é abrir uma verificação nova para ele. Toda verificação termina em um destes três eventos, então nenhuma pendência fica aberta para sempre esperando um webhook que nunca chega.
Há também verification.monitoring, o alerta do monitoramento contínuo (módulo monitoring_aml): quando o titular de uma verificação aprovada com o monitoramento ligado aparece em uma lista de sanções ou de PEP depois do onboarding, uma verificação de acompanhamento é criada com status: "review" e este evento sai no mesmo envelope assinado, com a evidência no check_details do módulo. O alerta nunca decide sozinho: quem revisa e decide é você. Receptores devem tolerar tipos de evento novos, como a política de versionamento já pede.
Por fim, verification.subject_erased: sai quando os dados pessoais do titular daquela verificação são apagados do nosso lado (pelo botão de apagar do painel, ou em atendimento a um pedido do próprio titular). O corpo é mínimo de propósito: só verification_id, flow_id, reference_id, environment e subject_data_erased: true, sem nenhum dado de identidade e sem os campos de decisão. Ele existe por causa do art. 18, §6º da LGPD: quem elimina um dado deve comunicar os agentes com quem o compartilhou, para que repitam o procedimento. Ao receber este evento, repita o apagamento nos seus sistemas (e nos de quem recebeu o resultado de você); não o trate como mudança de decisão, porque a decisão registrada não muda.
No flow com o módulo pld_monitor chega também pld.alert.created, quando a política de PLD/FT liga o aviso por webhook (ele nasce desligado): o monitoramento de PLD/FT selecionou uma operação ou situação. Ele não é evento de verificação e tem corpo próprio, mínimo e sigiloso: o id do alerta, a sua reference_id, a severidade, os itens da norma, os vencimentos e o caminho do alerta no painel, sem evidência e sem valor. A assinatura, os headers e as retentativas são os mesmos. O contrato completo está em PLD/FT pela regra da norma.
identity_proven vem sempre, verdadeiro ou falso. Ele é verdadeiro quando a verificação leu um documento de pessoa junto do face e do liveness, que é o que liga a pessoa presente ao documento. Um flow que lê o documento sem esse par confere o documento, mas não prova identidade, e sai com identity_proven: false. Ao criar ou editar um flow assim, a resposta traz o aviso identity_not_proven em warnings. O desfecho continua no status.
Alguns campos aparecem só às vezes, e é melhor você saber deles antes de escrever um parser estrito. reference_role vem só no alerta do transaction_monitor e diz o que o reference_id identifica: subject (o titular), counterparty (a conta de destino) ou device (o aparelho). blocklist_face_match vem quando o portão de rosto da sua lista de bloqueio moveu a decisão, com a evidência para você revisar. origin_tag vem quando a sessão foi criada com o rótulo de origem da jornada, exato como você mandou; a recusa de consentimento, que termina antes de qualquer captura, sai sem ele. step_up_of vem só na verificação da sessão de prova que um gate abriu (o step-up com prova de humano do transaction_gate): source é o gate, verification_id é a verificação do pagamento e event_type é o tipo do evento, para você ligar o desfecho da prova ao pagamento que estava esperando. act e purpose vêm só na verificação de uma sessão de prova com ato; purpose só quando ela nasceu de um link de confirmação. attestation vem só quando o flow tem o módulo human_attestation. passkey_binding vem só quando o flow vincula passkey: bound verdadeiro com o passkey_id da chave vinculada, ou falso com o reason (verification_not_approved, not_registered quando a pessoa não criou a chave, not_eligible quando ela criou e o vínculo não coube). E o par truncated mais truncated_fields vem quando o corpo passou de 256 KB (acontece com quadro societário grande): nesse caso podamos os blocos volumosos de check_details e dizemos quais. A decisão nunca é podada: status, score, risk_level, recommendation, os saldos e o checks chegam sempre. O detalhe completo fica no painel. E billing vem só no alerta do monitoramento transacional entregue sem cobrança, com waived dizendo o porquê: above_volume (acima do volume contratado) ou no_credit (sem saldo). Alerta de severidade alta nunca é retido por volume nem por falta de saldo.
Quando a evidência de um módulo sai. O dado que sustentava um módulo sai no prazo de 180 dias contados da verificação, ou antes, num apagamento a pedido (de uma verificação ou por titular). Daí em diante o resumo checks diz expired no módulo cuja palavra dependia desse dado, e o bloco do módulo em check_details traz data.evidence_expired: true, com null em todo campo que vinha dele (a lista vem vazia), nunca false ou 0. Trate expired como “a evidência saiu”: não é aprovação nem recusa, e a decisão da verificação não muda. Guarde no recebimento do webhook o que precisar guardar por mais tempo.
Nos antecedentes criminais (federal e estadual), nos mandados de prisão e na verificação de idade, o que sai aos 180 dias é o próprio veredito: o resumo diz expired, o bloco traz data.evidence_expired: true e o motivo da verificação que apontava o módulo passa a manual_review. Nos demais módulos o veredito fica e saem as medidas e os dados da pessoa, como no rosto, na prova de vida e nos documentos.
Corpo do evento: completo ou enxuto. Cada destino escolhe o corpo que recebe, no cadastro ou depois, na aba Webhooks do painel. O completo (full) é o padrão e é o corpo acima, com o check_details e o dado do titular de cada módulo. O enxuto (minimal) leva os identificadores, a decisão, o motivo, os saldos e o resumo checks, e nenhum dado do titular. Para o mesmo exemplo, o enxuto chega assim:
check_details inteiro, ou seja, o bloco de cada módulo, com nome, CPF, data de nascimento, documento, endereço, telefones, e-mails e quadro societário. As chaves que aparecem só às vezes, descritas acima, continuam chegando quando valem, porque nenhuma delas carrega dado do titular. A assinatura, os headers, as retentativas e o verification.subject_erased são os mesmos nos dois corpos.
Onde ver o detalhe. Com o enxuto, o detalhe de cada verificação fica no painel, na tela da verificação, e cada perfil vê o que o papel dele permite: quem só lê a conta vê o dado mascarado. Não existe rota da chave secreta que leia o resultado (ela responde 403 credential_type_not_allowed). O painel guarda o detalhe pelo prazo de retenção da conta; se a sua atividade tem dever de guarda maior que esse prazo, como a de instituição autorizada pelo Banco Central, use o completo e guarde a sua cópia. A troca vale para os eventos montados depois dela: a retentativa, o reenvio e o replay repetem o corpo exato que já tinha sido montado.
Um bloco de módulo pode chegar parcial. Quando a consulta de um módulo respondeu e parte do dado não veio, o item de check_details traz o data com o que veio, null no que faltou, e completeness: "partial". Bloco completo não traz o campo, então trate a ausência como completo. Já o módulo que falhou do nosso lado sai sem data, como o que nem rodou. No sandbox, o CNPJ de teste da família 4 simula a resposta parcial.
O par de saldo é sempre entregue. usedBalance é o quanto esta verificação debitou (somando todos os lançamentos dela) e remainingBalance é o saldo depois do último deles, os dois em centavos. Eles são estáveis entre reentregas do mesmo evento, então dá para conciliar custo direto do webhook, sem consultar mais nada. Em sandbox não há cobrança e os dois vêm com um valor fixo. Se quiser o extrato completo, o painel exporta o período em CSV.
O bloco reason_code é o vocabulário estável do motivo. code é a categoria (lista fechada), module diz qual módulo puxou a decisão e secondary traz até quatro que também pesaram. subject_message é a frase que o titular leu na tela: use exatamente ela ao falar com ele, para os dois estarem contando a mesma história. Ela vem null quando a decisão foi tomada sob outra versão do catálogo (catalog_version, no formato rc_ mais doze caracteres), porque nesse caso o texto de hoje pode não ser o que ele leu. Já decision_reason é o motivo técnico e pode ganhar valores novos: trate como texto, nunca como enum fechado.
Dentro do mesmo bloco, reasons separa o que foi visto do que isso causou. Cada item traz code, a evidência, no mesmo vocabulário de decision_reason, e outcome_effect, que vale blocked, review ou info (registrada, sem mover a decisão). Traz também module, o aspect a que ela pertence e dois textos prontos em português: display_pt, um rótulo curto, e action_pt, o que fazer a seguir. O primeiro item é sempre a razão que puxou a decisão, e o code dele é igual ao decision_reason. Ao lado, aspects indexa esses códigos pelos cinco grupos (document, biometrics, data_validation, fraud_signals e channel), sempre com as cinco chaves, mesmo vazias. display_pt e action_pt são para você, não para o titular: o texto do titular é o subject_message, e só ele.
verification.blocked sai quando o documento está na sua blocklist, e a verificação não é cobrada. Num flow de empresa, ele sai também quando alguém do quadro societário está na sua lista: o CNPJ apresentado passa, e o motivo vem como partner_blocklisted. Nesse segundo caso a verificação continua sem cobrança, mas a consulta cadastral do quadro societário já foi feita e entregue a você, então ela é cobrada e aparece no extrato com esse motivo.
O destino precisa ser público e https. Quem faz o POST somos nós, do nosso servidor, então o endereço tem que ser alcançável pela internet, e o corpo leva dado pessoal. No cadastro recusamos na hora http:// e qualquer endereço IP interno escrito direto na URL (10.x, 127.x, 192.168.x, link-local). Um nome que aponte para dentro da sua rede (localhost, um host só resolvível internamente) passa no cadastro e é recusado na entrega, quando resolvemos o DNS: o sintoma é a entrega falhar, não o cadastro. O endereço não precisa estar no ar para você cadastrar o destino e criar o seu primeiro flow: o cadastro valida a forma da URL, não se ela responde. Ou seja, dá para começar com a URL que o seu backend vai ter em produção e só depois ligá-la.
Teste sem rodar uma verificação. Na aba Webhooks do painel, o botão Enviar evento de teste dispara agora um POST assinado com o seu segredo real para o destino cadastrado. O corpo se anuncia como webhook.test (nunca como uma verificação aprovada, justamente para nenhum handler liberar cadastro por engano) e a entrega aparece na mesma lista, com o payload exato e o botão de reenvio. É assim que você confere, em segundos, três coisas que antes só apareciam depois de uma jornada inteira: o endereço responde, a sua validação de assinatura aceita a nossa, e o seu parser entende o envelope.
Ainda não tem endereço público? Em desenvolvimento, a resposta do evento de teste traz o corpo exato e o header de assinatura, então dá para repetir a entrega na sua máquina sem túnel nenhum:
adaptive, o titular pode fazer a prova curta (só a selfie) ou a prova com 1 ou 2 gestos, e quem decide é o nosso servidor, por sessão. O bloco friction do módulo liveness diz exatamente qual caminho aquele titular percorreu: mode (o modo do seu flow), level (1, 2 ou 3) e actions_required (0, 1 ou 2 gestos pedidos). Com isso você audita caso a caso e pode exigir mais na sua ponta, por exemplo pedir a sua própria confirmação quando um valor alto vier com level 1. Em flow fixed o bloco sai sempre como level 3, que é o comportamento de sempre.
O que não vai junto, de propósito: o risco que escolheu o nível. Publicar esse número ensinaria a quem tenta fraudar a diferença entre “caí no nível 3 porque o aparelho é novo” e “caí no nível 3 porque fui considerado arriscado”, e essa é exatamente a informação que torna a sondagem barata. A leitura de risco que você compra continua onde sempre esteve: score, risk_level, reason_code e o bloco fraud_assessment do módulo fraud_ai.
Entrega pelo menos uma vez, e o seu endpoint precisa ser idempotente. A gente garante que o desfecho chega, não que chega uma vez só: retentativa, reemissão manual e replay podem trazer o mesmo resultado de novo. Deduplique pelo id do evento, que é estável por verificação e por tipo de evento, ou trate tudo como upsert pelo verification_id. As duas leituras são seguras porque o id e o corpo andam juntos: o mesmo id sempre carrega o mesmo corpo (a retentativa reenvia o texto exato da primeira tentativa, não uma remontagem do estado de agora), e quando a decisão muda, o id muda: revisão manual no painel e re-decisão automática (uma análise que continuou e voltou com outro resultado) saem com um id novo, terminado em _r<número>, que você aplica por cima do anterior. A resposta que encerra o ciclo é 2xx: timeout, erro de rede e 5xx viram retentativa, e o 4xx de contrato (400, 401, 403, 404, 405, 410 e 422) é lido como recusa definitiva daquele destino. As duas exceções são as que um receptor sob carga devolve: 408 e 429 re-tentam. E 3xx não é entrega e não é seguido: nós fazemos um POST na URL cadastrada e paramos ali, então redirecionar o destino faz o evento parar de chegar e quem conserta isso é o cadastro da URL. A regra completa está na política de versão, junto com o significado do schema_version que vem no corpo.
Você tem 5 segundos para responder. É esse o nosso limite de espera pelo seu 2xx: passou disso, abortamos a conexão e a entrega vira retentativa, ou seja, você recebe o mesmo evento de novo (mesmo id) enquanto talvez ainda esteja processando o primeiro. Por isso o handler dos exemplos grava e enfileira em vez de processar dentro do ciclo: validar a assinatura, persistir o evento e responder cabe folgadamente no orçamento; consultar o seu antifraude, não.
Na entrega automática, três headers acompanham a assinatura, para você identificar a entrega sem abrir o corpo:
No reenvio manual,
x-idsaas-attempt não acompanha: o reenvio não faz parte da escada de novas tentativas, e por isso não tem posição nela. O x-idsaas-delivery-id continua chegando, com o mesmo valor da entrega original, e é por ele que você correlaciona o reenvio.
Se o destino tiver a cifra da carga ligada, o corpo chega como envelope. Em vez do evento em claro, o POST traz { "v": "unifokal-encrypted@1", ... } com o conteúdo cifrado para a chave pública que você registrou, e só a sua chave privada abre. Os headers acima, a assinatura, o id do evento e o ciclo de retentativas são exatamente os mesmos; o que muda é o corpo, e o jeito de abri-lo está em Valide a assinatura do webhook.
Dado cadastral só com identidade comprovada. Os módulos de Validação CPF exigem Verificação de Identidade + Face Match + Liveness no flow, e o dado oficial do titular só é entregue quando a biometria aprova e a leitura do documento (OCR) aprova: se o Face Match, o Liveness ou a leitura do documento reprovarem, o webhook sai sem o blocodata desses módulos e você não paga por eles. Além disso, cada conta tem um teto diário de consultas cadastrais externas: acima do teto, o módulo fica pendente e também não é cobrado. A leitura do documento (OCR) segue a mesma regra: acima do teto diário da conta, a leitura não sai, a verificação vai para review sem culpar o documento do titular, e essa revisão não é cobrada. Precisa de um teto maior? Fale com o suporte.
Valide a assinatura do webhook
A URL do seu webhook é alcançável por qualquer um na internet: sem validação, um atacante que descubra o endereço pode forjar um POST com"status": "approved" e o seu sistema aprovaria quem não deveria. Por isso todo evento sai assinado, e o seu backend deve validar antes de confiar no corpo.
Como funciona: cada POST leva o header X-IDSAAS-Signature: t=<timestamp>, v1=<hmac>. O v1 é um HMAC-SHA256 de {timestamp}.{corpo cru} calculado com o segredo de assinatura. Só quem tem o segredo consegue produzir a assinatura: se ela bater, o evento veio do UNIFOKAL.
O segredo é escolhido por você, no cadastro do destino, e nós nunca o reexibimos: guardamos só o hash e a cifra usada para assinar. Ele precisa ter no mínimo 32 caracteres aleatórios (openssl rand -hex 32 serve). O motivo do mínimo é concreto: quem recebe uma entrega tem em mãos o par texto assinado mais assinatura, e ataca o segredo fora do ar, na máquina dele, onde nenhum limite nosso participa. Segredo curto se quebra, e quem o quebra passa a enviar aprovações assinadas para o seu servidor. Guarde no seu cofre de variáveis de ambiente, nunca no repositório.
Trocar o segredo não derruba entrega. Durante a janela de transição assinamos com o antigo e o novo ao mesmo tempo, e o header vem com mais de um v1 (t=…,v1=A,v1=B). Por isso o seu código precisa aceitar se qualquer um deles casar. Um parse que fique só com o primeiro faz o seu endpoint recusar entregas legítimas durante toda a troca, e o sintoma aparece dias depois.
Os três verificadores completos (TypeScript, Python e a conferência no terminal) estão em Integração ponta a ponta, prontos para copiar. Aqui ficam os quatro detalhes que decidem se o seu endpoint realmente valida, porque são eles que costumam sair errados:
Responda
2xx só depois da validação. Reenvios (retry e replay) chegam com assinatura e t novos sobre o mesmo corpo, então a validação continua passando.
Reemissão manual (painel): quando o dono aprova ou recusa manualmente uma verificação, o webhook é reenviado com um id de evento novo e o corpo é o original com apenas os campos de decisão sobrescritos (status, recommendation, risk_level, decision_reason); trate como upsert pelo verification_id. Replay self-service: se o seu sistema ficou fora do ar, use GET /v1/webhook-events + replay (em lote, até 20 por chamada) para receber o corpo exato de novo.
Cifre a carga, se quiser que só o seu sistema leia o corpo. A assinatura prova quem enviou; a cifra da carga decide quem consegue ler. Com ela ligada, o corpo que chega ao seu endpoint deixa de ser o evento em claro e vira um envelope que só a sua chave privada abre: nem um log de proxy, nem uma fila de reprocessamento, nem quem opera a sua infraestrutura sem a chave lê o resultado da verificação. É opcional, por destino, e quem não liga não vê diferença nenhuma no que já recebe hoje.
Como ligar, em três passos. Primeiro, gere um par de chaves X25519 no seu lado (openssl genpkey -algorithm X25519 -out webhook.key) e guarde a chave privada no seu cofre: ela nunca sai da sua máquina, e nós nunca a pedimos. Depois, na aba Webhooks do painel, registre a metade pública do par, por ambiente (openssl pkey -in webhook.key -pubout -outform DER | base64 -w0, algoritmo x25519-hpke-v1): ela ganha um id whk_… e uma impressão digital que você confere de relance, e o painel devolve a chave inteira para você comparar byte a byte com a que registrou. Por fim, ligue a cifra no destino que deve recebê-la. Registrar a chave não liga a cifra sozinho, e ligar a cifra sem chave ativa é recusado com encryption_key_missing: um destino com cifra obrigatória nunca recebe em claro. Se a chave sumir, a entrega não acontece, fica registrada como não entregue e você a resgata pelo replay depois de registrar a chave nova.
X-IDSAAS-Signature cobre os bytes que chegam, ou seja, o envelope. Verifique-o primeiro, sobre o corpo cru, e só então abra o envelope: assim um corpo forjado morre antes de encostar na sua chave privada. O envelope é HPKE (RFC 9180) em modo base, com a suíte que o campo alg nomeia, X25519 com HKDF-SHA256 e AES-256-GCM, e sem dado adicional autenticado. O ciphertext é a chave pública efêmera (32 bytes), o conteúdo cifrado e a etiqueta de autenticação (16 bytes), nessa ordem, em base64. O info do HPKE não viaja no corpo: os dois lados o derivam da fórmula unifokal-webhook/v1|<kid>|<event_id>, com os dois valores lidos do próprio envelope. Isso amarra o conteúdo ao evento: um ciphertext colado em outro envelope não abre. Depois de abrir, confira que o id do evento decifrado é o event_id do envelope; se divergir, descarte. Os SDKs fazem as duas provas nessa ordem e devolvem o evento pronto:
kid do envelope diz qual delas abre. Aposentar a única chave ativa enquanto algum destino do ambiente exige a cifra é recusado com encryption_key_in_use: desligue a cifra nesses destinos, ou registre a chave nova antes. E uma chave pública que não é X25519, que não está em base64 canônico, ou que é um ponto de ordem pequena é recusada no registro, com invalid_public_key_format, invalid_public_key_encoding ou invalid_public_key_small_order.
Conferir e apresentar o atestado de pessoa verificada
Quando o flow tem o módulo Atestado de pessoa verificada, o webhook da verificação aprovada traz emdata.attestation.sd_jwt, no nível de cima de data e também no modo minimal, um SD-JWT VC assinado pela UNIFOKAL. O bloco do módulo em check_details traz só os dados, sem o token. Ele não substitui a assinatura HMAC do webhook: a HMAC prova que o corpo veio de nós para você; o atestado é o que você mostra a um terceiro, que confere sem nos consultar e sem receber dado pessoal direto.
Conferir. O emissor é https://unifokal.com, o vct é https://unifokal.com/vct/verified-person/v1, o alg é ES256 e o typ é dc+sd-jwt. As chaves públicas de produção vêm fixadas nos SDKs, que é o caminho recomendado, e também estão em https://unifokal.com/.well-known/jwt-vc-issuer, no formato de metadado de emissor do SD-JWT VC, para quem confere sem SDK. Os SDKs recusam por padrão qualquer kid que não seja de produção: nada emitido em sandbox, nem os vetores de teste, confere contra essas chaves.
iat no começo da hora em UTC; peça outro pelo replay do webhook ou pelo painel por até 180 dias desde a verificação.
Guarde como credencial. Esta versão não tem vínculo de chave: quem tem o token consegue apresentá-lo. E ele não esconde a pessoa da UNIFOKAL, que como emissora consegue ligar o atestado à verificação que o originou; o que ele garante é que dois serviços não ligam as contas entre si por meio dele. O download pelo painel é uma emissão nova, com sal e assinatura novos e as mesmas informações.
Como conciliar o que foi cobrado
Dá para conciliar de dois jeitos, e os dois casam pela mesma chave. Por evento: cadaverification.completed traz o id da verificação, o usedBalance (o quanto ela debitou, em centavos) e o remainingBalance (o saldo depois do último lançamento dela). Por período: o painel exporta o extrato em CSV, uma linha por lançamento, com created_at, type, amount_cents, balance_after_cents, verification_id, lookup_query_id, payment_id, module, description e entry_id.
O casamento é pelo verification_id: a soma dos amount_cents das linhas do CSV com o mesmo verification_id é o usedBalance do webhook daquela verificação, com o sinal trocado (no extrato, o que sai é negativo). Os lançamentos que não nascem de uma verificação casam por outra coluna: recarga pelo payment_id, consulta avulsa pelo lookup_query_id. O entry_id identifica cada linha e não se repete, então reimportar o mesmo período não duplica nada no seu sistema.
O reference_id não vai no CSV, de propósito: ele é um id do seu sistema, e o extrato é um arquivo que circula na área financeira. Para chegar do extrato ao seu titular, guarde o id da verificação junto do seu reference_id quando o webhook chegar.