Criar conta grátis

Documentação
Ver em Markdown

Webhooks

Webhooks & eventos

A verdade é o webhook: o resultado oficial vai ao seu backend por evento assinado. Os principais são verification.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.

POST https://seu-backend.com/webhooks/unifokal
X-IDSAAS-Signature: t=...,v1=...

{
  "id": "evt_ver_…_completed",
  "schema_version": 1,
  "event": "verification.completed",
  "livemode": true,
  "created": "2026-08-21T22:00:00.000Z",
  "data": {
    "object": "verification",
    "id": "ver_…", "verification_id": "ver_…",   // o mesmo id; verification_id é o alias do contrato
    "flow_id": "flow_01J8…", "reference_id": "usr_8842",
    "status": "approved", "score": 95,
    "risk_level": "low", "recommendation": "approve",
    "decision_reason": "auto_approved",
    "reason_code": { "code": "aprovado", "module": null, "secondary": [],
                     "subject_code": "aprovado", "catalog_version": "rc_9f2c1a4b7e03",
                     "subject_message": "Verificação concluída com sucesso.",
                     "reasons": [ { "code": "auto_approved", "outcome_effect": "info",
                                    "module": null, "aspect": null, "primary": true,
                                    "display_pt": "Aprovada automaticamente",
                                    "action_pt": "Nada a fazer. Siga com o seu fluxo." } ],
                     "aspects": { "document": [], "biometrics": [], "data_validation": [],
                                  "fraud_signals": [], "channel": [] } },
    "environment": "production",
    "completed_at": "2026-08-21T22:00:00.000Z",
    "saldoUsado": 640, "saldoRestante": 128360,   // CENTAVOS: o que esta verificação debitou, e o saldo depois
    "checks": { "identity": "pass", "liveness": 0.94, "cpf_contatos": "valid" },
    "check_details": [
      { "module": "cpf_ocr", "passed": true, "outcome": "approved", "score": 95,
        "data": { "name": "João Silva", "cpf": "123.456.789-00",
                  "document": { "type": "cnh", "number": "07969013668" } } },
      { "module": "cpf_contatos", "passed": true, "outcome": "approved", "score": 95,
        "data": { "nome": "JOÃO SILVA", "telefones": ["11999999999"],
                  "emails": ["joao@exemplo.com"] } },
      { "module": "liveness", "passed": true, "outcome": "approved", "score": 96,
        "data": { "live_probability": 0.97, "spoof_probability": 0.03,
                  "capture": "coerente",
                  "friction": { "mode": "adaptive", "level": 1, "actions_required": 0 },
                  "active": null } }
    ]
  }
}

Alguns campos aparecem só às vezes, e é melhor você saber deles antes de escrever um parser estrito. 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 transacao): 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 atestado_humano. 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.

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. saldoUsado é o quanto esta verificação debitou (somando todos os lançamentos dela) e saldoRestante é 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:

# a resposta do "Enviar evento de teste" traz payload + signature
curl -X POST http://localhost:3000/webhooks/unifokal \
  -H "Content-Type: application/json" \
  -H "X-IDSAAS-Signature: <signature da resposta>" \
  -d '<payload da resposta>'

Qual prova de vida foi feita. Com o flow em modo 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:

x-idsaas-event-idO mesmo valor do campo id do corpo, igual em toda tentativa da mesma entrega. É a chave de deduplicação: guarde o id que você já processou e ignore a entrega repetida, sem precisar abrir o corpo.
x-idsaas-attemptO número da tentativa dentro do ciclo de retentativas automáticas (1, 2, 3...). A reemissão manual pelo painel não manda este header, de propósito: ela não pertence a esse ciclo. O evento de teste sempre manda 1.
x-idsaas-delivery-idO identificador desta entrega para este destino. Ele é o mesmo em todas as tentativas automáticas, enquanto x-idsaas-attempt avança, e é o mesmo também quando você pede o reenvio de uma entrega. Use para correlacionar as tentativas de uma mesma entrega nos seus logs e para localizar a entrega no painel.

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:

Use o corpo CRUA assinatura cobre os bytes recebidos. Reserializar o JSON muda um espaço ou a ordem de uma chave e derruba tudo, sem erro visível. Em Express é express.raw, não express.json; em Flask é request.get_data(), não request.json.
Compare em tempo constante=== e == saem no primeiro byte diferente e viram um oráculo: o atacante mede o tempo e descobre a assinatura byte a byte, sem nunca saber o segredo. Use crypto.timingSafeEqual ou hmac.compare_digest.
Iguale o tamanho antesDetalhe que derruba endpoint em produção: timingSafeEqual lança quando os buffers têm tamanhos diferentes, e o candidato vem do header, ou seja, de fora. Uma assinatura curta forjada viraria exceção e 500 no seu servidor. Passar os dois lados por um sha256 iguala o tamanho sem abrir mão do tempo constante.
Feche a janela de repetiçãoRejeite t fora de 300 segundos: sem isso, uma entrega legítima capturada uma vez pode ser reenviada para sempre. Isso não conflita com as nossas retentativas: cada tentativa, inclusive replay e reemissão pelo painel, é assinada na hora do envio, com t novo.

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.

