# Webhooks

<https://unifokal.com/docs/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](https://unifokal.com/docs/pld-ft#pld-ft).

```
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](https://unifokal.com/docs/versionamento#entrega), 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-id` | O 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-attempt` | O 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-id` | O 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](https://unifokal.com/docs/webhooks#webhook-signature).

**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 bloco`data` 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](https://unifokal.com/docs/integracao#integracao), 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 CRU | A 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 antes | Detalhe 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ção | Rejeite `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](https://unifokal.com/docs/api-rest#get-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](https://unifokal.com/docs/modulos/atestado-humano#modulo-atestado-humano) (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](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_...,...
```