POST https://seu-backend.com/webhooks/unifokal
Content-Type: application/json
X-IDSAAS-Signature: t=...,v1=...

{
  "v": "unifokal-encrypted@1",
  "alg": "HPKE-X25519-HKDF-SHA256-AES256GCM",
  "kid": "whk_01J…",
  "event_id": "evt_ver_…_completed",
  "ciphertext": "<base64 de enc || conteúdo cifrado || tag>"
}

A ordem importa: valide a assinatura, depois decifre. O 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:

// TypeScript (SDK oficial): verifica a assinatura, abre o envelope, confere o id
import { parseEncryptedWebhookEvent, WebhookDecryptionError, WebhookSignatureError } from 'unifokal';

app.post('/webhooks/unifokal', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    const event = parseEncryptedWebhookEvent(
      req.body,                          // os bytes CRUS, nunca o JSON reserializado
      req.header('X-IDSAAS-Signature') ?? '',
      process.env.WEBHOOK_SECRET!,       // o segredo de assinatura deste destino
      process.env.WEBHOOK_PRIVATE_KEY!,  // a SUA chave privada: PEM PKCS#8, ou os 32 bytes em base64
    );
    enqueue(event);                      // grave e enfileire; responda 2xx rápido
    res.sendStatus(200);
  } catch (err) {
    if (err instanceof WebhookSignatureError || err instanceof WebhookDecryptionError) return res.sendStatus(400);
    throw err;
  }
});
// WebhookSignatureError: não veio da UNIFOKAL. WebhookDecryptionError: veio, mas esta chave não abre
// (confira o kid do envelope contra a chave que você registrou, e a chave aposentada que ainda guarda).
# Python (SDK oficial, extra "unifokal[crypto]"): a mesma ordem, o mesmo resultado
from unifokal import WebhookDecryptionError, WebhookSignatureError, parse_encrypted_webhook_event

@app.post("/webhooks/unifokal")
async def webhook(request: Request) -> Response:
    raw = await request.body()  # os bytes CRUS
    try:
        event = parse_encrypted_webhook_event(
            raw,
            request.headers.get("X-IDSAAS-Signature", ""),
            WEBHOOK_SECRET,       # o segredo de assinatura deste destino
            WEBHOOK_PRIVATE_KEY,  # a SUA chave privada: PEM PKCS#8, ou os 32 bytes em base64
        )
    except (WebhookSignatureError, WebhookDecryptionError):
        return Response(status_code=400)
    enqueue(event)  # grave e enfileire; responda 2xx rápido
    return Response(status_code=200)

Troca de chave, e a chave antiga. Registrar uma chave nova aposenta a anterior na mesma operação, e a próxima entrega já sai cifrada para a nova. Guarde a chave privada aposentada enquanto houver retentativa em curso e enquanto quiser reenviar uma entrega antiga pelo painel: o reenvio manda os mesmos bytes da entrega original, cifrados para a chave da época, e o 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 (em breve), o webhook da verificação aprovada traz em data.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/pessoa-verificada/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.

// TypeScript
import { verifyHumanAttestation, presentHumanAttestation } from "unifokal";

const att = evento.data.attestation;
if (att?.issued && att.sd_jwt) {
  const r = verifyHumanAttestation(att.sd_jwt);
  if (r.ok) {
    // r.attestation.claims traz as informações divulgadas; r.attestation.exp, a validade
  }
  // Mostrar ao auditor só a prova de vida, sem o identificador nem as outras informações
  const soProvaDeVida = presentHumanAttestation(att.sd_jwt, ["prova_de_vida"]);
}

# Python
from unifokal import verify_human_attestation, present_human_attestation

r = verify_human_attestation(sd_jwt)
so_prova_de_vida = present_human_attestation(sd_jwt, ["prova_de_vida"])

Apresentar só uma parte. A apresentação devolve o mesmo token com as divulgações que você escolheu. A assinatura continua valendo e quem recebe não vê o resto. O atestado vale 180 dias, com o iat e o exp arredondados ao começo do dia em UTC.

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: cada verification.completed traz o id da verificação, o saldoUsado (o quanto ela debitou, em centavos) e o saldoRestante (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 saldoUsado 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 é seu e pode carregar um documento quando a sua integração escolhe assim, 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.

# Por evento: guarde o custo quando o webhook chegar
verification.completed  data.id = ver_...   saldoUsado = 590   saldoRestante = 94100

# Por período: no CSV do extrato, as linhas da mesma verificação somam o mesmo valor
created_at,type,amount_cents,balance_after_cents,verification_id,...
2026-09-25T14:02:11Z,debit,-590,94100,ver_...,...

Pronto para integrar? A chave de sandbox sai no painel, logo depois do cadastro. Criar conta grátis