# Documentação completa

<https://unifokal.com/docs/pacotes-para-ia/completo.md>

Todos os módulos do catálogo

REFERÊNCIA

## Documentação UNIFOKAL

<https://unifokal.com/docs>

Verificação de identidade ponta a ponta, sem call de vendas: você cria um **Flow**, instala o **Widget** e recebe o resultado por **Webhook**. A captura e o processamento (CPF, OCR, face match, liveness) rodam no UNIFOKAL, então o seu front **nunca toca foto, selfie ou documento** e você fica fora do escopo mais pesado da LGPD.

BASE URL · REST

https://api.unifokal.com/v1

WIDGET · CDN

api.unifokal.com/dist/widget.global.js

A API vive em `/v1`. Tudo o que já mudou está no [changelog](https://unifokal.com/docs/changelog), e o que consideramos mudança que quebra está na [política de versão](https://unifokal.com/docs/versionamento). Integrando por agente ou por ferramenta? O catálogo de API em [`/.well-known/api-catalog`](https://unifokal.com/.well-known/api-catalog) (RFC 9727) aponta, em uma leitura, para o spec e para esta documentação.

Do zero ao primeiro resultado

Cinco passos acontecem **uma vez**, no painel, e três ficam no seu código. O código inteiro, pronto para copiar, está em [Integração ponta a ponta](https://unifokal.com/docs/integracao#integracao).

| \# | passo | onde |
| --- | --- | --- |
| 01 | Confirme o e-mail da conta e conclua o onboarding. Sem isso, criar sessão devolve `403 email_not_verified`. | painel |
| 02 | Cadastre o **destino do webhook** e escolha o segredo de assinatura. Ele vem primeiro porque o Flow exige um destino já cadastrado. | painel |
| 03 | Crie o **Flow** (os módulos e o destino do webhook). Recebe um `flow_id`. | painel |
| 04 | Gere a **chave secreta**. A de sandbox (`sk_test`) sai na hora. A de produção (`sk_live`) passa por três portões, nesta ordem: e-mail confirmado, verificação da conta concluída (`403 organization_cnpj_required`) e verificação em duas etapas ativa (`403 mfa_enrollment_required`, e depois o código no corpo). Veja [Sandbox e produção](https://unifokal.com/docs/ambientes#ambientes). | painel |
| 05 | Registre as **origens** onde o widget vai rodar. Sem isso o navegador recusa a chamada e o widget não sobe. | painel |
| 06 | No seu backend, crie a sessão: `POST /v1/verification-sessions`. Recebe o `id`. | seu código |
| 07 | No seu front, monte o widget com esse `id`. Nenhum segredo vai ao navegador. | seu código |
| 08 | No seu backend, receba o **webhook**, valide a assinatura e trate o desfecho. | seu código |

! A ordem **02 antes de 03** não é preferência: o Flow exige a URL de um destino de webhook já cadastrado, no mesmo ambiente. E o passo **05** é o que mais derruba integração nova, porque a falha acontece no navegador, longe de qualquer log nosso.

Conceitos

| `flow` | Receita de verificação: quais módulos rodam e em que ordem. |
| --- | --- |
| `verification` | Uma execução do flow por um usuário. Tem status e score. |
| `reference_id` | O ID do usuário no seu sistema. Obrigatório na criação da sessão e do link, e volta igual no webhook e na API. |
| `livemode` | false em sandbox, true em produção. Vem em toda verificação e webhook. |
| `schema_version` | Versão da FORMA do corpo do webhook. Campo novo não muda esse número; leia a política de versão. |

### Mapa da documentação

A referência é dividida por assunto, uma página para cada um, e cada módulo tem a própria página. Todo link antigo para uma seção desta página continua valendo e leva ao lugar novo.

#### [O Widget](https://unifokal.com/docs/widget)

Como instalar o Widget UNIFOKAL em uma linha, montar com o id da sessão, tratar os eventos e fixar a versão do bundle com integridade.

- [O Widget](https://unifokal.com/docs/widget#widget)
  - [A forma de uma linha (sem JavaScript seu) (HTML)](https://unifokal.com/docs/widget#montar-widget-uma-linha)

#### [Verificação em aplicativo](https://unifokal.com/docs/aplicativo-nativo)

Como abrir a verificação UNIFOKAL numa WebView do seu aplicativo: os ajustes de iOS e de Android que a câmera exige e como o app sabe a hora de fechar a tela.

- [Verificação em aplicativo](https://unifokal.com/docs/aplicativo-nativo#aplicativo-nativo)

#### [Autenticação e segurança](https://unifokal.com/docs/autenticacao)

Chaves secretas de sandbox e de produção, os erros estáveis da criação de sessão, a segurança do fluxo e o acesso da equipe por SSO (SAML 2.0).

- [Autenticação](https://unifokal.com/docs/autenticacao#auth)
  - [Criar a sessão (curl)](https://unifokal.com/docs/autenticacao#criar-sessao-curl)
  - [Os erros que você vai encontrar no primeiro dia (curl)](https://unifokal.com/docs/autenticacao#erros-sessao-curl)
- [Segurança do fluxo](https://unifokal.com/docs/autenticacao#seguranca)
- [Acesso da equipe por SSO (SAML 2.0)](https://unifokal.com/docs/autenticacao#sso)

#### [Integração ponta a ponta](https://unifokal.com/docs/integracao)

Código completo para criar a sessão, montar o widget, receber o webhook assinado e tratar cada desfecho, em HTTP, TypeScript e Python.

- [Integração ponta a ponta](https://unifokal.com/docs/integracao#integracao)
  - [Criar a sessão (TypeScript / Node)](https://unifokal.com/docs/integracao#criar-sessao-ts)
  - [Criar a sessão (Python)](https://unifokal.com/docs/integracao#criar-sessao-python)
  - [A página que o seu usuário abre (TypeScript / Node)](https://unifokal.com/docs/integracao#montar-widget-ts)
  - [Receber e validar o webhook (TypeScript / Node)](https://unifokal.com/docs/integracao#webhook-ts)
  - [Receber e validar o webhook (Python)](https://unifokal.com/docs/integracao#webhook-python)
  - [Conferir uma assinatura na mão (curl)](https://unifokal.com/docs/integracao#webhook-sh)
- [Os desfechos, e o que fazer com cada um](https://unifokal.com/docs/integracao#desfechos)
  - [Tratar os sete desfechos (TypeScript / Node)](https://unifokal.com/docs/integracao#desfechos-ts)
  - [Tratar os sete desfechos (Python)](https://unifokal.com/docs/integracao#desfechos-python)
  - [Recuperar entregas que falharam (curl)](https://unifokal.com/docs/integracao#recuperar-entregas-curl)
- [Receitas por jornada](https://unifokal.com/docs/integracao#receitas-das-jornadas)
- [Humano verificado: qual peça para qual caso](https://unifokal.com/docs/integracao#humano-verificado)

#### [API REST](https://unifokal.com/docs/api-rest)

Os 4 endpoints públicos da API UNIFOKAL, o link de verificação hospedado, a confirmação fora de banda, a spec OpenAPI 3.1 com geração de tipos, a coleção importável e o briefing para agentes de IA.

- [API REST (4 endpoints públicos)](https://unifokal.com/docs/api-rest#api)
  - [POST /v1/verification-sessions](https://unifokal.com/docs/api-rest#post-sessions)
  - [GET /v1/webhook-events](https://unifokal.com/docs/api-rest#get-webhook-events)
  - [POST /v1/webhook-events/replay](https://unifokal.com/docs/api-rest#post-webhook-replay)
- [Link de verificação hospedado](https://unifokal.com/docs/api-rest#link-hospedado)
  - [POST /v1/verification-links](https://unifokal.com/docs/api-rest#post-verification-links)
- [Confirmação fora de banda](https://unifokal.com/docs/api-rest#confirmacao-fora-de-banda)
- [Spec OpenAPI e tipos](https://unifokal.com/docs/api-rest#openapi)
  - [GET /v1/capabilities](https://unifokal.com/docs/api-rest#get-capabilities)
  - [Gerar os tipos do contrato (curl)](https://unifokal.com/docs/api-rest#gerar-tipos-curl)
- [Coleção importável](https://unifokal.com/docs/api-rest#colecao-importavel)
- [Para agentes de IA](https://unifokal.com/docs/api-rest#agentes-de-ia)

#### [Pacotes para IA](https://unifokal.com/docs/pacotes-para-ia)

A documentação da UNIFOKAL recortada por produto, em Markdown, para colar no seu assistente de IA, com o tamanho medido de cada pacote e a versão em Markdown de cada página.

- [Pacotes para o seu assistente de IA](https://unifokal.com/docs/pacotes-para-ia#pacotes-para-ia)
- [Cada página em Markdown](https://unifokal.com/docs/pacotes-para-ia#markdown-por-pagina)

#### [Webhooks](https://unifokal.com/docs/webhooks)

O corpo do webhook da UNIFOKAL, os eventos, as retentativas e a validação da assinatura HMAC, com exemplos prontos.

- [Webhooks e eventos](https://unifokal.com/docs/webhooks#webhooks)
- [Valide a assinatura do webhook](https://unifokal.com/docs/webhooks#webhook-signature)
- [Conferir e apresentar o atestado de pessoa verificada](https://unifokal.com/docs/webhooks#atestado-de-pessoa-verificada)
- [Como conciliar o que foi cobrado](https://unifokal.com/docs/webhooks#conciliacao)

#### [Sandbox, produção e lista de bloqueio](https://unifokal.com/docs/ambientes)

Como o sandbox simula cada desfecho, o que muda entre sandbox e produção e como funciona a lista de bloqueio da conta e do flow.

- [Lista de bloqueio](https://unifokal.com/docs/ambientes#blocklist)
- [Sandbox](https://unifokal.com/docs/ambientes#sandbox)
- [Sandbox e produção: o que muda](https://unifokal.com/docs/ambientes#ambientes)

#### [Conta, dados e privacidade](https://unifokal.com/docs/conta-e-dados)

Exportação de dados, encerramento de conta e o aviso de privacidade e consentimento que o widget mostra ao titular.

- [Exportação de dados](https://unifokal.com/docs/conta-e-dados#exportacao-de-dados)
- [Encerramento de conta](https://unifokal.com/docs/conta-e-dados#encerramento-de-conta)
- [Privacidade e consentimento](https://unifokal.com/docs/conta-e-dados#privacidade-consentimento)

#### [Painel de operação](https://unifokal.com/docs/painel-de-operacao)

O funil por etapa e por flow, o registro de requisições sem corpo, o orçamento diário de gasto com o spend_cap_reached, a consulta em lote, a simulação de política no histórico e a lista de permissão.

- [Painel de operação](https://unifokal.com/docs/painel-de-operacao#painel-de-operacao)
- [Lista de permissão](https://unifokal.com/docs/painel-de-operacao#lista-de-permissao)
- [Consulta em lote por planilha](https://unifokal.com/docs/painel-de-operacao#consulta-em-lote)
- [Permissões próprias](https://unifokal.com/docs/painel-de-operacao#permissoes-proprias)

#### [PLD/FT pela regra da norma](https://unifokal.com/docs/pld-ft)

Como ligar o monitoramento de PLD/FT: a política aprovada, os campos do evento, o perfil do cliente, a fila com o prazo legal, o caso e o rascunho do Siscoaf.

- [PLD/FT pela regra da norma](https://unifokal.com/docs/pld-ft#pld-ft)
- [Dossiê selado: exportar e conferir](https://unifokal.com/docs/pld-ft#pld-ft-dossie-selado)
- [A contratação, pelos arts. 44, 45 e 47](https://unifokal.com/docs/pld-ft#pld-ft-contratacao)

#### [Erros da API](https://unifokal.com/docs/erros)

Todo código de erro da API UNIFOKAL, com o status HTTP, o que aconteceu, o que fazer e o que não fazer, cada um na própria âncora.

- [Erros da API](https://unifokal.com/docs/erros#erros)

#### [Glossário](https://unifokal.com/docs/glossario)

Os termos de verificação de identidade e de empresa (KYC, KYB, PEP, prova de vida, 1:1 e 1:N) e o vocabulário da API UNIFOKAL, um verbete por âncora.

- [Glossário](https://unifokal.com/docs/glossario#glossario)

#### [Módulos](https://unifokal.com/docs/modulos)

O [catálogo de módulos](https://unifokal.com/docs/modulos#catalogo-modulos) lista tudo o que dá para ligar num Flow. Cada página abaixo traz o que o módulo entrega e o payload de resultado dele no webhook.

- [Documento, face match e prova de vida](https://unifokal.com/docs/modulos/identidade#modulos-identidade)
  - [Documento (OCR): cpf_ocr](https://unifokal.com/docs/modulos/identidade#modulo-cpf-ocr)
  - [Face Match 1:1: face](https://unifokal.com/docs/modulos/identidade#modulo-face)
  - [Prova de vida: liveness](https://unifokal.com/docs/modulos/identidade#modulo-liveness)
  - [Detecção de injeção de câmera](https://unifokal.com/docs/modulos/identidade#deteccao-injecao-camera)
- [Comprovante de endereço](https://unifokal.com/docs/modulos/endereco-ocr#modulo-endereco-ocr)
- [Verificação de idade](https://unifokal.com/docs/modulos/idade#modulo-idade)
- [Detecção de múltiplas contas (1:N)](https://unifokal.com/docs/modulos/face-unica#modulo-face-unica)
- [Reautenticação facial](https://unifokal.com/docs/modulos/face-reauth#modulo-face-reauth)
- [Aprovação de ato com passkey](https://unifokal.com/docs/modulos/passkey#modulo-passkey)
- [Atestado de pessoa verificada](https://unifokal.com/docs/modulos/atestado-humano#modulo-atestado-humano)
- [Reuso de Documento](https://unifokal.com/docs/modulos/doclink#modulo-doclink)
- [Validação de canal: e-mail e telefone](https://unifokal.com/docs/modulos/canal#modulos-canal)
  - [Código de canal: como chega ao titular](https://unifokal.com/docs/modulos/canal#post-otp)
  - [Código de canal: limites](https://unifokal.com/docs/modulos/canal#post-otp-verify)
- [PEP e listas restritivas](https://unifokal.com/docs/modulos/pep#modulo-pep)
- [Consulta cadastral de CPF e de CNPJ](https://unifokal.com/docs/modulos/cadastrais#modulos-cadastrais)
- [OCR do documento de empresa](https://unifokal.com/docs/modulos/cnpj-ocr#modulo-cnpj-ocr)
- [Dados cadastrais entregues](https://unifokal.com/docs/modulos/dados-cadastrais#modulo-dados-cadastrais)
- [Benefícios do governo](https://unifokal.com/docs/modulos/beneficios-gov#modulo-beneficios-gov)
- [Screening do quadro societário](https://unifokal.com/docs/modulos/screening-socios#screening-socios)
- [Impedidos de apostar](https://unifokal.com/docs/modulos/impedidos-apostar#modulo-impedidos-apostar)
- [Mídia adversa](https://unifokal.com/docs/modulos/midia-adversa#modulo-midia-adversa)
- [Coerência cadastral](https://unifokal.com/docs/modulos/coerencia-cadastral#modulo-coerencia-cadastral)
- [Forense de documento](https://unifokal.com/docs/modulos/doc-forense#modulo-doc-forense)
- [Detecção de rede de fraude](https://unifokal.com/docs/modulos/fraud-network#modulo-fraud-network)
- [Cadastro de dispositivo Pix](https://unifokal.com/docs/modulos/pix-device#modulo-pix-device)
- [Monitoramento de sessão](https://unifokal.com/docs/modulos/sessao-monitor#modulo-sessao-monitor)
- [Sinais do aparelho](https://unifokal.com/docs/modulos/device-intel#modulo-device-intel)
- [Assinatura eletrônica](https://unifokal.com/docs/modulos/assinatura#modulo-assinatura)
- [Custódia da autorização de consulta](https://unifokal.com/docs/modulos/custodia-autorizacao#modulo-custodia-autorizacao)
- [Documento de viagem estrangeiro (MRZ)](https://unifokal.com/docs/modulos/doc-global#modulo-doc-global)
- [Cadeia societária até o beneficiário final](https://unifokal.com/docs/modulos/ubo-profundo#modulo-ubo-profundo)
- [Inscrição estadual](https://unifokal.com/docs/modulos/inscricao-estadual#modulo-inscricao-estadual)
- [Certidão trabalhista (CNDT)](https://unifokal.com/docs/modulos/cndt#modulo-cndt)
- [Representante vinculado à empresa](https://unifokal.com/docs/modulos/representante-pj#modulo-representante-pj)
- [Empresa estrangeira](https://unifokal.com/docs/modulos/kyb-estrangeira#modulo-kyb-estrangeira)
- [Motor Antifraude](https://unifokal.com/docs/modulos/fraud-ai#modulo-fraud-ai)
- [Gate transacional](https://unifokal.com/docs/modulos/transacao#modulo-transacao)
- [Monitoramento transacional](https://unifokal.com/docs/modulos/transacao-monitor#modulo-transacao-monitor)
- [Proteção de conta](https://unifokal.com/docs/modulos/conta#modulo-conta)
- [Risco de IP e de e-mail](https://unifokal.com/docs/modulos/risco#modulos-risco)
- [Análise de crédito](https://unifokal.com/docs/modulos/credito#modulos-credito)
- [Monitoramento contínuo](https://unifokal.com/docs/modulos/monitoring-aml#modulo-monitoring-aml)
- [Monitoramento PLD/FT](https://unifokal.com/docs/modulos/pld-monitor#modulo-pld-monitor)
- [Classificação de risco PLD/FT](https://unifokal.com/docs/modulos/pld-risco#modulo-pld-risco)
- [Vínculos, listas restritivas e processos](https://unifokal.com/docs/modulos/compliance-vinculos#modulos-compliance-vinculos)
- [Background check e sanções](https://unifokal.com/docs/modulos/background#modulos-background)

## O Widget

<https://unifokal.com/docs/widget>

### O Widget

São 2 passos. No **backend**, você cria a sessão do usuário no Flow, enviando o seu `reference_id` (o id do usuário no seu sistema; obrigatório: ele volta no webhook e na API para você correlacionar). A resposta traz o `id` da sessão. No **front**, você monta o widget com esse `id`. **Nenhum segredo vai ao navegador** (o único segredo é a sua `sk_`, que fica no seu backend). O ambiente (sandbox × produção) é definido pela chave secreta usada na criação da sessão. Os módulos são os do Flow. Você não os declara no widget.

**HTML** · A forma de uma linha (sem JavaScript seu) · no navegador

```html
<!-- Se voce so precisa montar o widget e nada mais, o proprio <script> faz tudo:
     basta o data-session. Nenhum segredo aqui, como no exemplo acima: o id da sessao
     vem do SEU servidor, e a sua chave sk_ nunca sai de la. -->
<div id="identidade"></div>
<script
  src="https://api.unifokal.com/dist/widget.global.js"
  data-session="vs_01J8ZQ4S8M2N3P4Q5R6S7T8U9V"
  data-container="#identidade"
  crossorigin="anonymous"
></script>

<!-- O QUE ESPERAR DE VOLTA: o widget monta sozinho no #identidade assim que o script carrega.
     Sem data-session ele nao faz nada, em silencio: nao ha o que montar sem sessao. -->
```

A versão com o seu backend criando a sessão, os cabeçalhos que a câmera exige e o tratamento de erro está inteira em [Integração ponta a ponta](https://unifokal.com/docs/integracao#integracao).

Para quem trava numa etapa, a [central de ajuda](https://unifokal.com/ajuda) explica cada mensagem do widget e o que fazer para seguir. Vale um link para ela na tela de ajuda do seu app.

COMO O WIDGET APARECE NA SUA PÁGINA

São três formas, e você escolhe no próprio trecho. **Embutido**: o card entra no lugar que você reservou. **Botão flutuante**: um botão fixo num canto abre a verificação, sem mexer no seu layout. **Aberto pelo seu botão**: o botão continua seu, e o widget abre quando alguém clica nele. Em qualquer uma delas o widget aparece do mesmo jeito para quem vai se verificar, e o seu site não consegue escondê-lo sem querer: ele se afirma na tela mesmo dentro de um layout que apaga tudo à volta.

Quem já integrou não precisa mudar nada. Reservar um lugar para o widget já é escolher o modo embutido, e é o que continua valendo. O painel também tem o seletor, em **Customização**, para quando você preferir decidir sem mexer no código; o que está escrito no trecho vence o painel.

**HTML** · Embutido no seu layout · no navegador

```html
<!-- Reservar um lugar ja e escolher este modo: nao precisa de data-mode.
     E o comportamento de sempre, e nada muda para quem ja integrou. -->
<div id="identidade"></div>
<script
  src="https://api.unifokal.com/dist/widget.global.js"
  data-session="vs_01J8ZQ4S8M2N3P4Q5R6S7T8U9V"
  data-container="#identidade"
  crossorigin="anonymous"
></script>

<!-- O QUE ESPERAR DE VOLTA: o card aparece dentro do #identidade assim que o script carrega, e a
     verificacao comeca na hora. -->
```

**HTML** · Botão flutuante (sem reservar lugar na página) · no navegador

```html
<!-- Nao precisa de <div>: o botao e o painel nascem no fim do <body> e se posicionam
     contra a janela, entao nenhum layout seu precisa mudar. Cole onde preferir, inclusive
     no <head>: se o corpo da pagina ainda nao existir, o widget espera ele existir. -->
<script
  src="https://api.unifokal.com/dist/widget.global.js"
  data-session="vs_01J8ZQ4S8M2N3P4Q5R6S7T8U9V"
  data-mode="floating"
  data-position="bottom-right"
  crossorigin="anonymous"
></script>

<!-- O QUE ESPERAR DE VOLTA: um botao fixo no canto escolhido. A verificacao comeca quando a pessoa
     clica, nao quando a pagina carrega. Cantos: bottom-right (padrao), bottom-left,
     top-right, top-left. Valor fora da lista e ignorado e cai no padrao. -->
```

**HTML** · Aberto pelo SEU botão · no navegador

```html
<!-- O botao e seu: estilo, texto e lugar continuam seus. Duas formas de ligar, e as
     duas valem ao mesmo tempo. -->
<button id="verificar">Verificar identidade</button>
<script
  src="https://api.unifokal.com/dist/widget.global.js"
  data-session="vs_01J8ZQ4S8M2N3P4Q5R6S7T8U9V"
  data-mode="trigger"
  data-trigger="#verificar"
  crossorigin="anonymous"
></script>

<!-- Sem seletor: marque qualquer elemento com data-idsaas-open. Funciona junto. -->
<a href="#" data-idsaas-open>Comecar verificacao</a>

<!-- O QUE ESPERAR DE VOLTA: o clique abre o painel. Vale para elementos criados DEPOIS da carga
     (o ouvinte e um so, no documento), entao funciona em pagina que troca de tela sem
     recarregar. Seletor invalido e ignorado, e a forma por atributo continua valendo. -->
```

ANTES DE COLAR: REGISTRE A ORIGEM DO SITE

O widget só carrega em **origens que você registrou**. No painel, em **Organização, origens do widget**, cadastre cada endereço onde ele vai rodar (por exemplo `https://app.suaempresa.com` e o seu ambiente de homologação). Origem não registrada é recusada pelo navegador no preflight, e o widget simplesmente não sobe naquele site. O bloqueio aparece no console do navegador como erro de CORS, não como erro da nossa API, então é fácil procurar no lugar errado.

A tela grava em `PUT /v1/dashboard/organization/allowed-origins`, que é uma rota de **painel**: a sua `sk_` não a alcança, e isso é de propósito. Ligar o produto num domínio novo é operação de conta, não chamada de servidor.

Isso vale para **toda** origem: o seu domínio de produção, o de homologação e o endereço que você usa no desenvolvimento. Cada uma entra como origem completa (esquema, host e porta), até 20 por conta.

O QUE O TITULAR VÊ ENQUANTO A CONFERÊNCIA ACONTECE

Depois de enviar as fotos, o titular fica numa tela de espera que **anda**: o indicador se move, o widget conta **quantas conferências já concluíram** e, se a espera passar de alguns segundos, a mensagem muda para uma que pede para ele continuar na tela. Quando o conjunto é fixo, a contagem sai como _2 de 3_; quando o seu Flow decide etapas pelo caminho, sai só o quanto já andou, porque prometer um total que muda seria pior do que não prometer nenhum. Nada disso diz **o que** estamos conferindo nem revela resultado parcial: é andamento, não veredito.

Você não configura nem liga nada para isso, e não custa nada. Vale para a tela do aparelho que capturou; no computador que só mostra o QR Code, a tela continua sendo a de **siga no celular**. A contagem é apresentação: a verdade do desfecho continua sendo o seu **webhook**.

BOTÃO DE ATENDIMENTO DENTRO DA VERIFICAÇÃO

Quando o titular trava (documento recusado, selfie reprovada, análise pendente), o widget pode mostrar um botão que abre **o seu canal de atendimento**, com o `reference_id` daquela sessão já dentro do link. O atendente é seu: nós não hospedamos chat e não cobramos por isso, está incluso. Você liga no painel, em **Customização**, informando o endereço do seu canal (WhatsApp, Crisp, Intercom, Zendesk, a sua central) e o texto do botão.

O endereço é um **modelo**: onde você escrever `{reference_id}`, nós colocamos o identificador daquela sessão. Duas regras que a validação aplica no save, e vale saber antes de colar: o endereço precisa ser **https**, e o `{reference_id}` só pode aparecer na **query** ou no **fragmento**, nunca no host nem no caminho. Exemplos que passam: `https://suporte.suaempresa.com/chat?ref={reference_id}` e `https://wa.me/5511999999999?text=ajuda%20{reference_id}`.

Preferimos que você use o **fragmento** (`#ref=` em vez de `?ref=`): o fragmento não é enviado ao servidor de destino, então o identificador do titular fica fora do log HTTP do seu fornecedor de chat e continua legível pelo JavaScript dele. O `wa.me` precisa de query e continua valendo.

Três coisas que a validação recusa de propósito, e por quê: **chave secreta na URL** (parâmetros como `token`, `api_key` ou `secret`), porque não guardamos segredo de terceiro; **o nosso próprio domínio**, porque o titular veria a nossa marca na barra de endereço de uma página que não é nossa; e **o placeholder no host ou no caminho**, porque ali um `reference_id` mal-intencionado mudaria o destino final do link. O titular vê o host de destino escrito ao lado do botão antes de clicar, e a verificação continua aberta na aba dele.

O identificador só acompanha o link nas **primeiras 24 horas** da sessão. Depois disso o botão continua aparecendo, e o link continua abrindo o seu canal, mas sem o `reference_id`: o atendente identifica pelo próprio canal. Isso existe para que um endereço de sessão vazado não vire uma consulta permanente ao identificador do titular.

LEMBRETE DE VERIFICAÇÃO NÃO CONCLUÍDA

Quando o seu fluxo tem verificação de e-mail por código e o titular **confirma o código** mas abandona a verificação em seguida, enviamos **um único** lembrete para aquele endereço, ainda dentro do prazo em que a sessão pode ser concluída. É um por verificação, para sempre: não existe segunda cobrança, nem sequência.

Três coisas que esse e-mail **não** faz, e vale saber antes de perguntar: ele não carrega link de acesso à verificação (o titular volta pela sua página, e nenhuma credencial de sessão viaja por e-mail), não contém dado do documento nem resultado, e não vai para quem apenas **digitou** um endereço sem confirmar o código. Endereço não confirmado pode ser de outra pessoa, e mandar mensagem para ela seria o defeito, não a feature.

O titular pode recusar novos lembretes pelo próprio e-mail. Quando ele recusa, guardamos só um código derivado do endereço, nunca o endereço, e a recusa vale para toda a plataforma, não apenas para a sua conta. A cláusula C.14 da Política de Privacidade descreve o tratamento. Lembrete por **SMS** não existe hoje.

O QUE A SUA PÁGINA PRECISA PERMITIR

O widget roda **dentro da sua página**, e é ela que abre a câmera. Então a sua página precisa: ser servida por **https** (o navegador não entrega câmera em http, salvo em `localhost`), **não negar a câmera** no cabeçalho `Permissions-Policy`, e ter uma `Content-Security-Policy` que permita o nosso bundle, a nossa API, o canal de tempo real e os workers do detector de rosto. O exemplo em [Integração ponta a ponta](https://unifokal.com/docs/integracao#integracao) traz os cabeçalhos prontos.

**Uma ressalva sobre Trusted Types.** O widget roda no seu documento, não em um iframe, então uma política [Trusted Types](https://developer.mozilla.org/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types) que você publique alcança o nosso código junto com o seu. Hoje, `require-trusted-types-for 'script'` em modo de bloqueio na sua página interrompe a captura. O sintoma é ruim de diagnosticar: a tela fica em branco e o erro sai só no console do navegador, longe de qualquer log nosso. Se a sua política exige Trusted Types, use `Content-Security-Policy-Report-Only` na rota da verificação enquanto avalia, e fale com a gente antes de ligar o bloqueio. Essa decisão é sua, e o cabeçalho é da sua página: nós não temos como relaxá-lo do nosso lado.

**Quer fixar a versão do bundle (SRI)?** A URL acima é a **móvel**: ela sempre serve a versão atual, com `no-cache`, e é a escolha certa para a maioria (você recebe correção nossa sem fazer nada). Se a sua política exige [Subresource Integrity](https://developer.mozilla.org/docs/Web/Security/Subresource_Integrity), use a **URL versionada**: ela é imutável por versão e vem com o hash publicado por nós. O par (URL, hash) está sempre em `https://api.unifokal.com/dist/integrity.json`, que é **gerado no nosso build** a partir dos bytes servidos. Nunca copie um hash de um texto (inclusive deste): leia do manifesto, de preferência pelo seu CI.

```
# 1) leia o par URL + hash do manifesto (gerado no nosso build)
curl -s https://api.unifokal.com/dist/integrity.json
{
  "_leia": "Par URL + integrity do bundle do widget UNIFOKAL, gerado no build …",
  "version": "b1efde7e5c593de78",
  "files": {
    "widget.global.js": {
      "url": "/dist/b1efde7e5c593de78/widget.global.js",
      "integrity": "sha384-…",
      "bytes": 166951
    },
    "widget.esm.js": {
      "url": "/dist/b1efde7e5c593de78/widget.esm.js",
      "integrity": "sha384-…",
      "bytes": 166820
    }
  }
}
# são DOIS artefatos: widget.global.js (a tag <script>, expõe IdSaas) e widget.esm.js
# (import de módulo, para quem empacota o próprio front). O par URL + integrity é por arquivo.

# 2) use os dois JUNTOS na página (o crossorigin é obrigatório para o SRI ser avaliado)
<script src="https://api.unifokal.com/dist/b1efde7e5c593de78/widget.global.js"
        integrity="sha384-…"
        crossorigin="anonymous"></script>
```

O que isso significa na prática: a versão que você fixou **não muda de conteúdo**, nunca. Quando publicamos um bundle novo, a versão passa a ser outra e o manifesto muda junto, então **fixar exige atualizar** (por isso a recomendação de ler o manifesto no seu CI em vez de colar o hash na mão). Uma versão que sai do ar responde `404`, e não outro conteúdo: preferimos falhar claro a servir bytes diferentes sob a URL que você assinou.

O EVENTO QUE O WIDGET DISPARA NA SUA PÁGINA

O widget avisa a sua página quando o estado da verificação muda, e avisa de novo quando ela **acaba**. São dois eventos, e os nomes são estáveis: `unifokal:state` a cada transição e `unifokal:done` uma única vez, no fim. É por eles que um aplicativo que abre a verificação num webview sabe a hora de fechar a tela, sem esperar o seu servidor avisar.

O mesmo aviso chega por **três canais**, e você escolhe o que couber: um evento no `window` da página (`window.addEventListener`), o `onEvent` que você passa no `mount()`, e `window.ReactNativeWebView.postMessage` quando a página está dentro de um webview React Native. O payload é o mesmo nos três: `{ event, state, outcome }`. O `outcome` vem preenchido só no `unifokal:done`, e é `null` em qualquer aviso intermediário.

```
// A sua página, ouvindo o fim da verificação. Nada além disto é necessário.
window.addEventListener("unifokal:done", (ev) => {
  const { outcome } = (ev as CustomEvent<{ outcome: string }>).detail;
  // outcome: "approved" | "pending" | "declined" | "failed" | "expired" | "analysis_incomplete"
  fecharTelaDaVerificacao(outcome);
});

// Quem monta por JS recebe o mesmo payload pelo callback, sem ouvir o window:
IdSaas.mount({ sessionId: "vs_01J8ZQ4S8M2N3P4Q5R6S7T8U9V", onEvent: (e) => console.log(e) });
```

**Duas garantias que você pode assumir.** O aviso intermediário não se repete: enquanto o widget espera, ele reconsulta o estado a cada poucos segundos, e só uma transição de verdade vira evento. E o final é **único**: depois do `unifokal:done` nenhum outro evento sai daquele widget, então você pode fechar a tela sem se proteger de um segundo disparo. Um erro dentro do seu listener nunca derruba a verificação do titular.

**O que o evento nunca carrega**, e isso é desenho, não omissão: nenhuma credencial (o identificador da sessão é a credencial do widget, e ele não viaja por aqui), nenhum dado do titular, nenhum resultado de módulo e nenhuma mensagem de erro. Quem escuta pode estar fora do seu controle, porque é a página que hospeda o widget, e o que sai daqui é sinal de interface para fechar tela.

**E a verdade do desfecho continua sendo o seu webhook.** Este evento vive no navegador, e quem controla a página pode forjá-lo. Use-o para a experiência, nunca para liberar, creditar ou dar por concluído qualquer coisa do seu lado: isso se faz com a entrega assinada que chega ao seu servidor.

OPÇÕES DO mount()

| campo | tipo | descrição |
| --- | --- | --- |
| sessionId | obrigatório | ID da sessão criada no backend (vs\_…). É a credencial do widget: nenhum segredo na página. |
| container | opcional | Seletor CSS onde o widget monta (default: #idsaas-widget). |
| baseUrl | opcional | Origem da API UNIFOKAL (default: a origem de onde o widget é servido). |
| locale | opcional | Dica de idioma da interface (por exemplo "en"). É só uma dica: o idioma da sessão, resolvido no servidor, vence, e um valor desconhecido é ignorado. |
| theme | opcional | Tema do widget: "light", "dark" ou "auto" (auto segue o modo claro/escuro do aparelho do titular, ao vivo). Sem o campo, vale a customização da sua conta; sem escolha nenhuma, o widget é claro como sempre. Valor desconhecido é ignorado. |
| accent | opcional | Cor de acento em hex (por exemplo "#7c5cff"): barra, botões e destaques daquela página. A tinta do texto sobre o acento (clara ou escura) é escolhida automaticamente conforme a cor. Valor fora do formato hex é ignorado e o widget fica na cor da conta. |

Todo campo da tabela também existe na **forma declarativa**, como atributo `data-` do próprio `<script>`: `data-session`, `data-container`, `data-base-url`, `data-locale`, `data-theme`, `data-accent`. Com `data-session` presente o widget monta sozinho, sem você escrever uma linha de JavaScript. É a forma do exemplo acima.

**Como o idioma é escolhido**, do mais forte para o mais fraco: o idioma da **sua conta**, se você escolheu um em Customização; depois a dica desta página (`locale` ou `data-locale`); depois o idioma do navegador de quem acessa; e por último o português. Hoje o widget fala português, inglês e espanhol.

Escolha o idioma da conta quando o seu público fala uma língua só: ele vale para toda sessão criada a partir daquele momento, e fica **congelado** em cada sessão. Trocar a configuração no meio de uma verificação não muda a língua de quem já está com o widget aberto, de propósito. Deixando em detectar, cada pessoa vê o widget no idioma do próprio aparelho, que costuma ser a escolha certa para público misto.

TEMA (CLARO, ESCURO, AUTO) E COR DE ACENTO

O widget nasce **claro** e continua claro para quem não configura nada. Se a sua página tem modo escuro, peça `dark` (fixo) ou `auto`: no auto o widget segue o modo do aparelho do titular e acompanha a troca ao vivo. O `accent` muda a cor de destaque (barra, botões) só naquela página, sem mexer na customização da conta. Os dois são locais: viram classe e variável CSS no próprio widget e **nunca viajam para a API**.

```
// TypeScript, na sua página (a forma declarativa usa data-theme e data-accent):
IdSaas.mount({
  sessionId: "vs_01J8ZQ4S8M2N3P4Q5R6S7T8U9V",
  theme: "auto",      // "light" | "dark" | "auto"
  accent: "#7c5cff",  // hex; a tinta sobre o acento (clara ou escura) e escolhida automaticamente
});
```

A precedência é simples: o que a **página** pede vence a customização da **conta**; contas que já usam fundo escuro na tela de customização ganham o widget escuro sem mudar nada. Os dois temas mantêm contraste AA nos textos e ícones da paleta padrão. Valor fora da lista (`theme`) ou fora do formato hex (`accent`) é ignorado em silêncio: o widget cai no visual padrão, nunca quebra.

## Verificação em aplicativo

<https://unifokal.com/docs/aplicativo-nativo>

### Verificação em aplicativo

Seu aplicativo abre a verificação numa WebView, o titular faz tudo sem sair do app, e o app fecha a tela sozinho quando termina. É o mesmo fluxo do navegador, com o mesmo widget e o mesmo webhook: o que muda é a configuração da WebView, porque as duas plataformas nascem com defaults que atrapalham a câmera.

Você tem duas portas, e as duas funcionam aqui. Pode carregar na WebView a sua própria página com o widget montado, ou pode carregar direto o link de verificação hospedado, que é uma página nossa e não exige front nenhum do seu lado. Em qualquer uma delas, a hora de fechar a tela chega pelo evento que o widget dispara, descrito em [O Widget](https://unifokal.com/docs/widget#widget).

iOS (WKWebView)

A permissão de câmera é do **app hospedeiro**, nunca da página: declare o uso no app e peça a permissão nativa antes de abrir a tela da verificação. Feito isso, três ajustes decidem se a captura acontece dentro da sua tela ou não acontece.

| ajuste | default da plataforma | deixar como está significa |
| --- | --- | --- |
| `allowsInlineMediaPlayback` | false no iPhone, true no iPad | No iPhone, com o default, o vídeo da câmera tenta abrir em tela cheia nativa e a captura não acontece dentro da sua tela. Ligue. |
| `getUserMedia (WKWebView, iOS 14.3+)` | exposto só quando o app hospedeiro pode capturar nativamente | Declare o uso da câmera no app e peça a permissão nativa. Sem isso a API simplesmente não existe dentro da WebView. |
| `requestMediaCapturePermissionFor (iOS 15+)` | prompt padrão do sistema | Implemente se quiser decidir a permissão no seu código, em vez de deixar o prompt padrão aparecer por cima da sua tela. |

Android (WebView)

Aqui são quatro, e o primeiro é o que mais aparece em chamado de integração: a WebView do Android nasce **sem JavaScript**.

| ajuste | default da plataforma | deixar como está significa |
| --- | --- | --- |
| `setJavaScriptEnabled` | false | Com o default a verificação não carrega. Ligue. |
| `setMediaPlaybackRequiresUserGesture` | true | Com o default o vídeo da câmera só começa depois de um toque extra do titular, dentro de uma tela que já pediu um. Desligue. |
| `setDomStorageEnabled` | false | Com o default o titular que sai do app e volta recomeça do zero. Ligue e a verificação retoma de onde parou. |
| `onPermissionRequest (WebChromeClient)` | negado, quando não implementado | A permissão de câmera é decisão do seu app, não da nossa página. Sem implementar, a câmera nunca abre. |

COMO O APP SABE QUE ACABOU

O widget avisa a página quando a verificação termina, e essa é a deixa para o app fechar a WebView. Numa WebView React Native o aviso chega sozinho por `postMessage`; numa WebView nativa, injete uma linha que reenvia o aviso pela ponte que você já usa com a sua tela. O contrato completo do evento, com o payload, os três canais e quando cada campo vem preenchido, está em [O Widget](https://unifokal.com/docs/widget#widget).

```
// Injetado na WebView: reenvia o fim da verificação para o código nativo.
// O nome do evento e o formato do payload estão na página do Widget.
window.addEventListener("unifokal:done", (ev) => {
  pontePraNativo(JSON.stringify(ev.detail));
});
```

**E o desfecho de verdade continua chegando ao seu servidor.** O evento fecha a tela; quem libera, credita ou dá por concluído é o webhook assinado. Um app que decide pelo evento está decidindo por um sinal que vive no navegador.

O QUE ESTA INTEGRAÇÃO COBRE

Esta página cobre a verificação dentro da sua WebView: câmera, captura, fluxo completo e o aviso de fim. Leitura de chip por NFC e atestação de dispositivo pelos serviços da Apple e do Google não fazem parte dela. Se o seu caso pede um desses, fale com a gente antes de desenhar a tela.

## Autenticação e segurança

<https://unifokal.com/docs/autenticacao>

### Autenticação

Chamadas REST usam **Bearer token** no header. A chave secreta define o ambiente: `sk_test` roda em sandbox, `sk_live` em produção. Nunca exponha a secret no front: ela é só server-side.

**curl** · Criar a sessão · no terminal

```sh
# A chave secreta NUNCA sai do seu servidor. Aqui ela vem do ambiente, nunca do arquivo:
#   export UNIFOKAL_SECRET_KEY="sk_live_..."     (use sk_test_... para sandbox)
#
# O ambiente (sandbox ou producao) e definido pela CHAVE, nao por um campo do corpo.

curl -sS -i -X POST https://api.unifokal.com/v1/verification-sessions \
  -H "Authorization: Bearer $UNIFOKAL_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "flow_id": "flow_01J8ZQ2R6K7T8V9W0X1Y2Z3A4B",
        "reference_id": "usr_8842"
      }'

# NAO existe campo "email" nem campo "phone": o contato do titular NUNCA entra aqui. Quem pergunta
# o e-mail e o telefone a pessoa, e dispara o codigo em seguida, e o widget. Mandar um dos dois
# devolve 422 email_not_accepted / phone_not_accepted (recusamos com nome, nunca ignoramos em
# silencio). Um flow com email_otp cria sessao com este MESMO corpo.

# O QUE ESPERAR DE VOLTA (201). Guarde o "id": e ele, e so ele, que vai para o navegador.
# A resposta vem comentada para o bloco inteiro poder ser colado no terminal sem erro de sintaxe.
#
# HTTP/1.1 201 Created
# {
#   "id": "vs_01J8ZQ4S8M2N3P4Q5R6S7T8U9V",
#   "flow_id": "flow_01J8ZQ2R6K7T8V9W0X1Y2Z3A4B",
#   "environment": "production",
#   "reference_id": "usr_8842",
#   "status": "requires_input",
#   "expires_at": "2026-08-25T18:15:00.000Z",
#   "created_at": "2026-08-25T18:00:00.000Z",
#   "modules": ["cpf_ocr", "face", "liveness"],
#   "livemode": true,
#   "expires_in": 900
# }

# "expires_in" e o prazo em SEGUNDOS: 900 = 15 minutos. Depois disso o widget nao sobe
# mais com esse id, e voce cria outra sessao.
# "modules" sao os modulos do flow, na ordem. Voce nao declara modulos no widget.
#
# A criacao NAO cobra nada: nenhum centavo sai da sua conta aqui. A cobranca acontece quando a
# verificacao TERMINA, e o saldo atualizado chega no seu webhook do desfecho.
#
# IDEMPOTENCIA: nao existe header. O "reference_id" (obrigatorio) E a chave, do nosso lado. Repetir
# a chamada com o MESMO reference_id, enquanto a sessao estiver viva, devolve a MESMA sessao, com o
# header "Idempotent-Replay: true": um retry do seu backend nao cria (nem cobra) uma segunda. Quando
# a sessao expira (900 s), o mesmo reference_id cria uma sessao NOVA, que e o que o
# titular precisa para tentar de novo.
```

**Todo erro tem a mesma forma:** `{ "error": "<código>", "message": "<texto>" }`. Trate pelo `error`, que é contrato estável dentro de `/v1`. O `message` é para humano e pode mudar sem aviso. A lista completa dos que você encontra no primeiro dia está logo abaixo.

**curl** · Os erros que você vai encontrar no primeiro dia · no terminal

```sh
# O envelope de erro e SEMPRE o mesmo, em toda a API:
#     { "error": "<codigo estavel>", "message": "<texto legivel>" }
# Trate pelo "error". O "message" e para humano e pode mudar; o codigo nao muda dentro de /v1.

# Reproduza um erro AGORA, com a sua chave de sandbox, e confira o formato com os proprios olhos:
curl -sS -i -X POST https://api.unifokal.com/v1/verification-sessions \
  -H "Authorization: Bearer $UNIFOKAL_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"flow_id": "flow_esse_nao_existe"}'

# O QUE ESPERAR DE VOLTA: 404 com o envelope acima. Os outros que voce vai encontrar estao listados
# abaixo, comentados, para este bloco inteiro poder ser colado no terminal sem erro de sintaxe.

# 401 -> chave ausente, mal embalada, inexistente ou REVOGADA. A mensagem diz qual dos casos:
#        "Missing API key" (nao mandou nada), "API key not recognized" (mandou a chave crua no
#        Authorization, sem o "Bearer "), "API key not found" ou "This API key was revoked".
#   { "error": "invalid_api_key", "message": "API key not found: check the key and the environment it was created in." }

# 400 -> campo faltando ou malformado. E o erro mais comum do primeiro dia. A mensagem lista os
#        campos, os valores aceitos e os limites; ela NUNCA repete o valor que voce mandou.
#   { "error": "validation_error", "message": "\"flow_id\" any.required" }
#        Caso especial que vale conhecer: POST sem o header Content-Type: application/json nao chega
#        a ter corpo nenhum, e a resposta diz isso na primeira frase.

# 403 -> a organizacao esta suspensa: TODA chamada sk_ para de funcionar de uma vez.
#   { "error": "organization_suspended", "message": "Organization suspended: every API call is blocked until it is reactivated. Sign in to the dashboard to see the reason, or contact support." }

# 403 -> o prefixo da chave e o registro dela discordam de ambiente (chave guardada na variavel
#        errada do deploy e o caso classico).
#   { "error": "test_key_used_in_production", "message": "API key prefix says production but the key belongs to sandbox: use the key of the environment you want to call." }

# 403 -> a conta ainda nao confirmou o e-mail. E o tropeco mais comum do primeiro dia:
#        a chave e valida, o flow existe, e mesmo assim nenhuma sessao e criada.
#   { "error": "email_not_verified", "message": "Confirm your email address before using this resource" }

# 403 -> voce mandou a credencial do PAINEL (dsk_) nesta rota. Aqui so entra sk_.
#   { "error": "credential_type_not_allowed", "message": "This route does not accept dashboard credentials" }

# 404 -> flow_id inexistente NESTE ambiente. Chave de teste nao enxerga flow de producao,
#        e vice-versa: o mesmo id nao existe dos dois lados. E o erro do curl acima.
#   { "error": "flow_not_found", "message": "Flow not found in sandbox: check the flow id, and remember the environment is the API key you used (a flow lives in one environment only, and the same id does not exist on the other side)." }

# 422 -> o flow existe, mas nao esta live
#   { "error": "flow_not_live", "message": "Flow is not live" }

# 422 -> voce mandou "email" (ou "phone") no corpo. Contato do titular nao entra na criacao:
#        quem pergunta a pessoa e dispara o codigo e o widget. Recusado na entrada, custo zero.
#   { "error": "email_not_accepted", "message": "Email is always collected from the end user in the widget: remove email from the request." }
#   { "error": "phone_not_accepted", "message": "Phone is always collected from the end user in the widget: remove phone from the request." }

# 422 -> voce mandou "phone". Ele nao entra aqui: o telefone e sempre digitado pelo titular
#        no widget. Recusamos com nome em vez de ignorar, senao voce acharia que travou o
#        destino do SMS quando nao travou.
#   { "error": "phone_not_accepted", "message": "Phone is always collected from the end user in the widget: remove phone from the request." }

# 402 -> o saldo nao cobre o maximo que a verificacao pode custar. A mensagem diz quanto falta.
#        Recarregue, ou ligue a recarga automatica no painel.
#   { "error": "insufficient_credit", "message": "Insufficient credit balance to start a production verification: balance is 0 cents and 68 more cents are needed to cover the maximum this verification can cost. Add credit in the dashboard (Cobranca), or use a sandbox key (sk_test_), which never charges." }

# 429 -> teto MENSAL de sandbox, ou teto de chamadas por minuto da conta.
#        Os dois sao 429 com codigos diferentes: nao trate 429 como um caso so.
#   { "error": "sandbox_limit_reached", "message": "Sandbox monthly limit reached." }
#   { "error": "rate_limited", "message": "Rate limit exceeded." }

# 409 / 422 -> idempotencia
#   { "error": "idempotency_conflict",  "message": "A request with this reference_id is already in progress." }
#   { "error": "idempotency_key_reuse", "message": "reference_id reused with a different request body." }

# 422 -> politica por sessao para um modulo que o flow NAO tem. Recusamos com nome em vez de
#        ignorar: politica aceita-e-ignorada faria voce acreditar que desligou algo que segue ligado.
#   { "error": "policy_module_not_in_flow", "message": "Flow has no pep_sancoes module; remove policy.allow_pep from the request." }

# 400 -> chave DESCONHECIDA dentro de "policy" ou de "monitoring". Os dois blocos sao whitelist
#        fechada: recusamos com nome em vez de descartar o campo em silencio, porque um 201 com a
#        chave engolida faria voce acreditar que a politica valeu. O nome da chave recusada vem na
#        "message". Valem os mesmos blocos tambem no link hospedado.
#   { "error": "unknown_policy_key",     "message": "\"policy.allowBettingBan\" object.unknown (unknown_policy_key)" }
#   { "error": "unknown_monitoring_key", "message": "\"monitoring.cancel\" object.unknown (unknown_monitoring_key)" }

# 422 -> o flow informado so recebe alertas do monitoramento transacional e nao aceita sessao de
#        verificacao. Mande os eventos com o bloco "transaction", ou crie a sessao no seu flow
#        de verificacao. Vale igual na emissao do link hospedado.
#   { "error": "session_not_supported", "message": "the flow's module transacao only receives alerts from transaction monitoring and does not accept a verification session." }

# 422 -> "policy.ubo_max_paid_nodes" acima do teto do proprio flow. O teto da sessao so APERTA o do
#        flow (gasto so sobe por decisao registrada no flow, nunca por um campo de request).
#   { "error": "policy_ubo_cap_above_flow", "message": "policy.ubo_max_paid_nodes (8) exceeds the flow limit (3); the session policy can only lower it." }

# Nenhum destes cria sessao e nenhum e cobrado. Repetir a chamada so resolve 429 (espere) e 402
# (recarregue). Nos outros, a correcao e de configuracao: repetir devolve o mesmo erro.
```

! Os dois ambientes são **mundos separados**: um `flow_id` de produção não existe para uma chave de teste, e o erro que volta é `404 flow_not_found`, não um 403 de permissão. Se um flow que você acabou de criar "não existe", confira o ambiente da chave antes de qualquer outra coisa (veja [Sandbox](https://unifokal.com/docs/ambientes#sandbox)).

### Segurança do fluxo

O front recebe do seu backend apenas o `sessionId` (`vs_`), **escopado a UMA única sessão** (uso curto, com TTL). Ele é a credencial do widget: **nenhum segredo vai ao navegador**. Não é uma chave global: só habilita a captura daquela verificação, então expor por engano não compromete outras sessões nem a sua conta. A `sk_` (secret), usada no backend para **criar a sessão**, é a única credencial sensível e nunca sai do servidor. A captura e o processamento acontecem no UNIFOKAL; o resultado oficial só trafega no **webhook assinado** para o seu backend.

Encontrou uma vulnerabilidade? A [política de divulgação responsável](https://unifokal.com/seguranca/divulgacao-responsavel) diz como relatar, o escopo e o que esperar, e o arquivo [/.well-known/security.txt](https://unifokal.com/.well-known/security.txt) traz os mesmos canais em formato legível por máquina. Os documentos de segurança, privacidade e contrato ficam reunidos na [central de confiança](https://unifokal.com/confianca).

### Acesso da equipe por SSO (SAML 2.0)

A sua equipe pode entrar no painel pelo **provedor de identidade da própria empresa**, em vez de e-mail e senha por pessoa. Falamos **SAML 2.0** no perfil Web Browser SSO, que é o caminho documentado por **Okta**, **Microsoft Entra ID** e **Google Workspace** para aplicações de terceiros. **Está incluso**: não é add-on, não tem mensalidade e não consome crédito.

Quem liga é o **dono da conta**, em Segurança da conta no painel. A tela mostra os dois valores que você cola no seu provedor (o _Entity ID_ e a _URL de retorno_, que o Entra chama de _Reply URL_) e recebe de volta o identificador, a URL de login e o certificado de assinatura que o seu provedor gera.

**Como fica o segundo fator:** quem entra pelo seu provedor não precisa também do nosso código de verificação em duas etapas, porque o segundo fator é o que a sua empresa já exige no próprio provedor. A exigência de duas etapas aqui continua valendo para quem entra por e-mail e senha.

**Ao exigir SSO**, a equipe deixa de entrar por senha. O dono da conta continua conseguindo entrar por senha de propósito: é a saída de emergência para o dia em que o certificado do provedor expirar ou a configuração sair errada, situação em que ninguém mais consegue entrar. Pelo mesmo motivo, você pode cadastrar **mais de um certificado ao mesmo tempo** e trocar sem janela de queda: cadastre o novo antes de virar a chave no seu provedor.

**Provisionamento automático (SCIM 2.0).** Além de entrar pelo seu provedor, a sua equipe pode ser gerida por ele: Okta e Microsoft Entra ID criam, atualizam e desativam contas daqui pelo protocolo SCIM 2.0 (RFC 7643 e 7644). O ganho de segurança é o desligamento: quem for desativado no seu diretório perde o acesso aqui na hora, com as sessões abertas derrubadas no mesmo instante, sem depender de alguém lembrar de mexer no painel.

Para ligar: na tela de Segurança da conta, com a conexão de SSO ativa, o dono da conta gera o token de provisionamento e copia o endereço do SCIM. No Okta, os dois entram em Provisioning como SCIM connector base URL e API token (autenticação HTTP Header); no Entra, como Tenant URL e Secret Token do provisionamento do aplicativo. O token é exibido uma única vez, como as chaves de API: perdeu, rotacione. Rotacionar invalida o anterior no mesmo instante, e revogar interrompe a sincronização sem mexer em quem já foi provisionado.

O recorte, dito com todas as letras: o identificador da pessoa (userName) é o e-mail e não se renomeia por SCIM; quem nasce pelo provisionamento entra com o papel padrão da conexão, e o papel de quem já existe nunca é reescrito; grupos não são provisionados (papel por grupo continua sendo do mapeamento do SAML); o titular da conta não é gerenciável por SCIM, de propósito, porque é ele quem mantém a saída de emergência; remover alguém pelo provedor desativa a conta aqui, sem apagar o histórico de quem fez o quê; e uma remoção feita por uma pessoa no painel não é desfeita pelo provedor.

O que **ainda não** existe, para você não planejar em cima do que não temos: **logout único (SLO)** e **login iniciado pelo provedor** (o acesso começa sempre pela nossa tela de entrada).

## Integração ponta a ponta

<https://unifokal.com/docs/integracao>

### Integração ponta a ponta

Tudo o que segue é **código completo**, com tratamento de erro, para copiar e rodar. Em três formas: a chamada HTTP crua (serve para qualquer stack), **TypeScript/JavaScript** e **Python**. Escolha a sua e ignore as outras duas: elas fazem exatamente a mesma coisa.

Antes de começar, confira os passos **01 a 05** da tabela do início: eles acontecem no painel, uma vez, e nenhum deles é opcional. Sem o e-mail confirmado a criação de sessão responde `403`; sem a origem registrada o widget não sobe.

Prefere tipos a copiar exemplo? O contrato inteiro existe como [spec OpenAPI 3.1](https://unifokal.com/docs/api-rest#openapi) e vira tipos TypeScript com [uma linha de `npx openapi-typescript`](https://unifokal.com/docs/api-rest#gerar-tipos-curl).

PASSO 06 · CRIE A SESSÃO NO SEU BACKEND

Uma chamada, um campo obrigatório (`flow_id`). Mande o `reference_id` (o id do usuário no seu sistema): ele volta igual no webhook e é o que liga o resultado ao seu cadastro. A versão `curl` está em [Autenticação](https://unifokal.com/docs/autenticacao#auth).

! **Você não gerencia chave de idempotência.** Não existe header aqui: o `reference_id` (obrigatório) é a chave, do nosso lado. Um retry do seu backend (rede ruim, timeout, deploy no meio) com o mesmo `reference_id` recebe a **mesma sessão**, com o header `Idempotent-Replay: true`, em vez de criar uma segunda (e sessão duplicada seria cobrança duplicada). A selagem dura o que a sessão dura, 15 minutos: quando ela vence, o mesmo `reference_id` abre uma sessão nova, que é o que a pessoa precisa para tentar de novo.

**TypeScript / Node** · Criar a sessão · no seu servidor

```ts
// unifokal.ts -> roda no SEU SERVIDOR. Nunca importe este arquivo em codigo de navegador:
// a chave secreta mora aqui, e no navegador ela ficaria visivel para qualquer visitante,
// que passaria a abrir sessoes na sua conta com voce pagando por elas.
const API = "https://api.unifokal.com/v1";

const SECRET_KEY = process.env.UNIFOKAL_SECRET_KEY;
if (!SECRET_KEY) throw new Error("Defina UNIFOKAL_SECRET_KEY no ambiente do servidor.");

export class UnifokalError extends Error {
  constructor(readonly status: number, readonly code: string, message: string) {
    super(message);
    this.name = "UnifokalError";
  }
}

export interface SessaoCriada {
  id: string; // vs_... -> o UNICO valor que pode ir ao navegador
  expiresIn: number; // segundos de vida da sessao (900 = 15 min)
  modules: string[]; // modulos do flow, na ordem
  livemode: boolean; // false em sandbox
}

export async function criarSessao(entrada: {
  flowId: string;
  /**
   * OBRIGATORIO, e ele e a chave de idempotencia do NOSSO lado: repetir a chamada com o mesmo
   * reference_id devolve a MESMA sessao enquanto ela viver, em vez de criar (e cobrar) outra. Nao
   * existe header de idempotencia nesta rota.
   *
   * NAO existe campo de e-mail nem de telefone: o contato do titular e sempre digitado no widget,
   * que tambem dispara o codigo. Mandar qualquer um dos dois e 422 (email_not_accepted /
   * phone_not_accepted).
   */
  referenceId: string;
}): Promise<SessaoCriada> {
  const corpo: Record<string, unknown> = {
    flow_id: entrada.flowId,
    reference_id: entrada.referenceId,
  };

  const resp = await fetch(`${API}/verification-sessions`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${SECRET_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(corpo),
    signal: AbortSignal.timeout(15_000),
  });

  // Le como TEXTO primeiro: um 502 do seu proxy volta em HTML, e resp.json() lancaria
  // um erro de parse que esconde o status real.
  const bruto = await resp.text();
  let dados: Record<string, unknown> = {};
  try {
    dados = bruto ? (JSON.parse(bruto) as Record<string, unknown>) : {};
  } catch {
    /* resposta nao-JSON: seguimos com o status, que e a informacao que importa */
  }

  if (!resp.ok) {
    const code = typeof dados.error === "string" ? dados.error : "unknown_error";
    const message = typeof dados.message === "string" ? dados.message : bruto.slice(0, 200);
    throw new UnifokalError(resp.status, code, message);
  }

  return {
    id: String(dados.id),
    expiresIn: Number(dados.expires_in),
    modules: Array.isArray(dados.modules) ? (dados.modules as string[]) : [],
    livemode: dados.livemode === true,
  };
}

// O QUE ESPERAR DE VOLTA: em sucesso, o objeto acima com "id" comecando em vs_ e expiresIn = 900.
// Em falha, um UnifokalError com o codigo estavel em .code. Trate ao menos estes tres, que sao os
// que mudam o que voce mostra ao usuario:
//
//   try {
//     const sessao = await criarSessao({ flowId, referenceId: usuario.id });
//   } catch (err) {
//     if (err instanceof UnifokalError && err.code === "insufficient_credit") {
//       // sua conta ficou sem saldo: avise o time, e mostre "tente em instantes" ao usuario
//     } else if (err instanceof UnifokalError && err.code === "rate_limited") {
//       // espere e repita: e teto por minuto, some sozinho
//     } else if (err instanceof UnifokalError && err.code === "flow_not_found") {
//       // configuracao errada (flow de outro ambiente): repetir NAO resolve
//     } else if (err instanceof UnifokalError && err.code === "email_not_accepted") {
//       // voce mandou "email" no corpo: tire. O widget pergunta o endereco ao titular. Repetir
//       // com o campo NAO resolve, e nenhuma sessao foi criada nem cobrada.
//     }
//     throw err;
//   }
```

**Python** · Criar a sessão · no seu servidor

```python
# unifokal.py -> roda no SEU SERVIDOR. A chave secreta mora aqui, em variavel de ambiente.
# Nunca a mande ao navegador: la ela fica visivel para qualquer visitante, que passaria a abrir
# sessoes na sua conta com voce pagando por elas.
#
# So biblioteca padrao: nada para instalar.
import json
import os
import urllib.error
import urllib.request

API = "https://api.unifokal.com/v1"
SECRET_KEY = os.environ["UNIFOKAL_SECRET_KEY"]  # sk_live_... ou sk_test_...


class UnifokalError(Exception):
    def __init__(self, status: int, code: str, message: str) -> None:
        super().__init__(f"{status} {code}: {message}")
        self.status = status
        self.code = code


def criar_sessao(
    flow_id: str,
    reference_id: str,
) -> dict:
    """Cria a sessao e devolve o corpo 201 inteiro.

    reference_id: OBRIGATORIO, e e a chave de idempotencia do NOSSO lado. Se o seu
    processo re-executar, o mesmo reference_id devolve a MESMA sessao em vez de criar
    (e cobrar) outra. Nao existe header de idempotencia nesta rota.

    Nao existe parametro de e-mail nem de telefone: o contato do titular e sempre
    digitado no widget, que tambem dispara o codigo. Mandar qualquer um dos dois no
    corpo e 422 (email_not_accepted / phone_not_accepted).
    """
    corpo = {"flow_id": flow_id, "reference_id": reference_id}

    req = urllib.request.Request(
        f"{API}/verification-sessions",
        method="POST",
        data=json.dumps(corpo).encode("utf-8"),
        headers={
            "Authorization": f"Bearer {SECRET_KEY}",
            "Content-Type": "application/json",
        },
    )

    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as err:
        bruto = err.read().decode("utf-8", "replace")
        try:
            dados = json.loads(bruto)
        except ValueError:
            dados = {}  # 502 do seu proxy volta em HTML: fique com o status, que e o que importa
        raise UnifokalError(
            err.code,
            dados.get("error", "unknown_error"),
            dados.get("message", bruto[:200]),
        ) from err


# O QUE ESPERAR DE VOLTA: em sucesso, o dicionario 201 com "id" comecando em vs_ e "expires_in" = 900.
# Em falha, um UnifokalError com o codigo estavel em .code. Os tres que mudam o que voce mostra:
#
#     try:
#         sessao = criar_sessao(FLOW_ID, reference_id=usuario.id)
#     except UnifokalError as err:
#         if err.code == "insufficient_credit":
#             ...  # conta sem saldo: avise o time, mostre "tente em instantes" ao usuario
#         elif err.code == "rate_limited":
#             ...  # teto por minuto: espere e repita
#         elif err.code == "flow_not_found":
#             ...  # flow de outro ambiente: repetir NAO resolve, e configuracao
#         raise
```

PASSO 07 · MONTE O WIDGET NA PÁGINA

O exemplo abaixo é a fronteira inteira do produto num arquivo: o servidor cria a sessão com a chave secreta e entrega ao navegador **só o id da sessão**. Ele também traz os cabeçalhos que a câmera exige, que é a segunda causa mais comum de widget que carrega e trava.

**TypeScript / Node** · A página que o seu usuário abre · no seu servidor

```ts
// A rota que RENDERIZA a pagina. Ela roda NO SEU SERVIDOR: e la que a chave secreta vive, e e
// de la que ela nunca sai. Ao navegador vai SO o id da sessao. E a fronteira inteira do produto
// num arquivo: de um lado a chave, do outro o id descartavel.
import express from "express";
import { criarSessao } from "./unifokal";

const app = express();

app.get("/verificacao", async (req, res, next) => {
  try {
    const usuario = req.user; // o seu usuario ja autenticado no SEU sistema

    // O `reference_id` e a chave de idempotencia, e voce nao precisa gerenciar nada: a nossa
    // selagem dura o que a SESSAO dura (15 minutos), nao 24h. Enquanto a
    // sessao esta viva, repetir esta chamada devolve ela mesma (recarregar a pagina reusa a sessao,
    // e um retry de rede nao cria nem cobra uma segunda). Quando ela vence, o MESMO `reference_id`
    // abre uma sessao nova, que e o que o titular precisa para tentar de novo.
    const sessao = await criarSessao({
      flowId: process.env.UNIFOKAL_FLOW_ID!,
      referenceId: usuario.id,
    });

    // A pagina que monta a camera precisa DESTES cabecalhos. Sem eles o widget carrega
    // e trava sem mensagem, porque o bloqueio acontece no browser, nao na nossa API.
    //
    // O nonce e por REQUISICAO, nunca fixo: um nonce constante e o mesmo que 'unsafe-inline',
    // so que com mais passos. Ele autoriza o UNICO script inline desta pagina, o que chama
    // IdSaas.mount. Sem ele, o bundle carrega, o mount nao roda, e o seu usuario ve uma area
    // em branco no meio do cadastro, sem erro nenhum na tela.
    const nonce = crypto.randomUUID();
    res.setHeader(
      "Content-Security-Policy",
      [
        "default-src 'self'",
        // o bundle do widget e servido pela nossa origem; o nonce libera o mount inline
        `script-src 'self' https://api.unifokal.com 'nonce-${nonce}'`,
        // REST + o canal de tempo real (wss) do widget
        "connect-src 'self' https://api.unifokal.com wss://api.unifokal.com",
        // o detector de rosto roda num worker de blob:, e os frames viram blob:/data:
        "worker-src 'self' blob:",
        "img-src 'self' data: blob:",
        "style-src 'self' 'unsafe-inline'",
      ].join("; "),
    );
    // A camera roda NO SEU DOCUMENTO. Se a sua pagina manda camera=(), o widget nao
    // consegue abrir a camera e o usuario fica preso na primeira tela.
    res.setHeader("Permissions-Policy", "camera=(self), microphone=()");

    res.send(`<!doctype html>
<html lang="pt-BR">
  <head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /></head>
  <body>
    <div id="identidade"></div>

    <!-- Aqui NAO existe segredo nenhum. O unico valor que chega ao navegador e o id da
         sessao (vs_...), que so serve para ESTA verificacao e vence em 15 minutos.
         A sua chave sk_ ficou no servidor, na chamada acima. -->
    <script src="https://api.unifokal.com/dist/widget.global.js" crossorigin="anonymous"></script>
    <script nonce="${nonce}">
      IdSaas.mount({ sessionId: ${JSON.stringify(sessao.id)}, container: "#identidade" });

      // SINAL DE UI, e so isso. O widget avisa a sua pagina quando o titular termina:
      // "unifokal:state" a cada transicao e "unifokal:done" no fim. O detalhe traz
      // { event, state, outcome } e NADA MAIS: sem sessionId, sem PII, sem resultado de modulo.
      // Use para fechar a tela, tirar o spinner, navegar. NUNCA para liberar cadastro:
      // isto roda no navegador do titular, e o que roda la ele consegue forjar.
      window.addEventListener("unifokal:done", function (evento) {
        // outcome: "approved" | "pending" | "declined" | "failed" | "expired" | "analysis_incomplete"
        mostrarTelaDeEspera(evento.detail.outcome); // o desfecho OFICIAL vem do webhook
      });
    </script>
  </body>
</html>`);
  } catch (err) {
    next(err);
  }
});

// O QUE ESPERAR DE VOLTA: o widget desenha dentro de #identidade, conduz o usuario ate o fim e dispara
// "unifokal:done" no navegador quando terminar.
//
// O QUE NAO EXISTE, e e de proposito: rota de consulta do resultado com a sk_. O desfecho
// OFICIAL chega ao SEU BACKEND pelo webhook, voce grava, e o seu front pergunta ao SEU backend.
// O evento acima e best-effort: quem controla a pagina consegue FORJAR um "unifokal:done" com
// outcome "approved" e nada nele prova coisa alguma. Quem credita, libera ou aprova a partir dele
// esta confiando no navegador do proprio titular. Ele serve para a TELA, e so.
```

! **O evento do navegador serve para a tela, nunca para liberar nada.** O widget **dispara** `unifokal:state` a cada transição e `unifokal:done` no fim, e é assim que um aplicativo que abre a verificação num webview sabe a hora de fechar a tela. O contrato inteiro (o payload, os canais e o que o evento nunca carrega) está em [O Widget](https://unifokal.com/docs/widget#widget), que é a página dona do assunto. O que **não existe** é rota de consulta do resultado com a `sk_`: o desfecho oficial chega ao **seu backend** pelo webhook, você grava, e o seu front pergunta ao **seu** backend. Resultado que passa pelo navegador é resultado que o usuário pode forjar, e por isso a decisão não passa por lá.

PASSO 08 · RECEBA O WEBHOOK E VALIDE A ASSINATURA

Esta é a parte que mais sai errada e a que custa mais caro. A URL do seu webhook é alcançável por qualquer um na internet: **sem validação**, quem descobrir o endereço forja um POST com `"status": "approved"` e o seu sistema aprova quem não deveria. Os três exemplos abaixo validam antes de ler o corpo, comparam em **tempo constante** e fecham a janela de repetição. A explicação de cada linha está em [Valide a assinatura do webhook](https://unifokal.com/docs/webhooks#webhook-signature).

**TypeScript / Node** · Receber e validar o webhook · no seu servidor

```ts
// webhook.ts -> o endpoint que recebe o resultado. Este e o arquivo que decide se um
// estranho consegue aprovar quem quiser no seu sistema, entao ele valida ANTES de ler o corpo.
import crypto from "node:crypto";
import express from "express";

const SIGNING_SECRET = process.env.UNIFOKAL_WEBHOOK_SECRET;
if (!SIGNING_SECRET || SIGNING_SECRET.length < 32) {
  // O segredo e ESCOLHIDO POR VOCE quando cadastra o destino no painel, e tem no minimo
  // 32 caracteres. Use "openssl rand -hex 32". Segredo curto se
  // quebra fora do ar, sem passar por nos, e quem o quebra passa a forjar aprovacoes.
  throw new Error("UNIFOKAL_WEBHOOK_SECRET ausente ou curto demais.");
}

const JANELA_SEGUNDOS = 300;

interface Assinatura {
  t: number;
  v1s: string[];
}

/**
 * O header vem como "t=<epoch>,v1=<hex>" e pode trazer MAIS DE UM v1 ("t=...,v1=A,v1=B").
 * E assim que a troca de segredo acontece sem derrubar entrega. Um parse que devolva so o
 * primeiro (Object.fromEntries, por exemplo) faz o seu endpoint RECUSAR entregas legitimas
 * durante toda a janela de rotacao, e o sintoma aparece dias depois.
 */
function parseAssinatura(header: string | undefined): Assinatura | null {
  if (!header) return null;
  let t: number | null = null;
  const v1s: string[] = [];
  for (const par of header.split(",")) {
    const i = par.indexOf("=");
    if (i < 0) continue;
    const chave = par.slice(0, i).trim();
    const valor = par.slice(i + 1).trim();
    if (chave === "t") t = Number(valor);
    else if (chave === "v1" && valor) v1s.push(valor);
  }
  if (t === null || !Number.isFinite(t) || v1s.length === 0) return null;
  return { t, v1s };
}

/**
 * Comparacao em TEMPO CONSTANTE. Dois cuidados que separam "compara" de "compara de verdade":
 *   1. "===" sai no primeiro byte diferente e vira um oraculo: o atacante mede o tempo e
 *      descobre a assinatura byte a byte, sem nunca saber o segredo;
 *   2. timingSafeEqual LANCA quando os buffers tem tamanhos diferentes, e o candidato vem do
 *      header, ou seja, de fora. Uma assinatura curta forjada viraria excecao e 500 no seu
 *      servidor. Passar os dois lados por um sha256 iguala o tamanho (sempre 32 bytes) sem
 *      abrir mao do tempo constante.
 */
function iguaisEmTempoConstante(a: string, b: string): boolean {
  const ha = crypto.createHash("sha256").update(a).digest();
  const hb = crypto.createHash("sha256").update(b).digest();
  return crypto.timingSafeEqual(ha, hb);
}

const app = express();

app.post(
  "/webhooks/unifokal",
  // express.raw, NAO express.json: a assinatura cobre os BYTES CRUS recebidos. Reserializar o
  // JSON muda um espaco ou a ordem de uma chave e derruba toda validacao, sem erro visivel.
  express.raw({ type: "application/json", limit: "1mb" }),
  async (req, res) => {
    const corpoCru = req.body as Buffer;
    const assinatura = parseAssinatura(req.header("X-IDSAAS-Signature") ?? undefined);
    if (!assinatura) return res.status(400).json({ error: "assinatura ausente ou malformada" });

    // Janela anti-replay: rejeita um corpo CAPTURADO e re-postado depois. Isto nao conflita com
    // as nossas retentativas: cada tentativa (inclusive replay e reemissao pelo painel) e
    // assinada NA HORA DO ENVIO, com t novo, entao retry legitimo sempre passa aqui.
    const agora = Math.floor(Date.now() / 1000);
    if (Math.abs(agora - assinatura.t) > JANELA_SEGUNDOS) {
      return res.status(400).json({ error: "assinatura fora da janela" });
    }

    const esperado = crypto
      .createHmac("sha256", SIGNING_SECRET)
      .update(`${assinatura.t}.${corpoCru.toString("utf8")}`)
      .digest("hex");

    // Aceita se QUALQUER v1 casar: e o que sustenta a janela de rotacao de segredo.
    if (!assinatura.v1s.some((v1) => iguaisEmTempoConstante(v1, esperado))) {
      return res.status(400).json({ error: "assinatura invalida" });
    }

    // So DEPOIS de validar o corpo vira dado.
    const evento = JSON.parse(corpoCru.toString("utf8"));

    // Entrega e pelo menos UMA vez: retentativa, replay e reemissao manual trazem o mesmo
    // resultado de novo. Deduplique pelo id do evento (estavel por verificacao e por tipo).
    if (await jaProcessamos(evento.id)) return res.status(200).end();

    // Responda rapido e processe fora do ciclo: se voce demorar, nos re-tentamos e voce
    // recebe o mesmo evento outra vez enquanto ainda esta processando o primeiro.
    await enfileirar(evento);
    return res.status(200).end();
  },
);

// O QUE ESPERAR DE VOLTA: 200 em toda entrega legitima, 400 no que voce recusar. Do nosso lado, 2xx encerra o
// ciclo; timeout, erro de rede e 5xx viram retentativa (ate 3); 4xx e lido como recusa definitiva
// daquele destino, entao nao devolva 4xx por erro transitorio do seu banco: devolva 5xx e receba de novo.
//
// O nosso limite de espera pela SUA resposta e de 5 segundos: estourou, abortamos
// a conexao e a entrega vira retentativa: voce recebe o mesmo evento de novo, com o mesmo id. E por
// isso que o handler acima grava e enfileira em vez de processar dentro do ciclo.
```

**Python** · Receber e validar o webhook · no seu servidor

```python
# webhook.py -> o endpoint que recebe o resultado. Este e o arquivo que decide se um estranho
# consegue aprovar quem quiser no seu sistema, entao ele valida ANTES de ler o corpo.
#
# Framework a gosto: o que importa e (1) pegar os BYTES CRUS e (2) comparar em tempo constante.
import hashlib
import hmac
import json
import os
import time

from flask import Flask, request

SIGNING_SECRET = os.environ["UNIFOKAL_WEBHOOK_SECRET"].encode("utf-8")
if len(SIGNING_SECRET) < 32:
    # O segredo e ESCOLHIDO POR VOCE ao cadastrar o destino no painel, com no minimo
    # 32 caracteres ("openssl rand -hex 32"). Segredo curto se quebra
    # fora do ar, sem passar por nos, e quem o quebra passa a forjar aprovacoes assinadas.
    raise RuntimeError("UNIFOKAL_WEBHOOK_SECRET curto demais.")

JANELA_SEGUNDOS = 300

app = Flask(__name__)


def parse_assinatura(header: str | None) -> tuple[int, list[str]] | None:
    """Le "t=<epoch>,v1=<hex>". O header pode trazer MAIS DE UM v1 ("t=...,v1=A,v1=B"):
    e assim que a troca de segredo acontece sem derrubar entrega. Um parse que fique so com
    o primeiro (dict(par.split("=")) por exemplo) faz o seu endpoint RECUSAR entregas
    legitimas durante toda a janela de rotacao, e o sintoma aparece dias depois.
    """
    if not header:
        return None
    t = None
    v1s = []
    for par in header.split(","):
        chave, _, valor = par.partition("=")
        chave, valor = chave.strip(), valor.strip()
        if chave == "t":
            try:
                t = int(valor)
            except ValueError:
                return None
        elif chave == "v1" and valor:
            v1s.append(valor)
    if t is None or not v1s:
        return None
    return t, v1s


@app.post("/webhooks/unifokal")
def receber():
    # get_data() devolve os BYTES CRUS. Nao use request.json aqui: a assinatura cobre os bytes
    # recebidos, e reserializar o JSON muda um espaco ou a ordem de uma chave e derruba tudo.
    corpo_cru = request.get_data()
    lido = parse_assinatura(request.headers.get("X-IDSAAS-Signature"))
    if lido is None:
        return {"error": "assinatura ausente ou malformada"}, 400
    t, v1s = lido

    # Janela anti-replay: rejeita um corpo CAPTURADO e re-postado depois. Nao conflita com as
    # nossas retentativas: cada tentativa (inclusive replay e reemissao pelo painel) e assinada
    # na hora do envio, com t novo, entao retry legitimo sempre passa aqui.
    if abs(int(time.time()) - t) > JANELA_SEGUNDOS:
        return {"error": "assinatura fora da janela"}, 400

    esperado = hmac.new(
        SIGNING_SECRET,
        f"{t}.".encode("utf-8") + corpo_cru,
        hashlib.sha256,
    ).hexdigest()

    # hmac.compare_digest e a comparacao em TEMPO CONSTANTE da biblioteca padrao. Nunca use "==":
    # ele sai no primeiro byte diferente e vira um oraculo, e o atacante descobre a assinatura
    # byte a byte sem nunca saber o segredo. Aceita se QUALQUER v1 casar (janela de rotacao).
    if not any(hmac.compare_digest(v1, esperado) for v1 in v1s):
        return {"error": "assinatura invalida"}, 400

    # So DEPOIS de validar o corpo vira dado.
    evento = json.loads(corpo_cru)

    # Entrega e pelo menos UMA vez: retentativa, replay e reemissao manual trazem o mesmo
    # resultado de novo. Deduplique pelo id do evento (estavel por verificacao e por tipo).
    if ja_processamos(evento["id"]):
        return "", 200

    # Responda rapido e processe fora do ciclo: se voce demorar, nos re-tentamos e voce recebe
    # o mesmo evento outra vez enquanto ainda esta processando o primeiro.
    enfileirar(evento)
    return "", 200


# O QUE ESPERAR DE VOLTA: 200 em toda entrega legitima, 400 no que voce recusar. Do nosso lado, 2xx encerra o
# ciclo; timeout, erro de rede e 5xx viram retentativa (ate 3); 4xx e lido como recusa definitiva
# daquele destino, entao nao devolva 4xx por erro transitorio do seu banco: devolva 5xx e receba de novo.
#
# O nosso limite de espera pela SUA resposta e de 5 segundos: estourou, abortamos
# a conexao e a entrega vira retentativa: voce recebe o mesmo evento de novo, com o mesmo id. E por
# isso que o handler acima grava e enfileira em vez de processar dentro do ciclo.
```

E o mesmo cálculo no terminal, para quando a pergunta for "o problema é o meu segredo ou o meu código?":

**curl** · Conferir uma assinatura na mão · no terminal

```sh
#!/bin/sh
# Confere, na sua maquina, se o segredo cadastrado e o mesmo que assinou a entrega. E o teste
# que responde "e o meu segredo que esta errado, ou o meu codigo?" em trinta segundos.
#
# Salve os BYTES CRUS do corpo recebido em um arquivo. Nao passe o JSON por um formatador:
# a assinatura cobre os bytes, e um espaco a mais ja derruba a conferencia.

CORPO_ARQUIVO="corpo-recebido.json"

# O valor do header X-IDSAAS-Signature da entrega, colado como veio. Ele pode trazer MAIS DE UM
# v1 ("t=...,v1=A,v1=B"): e assim que a rotacao de segredo acontece sem derrubar entrega, e o
# laco abaixo aceita se QUALQUER um casar.
HEADER='t=1756143600,v1=6f0c9a1d2b3e4f50617283940a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7'
SEGREDO="$UNIFOKAL_WEBHOOK_SECRET"

T=$(printf '%s' "$HEADER" | tr ',' '\n' | sed -n 's/^t=//p')

# O material assinado e "<t>.<corpo cru>". O "cat" preserva os bytes exatos, inclusive a
# ultima quebra de linha, que um "printf" com variavel de shell comeria.
ESPERADO=$({ printf '%s.' "$T"; cat "$CORPO_ARQUIVO"; } \
  | openssl dgst -sha256 -hmac "$SEGREDO" -r | cut -d' ' -f1)

# Comparacao em TEMPO CONSTANTE sem primitiva de tempo constante no shell: aplique um HMAC com
# uma chave EFEMERA nos dois lados e compare os digests. O "=" do shell continua saindo no
# primeiro byte diferente, mas agora ele compara digests sob uma chave que o atacante nao
# conhece, entao o tempo nao ensina nada sobre a assinatura. E a mesma defesa que hmac.compare_digest
# e crypto.timingSafeEqual dao de graca nas outras duas formas: aqui ela e explicita.
CHAVE=$(openssl rand -hex 32)
mascara() { printf '%s' "$1" | openssl dgst -sha256 -hmac "$CHAVE" -r | cut -d' ' -f1; }

ALVO=$(mascara "$ESPERADO")
for V1 in $(printf '%s' "$HEADER" | tr ',' '\n' | sed -n 's/^v1=//p'); do
  if [ "$(mascara "$V1")" = "$ALVO" ]; then
    echo "assinatura confere"
    exit 0
  fi
done

echo "assinatura NAO confere"
exit 1

# O QUE ESPERAR DE VOLTA: "assinatura confere" e saida 0 quando o segredo do ambiente e o mesmo que
# cadastrou no painel. "assinatura NAO confere" aponta para uma destas tres causas, nesta
# ordem de frequencia: segredo do ambiente errado, corpo reformatado, ou o "t" do header
# trocado pelo horario de agora.
#
# Isto e DIAGNOSTICO. Em producao a validacao roda no seu servidor, nas formas acima.
```

### Os desfechos, e o que fazer com cada um

O campo `status` tem **seis** valores, e só seis. Trate os seis: um `default` que aprova é a forma mais rápida de transformar uma recusa nossa num cadastro liberado no seu sistema.

| status | o que significa | cobrado? |
| --- | --- | --- |
| approved | Identidade comprovada. Libere o que depende dela. | sim |
| denied | Reprovou em um portão (biometria, documento, coerência). Não libere. | sim |
| review | Nem aprovado nem reprovado: alguém seu precisa olhar. Existe justamente para não aprovar no automático o que pede julgamento humano. | sim |
| blocked | O documento (ou um sócio) está na sua lista de bloqueio. | não |
| pending | Ainda processando, ou uma fonte externa não respondeu. Não é desfecho final: aguarde o próximo evento da mesma verificação. | não |
| failed | Erro interno nosso. O usuário pode refazer. | não |

Ao lado do `status` vêm `recommendation` (`approve`, `review` ou `decline`) e `risk_level` (`low`, `medium` ou `high`). Os dois existem para você **apertar** a sua regra além da nossa: mandar para análise humana todo `approved` com `risk_level` alto acima de um certo valor de transação, por exemplo. O que não recomendamos é ler só o `score`: ele é um número comparável dentro de uma política, e a política pode mudar.

**TypeScript / Node** · Tratar os sete desfechos · no seu servidor

```ts
// Os sete valores de status sao o contrato inteiro. Trate os sete: um "default" que aprova
// e a forma mais rapida de transformar uma recusa nossa num cadastro liberado no seu sistema.
type Status = "approved" | "denied" | "pending" | "blocked" | "failed" | "review" | "consent_declined";

export async function aplicarDesfecho(evento: {
  event: string; // "verification.completed" | "verification.blocked" | ...
  data: {
    verification_id: string;
    reference_id: string | null;
    status: Status;
    score: number | null;
    risk_level: "low" | "medium" | "high" | null;
    recommendation: "approve" | "review" | "decline" | null;
    reason_code?: unknown;
  };
}): Promise<void> {
  const { status, reference_id: referenceId, verification_id: verificationId } = evento.data;

  switch (status) {
    case "approved":
      // Identidade comprovada. Libere o que depende dela.
      await liberarCadastro(referenceId, verificationId);
      return;

    case "denied":
      // Reprovou em um portao (biometria, documento, coerencia). Nao libere.
      await recusarCadastro(referenceId, verificationId);
      return;

    case "review":
      // Nem aprovado nem reprovado: alguem seu precisa olhar. E o desfecho que existe
      // justamente para nao aprovar no automatico o que pede julgamento humano.
      await enviarParaAnaliseHumana(referenceId, verificationId);
      return;

    case "blocked":
      // O documento (ou um socio) esta na SUA lista de bloqueio. Nao e cobrado.
      await recusarCadastro(referenceId, verificationId);
      return;

    case "pending":
      // Ainda processando, ou uma fonte externa nao respondeu. NAO e um desfecho final:
      // nao libere e nao recuse, aguarde o proximo evento da mesma verificacao.
      return;

    case "failed":
      // Erro do nosso lado. Nao e cobrado, e o usuario pode refazer.
      await pedirNovaTentativa(referenceId);
      return;

    case "consent_declined":
      // O titular optou por nao continuar na tela de consentimento. NAO e reprovacao: nao
      // recuse o cadastro. Nao e cobrado. Se ele reconsiderar, uma verificacao NOVA chega
      // num proximo evento.
      return;
  }
}

// "recommendation" (approve | review | decline) e a nossa sugestao, e "risk_level"
// (low | medium | high) e a leitura de risco. Os dois existem para voce apertar a sua regra
// alem da nossa: por exemplo, mandar para analise humana todo approved com risk_level "high"
// acima de um certo valor de transacao. O que voce NAO deve fazer e ler so o score: ele e um
// numero comparavel dentro de uma politica, e a politica pode mudar.

// O QUE ESPERAR DE VOLTA: cada verificacao chega uma vez em um desfecho final (approved, denied, review,
// blocked, failed ou consent_declined). "pending" pode chegar antes, e nao substitui o final.
// Como a entrega e pelo menos uma vez, escreva este trecho como upsert por verification_id:
// rodar duas vezes com o mesmo evento tem que dar o mesmo resultado.
```

**Python** · Tratar os sete desfechos · no seu servidor

```python
# Os sete valores de status sao o contrato inteiro. Trate os sete: um "else" que aprova e a
# forma mais rapida de transformar uma recusa nossa num cadastro liberado no seu sistema.
def aplicar_desfecho(evento: dict) -> None:
    dados = evento["data"]
    status = dados["status"]
    reference_id = dados.get("reference_id")
    verification_id = dados["verification_id"]

    if status == "approved":
        # Identidade comprovada. Libere o que depende dela.
        liberar_cadastro(reference_id, verification_id)

    elif status == "denied":
        # Reprovou em um portao (biometria, documento, coerencia). Nao libere.
        recusar_cadastro(reference_id, verification_id)

    elif status == "review":
        # Nem aprovado nem reprovado: alguem seu precisa olhar. E o desfecho que existe
        # justamente para nao aprovar no automatico o que pede julgamento humano.
        enviar_para_analise_humana(reference_id, verification_id)

    elif status == "blocked":
        # O documento (ou um socio) esta na SUA lista de bloqueio. Nao e cobrado.
        recusar_cadastro(reference_id, verification_id)

    elif status == "pending":
        # Ainda processando, ou uma fonte externa nao respondeu. NAO e desfecho final:
        # nao libere e nao recuse, aguarde o proximo evento da mesma verificacao.
        return

    elif status == "failed":
        # Erro do nosso lado. Nao e cobrado, e o usuario pode refazer.
        pedir_nova_tentativa(reference_id)

    elif status == "consent_declined":
        # O titular optou por nao continuar na tela de consentimento. NAO e reprovacao: nao
        # recuse o cadastro. Nao e cobrado. Se ele reconsiderar, uma verificacao NOVA chega
        # num proximo evento.
        return

    else:
        # Status desconhecido: a politica publicada permite VALOR NOVO em enum de saida sem
        # trocar a versao. Registre e segure, nunca aprove por omissao.
        registrar_status_desconhecido(verification_id, status)


# "recommendation" (approve | review | decline) e a nossa sugestao, e "risk_level"
# (low | medium | high) e a leitura de risco. Os dois existem para voce apertar a sua regra alem
# da nossa: por exemplo, mandar para analise humana todo approved com risk_level "high" acima de
# um certo valor de transacao. O que voce NAO deve fazer e ler so o score: ele e um numero
# comparavel dentro de uma politica, e a politica pode mudar.

# O QUE ESPERAR DE VOLTA: cada verificacao chega uma vez em um desfecho final (approved, denied, review,
# blocked, failed ou consent_declined). "pending" pode chegar antes, e nao substitui o final.
# Como a entrega e pelo menos uma vez, escreva este trecho como upsert por verification_id:
# rodar duas vezes com o mesmo evento tem que dar o mesmo resultado.
```

QUANDO O SEU SISTEMA FICA FORA DO AR

Nada se perde. Liste o que não foi entregue e redispare: você recebe o corpo **exato** que teria recebido, com assinatura nova.

**curl** · Recuperar entregas que falharam · no terminal

```sh
# O seu sistema ficou fora do ar e os webhooks falharam. Nada se perde: liste o que nao foi
# entregue e redispare. Voce recebe o corpo EXATO que teria recebido, com assinatura nova.
# As respostas vao comentadas para o bloco inteiro poder ser colado no terminal.

# 1) o que ficou para tras (o teto por pagina e 20, mesmo que voce peca mais; pagine com page=2, 3, ...)
curl -sS "https://api.unifokal.com/v1/webhook-events?page=1&limit=20" \
  -H "Authorization: Bearer $UNIFOKAL_SECRET_KEY"

# O QUE ESPERAR DE VOLTA (200): uma entrada por verificacao mais evento, ja idempotente.
# {
#   "data": [
#     { "event_type": "completed",
#       "verification_id": "ver_01J8ZQ7V1X2Y3Z4A5B6C7D8E9F",
#       "reference_id": "usr_8842",
#       "target_url": "sua.api",
#       "status": "error", "status_code": null, "attempts": 3,
#       "failed_at": "2026-08-25T21:14:09.312Z" }
#   ],
#   "page": 1, "totalPages": 1, "totalItems": 1
# }

# 2) redispare em lote (minimo 1, maximo 20 ids por chamada; 30 chamadas por minuto na conta)
curl -sS -X POST https://api.unifokal.com/v1/webhook-events/replay \
  -H "Authorization: Bearer $UNIFOKAL_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"verification_ids": ["ver_01J8ZQ7V1X2Y3Z4A5B6C7D8E9F"]}'

# O QUE ESPERAR DE VOLTA (200): resultado POR verificacao. Erro de um item nao derruba os demais.
# {
#   "results": [
#     { "verification_id": "ver_01J8ZQ7V1X2Y3Z4A5B6C7D8E9F", "resent": true, "http_status": 200 }
#   ],
#   "resent": 1, "failed": 0
# }

# Reenviou com sucesso? A verificacao sai da listagem de erros.
#
# O target_url que a listagem devolve e o HOST do destino: o endereco completo do seu webhook so
# aparece para quem administra os endpoints, no painel.
#
# O DESTINO e o da propria entrega (o mesmo alvo que a listagem rotula), nunca "o webhook que
# o flow aponta hoje": cada linha e a promessa de entrega a UM endereco, e redirecionar o reenvio
# marcaria como entregue uma linha que nunca chegou ao destino dela. Trocou de endpoint e o antigo
# nao existe mais? O item volta { "resent": false, "error": "target_unavailable" } e o resgate e
# reemitir pelo painel, que cria a entrega do endereco novo.
# Corpo expurgado pelo prazo de retencao (ou anterior ao carimbo de corpo): "payload_unavailable".
```

### Receitas por jornada

Cada jornada da página de [soluções](https://unifokal.com/solucoes) vira um flow de sandbox com três chamadas, sempre nesta ordem. Primeiro o **webhook de sandbox**: flow sem webhook do mesmo ambiente é recusado com `webhook_required`. Depois o **flow**, com os módulos da jornada que estão à venda hoje. Por fim a **sessão**, com o `reference_id` obrigatório.

As duas primeiras chamadas são as que o painel faz quando você cria o webhook e o flow na tela, e usam a sessão do painel. A terceira roda no seu servidor, com a chave secreta de sandbox. Troque cada valor entre `<` e `>` pelo seu, ou pelo `id` devolvido na chamada anterior. O segredo do webhook tem pelo menos 32 caracteres.

#### KYC de pessoa

A jornada completa, com o porquê de cada passo, está em [/solucoes/kyc-pessoa](https://unifokal.com/solucoes/kyc-pessoa).

1\. POST Cadastre o webhook de sandbox com a sessão do painel

```
curl -sS -X POST https://api.unifokal.com/v1/dashboard/webhook-endpoints \
  -H "Authorization: Bearer $UNIFOKAL_PAINEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "<webhook_url>",
        "secret": "<webhook_secret>",
        "environment": "sandbox"
      }'
```

2\. POST Crie o flow da jornada com a sessão do painel

```
curl -sS -X POST https://api.unifokal.com/v1/flows \
  -H "Authorization: Bearer $UNIFOKAL_PAINEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "KYC de pessoa",
        "subject_type": "person",
        "modules": [
          "cpf_ocr",
          "face",
          "liveness",
          "face_unica",
          "cpf_receita",
          "pep_sancoes"
        ],
        "webhook_endpoint_id": "<webhook_endpoint_id>"
      }'
```

3\. POST Crie a sessão de verificação no seu servidor

```
curl -sS -X POST https://api.unifokal.com/v1/verification-sessions \
  -H "Authorization: Bearer $UNIFOKAL_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "flow_id": "<flow_id>",
        "reference_id": "<reference_id>"
      }'
```

#### KYB de empresa

A jornada completa, com o porquê de cada passo, está em [/solucoes/kyb-empresa](https://unifokal.com/solucoes/kyb-empresa). Ficam fora do flow, por estarem "Em breve": Certidão trabalhista (CNDT), Inscrição estadual, Representante vinculado à empresa.

1\. POST Cadastre o webhook de sandbox com a sessão do painel

```
curl -sS -X POST https://api.unifokal.com/v1/dashboard/webhook-endpoints \
  -H "Authorization: Bearer $UNIFOKAL_PAINEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "<webhook_url>",
        "secret": "<webhook_secret>",
        "environment": "sandbox"
      }'
```

2\. POST Crie o flow da jornada com a sessão do painel

```
curl -sS -X POST https://api.unifokal.com/v1/flows \
  -H "Authorization: Bearer $UNIFOKAL_PAINEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "KYB de empresa",
        "subject_type": "company",
        "modules": [
          "cnpj_ocr",
          "cnpj_cadastro",
          "cnpj_socios",
          "credito_pgfn"
        ],
        "webhook_endpoint_id": "<webhook_endpoint_id>"
      }'
```

3\. POST Crie a sessão de verificação no seu servidor

```
curl -sS -X POST https://api.unifokal.com/v1/verification-sessions \
  -H "Authorization: Bearer $UNIFOKAL_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "flow_id": "<flow_id>",
        "reference_id": "<reference_id>"
      }'
```

#### Antifraude transacional

A jornada completa, com o porquê de cada passo, está em [/solucoes/antifraude-transacional](https://unifokal.com/solucoes/antifraude-transacional). Ficam fora do flow, por estarem "Em breve": Reautenticação facial, Dispositivo PIX, Sinais do aparelho.

1\. POST Cadastre o webhook de sandbox com a sessão do painel

```
curl -sS -X POST https://api.unifokal.com/v1/dashboard/webhook-endpoints \
  -H "Authorization: Bearer $UNIFOKAL_PAINEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "<webhook_url>",
        "secret": "<webhook_secret>",
        "environment": "sandbox"
      }'
```

2\. POST Crie o flow da jornada com a sessão do painel

```
curl -sS -X POST https://api.unifokal.com/v1/flows \
  -H "Authorization: Bearer $UNIFOKAL_PAINEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Antifraude transacional",
        "subject_type": "person",
        "modules": [
          "transacao"
        ],
        "webhook_endpoint_id": "<webhook_endpoint_id>"
      }'
```

3\. POST Crie a sessão de verificação no seu servidor

```
curl -sS -X POST https://api.unifokal.com/v1/verification-sessions \
  -H "Authorization: Bearer $UNIFOKAL_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "flow_id": "<flow_id>",
        "reference_id": "<reference_id>",
        "transaction": {
          "type": "payment",
          "amount_cents": 1900,
          "external_id": "<external_id>"
        }
      }'
```

#### Background check

A jornada completa, com o porquê de cada passo, está em [/solucoes/background-check](https://unifokal.com/solucoes/background-check). Ficam fora do flow, por estarem "Em breve": Antecedentes Estaduais, Processos Judiciais, Antecedentes Criminais, Mandados e Interpol, OFAC - Sanções Internacionais.

1\. POST Cadastre o webhook de sandbox com a sessão do painel

```
curl -sS -X POST https://api.unifokal.com/v1/dashboard/webhook-endpoints \
  -H "Authorization: Bearer $UNIFOKAL_PAINEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "<webhook_url>",
        "secret": "<webhook_secret>",
        "environment": "sandbox"
      }'
```

2\. POST Crie o flow da jornada com a sessão do painel

```
curl -sS -X POST https://api.unifokal.com/v1/flows \
  -H "Authorization: Bearer $UNIFOKAL_PAINEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Background check",
        "subject_type": "person",
        "modules": [
          "cpf_ocr",
          "face",
          "liveness",
          "pep_sancoes",
          "midia_adversa"
        ],
        "webhook_endpoint_id": "<webhook_endpoint_id>"
      }'
```

3\. POST Crie a sessão de verificação no seu servidor

```
curl -sS -X POST https://api.unifokal.com/v1/verification-sessions \
  -H "Authorization: Bearer $UNIFOKAL_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "flow_id": "<flow_id>",
        "reference_id": "<reference_id>"
      }'
```

### Humano verificado: qual peça para qual caso

A prova de humano verificado tira a decisão do canal em que o pedido chegou e a entrega ao titular, com a credencial dele, e devolve a prova disso com o mínimo de dado pessoal. Ela não é um módulo só: são peças que você liga no flow, cada uma respondendo a uma pergunta.

! **Em breve.** Os módulos `passkey`, `face_reauth` e `atestado_humano` estão com a venda pausada: aparecem na [tabela de preços](https://unifokal.com/precos) com o selo "Em breve", e o `create` de flow os recusa até a abertura. O contrato abaixo é o que a API já emite, para você planejar a integração.

**As cinco perguntas, e a peça que responde cada uma.**

1. **Há uma pessoa viva diante da câmera agora?** A prova de vida, módulo `liveness` ([documento, face match e prova de vida](https://unifokal.com/docs/modulos/identidade#modulos-identidade)).
2. **É a mesma pessoa que você aprovou antes?** A reautenticação facial, módulo `face_reauth`, contra a matrícula da própria conta ([Reautenticação facial](https://unifokal.com/docs/modulos/face-reauth#modulo-face-reauth)). O resultado é semelhança com a matrícula, e se lê como confirmação de que é a mesma pessoa.
3. **É a credencial que ela vinculou à conta?** A passkey do titular, vinculada no onboarding com `passkey_bind` e conferida pelo módulo `passkey` ([Aprovação de ato com passkey](https://unifokal.com/docs/modulos/passkey#modulo-passkey)).
4. **Ela aprova este ato, por um canal que o pedido não controla?** O bloco `act` na sessão, o `step_up` do [gate transacional](https://unifokal.com/docs/modulos/transacao#modulo-transacao) e o link de [confirmação fora de banda](https://unifokal.com/docs/api-rest#confirmacao-fora-de-banda), que você manda pelo canal que já tinha cadastrado.
5. **Como provar isso a um terceiro sem entregar a identidade?** O [atestado de pessoa verificada](https://unifokal.com/docs/modulos/atestado-humano#modulo-atestado-humano), que a plataforma [confere e apresenta](https://unifokal.com/docs/webhooks#atestado-de-pessoa-verificada) em parte.

**Qual embalagem para qual caso.**

| Caso | Embalagem | O que você liga |
| --- | --- | --- |
| Pagamento, troca de chave Pix ou alteração de cadastro pelo seu app ou site | Aprovação verificada | `passkey` com o bloco `act`, ou o `step_up` do `transacao` |
| Pedido que chegou por chamada, vídeo, mensagem ou e-mail | Confirmação fora de banda | o link com `purpose` igual a `confirmation`, sobre `passkey` ou `face_reauth` |
| Rede social, relacionamento, avaliações e comunidade | Pessoa real | `liveness` com `atestado_humano`, e `face_unica` para dizer que não há outra conta no seu serviço |

**O que volta.** Tudo chega no webhook assinado. O bloco do módulo `passkey` em `check_details` diz qual chave aprovou (`passkey_id`), como ela foi vinculada (`bound_by` e `assurance`), se ela é sincronizável (`backup_eligible` e `backup_state`), se houve verificação do usuário no aparelho (`user_verified`), qual ato ela assinou (`act_digest` e `act_kind`) e a evidência conferível sem nos consultar. Na confirmação, `data.purpose` e `data.act` casam a resposta com o seu pedido. No atestado, `data.attestation` traz o token assinado. Os eventos próprios são `passkey.bound`, `passkey.revoked` e `act.rejected`, este quando o titular diz que não reconhece o pedido.

**Os limites que mudam a sua decisão.**

- O atestado não é anônimo perante a emissora: a UNIFOKAL, que emite, consegue ligar o atestado à verificação que o originou. O que ele garante é que dois serviços que recebem atestados não ligam as contas entre si por meio dele.
- A passkey sincronizada não fica num aparelho só: ela acompanha o titular nos aparelhos em que ele usa o mesmo gerenciador de senhas. Quando o seu caso exige um aparelho só, ligue no flow a opção "Só credencial de um aparelho", e a chave sincronizável é recusada com `passkey_not_device_bound`. Sem a opção, leia `backup_eligible` no webhook.
- Só a chave vinculada com rosto e prova de vida (`assurance` igual a `identidade`) aprova ato e confirma pedido. A vinculada só com prova de vida (`presenca`) prova continuidade da mesma pessoa, e nunca serve de fator.
- O link de confirmação é transporte, não fator: quem o abre ainda precisa da passkey ou do rosto do titular. Mande-o sempre pelo canal que você já tinha, nunca pela conversa em que o pedido chegou.

Enquanto os módulos estão pausados, integre e teste o resto do fluxo no [sandbox](https://unifokal.com/docs/ambientes#sandbox), com o mesmo webhook que vai receber estes blocos.

## API REST

<https://unifokal.com/docs/api-rest>

### API REST (4 endpoints públicos)

A superfície server-to-server é **enxuta de propósito**: com a `sk_` você **cria a sessão** (ou **emite um link hospedado**, se não for montar o widget) e cuida da **confiabilidade do webhook**. Todo o resto (flows, verificações, blocklist, destinos de webhook, billing) se gerencia pela [plataforma](https://unifokal.com/login).

**POST** `/v1/verification-sessions`

```
POST /v1/verification-sessions
Authorization: Bearer sk_live_…        // a chave define o ambiente (sk_test = sandbox)
Content-Type: application/json
// SEM header de idempotência: o "reference_id" do corpo é a chave, do nosso lado.

// CORPO. Contato do titular NÃO entra aqui: o widget pergunta e dispara o código.
{
  "flow_id": "flow_01J8…",             // obrigatório
  "reference_id": "user_123",          // OBRIGATÓRIO: o id do usuário NO SEU sistema. Volta igual no
                                       // webhook, e é a CHAVE DE IDEMPOTÊNCIA da criação, comparada
                                       // BYTE A BYTE: "User_42" e "user_42" criam DUAS sessões.
                                       // Id OPACO e estável por pessoa. No HISTÓRICO DE TRANSAÇÕES e
                                       // na LISTA DE BLOQUEIO, maiúscula e minúscula são o MESMO
                                       // titular (User_42 = user_42); mande sempre a mesma grafia
  "policy": { "allow_pep": false },    // opcional: política por sessão; chave desconhecida é 400 unknown_policy_key
  "return_url": "meuapp://kyc/pronto", // opcional: para onde o widget manda o titular no fim. Serve
                                       // ao aplicativo nativo, que abre a verificação no navegador
                                       // do sistema. https ou o esquema do seu app; a volta NÃO
                                       // carrega resultado (quem conta o desfecho é o webhook)
  "origin_tag": "google-ads:black-friday", // opcional: o rótulo SEU da origem da jornada (campanha,
                                       // canal, tela). Volta exato no 201 e no webhook; até 64
                                       // caracteres, sem espaço nem arroba. Nunca dado pessoal

  // OPCIONAIS DE TRANSAÇÃO. Só em flow que contenha um módulo consumidor de transação;
  // sem consumidor, 422 transaction_not_supported. Os dois são EXCLUSIVOS entre si:
  // mandar os dois juntos é 422 transaction_conflict. Detalhes logo abaixo do corpo.
  "transaction": {                     // opcional: UM evento, a forma do Gate Transacional
    "type": "payment",                 // OBRIGATÓRIO: deposit | withdraw | transfer | payment | bet |
                                       // settlement | reversal | bet_profit | bet_loss
    "external_id": "pay_9f2c",         // OBRIGATÓRIO: é a idempotência durável do evento
    "amount_cents": 250000,
    "currency": "BRL",
    "occurred_at": "2026-09-11T14:02:00.000Z"
  },
  "transactions": [ /* … */ ],         // opcional: o LOTE. Proibido em flow com o gate síncrono
                                       // (422 batch_not_supported_for_gate): lá use o singular

  // OPCIONAL DE MONITORAMENTO CONTÍNUO: override POR SESSÃO do monitoring_enabled do flow.
  "monitoring": { "enabled": true }    // ausente ou null herda o flow; chave desconhecida aqui é 400 unknown_monitoring_key
}
// Não existe campo "phone": o telefone é sempre digitado pelo titular no widget.
// Mandar "phone" aqui devolve 422 phone_not_accepted (recusamos com nome, nunca ignoramos).

→ 201  // a resposta INTEIRA, não só três campos
{
  "id": "vs_01J8…",                    // a credencial do widget. É o único valor que vai ao navegador
  "flow_id": "flow_01J8…",
  "environment": "production",
  "reference_id": "user_123",
  "status": "requires_input",
  "expires_at": "2026-08-25T18:15:00.000Z",
  "created_at": "2026-08-25T18:00:00.000Z",
  "modules": ["cpf_ocr", "face", "liveness"],   // os módulos do flow, na ordem
  "livemode": true,
  "expires_in": 900                    // SEGUNDOS de vida da sessão: 900 = 15 minutos
}
// "origin_tag" entra nesta resposta só quando você o mandou no corpo, com o valor exato.
// "decision" entra nesta resposta só quando o flow tem o módulo conta: o veredito do evento
//   (allow | step_up | deny), o risk_score de 0 a 100 em faixas de 20, os códigos dos motivos
//   (lista aberta: trate o que não conhecer como informativo), o verification_id e o step_up.
// "policy" entra nesta resposta só quando o flow tem pep_sancoes ou impedidos_apostar: a política
//   efetiva com que a sessão nasceu e a origem de cada valor (flow, session ou session_inherited).
// Criar NÃO cobra: o dinheiro sai quando a verificação TERMINA, e o saldo vem no webhook.

// ERROS (envelope { "error", "message" } em todos):
//   401 invalid_api_key · 403 email_not_verified · 403 credential_type_not_allowed
//   404 flow_not_found  · 422 flow_not_live      · 402 insufficient_credit
//   422 email_not_accepted · 422 phone_not_accepted (contato do titular não entra aqui)
//   422 transaction_not_supported (flow sem módulo consumidor de transação)
//   422 session_not_supported (flow que só recebe alertas do monitoramento transacional)
//   422 origin_tag_not_supported (origin_tag junto do bloco de transação: ali não há jornada a rotular)
//   422 transaction_conflict (transaction e transactions juntos) · 422 batch_too_large
//   422 batch_not_supported_for_gate (lote em flow com gate síncrono)
//   422 transaction_required (flow com o Gate Transacional e corpo SEM o bloco; vale também no link hospedado)
//   Os demais códigos da ingestão de transação estão na página do Gate Transacional, um por regra:
//   external_id_required · amount_too_large · currency_not_supported · event_too_old · reference_id_charset
//   pii_shaped_value · settlement_status_invalid · unknown_settles_reference · already_settled
//   pending_lifecycle_not_enabled · 429 daily_ingest_cap_reached (teto diário por organização)
//   429 sandbox_limit_reached (teto mensal) · 429 rate_limited (teto por minuto)
//   429 spend_cap_reached (orçamento diário configurado no painel; Retry-After até 00:00 UTC)
//   409 idempotency_conflict (mesmo reference_id em voo) · 422 idempotency_key_reuse (mesmo reference_id, corpo outro)
// Nenhum deles cria sessão, e nenhum é cobrado. O exemplo com cada corpo está em Autenticação.
```

! **Você nunca envia o contato do titular.** Nem `email`, nem `phone`: mandar qualquer um dos dois falha com `422 email_not_accepted` ou `422 phone_not_accepted`, e **nenhuma sessão é criada nem cobrada**. Recusamos com nome em vez de aceitar e ignorar, para você não seguir acreditando que travou o destino do código. Quem pergunta o e-mail e o telefone à pessoa, e dispara o código em seguida, é o widget. Um flow com `email_otp` cria sessão com o mesmo corpo de qualquer outro. Guardamos o endereço **cifrado**: a resposta e o webhook levam só derivados (domínio e máscara), nunca o endereço completo.

**`origin_tag` é o rótulo da origem da jornada, e quem escolhe o valor é você**. Ele responde de qual campanha, canal ou tela a pessoa veio, sem tabela paralela do seu lado: o valor volta **exato** no `201` e no `data.origin_tag` do webhook da verificação, e a sessão renovada pelo widget herda o mesmo rótulo. São de 1 a 64 caracteres entre letras, dígitos, ponto, sublinhado, dois-pontos e hífen, começando por letra ou dígito. E-mail e URL não cabem, de propósito: **nunca coloque dado pessoal aqui**. A caixa é preservada, e ferramentas de análise tratam `Google` e `google` como valores diferentes, então prefira minúsculas. Como o campo entra no corpo, a idempotência vale para ele: repetir o `reference_id` com outro `origin_tag` é `422 idempotency_key_reuse`. Sem o campo, nenhuma resposta muda. Junto do bloco de transação ele é recusado com `422 origin_tag_not_supported`, porque a ingestão não tem jornada a rotular.

**`transaction` e `transactions` são a ingestão do Gate Transacional**, e só existem para flow que contenha um **módulo consumidor de transação**. Flow sem consumidor responde `422 transaction_not_supported`, e os dois campos são **exclusivos entre si**: mandar os dois no mesmo corpo é `422 transaction_conflict`.

No singular, `transaction` é **um** evento e `external_id` é **obrigatório**: ele é a idempotência durável do evento, e é o que faz o seu retry não virar uma segunda cobrança. A resposta `201` traz a forma de ingestão, com o id `ing_` e os contadores, e num flow com **gate síncrono** ela traz também o **veredito do gate**, que é o ponto do módulo: você tem um pagamento parado esperando, e a resposta vem na mesma chamada. **Ela vem sempre**, inclusive no seu retry do mesmo `external_id`, que recebe de volta o mesmo veredito da primeira vez e não é cobrado de novo. E no **sandbox** o gate decide também, de forma determinística, para você exercitar os três ramos antes de ir para produção. Os detalhes estão na página do [Gate Transacional](https://unifokal.com/docs/modulos/transacao).

! **Num flow que contenha o Gate Transacional, o lote é proibido: `transactions` responde `422 batch_not_supported_for_gate`**. A razão é de produto e não de implementação: um gate síncrono decide **um** pagamento, e um lote não teria veredito nenhum para devolver. Nesse flow use `transaction`, no singular. Leia isto antes de montar a integração: é a diferença entre um laço que funciona e um lote que nunca passa.

Fora do flow com gate, `transactions` é o **lote**, com a mesma condição de consumidor. Ele aceita de **1** até o teto por chamada, e acima disso responde `422 batch_too_large` **com o teto no corpo do erro**, para você não precisar descobrir o número por tentativa. O dedupe é por `external_id`: reenviar o mesmo lote não duplica evento nem cobra de novo.

**`monitoring` é `{ enabled: boolean }`, o override por sessão do Monitoramento Contínuo.** Ausente ou `null`, a sessão **herda** o `monitoring_enabled` do flow. `false` significa "esta sessão não inscreve" e é **sempre aceito**, mesmo com o módulo desligado, porque é um pedido que o produto honra em qualquer estado. `true` inscreve o titular aprovado. Chave desconhecida dentro do bloco é **400** `unknown_monitoring_key`, nunca ignorada: toggle de compliance aceito e ignorado faria você acreditar que monitora enquanto ninguém monitora.

O `422 monitoring_unavailable` **deixou de acontecer em 11 de setembro de 2026**, quando a venda do módulo abriu. Ele só volta se o módulo for desligado de novo, e nesse caso vale de novo a mesma regra de sempre: `enabled: false` continua passando, e só `true` é recusado.

**GET** `/v1/webhook-events`

```
GET /v1/webhook-events?page=1&limit=20
Authorization: Bearer sk_live_…

→ 200 { "data": [ { "event_type": "completed",
                    "verification_id": "ver_…", "reference_id": "user_123",
                    "target_url": "sua.api", "status": "error",
                    "status_code": null, "attempts": 3,
                    "failed_at": "2026-07-28T21:14:09.312Z" } ],
        "page": 1, "totalPages": 1, "totalItems": 1 }
// UMA entrada por verificação+evento (idempotente): reemissões não duplicam;
// attempts acumula o total de falhas. Ciclo: até 3 tentativas ("sending") →
// 2xx marca "success"; esgotou sem 2xx (4xx/5xx ou sem resposta) marca "error"
// e entra NESTA lista. Entregue com sucesso (original OU replay) sai da lista.
// failed_at = quando a última tentativa falhou (a data do erro).
// target_url = o HOST do destino desta entrega, que é exatamente onde o replay vai tentar de
// novo. O endereço completo do seu webhook nunca volta em resposta de leitura: ele aparece só
// para quem administra os endpoints, no painel. Trocou o webhook do flow depois disso? Este
// campo continua mostrando o destino antigo, porque é dele que a entrega falhada está falando.
// limit: máximo 20 por página (pagine com page=2, 3…)
// resource_id: filtro OPCIONAL. Sem ele, a listagem responde exatamente como sempre respondeu.
//
// Nem todo evento pertence a uma verificação. A revogação de um dispositivo, por exemplo, nasce
// de um ato sobre o aparelho e não de um fluxo de verificação: nela o verification_id vem null e
// o resource_id carrega a identificação do recurso. Use esse valor para filtrar aqui e para
// resgatar no replay.
```

**POST** `/v1/webhook-events/replay`

```
POST /v1/webhook-events/replay
Authorization: Bearer sk_live_…

{ "verification_ids": ["ver_aaa…"], "resource_ids": ["pxd_xxx:1"] }
// os dois campos são opcionais e pelo menos um é obrigatório;
// o máximo de 20 vale para a SOMA dos dois

→ 200 { "results": [
          { "verification_id": "ver_aaa…", "resent": true,  "http_status": 200 },
          { "resource_id": "pxd_xxx:1",    "resent": false, "http_status": null,
            "error": "nothing_to_replay" } ],
        "resent": 1, "failed": 1 }
// cada item volta pelo eixo em que foi pedido: verification_id ou resource_id, nunca os dois.
// cada item reenvia os webhooks com erro daquela verificação: o corpo EXATO salvo,
// com assinatura nova, no MESMO destino daquela entrega (o target_url que a listagem
// mostra). Reenviou com sucesso? A verificação sai da listagem de erros.
// Erro de um item não derruba os demais. Rate limit: 30 chamadas/min por conta.
//
// error possíveis por item:
//   nothing_to_replay   nenhuma entrega com erro nessa verificação (nada a fazer)
//   target_unavailable  o destino daquela entrega não existe mais (endpoint apagado
//                       ou desativado). Reaponte o flow e reemita pelo painel: isso
//                       cria a entrega do endereço NOVO. O replay nunca redireciona
//                       para outro endereço em nome de uma entrega que prometeu ir
//                       para o antigo.
//   payload_unavailable o corpo daquela entrega já foi expurgado pelo prazo de retenção
```

i Seu sistema ficou fora do ar? Liste com `GET /v1/webhook-events` e redispare pelos `verification_ids`: você recebe exatamente o mesmo corpo que teria recebido, sem nenhuma mudança. Os eventos que não pertencem a uma verificação, como a revogação de um dispositivo, são resgatados pelo `resource_id` que veio no próprio evento.

### Link de verificação hospedado

Nem toda integração quer montar o widget. Quando o titular não está no seu site (cobrança por e-mail, onboarding por WhatsApp, atendimento no balcão, QR num contrato), você emite um **link hospedado**: a página é nossa, o fluxo é o mesmo do widget e você só entrega a `url`.

Três diferenças importam antes de escolher. O **link vive horas** e a **sessão só nasce no resgate** (e aí vive os mesmos 900 segundos de sempre), então um link não vira sessão vencida na caixa de entrada de ninguém. O **token** volta **em claro uma única vez**, porque guardamos só o hash: não existe reexibição, e perder o token é emitir outro. E **emitir link não cobra nada**: quem cobra é a verificação que nascer do resgate, com o preço do flow que você escolheu.

**POST** `/v1/verification-links`

```
POST /v1/verification-links
Authorization: Bearer sk_live_…        // a chave define o ambiente (sk_test = sandbox)
Content-Type: application/json

{
  "flow_id": "flow_01J8…",             // obrigatório, e o flow precisa estar live NESTE ambiente
  "reference_id": "user_123",          // obrigatório: o seu id do titular; volta igual no webhook
  "expires_in": 86400,                 // opcional: validade do LINK em segundos (mínimo 60)
  "policy": { "ubo_max_paid_nodes": 3 }  // opcional: mesma política da criação de sessão
}
// Não existem campos "email" nem "phone" aqui, pelo mesmo motivo da criação de sessão: a própria
// página hospedada pergunta o contato ao titular (422 email_not_accepted / phone_not_accepted).

→ 201
{
  "id": "vl_01J8…",                    // o id do link (não é segredo)
  "url": "https://…/v/#vlt_…",         // ENTREGUE ISTO ao titular (o token vai no FRAGMENTO,
                                       // que o navegador nao envia ao servidor: nao cai em log)
  "token": "vlt_…",                    // em claro UMA vez: não há como reexibir
  "expires_in": 86400,                 // segundos de vida do LINK (a sessão só nasce no resgate)
  "environment": "production", "livemode": true,
  "flow_id": "flow_01J8…", "reference_id": "user_123",
  "contact_masked": "pe***@exemplo.com",   // só quando o flow verifica e-mail; o endereço fica cifrado
  "status": "pending", "expires_at": "…", "opened_at": null,
  "consumed_at": null, "session_id": null, "revoked_at": null,
  "created_via": "api", "created_by_member_id": null, "created_at": "…"
}

// ERROS (mesmo envelope { "error", "message" }):
//   401 invalid_api_key · 403 email_not_verified · 403 credential_type_not_allowed
//   404 flow_not_found  · 422 flow_not_live
//   422 email_not_accepted · 422 phone_not_accepted · 429 rate_limited (60 por minuto)
//   422 expires_in_too_long (acima do teto da casa: recusamos com nome, nunca cortamos em silêncio)
//   422 environment_mismatch ("environment" diferente do da chave: o ambiente É a chave)
//   422 policy_module_not_in_flow · 422 policy_ubo_cap_above_flow
//   422 session_not_supported (flow que só recebe alertas do monitoramento transacional)
// Emitir link não cobra nada, e link não resgatado expira sozinho.
```

i **Sem idempotência nesta rota, de propósito.** Selar a resposta a guardaria no banco, e a resposta aqui carrega o segredo do link. Repetir a chamada só cria outro link, que expira sozinho e não custa nada: o risco de guardar o segredo é maior que o de emitir um link a mais.

### Confirmação fora de banda

Um pedido chegou por chamada, por vídeo ou por mensagem: liberar um pagamento, trocar o telefone de uma conta, assinar uma contratação. Quem pede diz ser o seu cliente, e o rosto e a voz na tela parecem os dele. A **confirmação fora de banda** tira a decisão dessa conversa: você manda um link pontual pelo canal que já tinha cadastrado para aquela pessoa, e só ela confirma, com a passkey dela ou com a reautenticação facial com prova de vida.

! **Em breve.** A confirmação usa os módulos [Aprovação de ato com passkey](https://unifokal.com/docs/modulos/passkey#modulo-passkey) e [Reautenticação facial](https://unifokal.com/docs/modulos/face-reauth#modulo-face-reauth), que estão com a venda pausada. O que está descrito abaixo é o contrato que a API já emite, para você planejar a integração.

**Quando usar.** No financeiro, antes de liberar um pagamento pedido fora do fluxo normal. Na central de atendimento, antes de trocar e-mail, telefone ou senha de quem ligou. Na contratação remota, antes de aceitar a assinatura de quem apareceu só na chamada. Em todos, a regra é a mesma: quem inicia é você, pelo canal que você já tinha, com um pedido que só existe naquele momento.

**Como mandar pelo painel.** No detalhe de uma verificação da pessoa, ou na linha dela em Sessões, use **Confirmar identidade agora**. Escolha o flow que confirma (só aparecem os flows ativos com passkey ou com reautenticação facial), escreva o resumo do pedido, com até 140 caracteres, e crie. A tela mostra o link, o QR, o botão de copiar, o prazo de 10 minutos e o estado ao vivo: aguardando, aberto, em andamento, confirmado, não confirmado, recusado pelo titular, expirado ou revogado.

**Como mandar pela API.** É a mesma rota do [link hospedado](https://unifokal.com/docs/api-rest#link-hospedado), com `purpose` igual a `confirmation` e o bloco `act`, que descreve o pedido. O `act.external_id` é obrigatório pela API: é o id do pedido no seu sistema, e volta no webhook para você casar a resposta com o pedido.

```
POST /v1/verification-links
Authorization: Bearer sk_live_…
Content-Type: application/json

{
  "flow_id": "flow_01J8…",             // flow ativo com passkey ou face_reauth, neste ambiente
  "reference_id": "user_123",          // a pessoa que vai confirmar (a mesma do cadastro)
  "purpose": "confirmation",
  "act": {
    "kind": "pix_transfer",            // obrigatório, até 40 caracteres
    "summary": "Transferência de 12.000,00 reais para Fornecedor X",  // obrigatório, até 140
    "amount_cents": 1200000,           // opcional, inteiro em centavos
    "currency": "BRL",                 // opcional, só junto do valor
    "counterparty": "Fornecedor X",    // opcional, até 80 caracteres
    "external_id": "pedido_789"        // obrigatório pela API: o id do pedido no seu sistema
  },
  "expires_in": 600                    // opcional: padrão e máximo de 600 segundos
}

→ 201
{
  "id": "vl_01J8…",
  "purpose": "confirmation",
  "url": "https://…#t=vlt_…",          // ENVIE ISTO pelo canal cadastrado da pessoa
  "token": "vlt_…",                    // em claro uma vez só, como em todo link
  "expires_in": 600,
  "act_kind": "pix_transfer",
  "act_digest": "9f2c…",               // 64 hex: o MESMO digest que chega no webhook
  "reference_id": "user_123", "status": "pending", "expires_at": "…",
  "environment": "production", "livemode": true, "created_via": "api", "…": "…"
}

// ERROS próprios da confirmação (o resto é o vocabulário do link hospedado):
//   422 act_required (faltou o bloco act com kind e summary)
//   422 act_external_id_required (pela API, o external_id do pedido é obrigatório)
//   422 act_not_accepted (act só vale com purpose confirmation)
//   422 act_hostile_char · act_kind_invalid · act_summary_invalid
//   422 act_counterparty_invalid · act_amount_invalid · act_currency_invalid
//   400 unknown_act_key (campo que o bloco act não conhece)
//   422 expires_in_too_long_for_confirmation (acima de 600 segundos)
//   422 confirmation_requires_human_proof (o flow não tem passkey nem face_reauth)
//   422 no_factor_enrolled (a pessoa ainda não tem o fator que o flow pede)
//   429 confirmation_rate_limited (cinco por pessoa por hora; lê o Retry-After)
//   409 confirmation_unavailable (a página de confirmação está fora agora; tente de novo)
```

**Envie pelo canal que você já tinha cadastrado para esta pessoa. Nunca pelo chat da chamada ou da conversa em que o pedido chegou.** Pode ser e-mail, mensagem ou SMS para o contato do cadastro. A pessoa abre o link, vê a sua marca, o resumo do pedido e o prazo, e escolhe entre confirmar com o fator dela ou dizer **Não reconheço este pedido**.

**O que volta.** Quando a pessoa confirma, o seu webhook recebe `verification.completed` com `data.purpose` igual a `confirmation` e o bloco `data.act` com `kind`, `digest` e `external_id`. O `digest` é o mesmo `act_digest` da criação do link: um digest só do link ao webhook. O `check_details` diz qual fator confirmou. Com passkey, o bloco do módulo `passkey` traz `user_verified` e a evidência conferível da assinatura. Com o rosto, o bloco do módulo `face_reauth` traz a semelhança à matrícula feita no cadastro: é semelhança, e não se lê como autenticação.

```
// verification.completed de uma confirmação (trecho de data)
{
  "status": "approved",
  "reference_id": "user_123",
  "purpose": "confirmation",
  "act": { "kind": "pix_transfer", "digest": "9f2c…", "external_id": "pedido_789" },
  "check_details": [ { "module": "passkey", "passed": true, "outcome": "approved",
                       "data": { "passkey_id": "spk_01J9ZC6Q8R3M2V7K4T5N1B0XHD", "bound_by": "cadastro",
                                 "assurance": "identidade", "backup_eligible": false, "backup_state": false,
                                 "user_verified": true, "act_digest": "9f2c…", "act_kind": "pix_transfer",
                                 "evidence": { "format": "unifokal/passkey-assertion@1" },
                                 "model_version": "passkey-v1" } } ]
}
```

Se a pessoa disser que não reconhece o pedido, o webhook recebe `act.rejected`, a sessão fecha e não há cobrança. Trate como sinal para parar o pedido e falar com ela pelo canal cadastrado, e não como prova de fraude: quem recebeu o link encaminhado também consegue recusar. Se a confirmação não fechar, o `verification.completed` chega reprovado ou em revisão, e a revisão de uma confirmação nunca vira aprovação pelo painel: a decisão do pedido é sua, pelo seu canal. Se o prazo acabar sem resposta, nenhum webhook de verificação é enviado, e o estado aparece no painel como expirado.

**Os limites.** O link de confirmação vale no máximo 10 minutos, e a sessão que nasce dele vale o que resta desse prazo, sem renovação: quem perdeu o prazo pede um novo link. Existe no máximo um pedido aberto por pessoa, e o novo substitui o anterior, que é revogado. São no máximo cinco pedidos por pessoa por hora. O resumo aparece para a pessoa exatamente como você escreveu, então caractere invisível, de controle ou de direção do texto é recusado. No sandbox, a cerimônia da passkey é simulada na própria página, e o bloco do módulo traz `simulated`.

**O que ela não é.** O link é transporte, não fator: quem o abre ainda precisa da passkey ou do rosto da pessoa. Por isso e-mail nunca é fator de autenticação aqui, e um clique num link nunca confirma nada sozinho, como a norma pública NIST SP 800-63B-4, seção 3.1.3.1, exige. E a confirmação não é detecção de deepfake na chamada: ela não olha a chamada, ela tira a decisão de dentro dela.

### Spec OpenAPI e tipos

Tudo o que esta página promete existe também como **OpenAPI 3.1**: um documento **gerado do código do backend** e comparado com ele em teste a cada mudança, cobrindo as 4 rotas públicas, a rota de capacidades, o corpo assinado do webhook e o código estável de cada erro, por status. Spec escrita à mão envelhece e mente; esta não tem como.

SPEC · SITE (URL estável)

https://unifokal.com/docs/openapi.json

SPEC · API (mesmo documento)

https://api.unifokal.com/v1/openapi.json

Esta página declara o spec no `<head>` (`rel="describedby"` e `rel="service-desc"`), então ferramentas o encontram sozinhas. Para gerar tipos TypeScript do contrato, uma linha basta:

**curl** · Gerar os tipos do contrato · no terminal

```sh
# O contrato inteiro, legivel por maquina: OpenAPI 3.1 GERADA do codigo do backend e comparada
# com ele em teste a cada mudanca (spec que mente e pior que nao ter spec).
curl -sS https://unifokal.com/docs/openapi.json -o unifokal-openapi.json

# Tipos TypeScript do contrato em uma linha, sem SDK para instalar nem manter:
npx openapi-typescript@7 unifokal-openapi.json -o unifokal-api.d.ts

# O QUE ESPERAR DE VOLTA: o arquivo unifokal-api.d.ts com os tipos de request, resposta e erro das
# rotas publicas e do corpo do webhook. No seu codigo:
#   import type { paths, webhooks } from "./unifokal-api";
# A API serve o MESMO documento em https://api.unifokal.com/v1/openapi.json (com ETag: revalidar custa um 304),
# e o catalogo vivo de modulos e precos esta em GET https://api.unifokal.com/v1/capabilities.
```

**GET** `/v1/capabilities`

A **descoberta viva** da API: módulos, preços em centavos, estado `available`/`coming_soon` e o grafo `requires`, lidos do banco (a mesma fonte da vitrine de preços, nunca uma segunda lista). A credencial é **opcional**: anônimo recebe o catálogo geral com o preço-base público; com a sua `sk_` no `Authorization`, a **mesma URL** devolve o contrato efetivo da sua organização (preço negociado, ambiente e `livemode` da chave).

```
curl -sS https://api.unifokal.com/v1/capabilities

→ 200 { "api_version": "v1",
        "spec_url": "https://api.unifokal.com/v1/openapi.json",
        "docs_url": "https://unifokal.com/docs",
        "llms_url": "https://unifokal.com/llms.txt",
        "webhook_schema_version": 1,
        "context": { "authenticated": false, "environment": null,
                     "livemode": null, "pricing": "list" },
        "modules": [ { "module": "cpf_receita", "group": "validation",
                       "status": "available", "unit_cents": …,
                       "is_addon": false,
                       "requires": ["cpf_ocr", "face", "liveness"] }, … ] }
// unit_cents = preço em centavos lido do banco (o MESMO da vitrine de preços);
// o preço do flow é a soma dos módulos ligados. coming_soon = ainda não vendível.
// Com -H "Authorization: Bearer $UNIFOKAL_SECRET_KEY": o MESMO formato, com
// "context": { "authenticated": true, "pricing": "contract" } e o preço efetivo
// da SUA organização (contrato negociado aparece aqui, não o de tabela).
```

### Coleção importável

A API inteira numa coleção pronta para o seu cliente HTTP, no formato aberto **Postman Collection v2.1**, que o Postman, o Insomnia e o Bruno importam. Ela é gerada da mesma especificação OpenAPI a cada publicação, então acompanha a API: traz as rotas públicas da chave secreta com o corpo mínimo e a autenticação já montados, em pastas na ordem de uso e com o link para a seção de cada uma nesta documentação.

**A coleção não carrega chave nem script.** A autenticação usa a variável `UNIFOKAL_SECRET_KEY`, que você define no ambiente do seu cliente HTTP (ou no cofre dele), nunca dentro da coleção. Nenhum código roda ao importar.

1. Baixe o arquivo ou importe pelo endereço [https://unifokal.com/docs/unifokal.postman_collection.json](https://unifokal.com/docs/unifokal.postman_collection.json).
2. Crie um ambiente com `UNIFOKAL_SECRET_KEY` (a sua chave de sandbox, `sk_test`) e `UNIFOKAL_FLOW_ID` (um flow do mesmo ambiente).
3. Rode. O catálogo público responde sem chave nenhuma, e as outras chamadas usam a chave do ambiente.

[Baixar a coleção](https://unifokal.com/docs/unifokal.postman_collection.json)

### Para agentes de IA

Integrando com um agente (Claude, Cursor, Copilot ou o seu)? Aponte-o para [https://unifokal.com/llms.txt](https://unifokal.com/llms.txt): é o briefing, no formato **llmstxt.org**, do que um agente **não infere do spec**: retry seguro e idempotência, a semântica dos códigos de erro, o webhook como fonte da verdade, o sandbox determinístico e o que nunca fazer com a chave secreta. A versão expandida, com os 10 pontos por extenso, está em [https://unifokal.com/llms-full.txt](https://unifokal.com/llms-full.txt).

O contrato executável fica no [spec OpenAPI 3.1](https://unifokal.com/docs/api-rest#openapi) e a descoberta viva de módulos e preços em [`GET /v1/capabilities`](https://unifokal.com/docs/api-rest#get-capabilities). Os três arquivos são gerados do código e comparados com ele em teste: o que o seu agente lê é o que a API faz.

Para dar ao seu assistente a documentação inteira de um produto de uma vez, use os [pacotes de contexto](https://unifokal.com/docs/pacotes-para-ia#pacotes-para-ia): cada um é esta documentação em Markdown, recortada por produto, com o tamanho medido. E toda página daqui tem a própria versão em Markdown, na mesma URL com `.md` no fim.

O ponto de partida por máquina é o catálogo de API em [`/.well-known/api-catalog`](https://unifokal.com/.well-known/api-catalog) (RFC 9727): de lá saem o spec e esta documentação, em uma leitura. E o nosso [`robots.txt`](https://unifokal.com/robots.txt) nomeia os principais rastreadores de resposta, um a um, para dizer com todas as letras o que já valia: esta superfície é aberta para leitura por máquina. A área autenticada e as rotas de API seguem fora, para todo mundo igual.

## 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_...,...
```

## Sandbox, produção e lista de bloqueio

<https://unifokal.com/docs/ambientes>

### Lista de bloqueio

Marque um titular e novas verificações dele voltam `blocked` (evento `verification.blocked`) e **não são cobradas**. O bloqueio pode valer para a **conta inteira**, em todos os flows, ou **só para um flow**: a lista da conta vale em todos, e a do flow acrescenta. O valor marcado é guardado cifrado e nunca volta em texto puro.

Além de CPF, CNPJ e documento estrangeiro, a lista aceita o `reference_id`, o identificador do titular no seu sistema, que você envia ao criar a sessão. É a chave que existe antes de haver documento: serve para barrar quem abandonou a verificação no meio ou um titular que você já decidiu não atender. A partir dele, a próxima verificação daquele `reference_id` termina em `blocked`, sem cobrança, e o webhook `verification.blocked` traz `reason` igual a `reference_blocklisted`. Ao titular, a tela informa apenas que a empresa registrou uma restrição. Use sempre o mesmo `reference_id` para o mesmo titular, de preferência com letras, números e os sinais `. _ : @ -`. A comparação **não distingue maiúsculas de minúsculas**: `User_42` e `user_42` são o mesmo titular, então quem você bloqueou numa grafia fica bloqueado em todas. O bloqueio por referência vale só na sua organização e nunca é compartilhado com empresas vinculadas.

No painel, você bloqueia a partir da **linha da verificação**, que é onde acabou de ver o caso, e desbloqueia o titular em **Sessões**. Os interruptores de bloqueio automático são **configuração do flow**, junto do resto da política.

Cada bloqueio dura o prazo do seu tipo de identificador. CPF, CNPJ e `reference_id` valem enquanto você mantiver o bloqueio: CPF e CNPJ não passam a designar outra pessoa, e o `reference_id` é do seu sistema. O documento estrangeiro sai da lista **10 anos** depois do bloqueio, e o prazo recomeça se você bloquear de novo, porque um documento de viagem não segue válido por mais tempo do que isso. Quando um bloqueio passa **24 meses** sem barrar ninguém, o painel mostra ao lado dele o selo **Sem uso há mais de 24 meses**, para você revisar se ele ainda faz sentido. O selo não remove nada: a decisão continua sua.

### Sandbox

O sandbox roda o pipeline completo, grátis e com teto mensal (`livemode:false`). CPFs determinísticos disparam cada resultado para você testar o webhook sem capturar nada:

```
# 1. DESFECHO (documento, face, liveness)
000.000.000-00 → approved
000.000.000-01 → inconclusivo em CADA modulo (score 60, passed null): a verificacao
                 termina em review, com ou sem face e liveness no flow.
000.000.000-02 → denied (reprovado)
000.000.000-04 → review agora, approved depois (dois webhooks, ver abaixo)
000.000.000-05 → review agora, denied depois (dois webhooks, ver abaixo)
qualquer outro  → approved

# 2. CONTEUDO (screening, compliance, risco): o sufixo escolhe O QUE o modulo devolve,
#    e um achado forte leva a verificacao para review. A matriz esta na secao de cada
#    modulo: PEP (66/77/88/99), impedidos de apostar (02/01/33), midia adversa (02/33),
#    coerencia cadastral (02/01/33), ip_risk (33/44/55), email_risk (33/44/55),
#    ip_risk_plus (33/44), email_risk_plus (33/44/55), telefone_risco (33/44/55).
#    Nos cinco de risco o sufixo troca apenas o dado; o desfecho sai da lista 1 acima.

# 3. DOCUMENTO DE VIAGEM (doc_global): num flow global nao ha CPF lido do documento,
#    entao o sufixo viaja no campo opcional `document` do
#    POST /v1/verification-sessions/:id/submit. Os dois ultimos digitos escolhem o
#    cenario, e aqui eles mudam o DESFECHO:
000.000.000-00 → aprova
000.000.000-01 → pede nova foto (mrz_not_found)
000.000.000-02 → vai para review (mrz_tampered)
000.000.000-41 → vai para review (mrz_visual_mismatch)
000.000.000-42 → vai para review (mrz_expiry_unverified)
000.000.000-43 → pede outro arquivo (invalid_media)
000.000.000-45 → aprova com o passaporte vencido (document.expired: true)

# 4. CADASTRO DE CNPJ (cnpj_socios, cnpj_cadastro, cnpj_receita, cnpj_participacoes):
#    o CNPJ de teste terminado em 84 simula a resposta PARCIAL da base.
#    A empresa esta ativa e o modulo aprova, mas os dados nao vem: o item do
#    modulo em check_details sai com data.company null e completeness: partial.

# 5. DOCUMENTO BRASILEIRO (cpf_ocr): o mesmo campo document do submit.
000.000.000-41 → vai para review (mrz_visual_mismatch, o impresso nao bate com a MRZ)
000.000.000-45 → aprova com o documento vencido (document.expired: true)
```

Nas famílias 1 e 2 o CPF de teste vai no campo `document` do `POST /v1/verification-sessions/:id/submit`. O widget submete sem esse campo, então uma sessão de sandbox concluída pelo widget sempre termina aprovada: para ver o `-01`, o `-02`, o `-04` e o `-05`, chame o submit pela API.

Os sufixos `-04` e `-05` testam o webhook de atualização sem esperar um caso real. A verificação termina em review agora e você recebe o primeiro `verification.completed`. Cerca de 60 segundos depois (até 75), ela é decidida de novo e chega um segundo `verification.completed`, com um `id` de evento novo, terminado em `_r<número>`, e o status final: approved no `-04`, denied no `-05`. Se alguém decidir a verificação pelo painel antes disso, vale a decisão da pessoa e o segundo evento não sai. Nada disso é cobrado.

### Sandbox e produção: o que muda

O pipeline é o mesmo e o formato da resposta é o mesmo. O que muda está nesta tabela, e vale a pena ler antes do go-live, não depois: metade destes itens só aparece quando você troca a chave.

| diferença | sandbox | produção |
| --- | --- | --- |
| Criar a chave de produção passa por portões que o sandbox não tem | POST /v1/keys com environment sandbox devolve 201 na primeira tentativa. | environment production exige, em cascata: a conta com o e-mail confirmado, a verificação da conta concluída (403 organization_cnpj_required) e a verificação em duas etapas ativa (403 mfa_enrollment_required, depois 403 mfa_code_required pedindo o código no corpo). |
| Flow e destino de webhook são POR AMBIENTE | O flow_id e o endpoint de webhook que você criou no sandbox existem só no sandbox. | Trocar apenas a chave no dia do go-live devolve 404 flow_not_found. Recrie o flow e o destino do webhook em produção e use o novo flow_id. |
| Produção exige as imagens; o sandbox não | O submit com apenas o documento de teste, sem nenhuma mídia, conclui a verificação e dispara o webhook. | O mesmo corpo devolve action_required, com missing_image por módulo, e nenhum webhook sai enquanto a jornada não terminar: as fotos entram pelo widget. |
| Motivo de recusa: o sandbox alcança um subconjunto do catálogo | Os sufixos cobrem aprovado, selfie_nao_confere, vida_nao_confirmada, restricao_do_cliente e verificacao_nao_confirmada. | O motivo mais comum na vida real é doc_ilegivel (documento que o OCR não conseguiu ler), e ele não tem sufixo no sandbox. Trate o campo reason_code como aberto dentro do catálogo, nunca como a lista dos cinco que você viu. |
| Teto de chamadas por minuto | 240 criações de sessão por minuto. | 60 criações de sessão por minuto. O teto real vem sempre no header X-RateLimit-Limit, e o 429 traz Retry-After: leia os headers em vez de fixar o número. Uma rota pode ter mais de um teto ao mesmo tempo, e o header traz sempre o que restringe primeiro, com o X-RateLimit-Remaining correspondente: o número que você lê é o que vai barrar, e não é preciso descobrir qual teto é. |
| Retenção da mídia | 7 dias. | 180 dias. |
| Cobrança | Nada é cobrado, e o teto mensal de sessões é o único limite de volume. | Criar a sessão não cobra e não reserva nada: a cobrança acontece quando a verificação termina, e o saldo atualizado chega no seu webhook. A criação só confere se o saldo cobre o máximo que a verificação pode custar: o preço do flow mais, quando o flow tem cobrança por sócio ou por empresa da cadeia, essas parcelas no teto. Sem saldo para isso, ela devolve 402 insufficient_credit com o saldo e o valor que falta na mensagem. Um 402 de flow com o módulo Proteção de conta nunca aciona a recarga automática. |

**Não existe rota `sk_` que leia o resultado de uma verificação.** Tentar` GET /v1/verifications/{id}` com a chave secreta devolve `403 credential_type_not_allowed`: aquela rota é do painel. O resultado chega ao seu servidor pelo [webhook](https://unifokal.com/docs/webhooks#webhooks), e `GET /v1/webhook-events` lista o que ainda não foi entregue.

## Conta, dados e privacidade

<https://unifokal.com/docs/conta-e-dados>

### Exportação de dados

Você leva os dados da sua organização embora quando quiser, pela aba **Exportar dados** do painel. O pacote é **assíncrono**: o pedido entra numa fila, um worker monta um ZIP com arquivos NDJSON e um manifesto com a soma `sha256` de cada arquivo, e o pacote pronto fica disponível por **7 dias**. Depois disso ele é expurgado sozinho.

A fundação disto é a **portabilidade da LGPD**: o art. 18 dá ao titular, entre outros, o direito à portabilidade dos dados (inciso V), e o art. 19, § 3º manda entregar cópia eletrônica integral em formato que permita a utilização subsequente. Você é o controlador desses pedidos, e esta rota é a ferramenta com que o nosso DPA (Anexo II, item 8) promete que você consegue respondê-los; os Termos (item 15.4) somam a janela de 30 dias de exportação após o encerramento da conta.

```
POST   /v1/data-exports        pede um pacote (202; um pedido vivo por vez)
GET    /v1/data-exports        lista os pedidos do ambiente (nunca entrega URL)
GET    /v1/data-exports/{id}   o pacote pronto, com URLs de download de 120s
DELETE /v1/data-exports/{id}   expurga o pacote antes do prazo (o registro fica)

# credencial: sessao do painel (papel owner ou admin). A sk_ nao alcanca estas rotas.
```

O corpo do `POST` aceita `environment` (obrigatório), `scope` (`organization` inteiro ou uma `verification` única), o período `from`/`to` e `include` com os recortes `verifications`, `billing`, `config` e `pld` (omitido = os três primeiros; o registro de PLD/FT é opt-in, só no recorte da organização, e a tela Exportar dados o oferece marcado). A configuração sai **sem segredos**: chave e segredo de webhook nunca entram no pacote. Mídia bruta (fotos e documentos) também não sai no lote.

**Cada entrega de URLs vira evento.** A listagem nunca pré-assina nada; é o `GET` do item que gera as URLs (validade de 120 segundos) e registra `export.downloaded` na trilha de conformidade da conta, ao lado de `export.requested` e `export.purged`. O expurgo apaga o armazenamento **antes** de marcar a linha: não existe janela em que o registro diz expurgado e o arquivo ainda existe.

### Encerramento de conta

A conta se encerra pela aba **Encerrar conta** do painel, e só o **dono** consegue. O pedido tem três travas: digitar o nome da organização exatamente como aparece no painel, confirmar com o código do aplicativo autenticador, e não ter saldo negativo (dívida se quita antes de sair). Saldo **positivo** expira no encerramento, como dizem os Termos (item 6.6); as quatro hipóteses de devolução do mesmo item podem ser alegadas no pedido e entram em revisão manual.

```
POST /v1/account/closure         abre o pedido (202; carencia de 30 dias)
GET  /v1/account/closure         o pedido vivo, ou null (conta saudavel)
POST /v1/account/closure/cancel  cancela durante a carencia (MFA de novo)

# credencial: sessao do painel, papel owner. MFA conferido dentro da transacao.
```

O pedido abre uma **carência de 30 dias**, copiada para o registro no ato: quem pediu mantém o prazo que valia no dia. Durante ela a conta fica em somente-leitura (dá para entrar, ler e exportar; não dá para operar), a recarga automática é desligada e uma exportação completa é disparada sozinha, com os quatro recortes: verificações, faturamento, configuração e o registro de PLD/FT. Cancelar exige o segundo fator de novo e devolve a conta ao normal.

No fim da carência, a execução apaga primeiro o armazenamento de mídia, depois os dados do tenant tabela por tabela, e por fim anonimiza a organização. O que sobra é o **recibo**, gravado no próprio pedido e no evento `account.closed`: a contagem de linhas apagadas por tabela, o que foi retido e sob qual base legal, e o desfecho do saldo. O registro do encerramento não se apaga: é a prova, sua e nossa, de que ele aconteceu.

### Privacidade e consentimento

Antes de qualquer captura, o widget mostra à pessoa um **aviso automático** sobre o tratamento dos dados (o que é coletado, por quanto tempo é guardado e que as imagens não treinam inteligência artificial). Você **não precisa passar versão de termos** nem registrar consentimento no seu código: a versão vigente é definida pelo servidor e o consentimento fica registrado automaticamente, uma vez por sessão.

Leia o texto que a pessoa vê em [Aviso ao Usuário Final](https://unifokal.com/legal/aviso-titular). Como Cliente, o uso da plataforma é regido pelos [Termos de Uso](https://unifokal.com/termos) e pela [Política de Privacidade](https://unifokal.com/privacidade).

## Painel de operação

<https://unifokal.com/docs/painel-de-operacao>

### Painel de operação

Três telas do painel respondem ao que quem opera a integração pergunta todo dia: onde as sessões param, o que a integração chamou e quanto a conta pode gastar. Elas ficam no grupo **Operação** do menu, ao lado de Verificações e Sessões, e nenhuma delas tem rota na superfície da chave secreta: são telas de painel, lidas com a sessão de um membro.

**Funil.** A escada da jornada por ambiente e, se quiser, por flow: sessão criada, titular abriu a verificação, captura enviada, decisão emitida, aprovada. Para cada etapa você vê quantas sessões chegaram, quantas pararam ali e o tempo mediano de quem parou, além das decisões por dia na mesma janela. A escada é cumulativa (cada etapa é menor ou igual à anterior) e as cinco contagens de parados somam o total de sessões, provado por teste de propriedade no servidor. A janela padrão é de 14 dias e o máximo é 90. Os números vêm de uma consulta agregada, então valem para uma conta de qualquer tamanho: não há recorte de amostra. O gráfico da página inicial do painel lê a mesma série.

**Requisições.** O registro do que a sua integração chamou nos últimos 30 dias, com filtro por identificador, por chave ou membro e por período. Cada linha traz o `request_id` (o mesmo `x-request-id` que a resposta devolve, e que você pode enviar), a credencial usada, o método, a rota, o status, a duração e o código de erro do envelope. **Nunca o corpo, os cabeçalhos ou a query string**: a tabela não tem coluna onde isso caberia, e a rota gravada é o padrão (`/v1/verifications/:id`), nunca o caminho com o identificador do recurso. É a diferença deliberada contra o histórico que guarda a carga inteira: um registro com CPF dentro seria um segundo banco de dado pessoal. Para correlacionar uma chamada no suporte, cite o `request_id`.

**Orçamento.** O teto diário de gasto, por ambiente, para a organização e, se quiser, por chave. O sandbox não fatura e não tem orçamento. O teto nasce desligado; ligar é ato do proprietário da conta, com o código do aplicativo autenticador. A tela mostra o gasto do dia, avisa ao passar de 80% do teto e diz o que está agendado. **Diminuir vale na hora. Aumentar, ou desligar, passa a valer na virada do dia, às 00:00 UTC**: uma sessão de painel indevida não consegue gastar o orçamento de hoje. O teto de uma chave só aperta o da organização, nunca o afrouxa. Zero suspende o escopo. O gasto por chave conta as operações cobradas das sessões que a chave criou, inclusive quando a decisão da verificação sai depois: a sessão guarda a chave que a criou, a renovação herda essa chave e o link hospedado passa adiante a chave que o emitiu.

**Teto de operações.** No mesmo registro do orçamento, você pode limitar também quantas operações cobradas saem por dia, para a organização ou para uma chave. A contagem é a mesma do gasto: cada operação cobrada conta uma vez. O teto de operações segue a mesma regra de prazo (baixar vale na hora, subir ou apagar vale na virada do dia) e o mesmo aviso ao passar de 80%. Ao alcançá-lo, a criação de sessão responde `429 volume_cap_reached`, com o mesmo `Retry-After` até 00:00 UTC.

Quando o orçamento do dia acaba, a criação de sessão passa a responder `429 spend_cap_reached`, com o cabeçalho `Retry-After` em segundos até 00:00 UTC, quando o contador vira. Nada é cobrado nessa recusa. Quem nunca configurou um orçamento nunca recebe este código.

**O que o orçamento faz, exatamente.** Ele é uma porta de admissão: barra o que vem depois de o gasto do dia alcançar o teto. Uma verificação já admitida segue até o fim e é cobrada quando o fluxo termina, como sempre. Na prática, o total do dia pode passar do teto pelo que estava em andamento no instante em que ele foi alcançado. Se o seu controle precisa ser exato ao centavo, trate o orçamento como o freio e o `429 spend_cap_reached` como o sinal de parar de abrir sessões.

```
HTTP/1.1 429 Too Many Requests
Retry-After: 18342

{
  "error": "spend_cap_reached",
  "message": "The daily spend budget configured for this organization is exhausted. It resets at 00:00 UTC, or raise it in the dashboard (a raise takes effect the next day)."
}

# Trate como pausa, não como retentativa: espere o Retry-After ou suba o teto no painel
# (a subida vale no dia seguinte). O código é estável; a mensagem pode mudar.
```

**Simular política.** Na tela **Motor**, você testa um ajuste do flow sobre as verificações que já foram decididas, antes de mudar de verdade: aceitar ou não pessoa politicamente exposta, tolerar ou não o impedimento de apostas e tirar um módulo do flow. A simulação usa a evidência guardada e a mesma decisão do produto: **não cobra, não consulta nenhuma fonte e não abre foto**. O resultado diz quantas verificações mudariam de desfecho e em que direção (mais rígido, mais permissivo ou outro tipo de mudança), com a taxa sobre as que puderam ser refeitas, e sempre mostra ao lado quantas ficaram fora da conta e por quê. Ajustes que exigiriam rodar um módulo de novo aparecem desligados, com o motivo. Uma simulação por vez por ambiente, e a janela vai até 90 dias, dentro dos últimos 180.

### Lista de permissão

A lista de permissão reduz o desafio da prova de vida para titulares que a sua empresa já conhece. Ela vale nos flows com a fricção **adaptativa** ligada: no modo fixo, todo titular recebe o desafio completo, com ou sem permissão. E ela vale só para o próprio titular, no contexto em que ele já foi verificado: fora dele, o desafio é o de sempre.

**O que ela nunca dispensa.** A verificação de identidade continua inteira. Rosto, documento, prova de vida, os módulos do flow, a situação cadastral, sanções e pessoas politicamente expostas decidem exatamente como decidiriam sem a permissão, e a cobrança é a mesma. A [lista de bloqueio](https://unifokal.com/docs/ambientes#blocklist) sempre vence: uma referência bloqueada não recebe permissão, e um titular permitido que for bloqueado depois é barrado como qualquer outro.

**De onde ela nasce.** De uma verificação **aprovada**, aberta no painel em Verificações ou em Sessões. A chave da permissão é o `reference_id` que o seu servidor envia ao criar a sessão: o titular nunca escolhe a própria referência. Verificação sem referência, ou que não passou por prova de vida, não origina permissão.

**Quem concede.** Proprietário ou administrador com a **capacidade própria** de conceder, que só o proprietário da conta dá, inclusive para si mesmo, na tela de Segurança. Toda concessão pede o código do aplicativo autenticador. Revogar não pede código e vale a partir da próxima prova de vida do titular. Não existe rota da chave secreta para a lista: conceder exige uma pessoa, a capacidade e o segundo fator.

**Por quanto tempo, e onde.** 7, 30 ou 90 dias, ou até o fim do prazo de guarda da evidência da verificação de origem, nunca mais do que 180 dias depois dela. A permissão vence sozinha. Ela vale na conta inteira (todos os flows do ambiente) ou só no flow da verificação de origem, e sandbox e produção têm listas separadas.

**Trilha.** Cada concessão, revogação, vencimento e apagamento fica registrado com quem fez e quando, com o motivo em código e sem dado do titular, e a tela mostra quantas vezes cada permissão foi usada. Um pedido de exclusão do titular encerra a permissão dele.

### Consulta em lote por planilha

Na tela de consulta avulsa, a aba **Em lote** recebe um arquivo CSV e roda uma consulta avulsa por linha, no mesmo tipo de consulta e com a mesma finalidade para o arquivo inteiro. Cada linha é exatamente a consulta avulsa de um documento: o mesmo preço, a mesma finalidade registrada com o nome de quem enviou, os mesmos limites diários e a mesma regra de o dado sair uma vez. O lote não cria verificação e não consulta a sua lista de bloqueio, como a consulta avulsa.

**Formato.** Texto em UTF-8, separado por vírgula ou por ponto e vírgula, com o cabeçalho `reference_id,document` e um documento por linha. O `reference_id` é a sua referência (de 1 a 64 caracteres entre letras, dígitos, ponto, hífen, sublinhado e dois pontos) e não pode se repetir no arquivo. O `document` é um CPF ou um CNPJ, com ou sem pontuação, conforme o tipo de consulta. Um arquivo com qualquer linha inválida é recusado inteiro, com o número da linha, antes de qualquer cobrança.

```
reference_id,document
cliente-001,529.982.247-25
cliente-002,111.444.777-35
```

**Limites.** O arquivo sobe direto para o armazenamento, com teto de 5 MB e de 5.000 linhas. Em produção, o que vale no dia são os limites da consulta avulsa: o arquivo só entra se couber inteiro no que resta do dia para você e para a conta (o limite por membro hoje é de 100 consultas por dia). Senão, o envio é recusado com o mesmo código da consulta avulsa, `lookup_daily_cap`, `lookup_member_daily_cap` ou `lookup_distinct_daily_cap`, e a mensagem diz quanto resta. O saldo precisa cobrir todas as linhas, senão o envio recebe `insufficient_credit`. Há um lote em andamento por ambiente de cada vez, e o envio pede o segundo fator de quem envia.

**Durante.** As linhas rodam em segundo plano. Se o orçamento diário da conta, um limite diário ou o saldo acabar no meio, o lote pausa com o motivo na tela e continua sozinho quando puder. Se quem enviou sair da conta ou perder o papel, o lote pausa e só sai por cancelamento. O mesmo arquivo enviado de novo em 24 horas devolve o lote que já existe, e cada linha é cobrada uma vez.

**O que volta.** Um CSV com uma linha por linha do arquivo, na ordem do arquivo: as colunas `line_no`, `reference_id`, `status`, `lookup_query_id`, `found` e `error_code`, seguidas dos campos que a fonte devolveu. O `lookup_query_id` é o mesmo do extrato de créditos, então cada linha cobrada concilia pela mesma chave da consulta avulsa. O resultado sai por um link de 5 minutos, uma vez, para owner ou admin; uma nova emissão é só do owner, com o motivo registrado. A entrada e o resultado são apagados 7 dias depois do fim do lote.

### Permissões próprias

Algumas ações do painel não pertencem a um papel inteiro: são permissões que o proprietário da conta concede a uma pessoa específica da equipe. Elas ficam na tela **Organização**, no bloco **Permissões próprias**, logo abaixo da equipe, e só o proprietário vê esse bloco.

**Gerenciar lista de permissão.** Cria e edita a lista de pessoas liberadas das regras de bloqueio. O proprietário tem pelo papel; um administrador só tem se o proprietário conceder.

**Gerenciar política de decisão.** Ativa a política e os limiares do motor de decisão. O proprietário tem pelo papel; um administrador só tem se o proprietário conceder.

As duas só podem ser concedidas a quem tem o papel **Administrador** e está ativo na organização. Desenvolvedor e leitura não recebem, e a tentativa é recusada. Se o administrador deixar de ser administrador ou sair da organização, a concessão perde o efeito na hora, sem precisar revogar.

**Quem concede e como.** Só o proprietário concede e revoga, e cada concessão e cada revogação pedem o segundo fator dele (o código do aplicativo autenticador, um código de recuperação ou a passkey). Uma sessão do painel sequestrada não basta para entregar uma permissão. Chave de API nunca concede nem revoga: são ações só do painel. Cada concessão guarda quem concedeu e quando, e a tela mostra a data.

**A revogação vale na hora.** A permissão é conferida a cada ação da pessoa, sem cache: a próxima tentativa dela depois da revogação já é recusada, mesmo com a sessão aberta.

**Gerenciar casos de PLD.** Dá acesso ao livro e aos casos de prevenção à lavagem de dinheiro. Proprietário e administrador já têm pelo papel. A concessão dela a outros membros, como um analista de PLD com papel de desenvolvedor ou de leitura, **ainda não está aberta**: por enquanto o painel não oferece o controle.

## PLD/FT pela regra da norma

<https://unifokal.com/docs/pld-ft>

### PLD/FT pela regra da norma

! **Ainda não está aberto para venda.** Os dois módulos aparecem na [tabela de preços](https://unifokal.com/precos) com preço e com o selo "Em breve". O contrato abaixo é o que o backend já emite, para você planejar a integração.

O monitoramento de PLD/FT seleciona **operações e situações pela relação que a norma publica**: a Carta Circular BCB 4.001/2020 e a Circular BCB 3.978/2020 para as instituições do Banco Central, e a Portaria SPA/MF 1.143/2024 para os operadores de apostas, com o item da norma e, quando a norma tem um, o **código de enquadramento do Siscoaf** em cada alerta. Nos setores supervisionados pelo Coaf, pela CVM e pela Susep (Resoluções Coaf 36 e 41, Resolução CVM 50 e Circular Susep 612), as mesmas regras rodam com os parâmetros da sua política, e o alerta sai com o fundamento de monitoramento do seu regime. Em todos os regimes, o alerta nasce com o **prazo legal**, contado da data que a norma manda contar.

**A análise e a decisão de comunicar são do seu encarregado.** O produto entrega o prazo, a evidência, o caso formalizado e o rascunho da comunicação no formato do formulário do Siscoaf; quem transmite, com a credencial pessoal dele, é a sua organização. É a divisão que a Lei 9.613/1998 faz: o dever de comunicar é da pessoa obrigada.

**Como ligar, em quatro passos.**

1\. **Ative a política** no painel, em Conformidade, PLD/FT: o regime do seu setor, as regras que você liga, os parâmetros de cada uma e a nota de aprovação da sua governança. A política é versionada e nunca se reescreve: cada ativação é uma versão nova, e o alerta registra com qual versão foi selecionado.

2\. **Crie um flow com o módulo `pld_monitor`**. Ele é exclusivo no flow e não abre sessão de widget: o flow só recebe eventos. O webhook do flow é o destino do aviso de alerta.

3\. **Envie as transações pela mesma rota de ingestão** (`POST /v1/verification-sessions` com `transaction` ou `transactions`), acrescentando os dois campos do PLD/FT quando você os tiver: `cash` e `counterparty_country`. A seleção roda em produção: é a chave de produção que alimenta a fila, os prazos, o caso e, se a sua política ligar, o aviso `pld.alert.created`, e é lá que a pessoa monitorada conta no mês. No sandbox a ingestão valida o contrato dos eventos e do perfil, sem consumir saldo.

4\. **Opcional: mande o perfil do cliente** em `pld_profile`, na criação de sessão ou na ingestão. Com ele as regras de capacidade financeira e de cadastro passam a ter com o que comparar; sem ele, essas regras saem como não avaliadas, com o motivo escrito, e nunca como "nada consta".

```
{
  "flow_id": "flow_01J8PLD0000000000000000000",
  "reference_id": "cliente_42",
  "pld_profile": {
    "monthly_capacity_cents": 1500000,
    "activity_code": { "kind": "cbo", "code": "252210" },
    "residence_country": "BR",
    "pep_declared": false,
    "relationship_started_at": "2025-03-10"
  },
  "transactions": [
    {
      "type": "deposit",
      "amount_cents": 4500000,
      "external_id": "dep_1001",
      "cash": true,
      "counterparty_country": "BR"
    }
  ]
}
```

**Os campos novos do evento.** `cash` diz que a operação foi em espécie e vale `false` quando omitido. Ele só conta de verdade quando você o preenche: as regras de espécie da norma só avaliam quem declarou na política que informa o campo. O `counterparty_country` é o país da contraparte em ISO 3166-1 alfa-2 (`KY`, `PA`, `BR`), de uma **lista fechada**: código fora dela é `422 counterparty_country_invalid`, com o índice do evento na mensagem e nunca o valor.

**O perfil do cliente.** Todos os campos de `pld_profile` são opcionais, e nenhum é documento, nome ou contato: `monthly_capacity_cents` (a capacidade financeira mensal declarada), `net_worth_cents` (o patrimônio declarado; sem ele, a comparação com a capacidade usa só a renda ou o faturamento declarados), `activity_code` (`kind` `cnae` para empresa ou `cbo` para pessoa, e o `code`), `legal_nature_code`, `residence_country`, `pep_declared`, `public_servant`, `minor`, `parliamentary_amendment_account`, `relationship_started_at` (data, nunca no futuro) e `risk_class` (`baixo`, `medio` ou `alto`, quando você já tem a sua classificação). Com `declared_at` você diz em que data o seu cliente fez a declaração (nunca no futuro); sem ele, vale a data do recebimento. Cada declaração recebida fica registrada com a data, o recebimento e a chave que a enviou, e sai na exportação de dados da conta. Campo fora do formato é `422 pld_profile_invalid`, com o nome do campo na mensagem e nunca o valor. O bloco só é aceito onde há quem o consuma: num flow sem `pld_risco` na criação de sessão, ou sem `pld_monitor` na ingestão, a resposta é `422 pld_profile_not_supported`, nunca aceito e ignorado.

**A fila e os prazos.** Os alertas aparecem no painel de PLD/FT, com a severidade, os itens da norma e o estado de cada prazo: em dia, atenção, vence hoje, vencido ou providência imediata. O painel mostra tudo para todos os owner e admin. Se a sua política ligar o resumo por e-mail, uma vez por dia, quando há prazo novo pedindo atenção, o owner e o admin da organização recebem um e-mail só com os contadores, sem nome, documento ou valor. Ele nasce desligado. A disposição de cada alerta (triar, atribuir, arquivar com motivo e justificativa, escalar para caso) fica registrada com o autor, e com **quatro olhos** quando a sua política pede um segundo membro.

**O caso e o rascunho do Siscoaf.** O caso junta os alertas de um cliente, e dele sai o **dossiê** da análise e o **rascunho da comunicação** em JSON, texto ou CSV, no formato do formulário do Siscoaf. O rascunho é validado contra as regras que o Coaf publica e marca como "campo do comunicante" o que só a sua organização preenche. Depois de transmitir pelo Siscoaf, registre a decisão e o protocolo no caso; retificação e cancelamento também se registram ali, com a justificativa quando a norma exige.

**A declaração de não ocorrência.** Nos regimes que a exigem, o painel lembra o prazo de cada ano civil e registra a declaração quando ela é feita, ou marca que não se aplica quando houve comunicação no ano.

**A documentação e o relatório de efetividade.** O painel gera, em versões guardadas, a documentação do monitoramento e da seleção que o art. 40 da Circular BCB 3.978/2020 pede e o insumo do relatório de efetividade do art. 62, com os números do ano da data-base. A avaliação, as deficiências e o plano de ação são da sua instituição e saem em branco. Cada versão baixa selada, conferível fora do sistema como o dossiê, e cada leitura e exportação fica registrada.

**O aviso no seu webhook.** O aviso é por adesão: ele nasce desligado e sai só quando a sua política liga o aviso por webhook. Ligado, quando um alerta nasce o webhook do flow recebe o evento `pld.alert.created`, assinado como todo evento da UNIFOKAL. A carga é mínima de propósito: o id do alerta, a sua `reference_id`, a severidade, o tipo, os itens da norma, o fundamento, a data da seleção, os vencimentos e o caminho do alerta no painel. Nenhuma evidência, valor ou operação viaja no aviso.

```
{
  "id": "evt_plda_01J8PLDALERTA000000000000_pld_alert_created",
  "schema_version": 1,
  "event": "pld.alert.created",
  "livemode": true,
  "created": "2026-09-24T12:00:00.000Z",
  "data": {
    "object": "pld_alert",
    "id": "plda_01J8PLDALERTA000000000000",
    "reference_id": "cliente_42",
    "severity": "alta",
    "kind": "analise",
    "items": ["cc4001.I.d"],
    "basis": "Circular BCB 3.978/2020, art. 39; Carta Circular BCB 4.001/2020, art. 1, I, d",
    "selected_at": "2026-09-24T11:00:00.000Z",
    "deadlines": [{ "id": "analysis", "due_at": "2026-11-08T11:00:00.000Z" }],
    "dashboard_path": "/dashboard/pld?alerta=plda_01J8PLDALERTA000000000000"
  }
}
```

! **O alerta é informação sigilosa.** A Lei 9.613/1998, art. 11, II, manda comunicar sem dar ciência a ninguém, inclusive a quem a informação se refere. Não reflita o aviso na tela, no e-mail ou no atendimento do seu cliente final: ele é para o seu sistema de conformidade. No painel, só owner e admin leem a superfície de PLD/FT, e cada leitura fica registrada.

**Guarda, exclusão e exportação.** Enquanto a conta existe, o registro de PLD/FT (política, alerta, disposição, caso, dossiê, comunicação e declarações) fica guardado pelo prazo legal do seu regime, e o pedido de exclusão do titular não o alcança antes disso: a guarda é obrigação legal (LGPD, art. 16, I). A exportação de dados da conta leva o bloco `pld` quando você o marca na tela Exportar dados (ou o pede em `include`), no recorte da organização inteira, e nunca no recorte de uma verificação. No encerramento da conta, a exportação disparada com o pedido já leva o bloco `pld`: depois da carência o registro é eliminado, e a guarda pelo prazo da sua norma continua com a sua organização.

O payload de cada módulo está na página dele: [Monitoramento PLD/FT](https://unifokal.com/docs/modulos/pld-monitor#modulo-pld-monitor) e [Classificação de risco PLD/FT](https://unifokal.com/docs/modulos/pld-risco#modulo-pld-risco).

### Dossiê selado: exportar e conferir

A Circular BCB 3.978/2020 manda formalizar a análise em dossiê, independentemente da comunicação ao Coaf (art. 43, § 2º), registrar nele a decisão fundamentada (art. 48, § 1º) e mantê-lo à disposição do Banco Central (art. 67). A guarda regulatória é sua. Para você levar a peça ao seu arquivo e provar depois que ela é a mesma que o painel gerou, o dono ou o administrador exporta o dossiê do caso **selado**: um pacote JSON assinado com Ed25519 e, do mesmo texto, uma versão HTML para imprimir. Toda exportação fica registrada na trilha da sua conta.

O pacote traz o texto assinado (`canonical`), o SHA-256 dele, a assinatura destacada e o `key_id` da chave. O texto assinado leva o dossiê gravado e o estado das listas consultadas: por lista, o lote, a contagem, o hash do pacote, a data e o estado, e, para cada hit do titular, o lote vigente no dia do hit. Lista vencida ou desabilitada aparece como tal, nunca como "nada consta".

**A âncora é a lista abaixo, nunca a chave que viaja no arquivo.** Os dois SDKs oficiais trazem a mesma lista e conferem sem rede.

```
// Node 20+ (SDK oficial)
import { readFileSync } from "node:fs";
import { verifyPldDossierPackage } from "unifokal";

const pacote = JSON.parse(readFileSync("dossie-selado-pldc_....json", "utf8"));
const r = verifyPldDossierPackage(pacote);
// r.valid, r.errors (o motivo de cada recusa), r.dossier_id, r.content_hash, r.exported_at
```

```
# Python (SDK oficial, extra "unifokal[crypto]")
import json
from unifokal import verify_pld_dossier_package

r = verify_pld_dossier_package(json.load(open("dossie-selado-pldc_....json")))
# r["valid"], r["errors"], r["dossier_id"], r["content_hash"], r["exported_at"]
```

```
// chaves públicas do selo do dossiê (Ed25519, 32 bytes em base64). A âncora é ESTA lista.
[
  { "key_id": "sbx-250e08bd4bf1",
    "public_key": "p9/bS+qZIlV8UB7+aVLygeCOV4IHJ/sRgjHIEeejyWI=",
    "environment": "sandbox",
    "not_before": "2026-09-27T00:00:00.000Z" }
]
// A chave de produção entra nesta lista antes da primeira exportação de produção.
```

Sem SDK, o openssl 3 confere a mesma assinatura. Escolha na lista a chave com o `key_id` e o `environment` do pacote, confira que o `exported_at` do texto assinado não é anterior ao `not_before` dela, e rode:

```
# a chave pública da lista, em PEM (prefixo SPKI do Ed25519 + os 32 bytes)
( printf '302a300506032b6570032100' | xxd -r -p; echo 'p9/bS+qZIlV8UB7+aVLygeCOV4IHJ/sRgjHIEeejyWI=' | base64 -d ) \
  | openssl pkey -pubin -inform DER -out chave.pem

# o texto assinado (bytes exatos, UTF-8) e a assinatura, tirados do pacote
python3 - dossie-selado.json <<'FIM'
import base64, json, sys
p = json.load(open(sys.argv[1], encoding="utf-8"))
open("texto.txt", "wb").write(p["canonical"].encode("utf-8"))
open("assinatura.bin", "wb").write(base64.b64decode(p["signature"]))
FIM
sha256sum texto.txt          # igual ao campo "sha256" do pacote
openssl pkeyutl -verify -pubin -inkey chave.pem -rawin -in texto.txt -sigfile assinatura.bin
# Signature Verified Successfully
```

Um único byte trocado no texto faz a conferência falhar. Depois de conferir, leia o conteúdo do próprio `canonical`: o envelope do pacote (dossiê, caso e ambiente) não é assinado, e os SDKs recusam o pacote quando ele diverge do texto assinado.

### A contratação, pelos arts. 44, 45 e 47

Para as instituições do Banco Central, três artigos da Circular BCB 3.978/2020 falam de quem contrata ferramenta de monitoramento. O resumo abaixo segue o texto oficial, relido na fonte em 27/09/2026. O enquadramento da sua contratação é da sua instituição: esta seção não é parecer jurídico.

**Art. 44.** A análise do art. 43 não pode ser contratada de terceiros nem realizada no exterior. A vedação não inclui a contratação de terceiros para serviços auxiliares à análise.

**Art. 45.** A instituição deve dispor, no País, de recursos e competências necessários à análise de operações e situações suspeitas.

**Art. 47.** Na contratação de serviços de processamento e armazenamento de dados e de computação em nuvem usados no monitoramento e na seleção, e de serviços auxiliares à análise, a instituição observa o Capítulo III da Circular 3.909/2018, no caso de instituição de pagamento, ou o da Resolução 4.658/2018, no caso de instituição financeira e demais autorizadas, e, no que couber, os Capítulos IV e V de cada uma. A página de normativos do Banco Central registra que os arts. 1º a 26 da Circular 3.909 foram revogados pela Resolução BCB 85/2021, a partir de 1º/8/2021, e que a Resolução 4.658 foi revogada pela Resolução CMN 4.893/2021, a partir de 1º/7/2021.

§ [Circular BCB 3.978/2020, art. 44](https://normativos.bcb.gov.br/Lists/Normativos/Attachments/50905/Circ_3978_v5_L.pdf) conferido em 27/09/2026 § [Circular BCB 3.978/2020, art. 45](https://normativos.bcb.gov.br/Lists/Normativos/Attachments/50905/Circ_3978_v5_L.pdf) conferido em 27/09/2026 § [Circular BCB 3.978/2020, art. 47](https://normativos.bcb.gov.br/Lists/Normativos/Attachments/50905/Circ_3978_v5_L.pdf) conferido em 27/09/2026

**O que a sua avaliação encontra aqui.** O serviço é hospedado no Brasil, na região AWS sa-east-1 (São Paulo). A [lista de subprocessadores](https://unifokal.com/legal/subprocessadores) é pública, com o que cada um recebe e o país onde trata. A análise, a decisão de comunicar e a transmissão continuam com a sua equipe: o produto entrega a seleção, o prazo, o caso, o dossiê e o registro.

## Erros da API

<https://unifokal.com/docs/erros>

### Erros da API

Toda resposta de erro tem o mesmo envelope, em toda rota: `{ "error": "<código>", "message": "<texto>" }`. Trate pelo `error`, que é estável dentro de `/v1`. O `message` é para gente e pode mudar. Nenhum dos códigos abaixo cria sessão nem é cobrado.

Cada código tem a própria âncora: cole o código no fim do endereço desta página, depois de um `#`, e você cai direto nele. São 91 códigos em 4 grupos.

- [Criação de sessão: os erros do primeiro dia](https://unifokal.com/docs/erros#criacao-de-sessao)
- [Criação de sessão: códigos de um módulo do flow](https://unifokal.com/docs/erros#criacao-por-modulo)
- [Emissão de link hospedado](https://unifokal.com/docs/erros#link-hospedado)
- [Cifra da carga do webhook, no painel](https://unifokal.com/docs/erros#cifra-do-webhook)

#### Criação de sessão: os erros do primeiro dia

Respostas de POST /v1/verification-sessions que qualquer flow pode receber. O mesmo vocabulário vale nas outras rotas da chave secreta: credencial, conta e teto por minuto respondem igual em todas.

##### `validation_error` HTTP 400

**O que aconteceu.** Um campo obrigatório faltou ou veio fora do formato. A mensagem nomeia o campo, o valor aceito e o limite, e nunca repete o valor que você mandou.

**O que fazer.** Leia o campo nomeado na mensagem, corrija o corpo e mande de novo. Confira também o cabeçalho Content-Type: application/json, sem ele a requisição chega sem corpo.

**O que não fazer.** Não repita a mesma chamada esperando outra resposta: o corpo igual devolve o mesmo erro.

Veja também: [POST /v1/verification-sessions](https://unifokal.com/docs/api-rest#post-sessions)

##### `unknown_policy_key` HTTP 400

**O que aconteceu.** O bloco policy trouxe uma chave que não existe. O bloco é uma lista fechada, e a chave recusada vem nomeada na mensagem.

**O que fazer.** Tire a chave ou corrija o nome dela conforme a referência da criação de sessão e mande de novo.

**O que não fazer.** Não trate como aviso: nada foi criado, e a política que você achou que ligou não vale para sessão nenhuma.

Veja também: [POST /v1/verification-sessions](https://unifokal.com/docs/api-rest#post-sessions)

##### `unknown_monitoring_key` HTTP 400

**O que aconteceu.** O bloco monitoring trouxe uma chave que não existe. O bloco é uma lista fechada, e a chave recusada vem nomeada na mensagem.

**O que fazer.** Tire a chave ou corrija o nome dela e mande de novo. Sem o bloco, a sessão herda o que o flow define.

**O que não fazer.** Não conte com o monitoramento dessa sessão: a chamada foi recusada inteira.

Veja também: [POST /v1/verification-sessions](https://unifokal.com/docs/api-rest#post-sessions)

##### `invalid_api_key` HTTP 401

**O que aconteceu.** A chave secreta não veio, veio sem o prefixo Bearer, não existe ou foi revogada. A mensagem diz qual dos casos.

**O que fazer.** Mande o cabeçalho Authorization: Bearer seguido da chave sk_test\_ ou sk_live\_ do ambiente certo. Se a chave foi revogada, gere outra no painel e troque no seu cofre de segredos.

**O que não fazer.** Não mande a chave secreta pelo navegador nem pelo aplicativo: ela vive só no seu servidor.

Veja também: [Autenticação](https://unifokal.com/docs/autenticacao#auth)

##### `organization_suspended` HTTP 403

**O que aconteceu.** A organização está suspensa, e toda chamada com a chave secreta para de uma vez.

**O que fazer.** Entre no painel para ver o motivo da suspensão, ou fale com o suporte.

**O que não fazer.** Não gire chave nem crie conta nova para contornar: a suspensão é da organização, e a chave nova cai no mesmo erro.

Veja também: [Autenticação](https://unifokal.com/docs/autenticacao#auth)

##### `test_key_used_in_production` HTTP 403

**O que aconteceu.** O prefixo da chave diz um ambiente e o registro dela diz outro. O caso clássico é a chave guardada na variável errada do deploy.

**O que fazer.** Use a chave do ambiente que você quer chamar: sk_test\_ para sandbox, sk_live\_ para produção.

**O que não fazer.** Não edite o prefixo da chave à mão: o prefixo faz parte dela.

Veja também: [Sandbox e produção: o que muda](https://unifokal.com/docs/ambientes#ambientes)

##### `email_not_verified` HTTP 403

**O que aconteceu.** A conta ainda não confirmou o e-mail. A chave é válida e o flow existe, mas nenhuma sessão é criada antes da confirmação.

**O que fazer.** Confirme o e-mail da conta pelo link que enviamos e conclua o onboarding no painel.

**O que não fazer.** Não troque de chave: o bloqueio é da conta, não da credencial.

Veja também: [Autenticação](https://unifokal.com/docs/autenticacao#auth)

##### `credential_type_not_allowed` HTTP 403

**O que aconteceu.** Você mandou uma credencial do painel (dsk\_) numa rota que só aceita a chave secreta.

**O que fazer.** Chame esta rota com a chave secreta sk_test\_ ou sk_live\_, do seu servidor.

**O que não fazer.** Não reaproveite o token da sessão do painel na integração.

Veja também: [Autenticação](https://unifokal.com/docs/autenticacao#auth)

##### `flow_not_found` HTTP 404

**O que aconteceu.** O flow_id não existe neste ambiente. Um flow vive num ambiente só, e o ambiente é a chave que você usou: o mesmo id não existe do outro lado.

**O que fazer.** Confira o flow_id e a chave. Flow de produção se chama com sk_live\_, flow de sandbox com sk_test\_.

**O que não fazer.** Não tente o mesmo id com a chave do outro ambiente esperando achar o flow: crie o flow no ambiente em que vai usá-lo.

Veja também: [Sandbox e produção: o que muda](https://unifokal.com/docs/ambientes#ambientes)

##### `flow_not_live` HTTP 422

**O que aconteceu.** O flow existe, mas não está ativo: ainda é rascunho ou foi arquivado.

**O que fazer.** Ative o flow no painel, ou use o flow_id de um flow ativo, e mande de novo.

**O que não fazer.** Não repita a chamada em laço: enquanto o flow não estiver ativo, a resposta é a mesma.

Veja também: [Integração ponta a ponta](https://unifokal.com/docs/integracao#integracao)

##### `email_not_accepted` HTTP 422

**O que aconteceu.** O corpo trouxe email. O contato do titular nunca entra na criação de sessão: quem pergunta a pessoa e dispara o código é o widget.

**O que fazer.** Tire email do corpo. O titular digita o e-mail na verificação.

**O que não fazer.** Não mande o e-mail por outro campo para contornar: o widget é quem prova a posse do canal.

Veja também: [Validação de canal: e-mail e telefone](https://unifokal.com/docs/modulos/canal#modulos-canal)

##### `phone_not_accepted` HTTP 422

**O que aconteceu.** O corpo trouxe phone. O telefone do titular nunca entra na criação de sessão: quem pergunta a pessoa e dispara o código é o widget.

**O que fazer.** Tire phone do corpo. O titular digita o telefone na verificação.

**O que não fazer.** Não mande o telefone por outro campo para contornar: o widget é quem prova a posse do canal.

Veja também: [Validação de canal: e-mail e telefone](https://unifokal.com/docs/modulos/canal#modulos-canal)

##### `insufficient_credit` HTTP 402

**O que aconteceu.** O saldo de produção não cobre o máximo que esta verificação pode custar. A mensagem diz quanto falta.

**O que fazer.** Recarregue o saldo no painel (Cobrança) ou ligue a recarga automática, e mande de novo.

**O que não fazer.** Não troque para a chave de sandbox em produção: o sandbox não verifica ninguém de verdade.

Veja também: [Sandbox e produção: o que muda](https://unifokal.com/docs/ambientes#ambientes)

##### `sandbox_limit_reached` HTTP 429

**O que aconteceu.** O sandbox chegou ao teto mensal de 500 sessões.

**O que fazer.** Espere a virada do mês para seguir testando, ou passe a integração para produção.

**O que não fazer.** Não trate como o limite por minuto: esperar alguns segundos não resolve este 429.

Veja também: [Sandbox](https://unifokal.com/docs/ambientes#sandbox)

##### `rate_limited` HTTP 429

**O que aconteceu.** A conta passou do teto de chamadas por minuto. Na criação de sessão ele é de 60 em produção e 240 em sandbox.

**O que fazer.** Espere o prazo do cabeçalho Retry-After e tente de novo, com recuo exponencial se o volume for alto.

**O que não fazer.** Não repita na hora nem em paralelo: cada tentativa imediata conta no mesmo teto.

Veja também: [Sandbox e produção: o que muda](https://unifokal.com/docs/ambientes#ambientes)

##### `idempotency_conflict` HTTP 409

**O que aconteceu.** Uma chamada com o mesmo reference_id ainda está em andamento. O reference_id é a chave de idempotência da criação.

**O que fazer.** Espere a primeira terminar e repita: a repetição recebe a mesma sessão, com o cabeçalho Idempotent-Replay: true.

**O que não fazer.** Não gere um reference_id novo para escapar do conflito: ele identifica o seu usuário, não a tentativa.

Veja também: [POST /v1/verification-sessions](https://unifokal.com/docs/api-rest#post-sessions)

##### `idempotency_key_reuse` HTTP 422

**O que aconteceu.** O mesmo reference_id voltou com um corpo diferente dentro da janela em que a sessão anterior vale (15 minutos).

**O que fazer.** Repita com o mesmo corpo da primeira chamada, ou espere a sessão anterior vencer para mandar o corpo novo.

**O que não fazer.** Não reaproveite o reference_id de um usuário para outro usuário.

Veja também: [POST /v1/verification-sessions](https://unifokal.com/docs/api-rest#post-sessions)

##### `policy_module_not_in_flow` HTTP 422

**O que aconteceu.** O bloco policy ajusta um módulo que este flow não tem. A política é recusada com nome para você não achar que desligou algo que segue ligado.

**O que fazer.** Tire a chave de política do módulo ausente, ou adicione o módulo ao flow no painel.

**O que não fazer.** Não mantenha a chave esperando que ela valha quando o módulo entrar: a chamada inteira é recusada.

Veja também: [POST /v1/verification-sessions](https://unifokal.com/docs/api-rest#post-sessions)

##### `policy_ubo_cap_above_flow` HTTP 422

**O que aconteceu.** policy.ubo_max_paid_nodes veio acima do teto do próprio flow. O teto da sessão só aperta o do flow, nunca o afrouxa.

**O que fazer.** Mande um valor menor ou igual ao do flow, ou suba o teto no flow pelo painel.

**O que não fazer.** Não tente subir o gasto de uma sessão por este campo: o gasto só sobe por decisão registrada no flow.

Veja também: [Cadeia societária até o beneficiário final](https://unifokal.com/docs/modulos/ubo-profundo#modulo-ubo-profundo)

##### `session_not_supported` HTTP 422

**O que aconteceu.** O flow informado só recebe alertas do monitoramento transacional e não aceita sessão de verificação. Vale igual na emissão do link hospedado.

**O que fazer.** Mande os eventos com o bloco transaction, ou crie a sessão no seu flow de verificação.

**O que não fazer.** Não abra o widget para este flow: ele não tem jornada de titular.

Veja também: [Monitoramento transacional](https://unifokal.com/docs/modulos/transacao-monitor#modulo-transacao-monitor)

#### Criação de sessão: códigos de um módulo do flow

Estes só existem quando o flow tem o módulo correspondente, ou quando a conta ligou um teto no painel. Sem o módulo, a porta não existe.

##### `counterparty_country_invalid` HTTP 422

**O que aconteceu.** Um evento de transação trouxe em counterparty_country um código de duas letras que não está na lista fechada de países (ISO 3166-1 alfa-2). A mensagem aponta o índice do evento, nunca o valor.

**O que fazer.** Mande o código ISO 3166-1 alfa-2 do país da contraparte (por exemplo PA ou BR), ou deixe o campo de fora quando não souber.

**O que não fazer.** Não reenvie o lote igual: a validação vem antes de qualquer gravação, e o lote inteiro é recusado de novo.

Veja também: [PLD/FT pela regra da norma](https://unifokal.com/docs/pld-ft#pld-ft)

##### `pld_profile_invalid` HTTP 422

**O que aconteceu.** O corpo trouxe o bloco opcional pld_profile com um campo fora do formato. A mensagem traz o nome do campo, nunca o valor.

**O que fazer.** Corrija o campo apontado conforme o perfil descrito na página de PLD/FT, ou mande a chamada sem o bloco.

**O que não fazer.** Não repita a chamada igual: o bloco é validado antes de qualquer gravação, e a recusa se repete.

Veja também: [PLD/FT pela regra da norma](https://unifokal.com/docs/pld-ft#pld-ft)

##### `pld_profile_not_supported` HTTP 422

**O que aconteceu.** O corpo trouxe pld_profile num flow sem o módulo que o lê: pld_risco na criação de sessão, ou pld_monitor na ingestão de transação.

**O que fazer.** Ligue o módulo no flow, ou mande a chamada sem o bloco.

**O que não fazer.** Não conte com o bloco aceito e ignorado: sem o módulo, a chamada é recusada, para você não achar que informou o perfil do seu cliente.

Veja também: [PLD/FT pela regra da norma](https://unifokal.com/docs/pld-ft#pld-ft)

##### `transaction_conflict` HTTP 422

**O que aconteceu.** O corpo trouxe transaction e transactions juntos. Os dois são exclusivos.

**O que fazer.** Mande um evento no singular (transaction) ou o lote (transactions), nunca os dois.

**O que não fazer.** Não duplique o evento nos dois campos para garantir: a chamada é recusada inteira.

Veja também: [Gate transacional](https://unifokal.com/docs/modulos/transacao#modulo-transacao)

##### `transaction_not_supported` HTTP 422

**O que aconteceu.** O bloco de transação foi para um flow sem módulo que consome transação.

**O que fazer.** Mande a transação para o flow que tem o módulo transacional, ou adicione o módulo ao flow.

**O que não fazer.** Não conte com o evento gravado: nada foi aceito.

Veja também: [Gate transacional](https://unifokal.com/docs/modulos/transacao#modulo-transacao)

##### `batch_too_large` HTTP 422

**O que aconteceu.** O lote transactions passou do teto de eventos por chamada. O teto vem no corpo do erro.

**O que fazer.** Divida o lote em partes dentro do teto informado e mande cada parte. O external_id evita duplicar.

**O que não fazer.** Não descubra o teto por tentativa: ele já vem na resposta.

Veja também: [Gate transacional](https://unifokal.com/docs/modulos/transacao#modulo-transacao)

##### `batch_not_supported_for_gate` HTTP 422

**O que aconteceu.** O lote foi para um flow com o gate transacional síncrono, que decide um pagamento por vez.

**O que fazer.** Mande cada evento no singular, pelo campo transaction.

**O que não fazer.** Não espere veredito por item de um lote neste flow.

Veja também: [Gate transacional](https://unifokal.com/docs/modulos/transacao#modulo-transacao)

##### `transaction_required` HTTP 422

**O que aconteceu.** O flow contém o gate transacional e a chamada veio sem o bloco transaction. Vale na criação de sessão e na emissão de link hospedado.

**O que fazer.** Mande o evento no bloco transaction. Esse flow só recebe transação.

**O que não fazer.** Não abra o widget nem emita link para este flow: uma sessão de titular não teria o que perguntar a ele.

Veja também: [Gate transacional](https://unifokal.com/docs/modulos/transacao#modulo-transacao)

##### `external_id_required` HTTP 422

**O que aconteceu.** O evento de transação veio sem external_id, que é o token de idempotência do evento.

**O que fazer.** Mande um external_id estável por evento, o mesmo em toda reentrega daquele evento.

**O que não fazer.** Não gere um external_id novo a cada tentativa: é ele que impede evento duplicado e segunda cobrança.

Veja também: [Gate transacional](https://unifokal.com/docs/modulos/transacao#modulo-transacao)

##### `amount_too_large` HTTP 422

**O que aconteceu.** amount_cents veio acima do teto de valor aceito por evento.

**O que fazer.** Confira a unidade: o valor vai em centavos, como inteiro. Corrija e mande de novo.

**O que não fazer.** Não divida um movimento real em vários eventos para caber no teto.

Veja também: [Gate transacional](https://unifokal.com/docs/modulos/transacao#modulo-transacao)

##### `currency_not_supported` HTTP 422

**O que aconteceu.** A moeda do evento não é BRL, a única aceita.

**O que fazer.** Mande o valor em reais, com currency BRL.

**O que não fazer.** Não converta sem registrar: o evento precisa descrever o movimento como ele aconteceu em reais.

Veja também: [Gate transacional](https://unifokal.com/docs/modulos/transacao#modulo-transacao)

##### `event_too_old` HTTP 422

**O que aconteceu.** occurred_at está além da janela de importação de eventos antigos.

**O que fazer.** Mande só eventos dentro da janela. Dentro dela, o evento antigo entra normalmente.

**O que não fazer.** Não altere occurred_at para caber na janela: a data precisa ser a do movimento.

Veja também: [Gate transacional](https://unifokal.com/docs/modulos/transacao#modulo-transacao)

##### `reference_id_charset` HTTP 422

**O que aconteceu.** Um id opaco do evento, ou o act.external_id, trouxe caractere fora do conjunto aceito. A mensagem nomeia o campo recusado.

**O que fazer.** Use só os caracteres aceitos no campo nomeado, de preferência o id interno que você já usa.

**O que não fazer.** Não ponha dado pessoal no id para torná-lo legível.

Veja também: [Gate transacional](https://unifokal.com/docs/modulos/transacao#modulo-transacao)

##### `pii_shaped_value` HTTP 422

**O que aconteceu.** Um id opaco do evento, ou o act.external_id, tem forma de dado pessoal (CPF, CNPJ, e-mail, telefone, chave Pix ou número de cartão). O corpo do erro nunca devolve o valor.

**O que fazer.** Troque pelo seu identificador interno, sem dado pessoal dentro.

**O que não fazer.** Não mascare o dado pessoal para passar: o id é opaco por contrato.

Veja também: [Gate transacional](https://unifokal.com/docs/modulos/transacao#modulo-transacao)

##### `pending_lifecycle_not_enabled` HTTP 422

**O que aconteceu.** O evento veio com status pending num flow cujo módulo não aceita o ciclo pendente.

**O que fazer.** Mande o evento já confirmado. Se ele for desfeito depois, mande a reversão.

**O que não fazer.** Não mande o mesmo evento duas vezes, uma pendente e outra confirmada.

Veja também: [Gate transacional](https://unifokal.com/docs/modulos/transacao#modulo-transacao)

##### `settlement_status_invalid` HTTP 422

**O que aconteceu.** Uma liquidação ou reversão veio com status pending. Liquidação e reversão nunca chegam pendentes.

**O que fazer.** Mande a liquidação ou a reversão quando ela já tiver acontecido.

**O que não fazer.** Não antecipe a liquidação como pendente.

Veja também: [Gate transacional](https://unifokal.com/docs/modulos/transacao#modulo-transacao)

##### `unknown_settles_reference` HTTP 422

**O que aconteceu.** settles não aponta um evento seu no mesmo ambiente.

**O que fazer.** Mande antes o evento original, no mesmo ambiente, e depois a liquidação apontando o external_id dele.

**O que não fazer.** Não aponte evento de outro ambiente: sandbox e produção não se enxergam.

Veja também: [Gate transacional](https://unifokal.com/docs/modulos/transacao#modulo-transacao)

##### `already_settled` HTTP 409

**O que aconteceu.** O alvo já tem liquidação ou reversão. A segunda do mesmo alvo é recusada.

**O que fazer.** Trate como já registrado. Para desfazer um movimento, mande uma reversão do evento original.

**O que não fazer.** Não repita a liquidação com outro external_id.

Veja também: [Gate transacional](https://unifokal.com/docs/modulos/transacao#modulo-transacao)

##### `daily_ingest_cap_reached` HTTP 429

**O que aconteceu.** A organização chegou ao teto diário de eventos de transação. O lote recusado não gravou nada, e a resposta não traz Retry-After porque o balde é o dia.

**O que fazer.** Pause o envio e retome no dia seguinte, a partir do lote recusado.

**O que não fazer.** Não repita em laço durante o dia: a resposta é a mesma até a virada.

Veja também: [Gate transacional](https://unifokal.com/docs/modulos/transacao#modulo-transacao)

##### `assinatura_document_required` HTTP 422

**O que aconteceu.** O flow contém o módulo de assinatura e a chamada veio sem document_sha256. A emissão de link para esse flow cai no mesmo código.

**O que fazer.** Mande o bloco assinatura com o hash do documento. A sessão desse flow nasce pela API, não por link.

**O que não fazer.** Não emita link hospedado para este flow: o link não leva o documento.

Veja também: [Assinatura eletrônica](https://unifokal.com/docs/modulos/assinatura#modulo-assinatura)

##### `assinatura_not_supported` HTTP 422

**O que aconteceu.** O bloco assinatura foi para um flow sem o módulo de assinatura.

**O que fazer.** Tire o bloco, ou use o flow que tem o módulo.

**O que não fazer.** Não conte com o documento assinado: nada foi criado.

Veja também: [Assinatura eletrônica](https://unifokal.com/docs/modulos/assinatura#modulo-assinatura)

##### `document_blocklisted` HTTP 422

**O que aconteceu.** Na reautenticação facial, o documento da matrícula está na sua lista de bloqueio.

**O que fazer.** Revise a entrada na lista de bloqueio. Se ela não vale mais, solte o bloqueio pela verificação ou por Sessões no painel.

**O que não fazer.** Não matricule o mesmo titular de novo para contornar o bloqueio.

Veja também: [Reautenticação facial](https://unifokal.com/docs/modulos/face-reauth#modulo-face-reauth)

##### `reauth_rate_limited` HTTP 429

**O que aconteceu.** A conta passou do teto de tentativas de reautenticação facial.

**O que fazer.** Espere o prazo do cabeçalho Retry-After, em segundos, e tente de novo.

**O que não fazer.** Não repita antes do prazo: cada tentativa imediata recebe o mesmo 429.

Veja também: [Reautenticação facial](https://unifokal.com/docs/modulos/face-reauth#modulo-face-reauth)

##### `expected_address_not_supported` HTTP 422

**O que aconteceu.** expected_address foi para um flow sem o módulo de comprovante de endereço.

**O que fazer.** Tire o campo, ou use o flow que tem o módulo endereco_ocr.

**O que não fazer.** Não conte com o cruzamento de endereço: nada foi criado.

Veja também: [Comprovante de endereço](https://unifokal.com/docs/modulos/endereco-ocr#modulo-endereco-ocr)

##### `consultation_authorization_required` HTTP 422

**O que aconteceu.** O flow contém o módulo de custódia da autorização e a chamada veio sem o bloco consultation_authorization. A emissão de link para esse flow cai no mesmo código.

**O que fazer.** Mande o bloco com o hash do texto que o titular autorizou. A sessão desse flow nasce pela API.

**O que não fazer.** Não emita link hospedado para este flow: o link não leva a autorização.

Veja também: [Custódia da autorização de consulta](https://unifokal.com/docs/modulos/custodia-autorizacao#modulo-custodia-autorizacao)

##### `consultation_authorization_not_supported` HTTP 422

**O que aconteceu.** O bloco consultation_authorization foi para um flow sem o módulo de custódia.

**O que fazer.** Tire o bloco, ou use o flow que tem o módulo.

**O que não fazer.** Não conte com a autorização guardada: nada foi criado.

Veja também: [Custódia da autorização de consulta](https://unifokal.com/docs/modulos/custodia-autorizacao#modulo-custodia-autorizacao)

##### `unknown_consultation_authorization_key` HTTP 400

**O que aconteceu.** O bloco consultation_authorization trouxe uma chave que não existe. A chave recusada vem nomeada na mensagem.

**O que fazer.** Tire a chave ou corrija o nome dela e mande de novo.

**O que não fazer.** Não trate como aviso: a chamada foi recusada inteira.

Veja também: [Custódia da autorização de consulta](https://unifokal.com/docs/modulos/custodia-autorizacao#modulo-custodia-autorizacao)

##### `foreign_entity_required` HTTP 422

**O que aconteceu.** O flow verifica uma empresa estrangeira e a chamada não trouxe o bloco foreign_entity.

**O que fazer.** Mande o bloco com o LEI da empresa, ou com o nome e o país dela.

**O que não fazer.** Não tente pelo link hospedado: ele não leva a empresa.

Veja também: [Empresa estrangeira](https://unifokal.com/docs/modulos/kyb-estrangeira#modulo-kyb-estrangeira)

##### `foreign_entity_not_supported` HTTP 422

**O que aconteceu.** O bloco foreign_entity foi para um flow sem o módulo de empresa estrangeira.

**O que fazer.** Tire o bloco, ou use o flow que tem o módulo.

**O que não fazer.** Não conte com a empresa verificada: nada foi criado.

Veja também: [Empresa estrangeira](https://unifokal.com/docs/modulos/kyb-estrangeira#modulo-kyb-estrangeira)

##### `foreign_entity_lei_invalid` HTTP 422

**O que aconteceu.** O LEI informado não confere o dígito de controle da norma. Quase sempre é erro de digitação.

**O que fazer.** Confira o identificador com o cliente e mande de novo, ou mande o nome e o país.

**O que não fazer.** Não leia como empresa inexistente: o identificador é que está errado.

Veja também: [Empresa estrangeira](https://unifokal.com/docs/modulos/kyb-estrangeira#modulo-kyb-estrangeira)

##### `foreign_entity_too_many_owners` HTTP 422

**O que aconteceu.** O bloco declarou mais beneficiários finais do que o teto por sessão.

**O que fazer.** Declare só as pessoas naturais que controlam a empresa e mande de novo.

**O que não fazer.** Não divida em várias sessões da mesma empresa para passar do teto.

Veja também: [Empresa estrangeira](https://unifokal.com/docs/modulos/kyb-estrangeira#modulo-kyb-estrangeira)

##### `unknown_foreign_entity_key` HTTP 400

**O que aconteceu.** O bloco foreign_entity trouxe uma chave que não existe. A chave recusada vem nomeada na mensagem.

**O que fazer.** Tire a chave ou corrija o nome dela e mande de novo.

**O que não fazer.** Não trate como aviso: a chamada foi recusada inteira.

Veja também: [Empresa estrangeira](https://unifokal.com/docs/modulos/kyb-estrangeira#modulo-kyb-estrangeira)

##### `unknown_beneficial_owner_key` HTTP 400

**O que aconteceu.** Um beneficiário declarado trouxe uma chave que não existe. Hoje só o nome é aceito.

**O que fazer.** Mande cada beneficiário só com o campo name.

**O que não fazer.** Não trate como aviso: a chamada foi recusada inteira.

Veja também: [Empresa estrangeira](https://unifokal.com/docs/modulos/kyb-estrangeira#modulo-kyb-estrangeira)

##### `origin_tag_not_supported` HTTP 422

**O que aconteceu.** origin_tag veio junto do bloco de transação. A ingestão de transação não tem jornada a rotular.

**O que fazer.** Tire origin_tag das chamadas de transação e mantenha só nas de sessão.

**O que não fazer.** Não use origin_tag para marcar lote de transação: use o external_id.

Veja também: [POST /v1/verification-sessions](https://unifokal.com/docs/api-rest#post-sessions)

##### `spend_cap_reached` HTTP 429

**O que aconteceu.** A conta configurou um orçamento diário de gasto no painel e ele acabou hoje. Nada é cobrado nesta recusa.

**O que fazer.** Espere o Retry-After, que vai até 00:00 UTC, ou suba o teto no painel. Subir passa a valer na virada do dia.

**O que não fazer.** Não trate como retentativa curta: é uma pausa até a virada do dia.

Veja também: [Painel de operação](https://unifokal.com/docs/painel-de-operacao#painel-de-operacao)

##### `volume_cap_reached` HTTP 429

**O que aconteceu.** A conta configurou um teto diário de operações no painel e ele foi alcançado hoje.

**O que fazer.** Espere o Retry-After, que vai até 00:00 UTC, ou suba o teto no painel. Subir passa a valer na virada do dia.

**O que não fazer.** Não trate como retentativa curta: é uma pausa até a virada do dia.

Veja também: [Painel de operação](https://unifokal.com/docs/painel-de-operacao#painel-de-operacao)

##### `attempt_limit_reached` HTTP 429

**O que aconteceu.** A mesma referência (reference_id) já tentou vezes demais num período recente. A sessão não foi criada e nada foi cobrado.

**O que fazer.** Encerre a tentativa daquela pessoa e siga pelo seu atendimento. Se o caso for legítimo, fale com o suporte.

**O que não fazer.** Não repita automaticamente nem troque o reference_id para contornar: ele identifica o seu usuário, e a recusa não traz prazo de nova tentativa.

Veja também: [POST /v1/verification-sessions](https://unifokal.com/docs/api-rest#post-sessions)

##### `module_quota_reached` HTTP 429

**O que aconteceu.** A organização alcançou hoje a cota diária de sessões novas com o módulo de PEP e sanções. A sessão não foi criada e nada foi cobrado.

**O que fazer.** Espere o Retry-After, que vai até 00:00 UTC. Se o seu volume pede mais, fale com o suporte: a cota é ajustada por organização.

**O que não fazer.** Não repita em laço curto nem crie sessão sem o módulo para contornar: a renovação de uma sessão já criada não conta de novo.

Veja também: [POST /v1/verification-sessions](https://unifokal.com/docs/api-rest#post-sessions)

##### `account_event_required` HTTP 422

**O que aconteceu.** O flow contém o módulo de proteção de conta e a chamada veio sem o bloco account_event.

**O que fazer.** Mande o evento de conta (login, troca de senha, recuperação de acesso) no bloco account_event.

**O que não fazer.** Não abra o widget para este flow: ele avalia eventos de conta, não uma jornada de verificação.

Veja também: [Proteção de conta](https://unifokal.com/docs/modulos/conta#modulo-conta)

##### `account_event_not_supported` HTTP 422

**O que aconteceu.** O bloco account_event foi para um flow sem o módulo de proteção de conta.

**O que fazer.** Mande o evento para o flow que tem o módulo conta, ou adicione o módulo ao flow.

**O que não fazer.** Não conte com o evento avaliado: nada foi aceito.

Veja também: [Proteção de conta](https://unifokal.com/docs/modulos/conta#modulo-conta)

##### `account_event_ip_not_public` HTTP 422

**O que aconteceu.** account_event.device.ip veio com um endereço privado, de loopback ou de link-local. O campo precisa ser o IP público do usuário final.

**O que fazer.** Mande o IP público de quem fez a ação, lido na borda da sua infraestrutura (o primeiro IP confiável do cabeçalho do seu proxy).

**O que não fazer.** Não mande o IP do seu servidor, do balanceador ou da rede interna.

Veja também: [Proteção de conta](https://unifokal.com/docs/modulos/conta#modulo-conta)

##### `session_monitoring_not_supported` HTTP 422

**O que aconteceu.** O bloco session_monitoring foi para um flow sem o módulo de monitoramento de sessão.

**O que fazer.** Tire o bloco, ou use o flow que tem o módulo sessao_monitor.

**O que não fazer.** Não conte com a sessão monitorada: nada foi criado, e nenhum alerta sairia dela.

Veja também: [Monitoramento de sessão](https://unifokal.com/docs/modulos/sessao-monitor#modulo-sessao-monitor)

##### `reference_id_required` HTTP 422

**O que aconteceu.** O flow tem um módulo que amarra o resultado a um titular (proteção de conta, reautenticação facial, monitoramento de sessão ou dispositivo Pix) e a chamada veio sem reference_id utilizável.

**O que fazer.** Mande o reference_id do titular, o mesmo identificador que você usa para ele no seu sistema.

**O que não fazer.** Não mande um valor aleatório por chamada: sem o titular certo, o resultado não tem a quem se referir.

Veja também: [POST /v1/verification-sessions](https://unifokal.com/docs/api-rest#post-sessions)

##### `window_too_long` HTTP 422

**O que aconteceu.** session_monitoring.window_hours veio acima do teto da janela de monitoramento. A mensagem traz o teto. O valor é recusado com nome, nunca cortado em silêncio.

**O que fazer.** Mande uma janela dentro do teto informado, ou omita o campo para usar a janela padrão.

**O que não fazer.** Não programe o seu atendimento contando com uma cobertura maior que a janela aceita.

Veja também: [Monitoramento de sessão](https://unifokal.com/docs/modulos/sessao-monitor#modulo-sessao-monitor)

##### `enrollment_not_found` HTTP 422

**O que aconteceu.** Na reautenticação facial, não há matrícula biométrica utilizável para aquele reference_id. A matrícula nasce num onboarding aprovado com face e prova de vida.

**O que fazer.** Rode o onboarding do titular uma vez, num flow marcado para matricular, e depois peça a reautenticação.

**O que não fazer.** Não tente matricular pela aprovação manual da fila de revisão: matrícula nasce de prova, não de decisão de operador.

Veja também: [Reautenticação facial](https://unifokal.com/docs/modulos/face-reauth#modulo-face-reauth)

##### `enrollment_locked` HTTP 422

**O que aconteceu.** A matrícula do titular travou depois de reautenticações seguidas que não bateram.

**O que fazer.** Peça ao titular um novo onboarding aprovado: é ele que destrava a matrícula.

**O que não fazer.** Não insista na reautenticação: enquanto a matrícula estiver travada, a resposta é a mesma.

Veja também: [Reautenticação facial](https://unifokal.com/docs/modulos/face-reauth#modulo-face-reauth)

##### `pix_device_required` HTTP 422

**O que aconteceu.** O flow contém o módulo de dispositivo Pix e a chamada veio sem o bloco pix_device.

**O que fazer.** Mande o bloco pix_device com o fingerprint do aparelho que está sendo cadastrado.

**O que não fazer.** Não abra este flow sem o aparelho: o vínculo é aparelho e titular.

Veja também: [Cadastro de dispositivo Pix](https://unifokal.com/docs/modulos/pix-device#modulo-pix-device)

##### `pix_device_module_not_in_flow` HTTP 422

**O que aconteceu.** O bloco pix_device foi para um flow sem o módulo de dispositivo Pix.

**O que fazer.** Tire o bloco, ou use o flow que tem o módulo pix_device.

**O que não fazer.** Não conte com o aparelho cadastrado: nada foi criado.

Veja também: [Cadastro de dispositivo Pix](https://unifokal.com/docs/modulos/pix-device#modulo-pix-device)

##### `pix_device_reference_required` HTTP 422

**O que aconteceu.** O flow contém o módulo de dispositivo Pix e a chamada veio sem reference_id.

**O que fazer.** Mande o reference_id do titular dono do aparelho.

**O que não fazer.** Não reaproveite o reference_id de outro titular: o vínculo é aparelho e titular.

Veja também: [Cadastro de dispositivo Pix](https://unifokal.com/docs/modulos/pix-device#modulo-pix-device)

##### `credit_relationship_required` HTTP 422

**O que aconteceu.** O flow tem módulo de consulta de crédito e a chamada veio sem credit_relationship. A Lei 12.414, art. 15, exige que o consulente declare a relação com o cadastrado.

**O que fazer.** Mande credit_relationship com mantem ou pretende_manter, conforme a relação comercial ou de crédito com o titular.

**O que não fazer.** Não declare uma relação que não existe: a declaração é sua, e fica registrada na sessão.

Veja também: [Análise de crédito](https://unifokal.com/docs/modulos/credito#modulos-credito)

##### `credit_relationship_not_supported` HTTP 422

**O que aconteceu.** credit_relationship foi para um flow sem módulo de consulta de crédito.

**O que fazer.** Tire o campo, ou use o flow que tem o módulo de crédito.

**O que não fazer.** Não mande o campo em todo flow por padrão: ele só vale onde há consulta de crédito.

Veja também: [Análise de crédito](https://unifokal.com/docs/modulos/credito#modulos-credito)

##### `credit_purpose_required` HTTP 422

**O que aconteceu.** O flow tem módulo de consulta de crédito e não declara a finalidade da consulta, que a Lei 12.414, art. 7, exige.

**O que fazer.** Edite o flow no painel e declare a finalidade da consulta de crédito.

**O que não fazer.** Não troque o flow por outro sem módulo de crédito esperando o mesmo resultado.

Veja também: [Análise de crédito](https://unifokal.com/docs/modulos/credito#modulos-credito)

##### `credit_consulente_unverified` HTTP 422

**O que aconteceu.** A consulta de crédito exige a conta com CNPJ cadastrado e verificado, e a sua conta ainda não está assim.

**O que fazer.** Cadastre e verifique o CNPJ da conta no painel e mande de novo.

**O que não fazer.** Não use a conta de outra empresa: o consulente é quem responde pela consulta.

Veja também: [Análise de crédito](https://unifokal.com/docs/modulos/credito#modulos-credito)

##### `act_requires_passkey` HTTP 422

**O que aconteceu.** O corpo trouxe o bloco act, e o flow não tem o módulo passkey. O ato só é aprovado com o fator do titular.

**O que fazer.** Use um flow com o módulo passkey para a sessão com ato, ou tire o bloco act da chamada.

**O que não fazer.** Não tire o act e trate a sessão comum como aprovação do ato: ela não prova que a pessoa aprovou aquele ato.

Veja também: [Aprovação de ato com passkey](https://unifokal.com/docs/modulos/passkey#modulo-passkey)

##### `passkey_not_enrolled` HTTP 422

**O que aconteceu.** O flow aprova com a passkey, e a conta deste reference_id não tem passkey ativa que sirva para o pedido.

**O que fazer.** Vincule a passkey antes, com um flow de cadastro que a vincula, ou use outro fator para esta pessoa.

**O que não fazer.** Não repita a mesma chamada: sem a passkey vinculada, a resposta é a mesma.

Veja também: [Aprovação de ato com passkey](https://unifokal.com/docs/modulos/passkey#modulo-passkey)

##### `passkey_bind_needs_authentication` HTTP 422

**O que aconteceu.** A conta já tem passkey ativa, e vincular outra exige que o flow também peça a passkey atual.

**O que fazer.** Use um flow que vincula a passkey e tem o módulo passkey, para a pessoa entrar com a atual antes.

**O que não fazer.** Não revogue a passkey atual só para vincular outra sem a pessoa pedir.

Veja também: [Aprovação de ato com passkey](https://unifokal.com/docs/modulos/passkey#modulo-passkey)

##### `passkey_bind_needs_reproof` HTTP 422

**O que aconteceu.** A conta já teve passkey, e vincular de novo exige refazer a prova de identidade da pessoa.

**O que fazer.** Use um flow que vincula a passkey com documento, Face Match e Liveness, ou com a reautenticação facial.

**O que não fazer.** Não vincule de novo só com a prova de vida: a nova passkey precisa nascer de uma identidade provada.

Veja também: [Aprovação de ato com passkey](https://unifokal.com/docs/modulos/passkey#modulo-passkey)

##### `unknown_act_key` HTTP 400

**O que aconteceu.** O bloco act trouxe uma chave que não existe. A chave recusada vem nomeada na mensagem.

**O que fazer.** Tire a chave ou corrija o nome dela e mande de novo.

**O que não fazer.** Não trate como aviso: a chamada foi recusada inteira.

Veja também: [Aprovação de ato com passkey](https://unifokal.com/docs/modulos/passkey#modulo-passkey)

##### `act_hostile_char` HTTP 422

**O que aconteceu.** Um texto do bloco act (kind, summary ou counterparty) tem caractere invisível ou de controle, como quebra de linha ou marca de direção do texto.

**O que fazer.** Mande o texto em uma linha só, sem caractere invisível, e mande de novo.

**O que não fazer.** Não troque o caractere por outro parecido: o texto que a pessoa lê precisa ser o que o ato diz.

Veja também: [Aprovação de ato com passkey](https://unifokal.com/docs/modulos/passkey#modulo-passkey)

##### `act_kind_invalid` HTTP 422

**O que aconteceu.** O act.kind veio vazio, acima de 40 caracteres ou com um tipo reservado.

**O que fazer.** Mande um tipo curto do seu próprio vocabulário, como pix_transfer ou change_email.

**O que não fazer.** Não ponha dado pessoal no tipo: ele volta no webhook.

Veja também: [Aprovação de ato com passkey](https://unifokal.com/docs/modulos/passkey#modulo-passkey)

##### `act_summary_invalid` HTTP 422

**O que aconteceu.** O act.summary veio vazio ou acima de 140 caracteres.

**O que fazer.** Escreva o resumo do que a pessoa aprova em uma frase curta e mande de novo.

**O que não fazer.** Não corte o resumo no meio de uma palavra ou de um valor: a pessoa aprova o que lê.

Veja também: [Aprovação de ato com passkey](https://unifokal.com/docs/modulos/passkey#modulo-passkey)

##### `act_counterparty_invalid` HTTP 422

**O que aconteceu.** O act.counterparty veio vazio ou acima de 80 caracteres.

**O que fazer.** Mande o nome do favorecido abreviado, ou tire o campo, que é opcional.

**O que não fazer.** Não mande documento do favorecido no lugar do nome.

Veja também: [Aprovação de ato com passkey](https://unifokal.com/docs/modulos/passkey#modulo-passkey)

##### `act_amount_invalid` HTTP 422

**O que aconteceu.** O act.amount_cents não é um número inteiro de centavos, zero ou maior.

**O que fazer.** Mande o valor em centavos, como número inteiro, junto de currency BRL.

**O que não fazer.** Não mande o valor em reais com casas decimais: o campo é em centavos.

Veja também: [Aprovação de ato com passkey](https://unifokal.com/docs/modulos/passkey#modulo-passkey)

##### `act_currency_invalid` HTTP 422

**O que aconteceu.** A act.currency não é BRL, ou veio sem valor, ou o valor veio sem moeda.

**O que fazer.** Mande amount_cents e currency BRL juntos, ou tire os dois.

**O que não fazer.** Não mande a moeda sozinha: moeda só vale com valor.

Veja também: [Aprovação de ato com passkey](https://unifokal.com/docs/modulos/passkey#modulo-passkey)

#### Emissão de link hospedado

Códigos próprios de POST /v1/verification-links. Todo o resto do vocabulário da emissão é o mesmo da criação de sessão.

##### `expires_in_too_long` HTTP 422

**O que aconteceu.** expires_in veio acima do teto de validade do link. O valor é recusado com nome, nunca cortado em silêncio.

**O que fazer.** Mande uma validade dentro do teto e emita um link novo quando ele vencer.

**O que não fazer.** Não prometa ao titular um prazo maior que a validade aceita.

Veja também: [Link de verificação hospedado](https://unifokal.com/docs/api-rest#link-hospedado)

##### `environment_mismatch` HTTP 422

**O que aconteceu.** environment veio diferente do ambiente da chave. O ambiente é a chave.

**O que fazer.** Tire o campo, ou use a chave do ambiente que você quer: sk_test\_ para sandbox, sk_live\_ para produção.

**O que não fazer.** Não tente emitir link de produção com a chave de sandbox.

Veja também: [Link de verificação hospedado](https://unifokal.com/docs/api-rest#link-hospedado)

#### Cifra da carga do webhook, no painel

Respostas das telas de chave pública e de cifra por destino, no painel. Não aparecem nas rotas da chave secreta.

##### `encryption_key_missing` HTTP 409

**O que aconteceu.** Você tentou ligar a cifra de um destino sem chave pública ativa no ambiente dele.

**O que fazer.** Registre a chave pública do ambiente no painel e depois ligue a cifra no destino.

**O que não fazer.** Não desligue a cifra para receber em claro sem decidir isso de propósito: um destino com cifra obrigatória nunca recebe em claro.

Veja também: [Webhooks e eventos](https://unifokal.com/docs/webhooks#webhooks)

##### `encryption_key_in_use` HTTP 409

**O que aconteceu.** Você tentou aposentar a única chave ativa enquanto algum destino do ambiente exige a cifra.

**O que fazer.** Registre a chave nova primeiro, confirme que ela abre as entregas, e só então aposente a antiga.

**O que não fazer.** Não desligue a cifra do destino só para conseguir apagar a chave.

Veja também: [Webhooks e eventos](https://unifokal.com/docs/webhooks#webhooks)

##### `invalid_public_key_encoding` HTTP 400

**O que aconteceu.** A chave pública não está em base64 padrão, ou não está na forma canônica.

**O que fazer.** Exporte a chave pública em SPKI DER e codifique em base64 padrão, sem quebras de linha.

**O que não fazer.** Não cole a chave privada: o painel só recebe a pública.

Veja também: [Webhooks e eventos](https://unifokal.com/docs/webhooks#webhooks)

##### `invalid_public_key_format` HTTP 400

**O que aconteceu.** A chave enviada não é o SPKI DER de uma chave X25519 (44 bytes).

**O que fazer.** Gere um par X25519 e envie a parte pública em SPKI DER, em base64.

**O que não fazer.** Não envie chave RSA nem de curva P-256: o envelope usa X25519.

Veja também: [Webhooks e eventos](https://unifokal.com/docs/webhooks#webhooks)

##### `invalid_public_key_small_order` HTTP 400

**O que aconteceu.** A chave pública é um ponto de ordem pequena do X25519, que não serve para cifrar.

**O que fazer.** Gere um par novo com uma biblioteca de criptografia padrão e envie a parte pública.

**O que não fazer.** Não monte a chave à mão nem reaproveite chave de exemplo.

Veja também: [Webhooks e eventos](https://unifokal.com/docs/webhooks#webhooks)

## Glossário

<https://unifokal.com/docs/glossario>

### Glossário

Os termos de verificação de identidade e de empresa que aparecem no mercado, e o vocabulário da API UNIFOKAL: prefixos de id, estados da verificação e eventos de webhook. Os verbetes da API saem do mesmo contrato que a referência usa, então o que está aqui é o que a API devolve.

- [A](https://unifokal.com/docs/glossario#evento-act-rejected)
- [B](https://unifokal.com/docs/glossario#beneficiario-final)
- [C](https://unifokal.com/docs/glossario#chave-secreta)
- [D](https://unifokal.com/docs/glossario#recomendacao-decline)
- [E](https://unifokal.com/docs/glossario#envelope-de-erro)
- [F](https://unifokal.com/docs/glossario#face-match-1-1)
- [H](https://unifokal.com/docs/glossario#risco-high)
- [I](https://unifokal.com/docs/glossario#idempotencia)
- [K](https://unifokal.com/docs/glossario#kyb)
- [L](https://unifokal.com/docs/glossario#lgpd)
- [M](https://unifokal.com/docs/glossario#matricula-biometrica)
- [O](https://unifokal.com/docs/glossario#ocr-de-documento)
- [P](https://unifokal.com/docs/glossario#evento-passkey-bound)
- [R](https://unifokal.com/docs/glossario#reautenticacao-facial)
- [S](https://unifokal.com/docs/glossario#screening)
- [T](https://unifokal.com/docs/glossario#titular)
- [V](https://unifokal.com/docs/glossario#prefixo-verification)
- [W](https://unifokal.com/docs/glossario#webhook)

#### act.rejected

O titular recusou o ato que a sessão pedia para ele aprovar com a passkey.

Na documentação: [Aprovação de ato com passkey](https://unifokal.com/docs/modulos/passkey#modulo-passkey). Veja também: [Webhook](https://unifokal.com/docs/glossario#webhook), [Titular](https://unifokal.com/docs/glossario#titular).

#### Ambiente (sandbox e produção)

Sandbox é o ambiente de teste, gratuito e com desfechos simulados; produção verifica gente de verdade e é cobrado. O ambiente é definido pela chave usada.

Na documentação: [Sandbox e produção: o que muda](https://unifokal.com/docs/ambientes#ambientes). Veja também: [Chave secreta (sk_test\_ e sk_live\_)](https://unifokal.com/docs/glossario#chave-secreta), [Flow](https://unifokal.com/docs/glossario#flow).

#### approve (Aprovar)

Recomendação de aprovar, no campo recommendation do webhook.

Na documentação: [Os desfechos, e o que fazer com cada um](https://unifokal.com/docs/integracao#desfechos). Veja também: [Desfecho](https://unifokal.com/docs/glossario#desfecho), [review (Revisar)](https://unifokal.com/docs/glossario#estado-review).

#### approved (Aprovado)

A verificação foi aprovada. Libere o que dependia dela.

Na documentação: [Os desfechos, e o que fazer com cada um](https://unifokal.com/docs/integracao#desfechos). Veja também: [Desfecho](https://unifokal.com/docs/glossario#desfecho), [verification.completed](https://unifokal.com/docs/glossario#evento-verification-completed).

#### Assinatura eletrônica avançada

Assinatura de um documento ligada de forma única a quem assinou, com a identidade verificada e prova conferível fora da UNIFOKAL.

Na documentação: [Assinatura eletrônica](https://unifokal.com/docs/modulos/assinatura#modulo-assinatura). Veja também: [Dossiê](https://unifokal.com/docs/glossario#dossie), [KYC (conheça o seu cliente)](https://unifokal.com/docs/glossario#kyc).

#### Beneficiário final (UBO)

A pessoa natural que, no fim da cadeia societária, controla ou é dona de uma empresa, direta ou indiretamente.

Na documentação: [Cadeia societária até o beneficiário final](https://unifokal.com/docs/modulos/ubo-profundo#modulo-ubo-profundo). Veja também: [KYB (conheça a empresa)](https://unifokal.com/docs/glossario#kyb), [Screening](https://unifokal.com/docs/glossario#screening).

#### blocked (Bloqueado)

A verificação bateu na sua lista de bloqueio. Não é cobrada.

Na documentação: [Os desfechos, e o que fazer com cada um](https://unifokal.com/docs/integracao#desfechos). Veja também: [Lista de bloqueio](https://unifokal.com/docs/glossario#lista-de-bloqueio), [verification.blocked](https://unifokal.com/docs/glossario#evento-verification-blocked).

#### Busca 1:N (múltiplas contas)

Comparação de um rosto com todos os rostos já verificados na sua base, para saber se a mesma pessoa já abriu outra conta com outro nome.

Na documentação: [Detecção de múltiplas contas (1:N)](https://unifokal.com/docs/modulos/face-unica#modulo-face-unica). Veja também: [Face match 1:1](https://unifokal.com/docs/glossario#face-match-1-1), [Lista de bloqueio](https://unifokal.com/docs/glossario#lista-de-bloqueio).

#### Chave secreta (sk_test\_ e sk_live\_)

A credencial da sua integração, usada só no seu servidor. sk_test\_ chama o sandbox e sk_live\_ chama a produção.

Na documentação: [Autenticação](https://unifokal.com/docs/autenticacao#auth). Veja também: [Ambiente (sandbox e produção)](https://unifokal.com/docs/glossario#ambiente), [Credencial do painel (dsk\_)](https://unifokal.com/docs/glossario#credencial-do-painel).

#### consent_declined (Consentimento recusado)

O titular recusou o consentimento na abertura da verificação. É um desfecho neutro e final, nunca reprovação.

Na documentação: [Os desfechos, e o que fazer com cada um](https://unifokal.com/docs/integracao#desfechos). Veja também: [Desfecho](https://unifokal.com/docs/glossario#desfecho), [Titular](https://unifokal.com/docs/glossario#titular), [LGPD](https://unifokal.com/docs/glossario#lgpd).

#### Credencial do painel (dsk\_)

A credencial da sessão de um membro no painel. Não entra nas rotas da integração, que só aceitam a chave secreta.

Na documentação: [Autenticação](https://unifokal.com/docs/autenticacao#auth). Veja também: [Chave secreta (sk_test\_ e sk_live\_)](https://unifokal.com/docs/glossario#chave-secreta).

#### decline (Recusar)

Recomendação de recusar, no campo recommendation do webhook.

Na documentação: [Os desfechos, e o que fazer com cada um](https://unifokal.com/docs/integracao#desfechos). Veja também: [Desfecho](https://unifokal.com/docs/glossario#desfecho), [review (Revisar)](https://unifokal.com/docs/glossario#estado-review).

#### denied (Recusado)

A verificação foi recusada. Não libere o acesso, e fale com o titular pelo texto de subject_message.

Na documentação: [Os desfechos, e o que fazer com cada um](https://unifokal.com/docs/integracao#desfechos). Veja também: [Desfecho](https://unifokal.com/docs/glossario#desfecho), [verification.completed](https://unifokal.com/docs/glossario#evento-verification-completed).

#### Desfecho

O estado final de uma verificação (aprovada, recusada, em revisão e os demais) e o que você faz com cada um.

Na documentação: [Os desfechos, e o que fazer com cada um](https://unifokal.com/docs/integracao#desfechos). Veja também: [approved (Aprovado)](https://unifokal.com/docs/glossario#estado-approved), [denied (Recusado)](https://unifokal.com/docs/glossario#estado-denied), [review (Revisar)](https://unifokal.com/docs/glossario#estado-review), [approve (Aprovar)](https://unifokal.com/docs/glossario#recomendacao-approve).

#### Dossiê

O registro completo de uma verificação ou de uma assinatura, com as evidências e os carimbos, para guardar e apresentar a quem precisar conferir.

Na documentação: [Assinatura eletrônica](https://unifokal.com/docs/modulos/assinatura#modulo-assinatura). Veja também: [Verificação](https://unifokal.com/docs/glossario#verificacao), [Assinatura eletrônica avançada](https://unifokal.com/docs/glossario#assinatura-eletronica).

#### Envelope de erro

A forma de toda resposta de erro da API: um código estável em error e um texto legível em message. Trate pelo código.

Na documentação: [API REST (4 endpoints públicos)](https://unifokal.com/docs/api-rest#api). Veja também: [Idempotência](https://unifokal.com/docs/glossario#idempotencia).

#### Face match 1:1

Comparação de um rosto com outro rosto específico: a selfie do titular contra a foto do documento dele. Responde se são a mesma pessoa.

Na documentação: [Documento, face match e prova de vida](https://unifokal.com/docs/modulos/identidade#modulos-identidade). Veja também: [Busca 1:N (múltiplas contas)](https://unifokal.com/docs/glossario#busca-1-n), [Prova de vida (liveness)](https://unifokal.com/docs/glossario#prova-de-vida), [Reautenticação facial](https://unifokal.com/docs/glossario#reautenticacao-facial).

#### failed (Erro)

A análise não foi concluída do nosso lado. Não é cobrada e não é reprovação do titular: abra uma verificação nova para ele.

Na documentação: [Os desfechos, e o que fazer com cada um](https://unifokal.com/docs/integracao#desfechos). Veja também: [Desfecho](https://unifokal.com/docs/glossario#desfecho), [verification.failed](https://unifokal.com/docs/glossario#evento-verification-failed).

#### Flow

A receita da verificação: quais módulos rodam e em que ambiente. Você monta no painel, e cada sessão aponta um flow pelo flow_id.

Na documentação: [Integração ponta a ponta](https://unifokal.com/docs/integracao#integracao). Veja também: [Módulo](https://unifokal.com/docs/glossario#modulo), [flow\_ (flow)](https://unifokal.com/docs/glossario#prefixo-flow), [Ambiente (sandbox e produção)](https://unifokal.com/docs/glossario#ambiente).

#### flow\_ (flow)

Prefixo do id de um flow. É o valor que vai em flow_id na criação da sessão.

Na documentação: [POST /v1/verification-sessions](https://unifokal.com/docs/api-rest#post-sessions). Veja também: [Flow](https://unifokal.com/docs/glossario#flow).

#### high (risco alto)

Nível de risco alto, no campo risk_level do webhook.

Na documentação: [Os desfechos, e o que fazer com cada um](https://unifokal.com/docs/integracao#desfechos). Veja também: [Desfecho](https://unifokal.com/docs/glossario#desfecho).

#### HMAC (assinatura do webhook)

O cabeçalho que prova que o webhook saiu da UNIFOKAL e não foi alterado. O seu servidor confere com o segredo que você cadastrou antes de confiar no corpo.

Na documentação: [Valide a assinatura do webhook](https://unifokal.com/docs/webhooks#webhook-signature). Veja também: [Webhook](https://unifokal.com/docs/glossario#webhook), [Replay de webhook](https://unifokal.com/docs/glossario#replay).

#### Idempotência

Repetir a mesma chamada não duplica o efeito. Na criação de sessão o reference_id é a chave: o retry recebe a mesma sessão, sem cobrança dupla.

Na documentação: [Integração ponta a ponta](https://unifokal.com/docs/integracao#integracao). Veja também: [reference_id](https://unifokal.com/docs/glossario#reference-id), [Sessão de verificação](https://unifokal.com/docs/glossario#sessao-de-verificacao).

#### KYB (conheça a empresa)

Verificação de uma empresa: CNPJ, situação cadastral, quadro societário, representante e beneficiário final.

Na documentação: [Consulta cadastral de CPF e de CNPJ](https://unifokal.com/docs/modulos/cadastrais#modulos-cadastrais). Veja também: [KYC (conheça o seu cliente)](https://unifokal.com/docs/glossario#kyc), [Beneficiário final (UBO)](https://unifokal.com/docs/glossario#beneficiario-final), [Screening](https://unifokal.com/docs/glossario#screening).

#### KYC (conheça o seu cliente)

Verificação de que uma pessoa é quem diz ser, antes de abrir conta ou liberar um serviço. Reúne documento, face match, prova de vida e consultas cadastrais e de listas.

Na documentação: [Documento, face match e prova de vida](https://unifokal.com/docs/modulos/identidade#modulos-identidade). Veja também: [KYB (conheça a empresa)](https://unifokal.com/docs/glossario#kyb), [Prova de vida (liveness)](https://unifokal.com/docs/glossario#prova-de-vida), [Face match 1:1](https://unifokal.com/docs/glossario#face-match-1-1), [PEP (pessoa exposta politicamente)](https://unifokal.com/docs/glossario#pep).

#### LGPD

Lei Geral de Proteção de Dados Pessoais (Lei 13.709/2018), que rege o tratamento de dado pessoal no Brasil.

Na documentação: [Privacidade e consentimento](https://unifokal.com/docs/conta-e-dados#privacidade-consentimento). Veja também: [Titular](https://unifokal.com/docs/glossario#titular), [verification.subject_erased](https://unifokal.com/docs/glossario#evento-verification-subject-erased).

#### Link hospedado

Um endereço de verificação pronto para mandar ao titular por e-mail ou mensagem, sem integrar o widget na sua página.

Na documentação: [Link de verificação hospedado](https://unifokal.com/docs/api-rest#link-hospedado). Veja também: [Widget](https://unifokal.com/docs/glossario#widget), [Sessão de verificação](https://unifokal.com/docs/glossario#sessao-de-verificacao).

#### Lista de bloqueio

A lista de documentos, rostos e referências que você mesmo barrou. Vale para a conta inteira ou só para um flow, e uma verificação que bate nela termina bloqueada.

Na documentação: [Lista de bloqueio](https://unifokal.com/docs/ambientes#blocklist). Veja também: [blocked (Bloqueado)](https://unifokal.com/docs/glossario#estado-blocked), [verification.blocked](https://unifokal.com/docs/glossario#evento-verification-blocked), [reference_id](https://unifokal.com/docs/glossario#reference-id).

#### Listas restritivas e sanções

Listas oficiais de pessoas e empresas com restrição, como as de sanções internacionais. Conferir o titular contra elas é parte do screening.

Na documentação: [PEP e listas restritivas](https://unifokal.com/docs/modulos/pep#modulo-pep). Veja também: [PEP (pessoa exposta politicamente)](https://unifokal.com/docs/glossario#pep), [Screening](https://unifokal.com/docs/glossario#screening), [Mídia adversa](https://unifokal.com/docs/glossario#midia-adversa).

#### low (risco baixo)

Nível de risco baixo, no campo risk_level do webhook.

Na documentação: [Os desfechos, e o que fazer com cada um](https://unifokal.com/docs/integracao#desfechos). Veja também: [Desfecho](https://unifokal.com/docs/glossario#desfecho).

#### Matrícula biométrica

A referência facial de um titular, criada num onboarding aprovado e usada depois pela reautenticação facial. Existe uma por reference_id.

Na documentação: [Reautenticação facial](https://unifokal.com/docs/modulos/face-reauth#modulo-face-reauth). Veja também: [Reautenticação facial](https://unifokal.com/docs/glossario#reautenticacao-facial), [reference_id](https://unifokal.com/docs/glossario#reference-id).

#### medium (risco médio)

Nível de risco médio, no campo risk_level do webhook.

Na documentação: [Os desfechos, e o que fazer com cada um](https://unifokal.com/docs/integracao#desfechos). Veja também: [Desfecho](https://unifokal.com/docs/glossario#desfecho).

#### Mídia adversa

Notícia pública que associa o nome de uma pessoa a crime ou irregularidade, entregue como evidência para revisão.

Na documentação: [Mídia adversa](https://unifokal.com/docs/modulos/midia-adversa#modulo-midia-adversa). Veja também: [Screening](https://unifokal.com/docs/glossario#screening), [Listas restritivas e sanções](https://unifokal.com/docs/glossario#listas-restritivas).

#### Módulo

Uma capacidade que dá para ligar num flow, como documento, prova de vida, PEP ou consulta cadastral. Cada um tem seu resultado no webhook.

Na documentação: [Catálogo de módulos](https://unifokal.com/docs/modulos#catalogo-modulos). Veja também: [Flow](https://unifokal.com/docs/glossario#flow), [Verificação](https://unifokal.com/docs/glossario#verificacao).

#### Monitoramento contínuo

Vigilância do titular já aprovado nas listas de PEP e sanções, com aviso por webhook quando ele aparece numa delas depois do onboarding.

Na documentação: [Monitoramento contínuo](https://unifokal.com/docs/modulos/monitoring-aml#modulo-monitoring-aml). Veja também: [verification.monitoring](https://unifokal.com/docs/glossario#evento-verification-monitoring), [Screening](https://unifokal.com/docs/glossario#screening).

#### MRZ (zona de leitura mecânica)

As linhas de caracteres padronizados no pé de passaportes e documentos de viagem, no padrão ICAO 9303.

Na documentação: [Documento de viagem estrangeiro (MRZ)](https://unifokal.com/docs/modulos/doc-global#modulo-doc-global). Veja também: [OCR de documento](https://unifokal.com/docs/glossario#ocr-de-documento).

#### OCR de documento

Leitura automática dos campos impressos num documento fotografado, como nome, CPF e data de nascimento.

Na documentação: [Documento, face match e prova de vida](https://unifokal.com/docs/modulos/identidade#modulos-identidade). Veja também: [MRZ (zona de leitura mecânica)](https://unifokal.com/docs/glossario#mrz), [KYC (conheça o seu cliente)](https://unifokal.com/docs/glossario#kyc).

#### Onboarding

A entrada de um cliente novo no seu produto, o momento em que a verificação de identidade costuma acontecer.

Na documentação: [Integração ponta a ponta](https://unifokal.com/docs/integracao#integracao). Veja também: [KYC (conheça o seu cliente)](https://unifokal.com/docs/glossario#kyc), [Titular](https://unifokal.com/docs/glossario#titular), [Flow](https://unifokal.com/docs/glossario#flow).

#### passkey.bound

A passkey do titular passou a valer na conta dele na sua base, com o modo do vínculo e a garantia (Identidade ou Presença). Quando o vínculo é uma recuperação, avise o titular.

Na documentação: [Aprovação de ato com passkey](https://unifokal.com/docs/modulos/passkey#modulo-passkey). Veja também: [Webhook](https://unifokal.com/docs/glossario#webhook), [Titular](https://unifokal.com/docs/glossario#titular).

#### passkey.revoked

Uma passkey do titular deixou de valer, revogada pelo painel ou pelo apagamento dos dados dele. Ela não aprova mais nenhum ato.

Na documentação: [Aprovação de ato com passkey](https://unifokal.com/docs/modulos/passkey#modulo-passkey). Veja também: [Webhook](https://unifokal.com/docs/glossario#webhook), [Titular](https://unifokal.com/docs/glossario#titular).

#### pending (Pendente)

A verificação ainda não tem decisão final. Espere o webhook antes de agir.

Na documentação: [Os desfechos, e o que fazer com cada um](https://unifokal.com/docs/integracao#desfechos). Veja também: [Desfecho](https://unifokal.com/docs/glossario#desfecho), [verification.pending](https://unifokal.com/docs/glossario#evento-verification-pending).

#### PEP (pessoa exposta politicamente)

Pessoa que ocupa ou ocupou cargo público relevante, e os familiares e próximos dela. A regulação pede atenção reforçada, não recusa automática.

Na documentação: [PEP e listas restritivas](https://unifokal.com/docs/modulos/pep#modulo-pep). Veja também: [Listas restritivas e sanções](https://unifokal.com/docs/glossario#listas-restritivas), [PLD e AML](https://unifokal.com/docs/glossario#pld-aml), [Screening](https://unifokal.com/docs/glossario#screening).

#### pix_device.revoked

Aviso de que um dispositivo Pix cadastrado foi revogado pelo seu operador.

Na documentação: [Cadastro de dispositivo Pix](https://unifokal.com/docs/modulos/pix-device#modulo-pix-device). Veja também: [Webhook](https://unifokal.com/docs/glossario#webhook).

#### PLD e AML

Prevenção à lavagem de dinheiro. No onboarding, é conferir o cliente contra listas de PEP e de sanções e manter o registro do que foi conferido.

Na documentação: [PEP e listas restritivas](https://unifokal.com/docs/modulos/pep#modulo-pep). Veja também: [PEP (pessoa exposta politicamente)](https://unifokal.com/docs/glossario#pep), [Listas restritivas e sanções](https://unifokal.com/docs/glossario#listas-restritivas), [Monitoramento contínuo](https://unifokal.com/docs/glossario#monitoramento-continuo).

#### pld.alert.created

Aviso de que o monitoramento de PLD/FT selecionou uma operação ou situação para a sua análise. O corpo é mínimo e sigiloso, e o detalhe fica no painel.

Na documentação: [PLD/FT pela regra da norma](https://unifokal.com/docs/pld-ft#pld-ft). Veja também: [Webhook](https://unifokal.com/docs/glossario#webhook).

#### Prova de vida (liveness)

Confirmação de que a selfie é de uma pessoa presente diante da câmera naquele momento, e não de uma foto ou de uma tela apresentada à câmera.

Na documentação: [Documento, face match e prova de vida](https://unifokal.com/docs/modulos/identidade#modulos-identidade). Veja também: [Face match 1:1](https://unifokal.com/docs/glossario#face-match-1-1), [KYC (conheça o seu cliente)](https://unifokal.com/docs/glossario#kyc), [Selfie](https://unifokal.com/docs/glossario#selfie).

#### Reautenticação facial

Nova confirmação, numa ação sensível, de que quem age agora é a mesma pessoa aprovada no onboarding, sem pedir documento de novo.

Na documentação: [Reautenticação facial](https://unifokal.com/docs/modulos/face-reauth#modulo-face-reauth). Veja também: [Face match 1:1](https://unifokal.com/docs/glossario#face-match-1-1), [Matrícula biométrica](https://unifokal.com/docs/glossario#matricula-biometrica).

#### reference_id

O identificador do titular no SEU sistema, obrigatório na criação da sessão. Volta no webhook, serve de chave de idempotência e pode entrar na lista de bloqueio.

Na documentação: [POST /v1/verification-sessions](https://unifokal.com/docs/api-rest#post-sessions). Veja também: [Idempotência](https://unifokal.com/docs/glossario#idempotencia), [Titular](https://unifokal.com/docs/glossario#titular), [Lista de bloqueio](https://unifokal.com/docs/glossario#lista-de-bloqueio).

#### Replay de webhook

O reenvio, pedido por você, de entregas de webhook que o seu servidor perdeu, pela API ou pelo painel.

Na documentação: [POST /v1/webhook-events/replay](https://unifokal.com/docs/api-rest#post-webhook-replay). Veja também: [Webhook](https://unifokal.com/docs/glossario#webhook), [HMAC (assinatura do webhook)](https://unifokal.com/docs/glossario#assinatura-do-webhook).

#### review (Revisar)

A verificação precisa de uma decisão humana. Ela aparece na fila de revisão do painel para o seu time decidir.

Na documentação: [Os desfechos, e o que fazer com cada um](https://unifokal.com/docs/integracao#desfechos). Veja também: [Desfecho](https://unifokal.com/docs/glossario#desfecho), [review (Revisar)](https://unifokal.com/docs/glossario#recomendacao-review).

#### review (Revisar)

Recomendação de revisar antes de decidir, no campo recommendation do webhook.

Na documentação: [Os desfechos, e o que fazer com cada um](https://unifokal.com/docs/integracao#desfechos). Veja também: [Desfecho](https://unifokal.com/docs/glossario#desfecho), [review (Revisar)](https://unifokal.com/docs/glossario#estado-review).

#### Screening

A conferência de uma pessoa ou empresa contra listas de PEP, sanções e outras restrições, com a evidência de cada achado no resultado.

Na documentação: [Screening do quadro societário](https://unifokal.com/docs/modulos/screening-socios#screening-socios). Veja também: [PEP (pessoa exposta politicamente)](https://unifokal.com/docs/glossario#pep), [Listas restritivas e sanções](https://unifokal.com/docs/glossario#listas-restritivas), [Monitoramento contínuo](https://unifokal.com/docs/glossario#monitoramento-continuo).

#### Selfie

A foto do rosto do titular capturada pelo widget durante a verificação.

Na documentação: [O Widget](https://unifokal.com/docs/widget#widget). Veja também: [Prova de vida (liveness)](https://unifokal.com/docs/glossario#prova-de-vida), [Face match 1:1](https://unifokal.com/docs/glossario#face-match-1-1), [Busca 1:N (múltiplas contas)](https://unifokal.com/docs/glossario#busca-1-n).

#### Sessão de verificação

O que o seu servidor cria com a chave secreta para um titular fazer a verificação. Tem id com prefixo vs\_, vale por tempo limitado e é o que o widget recebe para abrir.

Na documentação: [POST /v1/verification-sessions](https://unifokal.com/docs/api-rest#post-sessions). Veja também: [vs\_ (sessão de verificação)](https://unifokal.com/docs/glossario#prefixo-verification-session), [Widget](https://unifokal.com/docs/glossario#widget), [Verificação](https://unifokal.com/docs/glossario#verificacao), [reference_id](https://unifokal.com/docs/glossario#reference-id).

#### Titular

A pessoa que está sendo verificada: o seu usuário final, dono dos dados que a verificação trata.

Na documentação: [Privacidade e consentimento](https://unifokal.com/docs/conta-e-dados#privacidade-consentimento). Veja também: [reference_id](https://unifokal.com/docs/glossario#reference-id), [Onboarding](https://unifokal.com/docs/glossario#onboarding).

#### ver\_ (verificação)

Prefixo do id de uma verificação. Chega no webhook em verification_id, e é por ele que você concilia.

Na documentação: [Webhooks e eventos](https://unifokal.com/docs/webhooks#webhooks). Veja também: [Verificação](https://unifokal.com/docs/glossario#verificacao), [Webhook](https://unifokal.com/docs/glossario#webhook).

#### Verificação

O resultado de uma sessão: a análise dos módulos do flow e o desfecho. Tem id com prefixo ver\_ e chega ao seu servidor pelo webhook.

Na documentação: [Os desfechos, e o que fazer com cada um](https://unifokal.com/docs/integracao#desfechos). Veja também: [ver\_ (verificação)](https://unifokal.com/docs/glossario#prefixo-verification), [Desfecho](https://unifokal.com/docs/glossario#desfecho), [Webhook](https://unifokal.com/docs/glossario#webhook).

#### verification.blocked

A verificação terminou barrada pela sua lista de bloqueio. Não é cobrada.

Na documentação: [Webhooks e eventos](https://unifokal.com/docs/webhooks#webhooks). Veja também: [Lista de bloqueio](https://unifokal.com/docs/glossario#lista-de-bloqueio), [blocked (Bloqueado)](https://unifokal.com/docs/glossario#estado-blocked).

#### verification.completed

A verificação terminou com uma decisão. O status do corpo diz qual, e é por ele que você age.

Na documentação: [Webhooks e eventos](https://unifokal.com/docs/webhooks#webhooks). Veja também: [Webhook](https://unifokal.com/docs/glossario#webhook), [approved (Aprovado)](https://unifokal.com/docs/glossario#estado-approved), [denied (Recusado)](https://unifokal.com/docs/glossario#estado-denied).

#### verification.failed

A análise não foi concluída do nosso lado. A verificação não é cobrada: trate como inconclusiva e abra outra.

Na documentação: [Webhooks e eventos](https://unifokal.com/docs/webhooks#webhooks). Veja também: [failed (Erro)](https://unifokal.com/docs/glossario#estado-failed).

#### verification.monitoring

Alerta do monitoramento contínuo: um titular aprovado apareceu numa lista de sanções ou de PEP depois do onboarding. Quem decide é você.

Na documentação: [Webhooks e eventos](https://unifokal.com/docs/webhooks#webhooks). Veja também: [Monitoramento contínuo](https://unifokal.com/docs/glossario#monitoramento-continuo), [review (Revisar)](https://unifokal.com/docs/glossario#estado-review).

#### verification.pending

A verificação ficou aguardando uma decisão. Trate como pendente até o próximo evento dela.

Na documentação: [Webhooks e eventos](https://unifokal.com/docs/webhooks#webhooks). Veja também: [pending (Pendente)](https://unifokal.com/docs/glossario#estado-pending), [review (Revisar)](https://unifokal.com/docs/glossario#estado-review).

#### verification.subject_erased

Os dados pessoais do titular daquela verificação foram apagados do nosso lado. Repita o apagamento nos seus sistemas (LGPD, art. 18, § 6º).

Na documentação: [Webhooks e eventos](https://unifokal.com/docs/webhooks#webhooks). Veja também: [LGPD](https://unifokal.com/docs/glossario#lgpd), [Titular](https://unifokal.com/docs/glossario#titular).

#### verification_link.claimed

Aviso de que a pessoa abriu o link de verificação que você emitiu e começou a jornada.

Na documentação: [Assinatura eletrônica](https://unifokal.com/docs/modulos/assinatura#modulo-assinatura). Veja também: [Webhook](https://unifokal.com/docs/glossario#webhook).

#### vs\_ (sessão de verificação)

Prefixo do id de uma sessão de verificação. É o que o widget recebe para montar.

Na documentação: [POST /v1/verification-sessions](https://unifokal.com/docs/api-rest#post-sessions). Veja também: [Sessão de verificação](https://unifokal.com/docs/glossario#sessao-de-verificacao), [Widget](https://unifokal.com/docs/glossario#widget).

#### Webhook

O aviso que o nosso servidor manda ao seu quando algo acontece, como a verificação terminar. É a fonte oficial do resultado.

Na documentação: [Webhooks e eventos](https://unifokal.com/docs/webhooks#webhooks). Veja também: [HMAC (assinatura do webhook)](https://unifokal.com/docs/glossario#assinatura-do-webhook), [Replay de webhook](https://unifokal.com/docs/glossario#replay), [verification.completed](https://unifokal.com/docs/glossario#evento-verification-completed).

#### webhook.test

O evento de teste disparado pelo painel, para conferir endereço, assinatura e parser. Nunca representa uma verificação.

Na documentação: [Webhooks e eventos](https://unifokal.com/docs/webhooks#webhooks). Veja também: [Webhook](https://unifokal.com/docs/glossario#webhook), [HMAC (assinatura do webhook)](https://unifokal.com/docs/glossario#assinatura-do-webhook).

#### whe\_ (destino de webhook)

Prefixo do id de um destino de webhook cadastrado no painel.

Na documentação: [Webhooks e eventos](https://unifokal.com/docs/webhooks#webhooks). Veja também: [Webhook](https://unifokal.com/docs/glossario#webhook).

#### whk\_ (chave pública de cifra)

Prefixo do id da chave pública que cifra a carga do webhook. Vem no campo kid do envelope cifrado e diz qual chave privada abre a entrega.

Na documentação: [Webhooks e eventos](https://unifokal.com/docs/webhooks#webhooks). Veja também: [Webhook](https://unifokal.com/docs/glossario#webhook).

#### Widget

A tela de verificação que você embute no seu site com uma linha. Monta com o id da sessão e nunca recebe a chave secreta.

Na documentação: [O Widget](https://unifokal.com/docs/widget#widget). Veja também: [Sessão de verificação](https://unifokal.com/docs/glossario#sessao-de-verificacao), [Link hospedado](https://unifokal.com/docs/glossario#link-hospedado).

## Catálogo de módulos

<https://unifokal.com/docs/modulos>

### Catálogo de módulos

Tudo o que dá para ligar num **Flow**, num lugar só. O **slug** é o mesmo nome nas três pontas: o módulo que você liga no flow, o `module` de cada item de `check_details` no webhook e a linha da [tabela pública de preços](https://unifokal.com/precos), que serve o preço direto do mesmo catálogo que a API cobra. O resumo `checks` usa chaves semânticas próprias (`identity`, `company`...), descritas em [Webhooks](https://unifokal.com/docs/webhooks#webhooks). Módulo com a venda pausada aparece com o selo "Em breve", já com preço na tabela.

| módulo | o que entrega | doc |
| --- | --- | --- |
| Identidade e biometria | | |
| cpf_ocr Documento (OCR) | Lê o documento (RG ou CNH) por OCR e extrai nome, CPF, número do documento e nascimento. | [seção](https://unifokal.com/docs/modulos/identidade#modulo-cpf-ocr) |
| face Face Match | Confirma que a pessoa da selfie é a mesma da foto do documento apresentado. | [seção](https://unifokal.com/docs/modulos/identidade#modulo-face) |
| liveness Liveness | Confirma que há uma pessoa viva na frente da câmera. Barra foto impressa, tela e vídeo gravado, e confere a origem da captura contra injeção de câmera. | [seção](https://unifokal.com/docs/modulos/identidade#modulo-liveness) |
| idade Verificação de Idade | Estima a faixa etária pela selfie que já foi capturada, sem pedir documento. Responde sim, não ou inconclusivo para a idade mínima de 18 anos: o sim só sai com margem de segurança acima dessa idade, e a faixa de dúvida vira inconclusivo em vez de chute. | [seção](https://unifokal.com/docs/modulos/idade#modulo-idade) |
| face_unica Detecção de Múltiplas Contas | Busca o rosto na SUA base (1:N) e aponta se ele já abriu outra conta. A base é sua, nunca compartilhada entre clientes. | [seção](https://unifokal.com/docs/modulos/face-unica#modulo-face-unica) |
| endereco_ocr Comprovante de Endereço | Lê o comprovante de endereço que o titular envia (conta de luz, água, gás, telefone ou internet, fatura ou extrato bancário, contrato de aluguel ou documento de órgão público) e devolve o endereço lido com CEP, cidade e UF, o tipo do documento, a data de emissão e há quantos dias ele foi emitido. Confere a recência: comprovante com mais de 90 dias sai como fora do prazo e o titular reenvia um mais novo. Cruza o nome do titular do comprovante com o nome lido do documento de identidade do mesmo fluxo e responde se os dois batem. Se você já tem o endereço do titular em cadastro, pode informar o CEP e a UF na criação da sessão, sempre do seu servidor, e a resposta também diz se o comprovante bate com ele; sem isso, esse cruzamento sai como não sabido, nunca como se batesse. É verificação documental com cruzamento de nome, e não prova absoluta de residência: por isso nome divergente nunca reprova sozinho, vai para revisão humana com a evidência. Endereço não localizado, comprovante fora do prazo e arquivo ilegível pedem o reenvio do arquivo, nunca a recusa do titular. Exige o documento de identidade e o face match no mesmo fluxo, porque o nome cruzado é o que foi lido do documento. | [seção](https://unifokal.com/docs/modulos/endereco-ocr#modulo-endereco-ocr) |
| doc_global Documento de Viagem | Lê a zona de leitura mecânica (MRZ) de passaportes e de documentos de identidade em cartão, de qualquer país, no padrão internacional ICAO 9303. Extrai número do documento, nome, nascimento, validade, sexo, país emissor e nacionalidade, e confere todos os dígitos verificadores impressos, inclusive o dígito composto, que é o que revela um documento com um campo alterado. Se a foto cortou a zona de leitura ou os dígitos não fecham por reflexo ou tremido, o titular reenvia a foto; ninguém é reprovado por foto ruim. Se a zona não fecha consigo mesma, a verificação vai para revisão humana com o laudo do que bateu e do que não bateu, nunca para recusa automática. Documento vencido não reprova: a validade volta no resultado como informação, e o que fazer com um documento fora da validade é decisão da sua política. Substitui a Verificação de Identidade no fluxo, porque os dois leem a mesma foto do documento. Não lê o chip do passaporte e não confere elementos de segurança do documento físico. | [seção](https://unifokal.com/docs/modulos/doc-global#modulo-doc-global) |
| doclink Reuso de Documento | Dispensa a foto do documento de quem você já aprovou antes: o titular faz a prova de vida e a selfie, e o módulo compara essa selfie nova com a selfie da verificação anterior da mesma conta na sua base, reaproveitando os dados que já foram lidos do documento naquela vez. O reuso acontece sempre dentro da sua conta, no mesmo ambiente e para a mesma conta que você identifica: não consulta base oficial, não consulta base de terceiro e nenhum dado seu é reusado por outro cliente. Quem nunca foi verificado por você não tem o que reusar, e a verificação vai para revisão humana em vez de ser recusada. Substitui a Verificação de Identidade e o Face Match no mesmo fluxo, e exige a prova de vida, que é o que impede alguém reusar uma identidade com a foto de uma foto. Uma selfie que não bate com a de origem nunca reprova sozinha: rosto muda com o tempo, com a luz e com a barba, e o caso vai para revisão humana. | [seção](https://unifokal.com/docs/modulos/doclink#modulo-doclink) |
| face_reauth Reautenticação facial Em breve | Reconfirma, em poucos segundos, que quem executa uma ação sensível (um saque, uma troca de chave Pix, um login em aparelho novo) é a mesma pessoa que você já aprovou no onboarding: o titular faz a prova de vida e uma selfie, e o módulo compara essa selfie nova com a matrícula biométrica que nasceu do onboarding aprovado dele, sem pedir documento e sem re-onboarding. A matrícula é criada de graça no onboarding que você marcar para matricular, exige face match e prova de vida aprovados, e vive só na sua conta e no seu ambiente: não há base compartilhada entre clientes. Quem não tem matrícula, ou ficou 180 dias sem autenticar, vai para revisão em vez de ser recusado, e a matrícula expira de vez após um ano, quando o titular refaz o onboarding. Tentativas seguidas que não batem travam a matrícula, e a tentativa seguinte já responde que ela está travada, para você proteger a conta. A trava em si não dispara webhook. | [seção](https://unifokal.com/docs/modulos/face-reauth#modulo-face-reauth) |
| passkey Aprovação com passkey Em breve | O titular aprova um ato seu, como uma transferência ou uma troca de chave Pix, com a passkey que ele vinculou à conta dele na sua base, confirmando com a biometria ou o PIN do próprio aparelho. A chave só passa a valer depois de um cadastro aprovado com prova de vida, e a assinatura cobre o resumo do ato que você enviou, então não serve para outro ato. Você recebe a evidência da aprovação, conferível sem consultar a UNIFOKAL. | [seção](https://unifokal.com/docs/modulos/passkey#modulo-passkey) |
| atestado_humano Atestado de pessoa verificada Em breve | Junto com a verificação aprovada, você recebe um atestado assinado pela UNIFOKAL de que a pessoa passou pela prova de vida, que você pode mostrar a um auditor ou parceiro sem entregar dado pessoal: ele traz um identificador aleatório próprio da sua plataforma, o resultado da prova de vida com a data e, quando o flow tem os módulos, que não encontramos outra conta dela no seu serviço e a maioridade pela data de nascimento do documento. Nenhum nome, CPF, rosto ou data de nascimento vai dentro. Quem recebe confere a assinatura sem consultar a UNIFOKAL e pode ver só a parte que você escolher mostrar. | [seção](https://unifokal.com/docs/modulos/atestado-humano#modulo-atestado-humano) |
| Validação cadastral de CPF | | |
| cpf_contatos CPF - Contatos | Confirma o CPF do documento na fonte oficial e devolve nome completo, telefones, WhatsApp e e-mails. | [seção](https://unifokal.com/docs/modulos/cadastrais#modulos-cadastrais) |
| cpf_enderecos CPF - Endereços | Confirma o CPF do documento e devolve nome, nascimento, situação na Receita e endereço completo com histórico. | [seção](https://unifokal.com/docs/modulos/cadastrais#modulos-cadastrais) |
| cpf_receita CPF - Receita e Óbito | Situação cadastral em tempo real na Receita, nome da mãe, gênero e indicador de óbito com comprovante. | [seção](https://unifokal.com/docs/modulos/cadastrais#modulos-cadastrais) |
| cpf_empresas CPF - Empresas no Nome | CNPJs em que o titular do documento participa do quadro societário, com qualificação e situação. | [seção](https://unifokal.com/docs/modulos/cadastrais#modulos-cadastrais) |
| coerencia_cadastral Coerência Cadastral | Confere o nome e o nascimento lidos do documento contra o cadastro oficial que a Validação CPF do mesmo fluxo já consultou, e devolve o veredito campo a campo: bate, não bate, ou não foi possível conferir. Também responde se o cadastro registra o titular como falecido. Não faz consulta nova e não devolve dado de cadastro: a resposta é o produto. Não confere filiação nem gênero, por decisão. Informativo: divergência de nome tem causa legítima (casamento, divórcio, mudança de prenome) e nunca reprova sozinha. | [seção](https://unifokal.com/docs/modulos/coerencia-cadastral#modulo-coerencia-cadastral) |
| dados_cadastrais Dados Cadastrais | Empacota e devolve os dados cadastrais oficiais da pessoa que você acabou de verificar: nome, data de nascimento, situação cadastral e se o cadastro registra o titular como falecido. Não faz consulta nova, usa o cadastro que a Validação CPF do mesmo fluxo já trouxe. Por decisão de privacidade, não entrega filiação, gênero, endereço nem contatos. Cada resposta traz o registro da finalidade: ao ligar este módulo você recebe dado pessoal de terceiro e passa a ser o controlador (LGPD), com base legal própria para a finalidade da verificação. | [seção](https://unifokal.com/docs/modulos/dados-cadastrais#modulo-dados-cadastrais) |
| Empresa (KYB) | | |
| cnpj_ocr OCR do CNPJ | Lê o documento societário enviado em PDF (cartão CNPJ, CCMEI ou contrato social registrado) e extrai o CNPJ. Sem câmera. | [seção](https://unifokal.com/docs/modulos/cnpj-ocr#modulo-cnpj-ocr) |
| cnpj_socios CNPJ + Sócios | Empresa e quadro de sócios completo (QSA) na mesma consulta: razão social, situação, CNAE, porte e cada sócio. | [seção](https://unifokal.com/docs/modulos/screening-socios#screening-socios) |
| cnpj_cadastro CNPJ - Cadastro Completo | Cadastro completo da empresa: razão social, nome fantasia, endereço, início das atividades, contatos e situação cadastral. | [seção](https://unifokal.com/docs/modulos/cadastrais#modulos-cadastrais) |
| cnpj_receita CNPJ - Receita em Tempo Real | Consulta em tempo real na Receita Federal: tudo do cadastro completo mais CNAE, natureza jurídica, responsável, porte, sócios, Simples Nacional e regimes tributários. | [seção](https://unifokal.com/docs/modulos/cadastrais#modulos-cadastrais) |
| cnpj_participacoes CNPJ - Participação Societária | Percentual de participação de cada sócio do quadro societário, inclusive sócios pessoa jurídica e estrangeiros. | [seção](https://unifokal.com/docs/modulos/cadastrais#modulos-cadastrais) |
| ubo_profundo Cadeia Societária | Sobe a cadeia societária da empresa quando um sócio dela é outra empresa, nível a nível, até chegar nas pessoas que de fato a controlam, com o percentual acumulado de cada uma pelo caminho. É a pergunta que a Instrução Normativa 2.119 da Receita e a Circular 3.978 do Banco Central fazem: quem detém 25% do capital, direta ou indiretamente, e quem exerce o controle. Cobra por empresa efetivamente subida, e o teto de empresas por verificação é você quem define no fluxo: o custo máximo aparece na tela antes de você salvar, e nunca é ultrapassado. Empresa sem sócio pessoa jurídica não sobe nada e não custa nada. Quando a cadeia não fecha, a resposta diz por quê: o seu teto foi atingido, o quadro tem ciclo societário, a sócia é estrangeira e não publica CNPJ, ou a fonte não respondeu. Isso nunca é entregue como "não há beneficiário final": cadeia incompleta é dita como incompleta. O módulo informa e não reprova ninguém, porque uma estrutura societária opaca é um fato sobre o registro público, não uma acusação contra quem está se verificando. Exige a Participação Societária no mesmo fluxo, que é de onde vem o percentual de cada sócio. | [seção](https://unifokal.com/docs/modulos/ubo-profundo#modulo-ubo-profundo) |
| inscricao_estadual Inscrição estadual Em breve | Consulta a inscrição estadual da empresa no cadastro de contribuintes da UF da matriz, a partir do CNPJ lido do documento societário, e devolve cada inscrição encontrada com a situação dela (habilitada ou não) e a data da consulta. A resposta cobre a UF da matriz: filial em outro estado tem inscrição própria. | [seção](https://unifokal.com/docs/modulos/inscricao-estadual#modulo-inscricao-estadual) |
| cndt Certidão trabalhista (CNDT) Em breve | Consulta a Certidão Negativa de Débitos Trabalhistas da empresa, emitida pela Justiça do Trabalho (CLT, art. 642-A), e devolve a situação (negativa, positiva ou positiva com efeito de negativa), o número, a data de emissão e a validade de 180 dias. A certidão vale para todos os estabelecimentos da empresa. | [seção](https://unifokal.com/docs/modulos/cndt#modulo-cndt) |
| representante_pj Representante vinculado à empresa Em breve | Confere o CPF do documento do representante que assina em nome da empresa contra o quadro societário da própria empresa, e devolve um entre três resultados: consta no quadro como administrador, consta no quadro mas sem função de administração, ou não consta no quadro. O terceiro caso não é recusa automática, vai para revisão humana com a evidência. O módulo diz o que o quadro societário publica sobre a pessoa, nunca se ela tem poderes para o ato específico: confirme o alcance da representação com o próprio cliente ou peça a procuração. | [seção](https://unifokal.com/docs/modulos/representante-pj#modulo-representante-pj) |
| kyb_estrangeira Empresa estrangeira Em breve | Verifica a empresa de fora do Brasil que não tem CNPJ, pelo identificador de entidade legal (LEI) ou, sem ele, pelo nome e pelo país. Com o LEI devolve a razão social, a jurisdição, a situação do registro e as controladoras contábeis direta e final. Sem o LEI devolve os candidatos para a sua equipe escolher. Não encontrar a empresa no índice não é recusa: vai para revisão, porque nem toda empresa tem LEI. A controladora contábil não é o beneficiário final. Os beneficiários finais que você declarar passam pelas listas de sanções e de pessoas expostas politicamente, e o resultado diz quais listas foram consultadas. | [seção](https://unifokal.com/docs/modulos/kyb-estrangeira#modulo-kyb-estrangeira) |
| Canal (e-mail e telefone) | | |
| email_otp Validação de e-mail | Prova posse da caixa de entrada: código de 6 dígitos enviado ao e-mail informado. Não prova identidade. | [seção](https://unifokal.com/docs/modulos/canal#modulos-canal) |
| sms_otp Telefone: código por SMS | Prova posse do número: código de 6 dígitos por SMS. Não prova identidade. | [seção](https://unifokal.com/docs/modulos/canal#modulos-canal) |
| Compliance | | |
| pep_sancoes PEP e Listas Restritivas | Consulta o CPF e o nome do documento nas listas oficiais: PEP e expulsões da CGU, empresas inidôneas (CEIS e CNEP) e sanções internacionais da ONU, do OFAC (Tesouro dos EUA) e do Reino Unido. Não cobre a lista de sanções da União Europeia. | [seção](https://unifokal.com/docs/modulos/pep#modulo-pep) |
| pld_monitor Monitoramento PLD/FT Em breve | Seleciona operações e situações pela relação da norma do seu setor, com o artigo e, quando a norma tem um, o código de enquadramento do Siscoaf em cada alerta, calibrada pela política que a sua organização aprovou. Cada alerta nasce com o prazo legal do seu regime, entra numa fila com disposição registrada e quatro olhos quando a política pede, e vira caso com dossiê e o rascunho da comunicação no formato do formulário do Siscoaf. A análise e a decisão de comunicar são do seu encarregado: o produto entrega o prazo, a evidência e o registro, guardados pelo prazo legal. | [seção](https://unifokal.com/docs/modulos/pld-monitor#modulo-pld-monitor) |
| pld_risco Classificação de risco PLD/FT Em breve | Classifica o risco do cliente na própria verificação, pela matriz da sua política, e devolve a faixa, os fatores e as medidas de diligência reforçada que a norma do seu regime pede, cada uma com o artigo. Faixa alta vira revisão com diligência reforçada, nunca recusa, e a data da próxima revisão cadastral já sai marcada. | [seção](https://unifokal.com/docs/modulos/pld-risco#modulo-pld-risco) |
| monitoring_aml Monitoramento Contínuo | Depois da aprovação, mantém o titular sob vigilância contínua nas mesmas listas do PEP e Listas Restritivas, sem nenhuma captura nova. Quando ele aparece numa lista em que não constava no onboarding, você recebe o webhook com a evidência minimizada e uma verificação de acompanhamento em revisão. O alerta nunca decide sozinho: quem revisa é você. A re-triagem periódica cobre as listas oficiais: mídia adversa não entra nela, então notícia publicada antes da inscrição não vira alerta. A carteira inteira fica no painel, com a data da última varredura de cada titular, e é de lá que você encerra o monitoramento de alguém quando o relacionamento termina. | [seção](https://unifokal.com/docs/modulos/monitoring-aml#modulo-monitoring-aml) |
| ofac_realtime OFAC - Sanções Internacionais Em breve | Consulta o CPF do documento na lista OFAC SDN de sanções internacionais em tempo real e devolve a atestação datada da consulta, com a data da versão da lista usada. Não devolve certidão em PDF. Add-on que se soma ao PEP e Listas Restritivas, não o substitui. | [seção](https://unifokal.com/docs/modulos/background#modulos-background) |
| antecedentes_cac Antecedentes Criminais Em breve | Consulta a Certidão de Antecedentes Criminais (CAC/SINIC) do CPF do documento em tempo real. O comprovante em PDF fica guardado, e a resposta traz o identificador dele para auditoria. | [seção](https://unifokal.com/docs/modulos/background#modulos-background) |
| mandados_interpol Mandados e Interpol Em breve | Consulta mandados de busca e apreensão (BNMP/CNJ) e alertas Interpol do CPF do documento. O comprovante em PDF fica guardado, e a resposta traz o identificador dele para auditoria. | [seção](https://unifokal.com/docs/modulos/background#modulos-background) |
| pep_parentes Parentes de PEP Em breve | Confere se o CPF do documento tem vínculo familiar com uma Pessoa Exposta Politicamente (PEP), a diligência reforçada que o PLD/FT pede sobre parentes e relacionados. Um vínculo encontrado nunca reprova sozinho: leva a verificação para revisão humana com a evidência do parentesco, e a decisão é sempre sua. Se a fonte não estiver disponível, o resultado sai indeterminado e a checagem não é cobrada, nunca um falso nada consta. | [seção](https://unifokal.com/docs/modulos/compliance-vinculos#modulos-compliance-vinculos) |
| impedidos_apostar Impedidos de Apostar | Confere o CPF e o nome lidos do documento contra a lista pública de agentes públicos do setor de apostas (Portal da Transparência), impedidos pela Lei 14.790/2023. Devolve as fontes e a versão de cada base usada como prova de diligência, e diz por fonte o que foi consultado e o que não foi. Não cobre atletas, árbitros nem dirigentes: a CBF não publica base para conferência automática, e listar sem consultar seria pior que não listar. Não cobre vínculo familiar, e não substitui a consulta obrigatória do operador ao SIGAP. | [seção](https://unifokal.com/docs/modulos/impedidos-apostar#modulo-impedidos-apostar) |
| impedidos_vinculos Vinculo Familiar com Impedido Em breve | Confere se o CPF lido do documento tem parente de 1o ou 2o grau que a nossa base pública de impedidos de apostar aponta como restrito (Lei 14.790/2023). O grafo familiar vem de fonte de parentesco de Pessoa Exposta Politicamente, e a resposta declara isso: parente que não aparece nessa fonte não entra na conferência. Cada resposta traz quantos parentes vieram, quantos foram conferidos e por que os demais não foram, para nada consta nunca parecer maior do que é. Nunca reprova sozinho: leva a verificação para revisão humana com evidência mínima, e nunca devolve nome, CPF ou grau do parente. Se nenhum parente puder ser conferido, o resultado sai indeterminado e a checagem não é cobrada. | [seção](https://unifokal.com/docs/modulos/compliance-vinculos#modulos-compliance-vinculos) |
| midia_adversa Mídia Adversa | Confere o nome lido do documento contra um corpus aberto de notícias (negative news) mantido por nós. Todo casamento é por nome: a resposta sempre declara o grau de confiança (forte ou possível homônimo) e a similaridade. Um hit forte nunca reprova sozinho: ele leva a verificação para revisão humana com a evidência (fonte, data e link da notícia). Provável homônimo apenas sinaliza, sem link e sem dossiê. Se a base não estiver disponível, o resultado sai indeterminado e a checagem não é cobrada, nunca um falso nada consta. | [seção](https://unifokal.com/docs/modulos/midia-adversa#modulo-midia-adversa) |
| pep_lista_restritiva Listas Restritivas Em breve | Consulta o CPF do documento nas listas restritivas do fornecedor de dados e devolve as entradas que casaram, como evidência para a sua revisão. Um resultado encontrado nunca reprova sozinho: leva a verificação para revisão humana e a decisão é sempre sua. Se a fonte não estiver disponível, o resultado sai indeterminado e a checagem não é cobrada, nunca um falso nada consta. Complementa o PEP e Listas Restritivas mantido por nós, não o substitui. | [seção](https://unifokal.com/docs/modulos/compliance-vinculos#modulos-compliance-vinculos) |
| antecedentes_estaduais Antecedentes Estaduais Em breve | Consulta antecedentes criminais nas bases estaduais cobertas pela fonte: Ceará, Minas Gerais, Mato Grosso e Rio Grande do Sul. A resposta declara sempre essa cobertura, e um nada consta vale apenas para esses estados. Complementa a certidão federal de Antecedentes Criminais, não a substitui. Um registro encontrado nunca reprova sozinho: leva a verificação para revisão humana com a evidência. | [seção](https://unifokal.com/docs/modulos/compliance-vinculos#modulos-compliance-vinculos) |
| processos_judiciais Processos Judiciais Em breve | Consulta ações e processos judiciais em nome do CPF do documento e devolve o dossiê com partes, classes e andamentos, como evidência para a sua revisão. Ter processo não é veredito: homônimos existem, e figurar como vítima ou autor não é risco. Por isso um resultado encontrado nunca reprova sozinho, leva a verificação para revisão humana e a decisão é sempre sua. Não se destina a seleção de candidatos a emprego. | [seção](https://unifokal.com/docs/modulos/compliance-vinculos#modulos-compliance-vinculos) |
| beneficios_gov Benefícios do Governo Em breve | Confere na fonte oficial do governo federal, a API do Portal da Transparência da CGU, se a pessoa que você acabou de verificar consta como beneficiária de programa social. A cobertura é declarada em toda resposta e é menor do que o nome sugere: a fonte publica quatro programas por CPF, o Novo Bolsa Família, o Seguro Defeso, o Garantia-Safra e o PETI. O Benefício de Prestação Continuada e o Auxílio Emergencial ficam de fora porque a fonte não os publica por pessoa, e um nada consta aqui vale sempre dentro da cobertura declarada. É informativo: nunca reprova e nunca leva a verificação para revisão, porque receber transferência de renda do Estado não é fraude, e recusar alguém por isso seria negar serviço por condição socioeconômica. Se o órgão não responder, o resultado sai pendente e a checagem não é cobrada, nunca um falso nada consta. Exige documento, face match e prova de vida no mesmo fluxo, porque o CPF consultado é o que foi lido do documento. | [seção](https://unifokal.com/docs/modulos/beneficios-gov#modulo-beneficios-gov) |
| Análise de crédito | | |
| credito_dividas Dívidas e Negativações Em breve | Consulta o CPF do documento e devolve score de crédito, renda presumida e negativações registradas. O resultado é assíncrono: normalmente chega em cerca de um minuto, entregue pelo webhook. | [seção](https://unifokal.com/docs/modulos/credito#modulos-credito) |
| credito_scr SCR Banco Central Em breve | Consulta o endividamento do CPF no Sistema de Informações de Crédito (SCR) do Banco Central: instituições, operações e carteira de crédito. O resultado é assíncrono: normalmente chega em cerca de um minuto, entregue pelo webhook. | [seção](https://unifokal.com/docs/modulos/credito#modulos-credito) |
| credito_boavista Boa Vista SCPC Em breve | Consulta o CPF no bureau Boa Vista SCPC: score e pendências financeiras registradas. O resultado é assíncrono: normalmente chega em cerca de um minuto, entregue pelo webhook. | [seção](https://unifokal.com/docs/modulos/credito#modulos-credito) |
| credito_protestos Protestos Cenprot Em breve | Consulta protestos em cartório (Cenprot) no nome do CPF do documento: cartório, UF, valor e data. O resultado é assíncrono: normalmente chega em cerca de um minuto, entregue pelo webhook. | [seção](https://unifokal.com/docs/modulos/credito#modulos-credito) |
| credito_cadin CADIN Em breve | Consulta o CPF no CADIN, o Cadastro Informativo de créditos não quitados do setor público federal. O resultado é assíncrono: normalmente chega em cerca de um minuto, entregue pelo webhook. | [seção](https://unifokal.com/docs/modulos/credito#modulos-credito) |
| credito_pgfn PGFN Dívida Ativa | Consulta o CNPJ lido do documento da empresa na Dívida Ativa da União e devolve, por papel de devedor, quantas inscrições existem, o valor somado, a situação delas, quantas estão ajuizadas e desde quando. É de pessoa jurídica: não consulta CPF. A fonte é o dado aberto oficial publicado pela PGFN, atualizado trimestralmente, e não uma certidão comprada de fornecedor: cada resposta carimba a competência publicada do arquivo do governo que respondeu, para você saber a que mês ela se refere. Principal, corresponsável e solidário vêm separados, nunca somados, porque corresponsável costuma ser dívida de outra empresa pela qual a consultada responde, e a política sobre isso é sua. A base agrega por documento e por papel, então o número da inscrição não é devolvido. Quando a base do governo está vencida ou incompleta, a resposta é indisponível, com o motivo, e a checagem não é cobrada: nunca um falso nada consta. | [seção](https://unifokal.com/docs/modulos/credito#modulos-credito) |
| scr_bacen Retrato de Endividamento SCR Em breve | Devolve o retrato de endividamento do CPF junto ao Sistema de Informações de Crédito, com quantidade de instituições, operações e carteira. É informativo: compõe a sua análise de crédito e nunca reprova a verificação de identidade. | [seção](https://unifokal.com/docs/modulos/compliance-vinculos#modulos-compliance-vinculos) |
| Contrato e assinatura | | |
| assinatura Assinatura eletrônica Em breve | Você cria a sessão com o hash SHA-256 do documento que o titular vai assinar; o documento em si nunca é enviado, você continua guardando os bytes. O titular passa pela verificação de identidade completa (documento, face match e prova de vida), e se a verificação aprovar, a plataforma emite o dossiê de assinatura: um manifesto que amarra o hash do documento à verificação aprovada e ao instante, assinado com a chave Ed25519 da plataforma. O dossiê chega no webhook e qualquer pessoa pode conferir a assinatura com a chave pública que a UNIFOKAL publica na documentação, sem nos consultar. É assinatura eletrônica AVANÇADA nos termos da lei brasileira (MP 2.200-2, art. 10, § 2º; Lei 14.063, art. 4º, II): a associação unívoca ao signatário é a biometria, o controle exclusivo é a captura no aparelho dele, e qualquer alteração posterior do documento muda o hash e desfaz a prova. Não é assinatura qualificada: atos que exigem certificado ICP-Brasil (nota fiscal eletrônica, transferência de imóvel) precisam de outro instrumento. Não emitimos carimbo de tempo RFC 3161 nesta fase. Se a verificação não aprovar, o dossiê não é emitido e o módulo não é cobrado. | [seção](https://unifokal.com/docs/modulos/assinatura#modulo-assinatura) |
| custodia_autorizacao Custódia da autorização de consulta Em breve | Guarda, em seu nome, a autorização de consulta ao SCR que o titular aceitou, amarrada à identidade verificada e ao consentimento da sessão, com prova assinada que qualquer pessoa confere sem depender da UNIFOKAL. A guarda conta cinco anos a partir da última consulta que você registra no painel, como pede a Resolução CMN 5.037/2022, e você pode ampliar o prazo. O texto da autorização nunca passa pela UNIFOKAL: só o hash e a versão. | [seção](https://unifokal.com/docs/modulos/custodia-autorizacao#modulo-custodia-autorizacao) |
| Antifraude | | |
| fraud_ai Motor Antifraude | Sinais de risco da própria sessão: rede e IP, dispositivo, velocidade, e-mail informado, blocklist e reuso na sua base. No e-mail, além das listas de domínio, o motor olha para onde o domínio entrega: um domínio criado hoje, que nenhuma lista conhece pelo nome, é reconhecido quando o servidor de e-mail dele é o de um serviço de caixa temporária. | [seção](https://unifokal.com/docs/modulos/fraud-ai#modulo-fraud-ai) |
| transacao Gate Transacional | Avalia a transação que você nos envia e devolve allow, step_up ou deny com as razões e os pontos de risco de cada sinal que disparou: valor fora do padrão do próprio titular, cadência, contraparte nova ou concentradora, troca de aparelho, horário e se aquele titular já foi verificado por você aqui. Roda sobre o seu histórico, sem consórcio com outros clientes. Quem decide liberar o pagamento é você: nós não liquidamos, não acessamos o DICT nem o MED, e o pior desfecho que emitimos é revisar. | [seção](https://unifokal.com/docs/modulos/transacao#modulo-transacao) |
| transacao_monitor Monitoramento transacional | Varre, no nosso relógio e sem ninguém esperando, as transações que você já nos mandou pela rota de ingestão, procurando padrões que uma transação sozinha não revela. Cada alerta chega no mesmo webhook assinado de sempre, com a janela e a evidência em número. A varredura é grátis: você paga só pelo alerta emitido, e alerta suprimido por repetição não custa nada. O alerta nunca reprova ninguém: o pior desfecho é revisão humana. Não é o Gate transacional, que responde na hora com um pagamento parado esperando. Não consultamos DICT, MED, bureau nem dado de consórcio entre instituições: o universo é só o que você nos mandou, sob isolamento por cliente. No painel você acompanha, no período que escolher, quantos alertas saíram por 1.000 eventos recebidos e quais ficaram retidos sem cobrança, com o motivo de cada um. | [seção](https://unifokal.com/docs/modulos/transacao-monitor#modulo-transacao-monitor) |
| conta Proteção de conta Em breve | Avalia na hora um evento de conta que você nos manda (login, login falhado, cadastro, troca de senha, recuperação, troca de e-mail e ação sensível) e devolve allow, step_up ou deny com as razões, para você decidir se pede uma prova a mais antes de deixar passar. Os sinais são publicados pelo nome: viagem impossível entre dois acessos, rajada de logins falhados, país novo para aquele titular, troca de credencial recente, rede de hospedagem, risco do IP, titular sem identidade verificada aqui e conta dormente que volta. A comparação é com o histórico do próprio titular na sua base (países, ASNs, horários e a velocidade entre os eventos), nunca com o de outro cliente. Não compramos reputação de IP de terceiro: os sinais saem da nossa base GeoIP local e do seu próprio histórico. Não bloqueamos ninguém: deny sai como revisão e step_up nunca aprova sozinho, e quem aplica qualquer consequência sobre a conta é você. Não é o Gate transacional, que avalia um pagamento, nem o Monitoramento de sessão, que observa a sessão já logada. | [seção](https://unifokal.com/docs/modulos/conta#modulo-conta) |
| ip_risk Verificação de IP | Diz de onde a verificação chegou: país, provedor e ASN, se a rede é de datacenter ou saída Tor (medido em listas que hospedamos), se o fuso do navegador bate com a geografia do IP e quantas identidades da sua base já usaram aquele IP. A classificação de VPN, proxy e rede residencial vem do Motor Antifraude e só aparece quando ele está no mesmo fluxo. Não diz quem está por trás do endereço. | [seção](https://unifokal.com/docs/modulos/risco#modulos-risco) |
| email_risk Risco de e-mail | Pontua o e-mail informado: descartável, caixa de função, alias com +, imitação de provedor popular, caractere de outro alfabeto e TLD de risco. Com a Validação de e-mail no mesmo flow analisa a caixa inteira; sem ela, só o domínio, e a resposta diz qual dos dois foi. A idade do domínio vem do Motor Antifraude e depende da consulta ao fornecedor externo: quando ela não responde, o campo sai como não medido, nunca como domínio antigo. | [seção](https://unifokal.com/docs/modulos/risco#modulos-risco) |
| ip_risk_plus Verificação avançada de IP Em breve | Tudo o que a Verificação de IP entrega, com o veredito de rede garantido em toda verificação: classe de conexão, anonimização do endereço, histórico recente de abuso e de automação naquele mesmo endereço, coerência geográfica com o restante da sessão e o alcance internacional da consulta. Ao contrário da Verificação de IP, o veredito nunca chega como não medido: sem resposta da consulta o módulo defere, e é ele que sai da cobrança, não a verificação inteira. Não diz quem está por trás do endereço. | [seção](https://unifokal.com/docs/modulos/risco#modulos-risco) |
| email_risk_plus Risco avançado de e-mail Em breve | Tudo o que o Risco de e-mail entrega, com a verificação real de entregabilidade da caixa garantida em toda consulta: se a caixa aceita mensagem de fato, se o endereço é descartável, a idade do domínio e a análise do endereço completo, não só do domínio. Ao contrário do Risco de e-mail, o veredito nunca chega como não medido: sem resposta da consulta o módulo defere, e é ele que sai da cobrança, não a verificação inteira. Não prova que a caixa é da pessoa, quem prova posse é a Validação de e-mail. | [seção](https://unifokal.com/docs/modulos/risco#modulos-risco) |
| telefone_risco Risco de telefone Em breve | Reputação e atributos da linha do número informado: se está ativa, o tipo de linha, a operadora, se é linha virtual, se é pré-paga e o histórico recente de abuso. Exige a Validação de telefone por SMS no mesmo flow, porque o número analisado é o que já teve posse confirmada. Confirma a linha, não a pessoa: não substitui a prova de posse por SMS nem a biometria, e nunca reprova sozinho. | [seção](https://unifokal.com/docs/modulos/risco#modulos-risco) |
| doc_forense Forense de Documento | Faz a perícia do arquivo do documento que a Verificação de Identidade já capturou, sem pedir nenhuma foto nova. Analisa o que o arquivo declara sobre si mesmo (a ferramenta que o gerou, as datas, as revisões anexadas de um PDF, os metadados da imagem) e o que a compressão revela: recaptura de tela, sinais de recorte e colagem com a região marcada sobre a imagem, e a presença de um manifesto que declare mídia gerada por modelo. Um achado forte nunca reprova sozinho: leva a verificação para revisão humana com o laudo, que diz o que cada sinal prova e o que ele não prova. Editar um PDF em um site de juntar páginas é comum e legítimo, e aparece no laudo como contexto, não como acusação. Não confere elementos de segurança do documento físico, como marca d'água, microtexto ou holograma. | [seção](https://unifokal.com/docs/modulos/doc-forense#modulo-doc-forense) |
| fraud_network Detecção de Rede de Fraude | Mostra se a pessoa que está se verificando agora está ligada a outras contas suas, por aparelho, rede, e-mail, telefone, rosto ou pela conta que você informa. A rede é sempre a sua: nenhum dado seu alimenta a rede de outro cliente, e você nunca vê dado de cliente nenhum. Ligação não é prova de fraude, e o módulo foi desenhado em cima disso: família no mesmo wi-fi, escritório de contabilidade com dezenas de empresas no mesmo IP e casal com um telefone só continuam aprovando, com a ligação visível para você decidir. Só quando há evidência de pessoa, o mesmo rosto ou a mesma conta em documentos diferentes, a verificação vai para revisão humana, com as contas ligadas e o tipo de ligação. Nunca reprova sozinho e não pede nenhuma foto ou passo novo. | [seção](https://unifokal.com/docs/modulos/fraud-network#modulo-fraud-network) |
| pix_device Dispositivo PIX Em breve | Transforma o cadastro de dispositivo Pix da Resolução BCB 403 em um fluxo verificado: no ato de cadastrar um aparelho novo, o titular passa pelo face match e pela prova de vida do mesmo fluxo, que são o segundo fator biométrico que a IN BCB 491 aceita, e o dispositivo só é vinculado ao titular quando a verificação inteira aprova. Você recebe o vínculo com a verificação que o aprovou como evidência, e os valores vigentes dos limites de dispositivo não cadastrado, de duzentos reais por transação e mil reais por dia, viajam em cada resposta como referência para o seu motor de limites. Quando o operador revoga um aparelho pela API, bloqueio ou exclusão da IN BCB 491, seu backend recebe o aviso assinado no mesmo endpoint de sempre. A revogação ainda não tem tela no painel: hoje ela é chamada pela sua chave de servidor. Um aparelho já ativo para outro titular nunca reprova sozinho: vira revisão humana, porque família dividindo um celular é comum e legítimo. O registro regulatório, o motor de limites e a resposta ao Banco Central continuam sendo do PSP: nós fornecemos o fator biométrico, o vínculo verificado e o aviso de revogação. | [seção](https://unifokal.com/docs/modulos/pix-device#modulo-pix-device) |
| sessao_monitor Monitoramento de sessão Em breve | Depois que o titular já está aprovado e logado no seu site, o navegador do titular, com a credencial daquela sessão, envia para a nossa rota de ingestão o que acontece na sessão dele: ações de negócio (entrada na conta, depósito, saque), um sinal de vida a cada 30 minutos, a localização que o navegador conceder e o aparelho. Um conjunto de regras determinísticas roda no nosso servidor e, quando uma fecha, você recebe um alerta no mesmo webhook assinado de sempre, com a evidência em número (distância, intervalo, velocidade implícita) e o nome da regra que disparou: nada de score de caixa-preta. A janela, o sinal de vida e os eventos são grátis: você paga só pelo achado. E o alerta nunca recusa ninguém sozinho: o pior desfecho é sempre revisão humana, com a evidência na mão de quem revisa. Não substitui o Monitoramento Contínuo, que reconsulta listas de compliance sobre a pessoa e cobra por mês. | [seção](https://unifokal.com/docs/modulos/sessao-monitor#modulo-sessao-monitor) |
| device_intel Sinais do aparelho Em breve | Durante a captura, o navegador do titular declara três coisas sobre si mesmo e o módulo agrega a pior observação da sessão num veredito explicável: se o navegador está sendo controlado por um programa de automação, se as funções nativas dele foram reescritas por algum script, e se um aparelho que se anuncia como celular admite não ter nenhum ponto de toque. Você recebe os três sinais nomeados no webhook, com a cobertura da leitura declarada, e não um score fechado. Não identificamos o aparelho: os três sinais são respostas de sim ou não e nenhuma delas distingue um celular de outro, então isso não serve para reconhecer quem volta. Roda inteiro no navegador do titular, sem instalar nada no aparelho dele. O sinal nunca recusa ninguém sozinho: é contexto para a revisão, então o pior desfecho é revisão humana. Navegador que esconde essas leituras por privacidade não é suspeito: leitura ausente é neutra, e o módulo diz quando não mediu. | [seção](https://unifokal.com/docs/modulos/device-intel#modulo-device-intel) |

## Documento, face match e prova de vida

<https://unifokal.com/docs/modulos/identidade>

### Documento, face match e prova de vida

Os três módulos base do KYC de pessoa: **Documento (OCR)** (`cpf_ocr`), **Face Match** (`face`) e **Liveness** (`liveness`). A captura inteira acontece no widget, e nenhuma foto passa pelo seu front nem chega ao seu backend. A leitura do `cpf_ocr` é feita por fornecedor externo, com o rosto do documento tarjado antes do envio quando o detector está disponível e encontra o rosto, e com o fornecedor declarado na lista de subprocessadores. No resumo `checks` do webhook, `cpf_ocr` e `face` saem **combinados** na chave `identity` (`"pass"` ou `"fail"`) e o liveness sai como número de 0 a 1; o detalhe de cada módulo vem em `check_details`, como abaixo.

#### Documento (OCR): `cpf_ocr`

Lê RG, CNH ou CIN e extrai **nome, CPF, número do documento e nascimento**. O CPF passa pelo dígito verificador ainda na leitura: CPF que não fecha vem `null`, nunca um número inválido entregue como bom. A foto do documento **não sai no webhook**: mídia fica no painel, com acesso restrito por papel.

```
// cpf_ocr no check_details: o que foi LIDO do documento
{ "module": "cpf_ocr", "passed": true, "outcome": "approved", "score": 95,
  "data": { "name": "João Silva", "cpf": "123.456.789-00",
            "birth_date": "01/03/1990", "birth_date_iso": "1990-03-01",
            "document": { "type": "cnh", "number": "07969013668",
                          "valid_until": "15/03/2029", "expired": false } } }
// "affiliation" (filiação) entra quando o documento traz o campo.
// "expired": true (vencido) | false (vigente) | null (não deu para afirmar).
// Com um módulo de Validação CPF no mesmo flow, name/birth_date preferem a fonte OFICIAL
// (o OCR vira fallback): o dado que chega é o mais confiável disponível.
```

**Documento vencido não reprova.** A validade volta em `document.valid_until` e o vencimento em `document.expired`, com **três** estados: `true` (vencido), `false` (vigente) e `null` (não deu para afirmar). O documento vencido é aceito e fica guardado como evidência; o que fazer com ele é decisão da sua política. `null` nunca é `false`.

**Na CIN e no passaporte, o impresso é conferido com a zona de leitura mecânica (MRZ).** Quando o nome, o nascimento ou o número impressos não batem com os da MRZ, a verificação vai para `review`, nunca para recusa automática, com `decision_reason: "identity_document_mrz_review"`. O motivo do módulo é `mrz_visual_mismatch`, e o painel mostra qual dos campos divergiu. Nesse caso os campos lidos do documento não vêm no `data`: não há como saber qual das duas identidades é a verdadeira, e quem decide é a pessoa que revisa.

No sandbox, com o sufixo no campo `document` do submit: `41` leva a verificação para `review` com `mrz_visual_mismatch`, e `45` aprova com o documento vencido (`document.expired: true`). A lista completa está em Sandbox.

#### Face Match 1:1: `face`

Compara a selfie com a foto do documento apresentado e responde **se é a mesma pessoa**. O payload separa a **verdade biométrica** (`match` e `similarity`, contra um limiar calibrado) do score de negócio, e entrega a **qualidade de imagem** dos dois lados: com `quality` baixo você sabe que uma recusa pode ser foto ruim, não fraude.

```
// face no check_details: verdade biométrica + qualidade das imagens
{ "module": "face", "passed": true, "outcome": "approved", "score": 93,
  "data": { "match": true,          // é a mesma pessoa? (limiar biométrico, não o score de negócio)
            "similarity": 0.82,     // similaridade 0..1 entre selfie e foto do documento
            "confidence": 0.97,
            "threshold": 0.36,      // o limiar que valeu NESTA decisão (carimbado com a política)
            "quality": { "selfie": 84, "document": 71 } } }   // qualidade de imagem (FIQA) 0..100
```

#### Prova de vida: `liveness`

Confirma que há **uma pessoa viva na frente da câmera**: barra foto impressa, tela e vídeo gravado. Num módulo só saem a prova **passiva** (a análise da selfie) e a **ativa** (o desafio de gestos, quando o flow pediu), mais dois vereditos de captura: `capture` (a origem dos bytes é coerente com uma câmera real?) e `active.volume` (o rosto tem volume 3D ou é um plano?). Detecção de mídia sintética **não faz parte do payload**: não há modelo de deepfake com licença que permita uso comercial de ponta a ponta, e preferimos não publicar um campo a publicar um campo que nunca tem valor. Se um dia a medição existir, o campo entra documentado aqui na mesma entrega. Repare na **unidade do `threshold`**: ele é o corte do **score do módulo**, de 0 a 100, e não um corte de `live_probability` (que é de 0 a 1). Comparar os dois inverte o sinal, e é o erro de integração mais fácil de cometer aqui. O bloco `friction` diz qual prova aquele titular fez, como explicado em [Webhooks](https://unifokal.com/docs/webhooks#webhooks).

```
// liveness no check_details: prova passiva + desafio ativo num módulo só
{ "module": "liveness", "passed": true, "outcome": "approved", "score": 96,
  "data": { "live_probability": 0.97,   // 0..1, alto = pessoa viva
            "spoof_probability": 0.03,  // 0..1, alto = artefato apresentado no lugar da pessoa
            "threshold": 80,            // ATENÇÃO à unidade: é o corte do SCORE do módulo (0..100),
                                        // NÃO um corte de live_probability. Nunca compare os dois.
            "frames_used": 3, "face_quality": 0.88,
            "capture": "coerente",      // origem dos bytes: coerente | suspeita | indeterminado
            "friction": { "mode": "adaptive", "level": 3, "actions_required": 2 },
            "active": { "challenge": ["turn_left", "look_up"], "challenge_passed": true,
                        "steps_passed": 2, "steps_total": 2,
                        "volume": "confirmado" } } }
// active = null quando o flow não pediu desafio (ou o nível adaptativo dispensou);
// volume: confirmado | plano | indeterminado (a prova de volume 3D por paralaxe)
```

##### Detecção de injeção de câmera

O veredito `capture` acima é uma capacidade com nome: **detecção de injeção de câmera**, dentro do `liveness`, sem módulo nem preço à parte. Dois caminhos independentes vigiam a origem do stream: o que o **navegador** declara na captura (automação declarada, câmera virtual, cadência implausível de frames) e o que o **pixel** denuncia (ausência de ruído de sensor, congelamento alinhado a bloco de codec, típico de vídeo injetado). A régua é deliberada: nenhum indício reprova sozinho. A recusa automática exige o único sinal forte, a automação declarada pelo próprio navegador, somado a ao menos mais um indício; os demais são sinais fracos, somados com teto, que seguram a verificação em **revisão**, nunca em recusa, porque câmera virtual e codec têm causas inocentes conhecidas. Não vendemos selo de laboratório sobre isso: a promessa é o veredito nomeado em cada verificação com prova de vida, que você audita no próprio payload.

## Comprovante de endereço

<https://unifokal.com/docs/modulos/endereco-ocr>

### Comprovante de endereço

O módulo `endereco_ocr` lê o **comprovante de endereço** que o titular envia e devolve o endereço impresso nele. Valem conta de luz, água, gás, telefone ou internet, fatura ou extrato bancário, contrato de aluguel e documento de órgão público. Além do endereço, ele responde duas perguntas que a leitura sozinha não responde: **o comprovante é recente?** e **o nome dele é o mesmo do documento de identidade lido neste fluxo?**

**Um passo novo no widget, e zero código a mais do seu lado.** O titular envia um **PDF ou uma foto**, de até **4 MB**, na mesma jornada em que fotografa o documento e faz a selfie. Do PDF vale a **primeira página**: é nela que o comprovante precisa trazer o nome, o endereço e a data de emissão, e o widget avisa isso na tela de envio. O arquivo não passa pelo seu front nem chega ao seu backend, e a mídia fica no painel com acesso restrito por papel, como as demais.

**Dependências e preço.** Ele exige `cpf_ocr` e `face` no mesmo flow, porque o nome cruzado é o que foi lido do documento de identidade: sem eles não há contra o que cruzar. Por isso o número que importa é o do **conjunto**, e não o do módulo sozinho: o flow mínimo com este módulo (`cpf_ocr` + `face` + `endereco_ocr`) custa **-** por verificação, e é esse o valor que entra na sua fatura. O unitário de cada módulo está na [tabela de preços](https://unifokal.com/precos), lida do mesmo catálogo.

```
// endereco_ocr no check_details: o endereço lido, a recência e os dois cruzamentos
{ "module": "endereco_ocr", "passed": true, "outcome": "approved", "score": 92,
  "data": { "doc_type": "utility_bill",   // utility_bill | bank_statement | telecom |
                                          // rent_contract | government | other
            "cep": "01310930", "city": "São Paulo", "uf": "SP",
            "issue_date": "2026-08-20",   // data de emissão lida do comprovante
            "issue_age_days": 27,         // há quantos dias ele foi emitido
            "name_match": true,           // o nome do comprovante é o do documento de identidade?
            "address_match": "match",     // match | mismatch | unknown
            "declared_address_match": "cep_match",  // cep_match | uf_match | mismatch | unknown
            "cep_consistency": "ok" } }  // ok | cep_fora_da_base | uf_divergente | unknown

// nome divergente: NUNCA é recusa. O portão é soft, e a verificação vai para revisão humana.
{ "module": "endereco_ocr", "passed": false, "outcome": "failed", "score": 40,
  "data": { "doc_type": "utility_bill",
            "cep": "01310930", "city": "São Paulo", "uf": "SP",
            "issue_date": "2026-08-20", "issue_age_days": 27,
            "name_match": false, "address_match": "match",
            "reason": "name_mismatch" } }

// comprovante fora do prazo: o titular reenvia um mais novo, ninguém é reprovado por isso.
// passed null + outcome "pending" são o shape de "ainda não dá para afirmar nada".
{ "module": "endereco_ocr", "passed": null, "outcome": "pending", "score": 0,
  "data": { "doc_type": null,
            "cep": "01310930", "city": "São Paulo", "uf": "SP",
            "issue_date": "2025-11-02", "issue_age_days": 318,
            "name_match": null, "address_match": "unknown",
            "reason": "address_doc_expired" } }
// reason só aparece quando existe: no caminho aprovado a chave não vem.
```

No resumo `checks` o módulo aparece com **vocabulário próprio**, e não com o `pass`/`fail` dos módulos de identidade: `verified` (extraiu, está no prazo e o nome cruzou), `mismatch` (o cruzamento de nome reprovou) e `pending` (toda a família de indeterminado, com o motivo fino em `data.reason`). Aqui não há lista consultada, há um documento conferido, e o vocabulário diz isso.

**A recência é regra do produto, não detalhe.** Um comprovante vale por até **90 dias** contados da emissão. A idade em dias vem no payload (`issue_age_days`) para você aplicar uma régua mais apertada se a sua política pedir: a nossa é o teto, nunca o piso.

**Os motivos, e o que fazer com cada um.** Eles vêm em `reason` dentro do `data` do check, e se dividem em dois grupos com ações opostas. **Pedem o reenvio do arquivo**, e nunca são veredito contra o titular: `address_not_found` (o endereço não foi localizado no comprovante), `address_doc_expired` (o comprovante passou dos 90 dias) e `address_doc_unreadable` (o arquivo não pôde ser lido). **Levam a revisão humana**, com a evidência na mão de quem revisa: `name_mismatch` (o nome do comprovante diverge do nome do documento), `identity_name_unavailable` (o nome do documento não estava disponível para o cruzamento) e `injection_suspected_text` (o arquivo pede conferência humana antes de qualquer cruzamento) e `address_doc_reused` (o mesmo arquivo de comprovante já foi enviado por outro titular da sua conta; nunca compara com outras contas). Quando o módulo segura a verificação, o `decision_reason` dela é `address_proof_review`.

! **Verificação documental com cruzamento de nome, e não prova absoluta de residência.** O que o módulo afirma é o que o documento diz e se esse documento é do titular do fluxo. Conta de luz em nome do cônjuge, do pai ou do locador é comum e legítima no Brasil, e por isso **nome divergente nunca é recusa automática**: vai para revisão humana, e quem decide aceitar aquele comprovante é você.

**Opcional: o endereço que você já tem em cadastro.** Na criação da sessão, do seu servidor, você pode enviar `expected_address` e ligar o sinal `address_match`. Ele aceita **só CEP e UF**, de propósito: é o recorte que responde "é o mesmo endereço?" sem que você precise nos mandar a rua e o número do titular. **Sem o campo, o sinal sai `unknown`**, que não é `mismatch` e muito menos `match`: é "não havia com o que comparar".

```
// criação de sessão (servidor, sk_): o campo é opcional e só aceita CEP e UF
{ "flow_id": "flow_...", "reference_id": "user_123",
  "expected_address": { "cep": "01310930", "uf": "SP" } }
```

Mandar `expected_address` num flow que não tem `endereco_ocr` é `422 expected_address_not_supported`, nunca aceito e ignorado. CEP ou UF fora do formato é `400 validation_error` com o campo nomeado.

Em sandbox nada é lido: o desfecho vem do sufixo do documento. `01` devolve o reenvio por endereço não localizado, `02` o nome divergente (que vai para revisão) e qualquer outro sufixo aprova. Com `expected_address` na sessão, o `address_match` do sandbox também vem do sufixo: `83` devolve `mismatch` e os demais `match`. Sem o campo, `unknown`, como em produção.

Quando o fluxo também tem a consulta cadastral de endereços, o `data` traz `declared_address_match`: o CEP e a UF do comprovante contra os endereços dessa consulta, com `cep_match`, `uf_match`, `mismatch` ou `unknown` quando falta um dos lados. É informação para você e nunca reprova a verificação.

Em todo comprovante o `data` traz também `cep_consistency`: o CEP lido conferido contra uma base de endereços, com `ok` (o CEP está na base, na mesma UF do comprovante), `uf_divergente` (está na base, em outra UF), `cep_fora_da_base` ou `unknown` (não houve conferência, por exemplo quando o CEP não foi lido). Fora da base não quer dizer inexistente: CEP novo ou de grande usuário pode faltar nela. É informação para a sua política, e nunca muda o desfecho da verificação.

## Verificação de idade

<https://unifokal.com/docs/modulos/idade>

### Verificação de idade

O módulo `idade` responde **uma** pergunta, e não a que a maioria espera: ele **não diz a idade do titular**, ele diz se o titular **aparenta ter pelo menos** a idade que o seu flow exige. A estimativa sai pela selfie, sem documento e sem captura nova, e existe para o caso em que pedir documento é desproporcional (barreira de conteúdo adulto, por exemplo). Ele depende da **prova de vida**: sem ela não há rosto ao vivo para estimar, e o módulo recusa antes de estimar qualquer coisa.

**Disponível desde 11 de setembro de 2026.** Ele aparece na tabela de preços e no `GET /v1/capabilities` com preço e status `available`, e pode ser ligado num flow. Até essa data a venda estava pausada, por licença do modelo que estima a idade: o peso anterior tinha origem restrita a pesquisa e foi retirado do produto. O que roda hoje tem cadeia de licença aberta, declarada na página de segurança.

**Dois campos numéricos, e o que decide é o segundo.** `minimum` é a idade que o seu caso de uso **exige** (o requisito legal, 18 por exemplo). `challenge` é o **corte efetivo** que aplicamos, sempre maior ou igual ao `minimum`, e é contra ele que a estimativa é comparada. A margem entre os dois é deliberada: estimativa de idade por imagem erra para os dois lados, e exigir aparentar um pouco mais é o que impede que um menor de idade passe por ruído do modelo. Hoje o corte padrão é **19** para o mínimo de 18, ou seja **um ano** de margem. Quem comparar `estimated_age` com `minimum` chega a uma conclusão diferente da nossa.

```
// idade: aparenta ter a idade exigida. O score sobe com a folga sobre o corte.
{ "module": "idade", "passed": true, "outcome": "approved", "score": 92,
  "data": { "decision": "YES",        // YES | NO | INCONCLUSIVE | NO_LIVENESS
            "estimated_age": 31.2,    // anos, uma casa decimal
            "minimum": 18,            // o que o seu caso EXIGE
            "challenge": 19 } }       // o corte que de fato aplicamos (>= minimum)

// zona cinzenta: nem confirma nem nega -> pending, e vai a revisão
{ "module": "idade", "passed": null, "outcome": "pending", "score": 50,
  "data": { "decision": "INCONCLUSIVE", "estimated_age": 17.6,
            "minimum": 18, "challenge": 19 } }

// a prova de vida não sustentou a estimativa: recusamos ANTES de estimar
{ "module": "idade", "passed": false, "outcome": "failed", "score": 5,
  "data": { "decision": "NO_LIVENESS", "estimated_age": null,
            "minimum": null, "challenge": null } }
```

Os quatro campos podem vir `null` juntos quando a selfie não teve qualidade para nenhuma medida. E os quatro vereditos têm significados distintos que vale separar: `YES` é "aparenta ter", `NO` é "aparenta não ter", `INCONCLUSIVE` é a faixa em que não afirmamos nem uma coisa nem outra (e a verificação vai a revisão, em vez de barrar alguém legítimo), e `NO_LIVENESS` é o caso em que nem chegamos a estimar. **No sandbox só existem `YES` e `NO`**: o ambiente de testes não produz `INCONCLUSIVE` nem `NO_LIVENESS`, e chega a emitir o par `outcome: "pending"` com `decision: "NO"`, que a produção nunca emite (lá `NO` é sempre `failed`). Trate esses dois vereditos como caminhos a implementar às cegas, e teste-os com o seu próprio duplo.

## Detecção de múltiplas contas (1:N)

<https://unifokal.com/docs/modulos/face-unica>

### Detecção de múltiplas contas (1:N)

O módulo `face_unica` busca o rosto da selfie na **sua base** e responde se ele já abriu outra conta. A comparação roda **só dentro da sua organização**: a base é sua, nunca compartilhada entre clientes. Não existe captura nova: ele usa a selfie que o flow já capturou, e o que entra no índice é o **vetor biométrico** (embedding), não a imagem.

A resposta é uma de três: `unique` (rosto inédito, entra no índice), `duplicate` (o rosto já pertence a **outra** conta: portão duro, reprova) e `possible_duplicate` (semelhança na banda de dúvida, ou match forte sem conta identificável dos dois lados: vai para `review` humano, nunca recusa automática). O `match` devolve a **referência** da conta casada (`verification_id` e o seu `reference_id`), nunca a selfie da outra conta. A decisão também sai direto no resumo: `"checks": { ... "face_unica": "unique" }`.

```
// face_unica no check_details: rosto inédito na sua base
{ "module": "face_unica", "passed": true, "outcome": "approved", "score": 95,
  "data": { "decision": "unique", "best_similarity": null, "candidates": 0, "match": null } }

// face_unica: o rosto já pertence a OUTRA conta (portão duro: reprova)
{ "module": "face_unica", "passed": false, "outcome": "failed", "score": 5,
  "data": { "decision": "duplicate", "best_similarity": 0.71, "candidates": 2,
            "match": { "verification_id": "ver_01H…", "reference_id": "usr_1207",
                       "similarity": 0.71 } } }

// face_unica: banda de dúvida entre os dois limiares: review humano, nunca recusa automática
{ "module": "face_unica", "passed": null, "outcome": "pending", "score": 50,
  "data": { "decision": "possible_duplicate", "best_similarity": 0.39, "candidates": 1,
            "match": { "verification_id": "ver_01H…", "reference_id": "usr_88",
                       "similarity": 0.39 } } }
```

Mande o `reference_id` na criação da sessão: é ele que permite ao módulo saber que um match forte é a **mesma conta** se reverificando (aí não é duplicata) ou **outra** (aí é). Sem ele, match forte vira `possible_duplicate` em vez de recusa, para nunca barrar o titular legítimo. O preço por checagem está na [tabela de preços](https://unifokal.com/precos), junto com a ingestão retroativa da base.

## Reautenticação facial

<https://unifokal.com/docs/modulos/face-reauth>

### Reautenticação facial

O módulo `face_reauth` reconfirma, numa ação sensível (um saque, uma troca de chave Pix, um login em aparelho novo), que quem age agora é a **mesma pessoa** que você já aprovou no onboarding. O titular faz a prova de vida e uma selfie, e o módulo compara essa selfie com a **matrícula biométrica** daquela conta, sem pedir documento e sem refazer o onboarding. É **step-up sob evento**, disparado pelo seu backend quando ele decide que a ação merece biometria, e nunca uma checagem contínua rodando em segundo plano.

! **Em breve.** A venda deste módulo está pausada: ele aparece na [tabela de preços](https://unifokal.com/precos) com preço e com o selo "Em breve", e o `create` de flow o recusa até a abertura. A criação de sessão de reautenticação também ainda não está publicada. O que está descrito abaixo é o contrato de resposta que o backend já emite, para você planejar a integração, não uma porta que dá para chamar hoje.

**A matrícula é a referência, e ela é sua.** Ela nasce de graça num onboarding aprovado: você marca o flow de onboarding para matricular, esse flow precisa ter `face` e `liveness`, e a matrícula é criada quando a verificação aprova **pelo caminho automático**, fora do sandbox e com o desafio de gestos cumprido. Aprovação manual na fila de revisão **não** matricula, e o sandbox também não: matrícula biométrica nasce de prova, nunca de decisão de operador. Existe uma matrícula ativa por conta (o seu `reference_id`), dentro da sua organização e do seu ambiente: não há base compartilhada entre clientes, e a comparação nunca acontece contra o índice de detecção de múltiplas contas, que serve a outra pergunta. Você paga a autenticação, nunca a matrícula.

**O flow de reautenticação é curto de propósito:** `liveness` mais `face_reauth`, sem documento. A prova de vida é **exigida** pelo catálogo, e o motivo é direto: sem ela, a foto impressa do titular bateria contra a matrícula e a reautenticação entregaria a conta. Pelo mesmo raciocínio, `face_reauth` não convive com `face` nem com `doclink` no mesmo flow: os dois comparam contra outra referência, e num flow sem documento o `face` nunca teria a foto do documento para comparar. Flow com reautenticação também desliga a renovação de sessão pelo widget: quem decide quando exigir biometria é o seu backend, não o navegador do titular.

```
// face_reauth no check_details: é a mesma pessoa da matrícula
{ "module": "face_reauth", "passed": true, "outcome": "approved", "score": 92,
  "data": { "match": true,                  // a resposta: true | false | null (indeterminado)
            "similarity": 0.83,             // cosseno 0..1 contra a matrícula
            "similarity_band": "high",      // high | gray | low
            "enrolled_at": "2026-03-02T18:20:11.000Z",
            "enrollment_age_days": 159,     // idade da matrícula, para a sua política
            "model_version": "face-reauth-v1" } }

// face_reauth: a comparação foi MEDIDA e não bateu (portão duro: reprova)
{ "module": "face_reauth", "passed": false, "outcome": "failed", "score": 40,
  "data": { "match": false, "similarity": 0.21, "similarity_band": "low",
            "enrolled_at": "2026-03-02T18:20:11.000Z", "enrollment_age_days": 159,
            "reason": "reauth_mismatch",
            "model_version": "face-reauth-v1" } }

// face_reauth: banda de dúvida (o cosseno ficou entre os dois limiares). Revisão humana,
// nunca recusa automática, e SEM "reason": quem identifica o caso é a própria banda.
{ "module": "face_reauth", "passed": null, "outcome": "pending", "score": 80,
  "data": { "match": null, "similarity": 0.39, "similarity_band": "gray",
            "enrolled_at": "2026-03-02T18:20:11.000Z", "enrollment_age_days": 159,
            "model_version": "face-reauth-v1" } }

// face_reauth: não havia matrícula utilizável (nunca houve, foi revogada ou venceu).
// Também revisão, e aqui o "reason" vem preenchido.
{ "module": "face_reauth", "passed": null, "outcome": "pending", "score": 80,
  "data": { "match": null, "similarity": null, "similarity_band": null,
            "enrolled_at": null, "enrollment_age_days": null,
            "reason": "reauth_enrollment_unavailable",
            "model_version": "face-reauth-v1" } }
```

No resumo `checks` o módulo sai com o vocabulário da própria pergunta: `"face_reauth": "match"`, `"no_match"` ou `"pending"`. O `pending` cobre toda a família de revisão. Na **banda de dúvida** o `reason` não vem, e quem identifica o caso é o `similarity_band` igual a `"gray"`; nos demais casos de revisão o `reason` vem preenchido.

**Os três caminhos de recusa por veredito, e só eles:** a comparação medida abaixo do limiar (`reauth_mismatch`), o desafio de gestos executado por um rosto e a selfie enviada de outro (`challenge_selfie_mismatch`) e o documento que você mesmo pôs na sua lista de bloqueio (`document_blocklisted`). Todo o resto que dependa do titular é **revisão**, e essa escolha é deliberada: quem não tem matrícula, ou está com a matrícula vencida, não tem selfie nenhuma que possa tirar para fazer aparecer uma matrícula que não existe, e recusar seria punir o titular legítimo pela ausência de um dado nosso.

Existe ainda um **quarto desfecho**, e ele não é um veredito sobre a pessoa: quando a falha é **nossa** (a selfie não chegou, o serviço de visão não respondeu, a consulta à matrícula falhou), o módulo sai `failed` e **sem o bloco `data`**. Bloco ausente é a assinatura de erro interno, nunca de fraude: a leitura correta é repetir a autenticação, e jamais punir a conta por ela.

Os motivos que chegam a você em `data.reason`: `reauth_mismatch` (recusa medida), `document_blocklisted` (o documento estava na sua lista de bloqueio), `challenge_selfie_mismatch` (o vínculo entre desafio e selfie reprovou), `reauth_binding_unavailable` (não foi possível provar esse vínculo, que é diferente de tê-lo provado falso), `reauth_enrollment_unavailable` (sem matrícula utilizável, incluindo a vencida), `reauth_locked` (matrícula travada por tentativas seguidas que não bateram), `reauth_rate_limited` (teto de tentativas da conta estourado), `reauth_capture_unusable` (a captura não serviu) e `reauth_embedder_drift` (a matrícula foi feita com outra versão do extrator e comparar seria comparar espaços diferentes).

O `reauth_rate_limited` sai como **429 com o cabeçalho** `Retry-After`, sempre em segundos e sempre um prazo real: é quando a vaga efetivamente abre para aquela conta, nunca um número fixo. Respeite o cabeçalho em vez de retentar em laço; a conta tem mais de um relógio, e quando mais de um está cheio o prazo devolvido já é o do relógio que libera por último. Esperar o que o cabeçalho diz basta: você não vai gastar uma chamada para descobrir que ainda falta outra espera.

! **Trate o `reauth_locked` como um evento de segurança.** Ele significa tentativas seguidas contra a matrícula daquela conta, e travar a conta do seu lado é a única reação que encerra um ataque de força bruta de rosto. O titular, por outro lado, recebe sempre o mesmo motivo genérico: os códigos finos existem para o seu backend e para a sua fila de revisão, nunca para quem está do outro lado da câmera.

**A matrícula tem prazo, e ele é curto por escolha.** Cada autenticação aprovada renova a guarda por 180 dias; quem passa 180 dias sem reautenticar perde a matrícula, que deixa de valer e entra na fila de expurgo, e existe um teto absoluto de um ano desde a criação. Passado o prazo, a próxima reautenticação sai como `pending` com `reauth_enrollment_unavailable`, e o caminho é refazer o onboarding, que cria uma matrícula nova.

**No painel.** A matrícula se liga no construtor de flow, na opção **Criar matrícula para reautenticação facial**, que só aparece habilitada quando o flow tem Face Match e Liveness (pela API, o campo é `enroll_reauth`, e o mesmo par de módulos é exigido, com o erro `enroll_reauth_requires_face_liveness`). O estado da matrícula de cada titular (ativa, travada, vencida ou ausente), com a data de criação e a validade, aparece no detalhe de qualquer verificação dele e na linha dele em Sessões. O painel mostra estado e datas, nunca o modelo do rosto.

**Matrícula travada não se destrava.** Um proprietário ou administrador da sua conta pode **revogar** a matrícula pelo painel, a partir da verificação ou da sessão do titular. A revogação pede o segundo fator de quem revoga e fica registrada com quem fez e quando. Depois dela, a matrícula renasce no próximo onboarding aprovado daquele titular. É também o que fazer quando você descobre que a conta foi tomada: tirar a matrícula até a pessoa provar de novo quem é.

**Tenha sempre um caminho sem biometria.** A reautenticação facial confirma que é a mesma pessoa, e não deve ser a única porta para a ação sensível: ofereça ao titular uma alternativa que não dependa do rosto dele. A aprovação com passkey, vinculada à conta do titular, é esse caminho no produto e está em breve.

## Aprovação de ato com passkey

<https://unifokal.com/docs/modulos/passkey>

### Aprovação de ato com passkey

O módulo `passkey` faz o titular aprovar um **ato** seu (uma transferência, uma troca de chave Pix, uma alteração de cadastro) com a passkey que ele vinculou à conta dele na sua base. O navegador mostra o pedido do sistema operacional, o titular confirma com a biometria ou o PIN do aparelho, e a assinatura cobre o **resumo do ato** que você enviou na criação da sessão. Não há selfie nem documento nesse caminho: quem responde é a chave.

! **Em breve.** A venda deste módulo está pausada: ele aparece na [tabela de preços](https://unifokal.com/precos) com o selo "Em breve", e o `create` de flow o recusa até a abertura. O que está descrito abaixo é o contrato que o backend já emite, para você planejar a integração.

**O vínculo nasce de prova, nunca de pedido.** Você liga o vínculo no flow de onboarding (`passkey_bind`), o titular cria a chave durante a sessão, e ela só passa a valer quando a verificação aprova pelo caminho automático. Com rosto e prova de vida aprovados, a chave nasce com garantia de `identidade`; com prova de vida só, nasce com garantia de `presenca`. Aprovação manual na fila de revisão não vincula chave nenhuma, e a sessão de aprovação de ato também não pode ser aprovada nem reprocessada pelo painel.

**O ato vai na criação da sessão, no campo `act`.** O backend canoniza o ato, calcula o `act_digest` (SHA-256 da forma canônica) e guarda o resumo cifrado para mostrar ao titular. O desafio que o aparelho assina é derivado desse digest, então a assinatura não serve para outro ato. A sessão de ato vale 10 minutos, e o titular pode recusar o ato na própria tela, o que gera o evento `act.rejected`.

```
// passkey no check_details: o ato foi aprovado pela chave vinculada
{ "module": "passkey", "passed": true, "outcome": "approved", "score": 100,
  "data": { "passkey_id": "spk_01J9ZC6Q8R3M2V7K4T5N1B0XHD",
            "bound_by": "cadastro",          // cadastro | presenca | chave_existente | reprova
            "assurance": "identidade",       // identidade | presenca
            "backup_eligible": false, "backup_state": false,
            "user_verified": true,
            "act_digest": "b64url-do-sha256-do-ato-canonico",
            "act_kind": "pix_transfer",
            "evidence": { "format": "unifokal/passkey-assertion@1", "...": "..." },
            "model_version": "passkey-v1" } }

// passkey: a assinatura foi conferida e reprovou (veredito sobre a credencial)
{ "module": "passkey", "passed": false, "outcome": "failed", "score": 0,
  "data": { "passkey_id": "spk_01J9ZC6Q8R3M2V7K4T5N1B0XHD", "bound_by": "cadastro",
            "assurance": "identidade", "backup_eligible": null, "backup_state": null,
            "user_verified": null, "act_digest": null, "act_kind": null, "evidence": null,
            "reason": "passkey_assertion_invalid",
            "model_version": "passkey-v1" } }

// passkey no sandbox: a cerimônia é simulada e o bloco diz isso
{ "module": "passkey", "passed": true, "outcome": "approved", "score": 100,
  "data": { "passkey_id": "spk_01SANDBOXEXEMPLO0000000000", "bound_by": "cadastro",
            "assurance": "identidade", "backup_eligible": true, "backup_state": true,
            "user_verified": true, "act_digest": "b64url-do-sha256-do-ato-canonico",
            "act_kind": "pagamento", "simulated": true,
            "evidence": { "format": "unifokal/passkey-assertion@1", "simulated": true, "...": "..." },
            "model_version": "passkey-v1" } }
```

Os motivos em `data.reason`: `passkey_assertion_invalid` (a assinatura não confere), `passkey_backup_flag_changed` (a chave mudou de estado de cópia desde o vínculo), `passkey_not_device_bound` (a política pedia chave presa ao aparelho), `passkey_counter_regressed` (o contador do autenticador voltou, vai para revisão) e `passkey_not_completed` (o titular não concluiu, sem cobrança).

**A evidência é conferível sem nos consultar.** O bloco `evidence` traz o RP ID, a origem, a chave pública em JWK, os dados do autenticador, o `client_data_json`, a assinatura, o nonce e o ato canônico. O desafio assinado é `SHA-256("unifokal/passkey-act/v1" || nonce || act_digest)`, e a assinatura cobre `authenticator_data || SHA-256(client_data_json)`, como em qualquer asserção WebAuthn. Guarde a evidência junto do ato: ela prova, anos depois, o que o titular aprovou.

Os eventos de webhook do módulo são `passkey.bound` (a chave passou a valer), `passkey.revoked` (revogada pelo painel ou pelo apagamento do titular) e `act.rejected` (o titular recusou o ato). A revogação pelo painel exige a confirmação do segundo fator de quem opera.

## Atestado de pessoa verificada

<https://unifokal.com/docs/modulos/atestado-humano>

### Atestado de pessoa verificada

O módulo `atestado_humano` entrega, junto com a verificação aprovada, uma declaração assinada pela UNIFOKAL de que a pessoa passou pela prova de vida. A sua plataforma pode mostrar essa declaração a um auditor, a um regulador ou a um parceiro, e quem recebe confere a assinatura sem consultar a UNIFOKAL e sem receber nome, CPF, rosto ou data de nascimento. O formato é o SD-JWT VC (RFC 9901 com o perfil de credencial verificável da IETF), e cada informação dentro dele pode ser mostrada ou escondida separadamente.

! **Em breve.** A venda deste módulo está pausada: ele aparece na [tabela de preços](https://unifokal.com/precos) com o selo "Em breve", e o `create` de flow o recusa até a abertura. O que está descrito abaixo é o contrato que o backend já emite, para você planejar a integração.

**Quando ele é emitido.** O módulo depende de `liveness` no mesmo flow e só emite em produção, quando a verificação é aprovada pelo caminho automático. Aprovação manual na fila de revisão não emite, e o sandbox nunca emite. A cobrança acontece só quando o atestado sai: não emitido não é cobrado.

**O que chega no webhook.** O corpo do evento ganha `data.attestation`, no nível de cima de `data`, inclusive no modo de payload `minimal`. É ali que vem o token compacto, com o que você precisa para guardá-lo sem abri-lo. São três formas:

```
// data.attestation: emitido
"attestation": {
  "issued": true,
  "id": "hat_01J9ZC6Q8R3M2V7K4T5N1B0XHD",
  "format": "dc+sd-jwt",
  "sd_jwt": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImRjK3NkLWp3dCIsImtpZCI6InByZC0uLi4ifQ.eyJ...~WyJ...~WyJ...~",
  "vct": "https://unifokal.com/vct/pessoa-verificada/v1",
  "kid": "prd-0123456789ab",
  "issued_at": "2026-09-26T00:00:00Z",
  "expires_at": "2027-03-25T00:00:00Z",
  "disclosable": ["sub", "prova_de_vida", "unica_no_servico", "maioridade"] }

// data.attestation: emitido, mas o token não pôde ser montado na entrega
"attestation": {
  "issued": true,
  "id": "hat_01J9ZC6Q8R3M2V7K4T5N1B0XHD",
  "format": "dc+sd-jwt",
  "sd_jwt": null,
  "reason": "attestation_signer_not_configured",
  "vct": "https://unifokal.com/vct/pessoa-verificada/v1",
  "issued_at": "2026-09-26T00:00:00Z",
  "expires_at": "2027-03-25T00:00:00Z" }

// data.attestation: não emitido, com o motivo
"attestation": { "issued": false, "reason": "manual_decision" }
```

No bloco do módulo em `check_details` (o item com `module` igual a `atestado_humano`), o campo `data.attestation` traz os mesmos dados **sem o token**: nada de `sd_jwt` ali. Quando o atestado foi emitido mas chegou com `sd_jwt` nulo, baixe uma emissão nova pelo painel quando a emissão voltar.

```
// atestado_humano no check_details: emitido (os dados, sem o token)
{ "module": "atestado_humano", "passed": true, "outcome": "approved", "score": 100,
  "data": { "attestation": { "issued": true, "id": "hat_01J9ZC6Q8R3M2V7K4T5N1B0XHD",
            "format": "dc+sd-jwt",
            "vct": "https://unifokal.com/vct/pessoa-verificada/v1",
            "kid": "prd-0a1b2c3d4e5f",
            "issued_at": "2026-09-26T00:00:00.000Z",
            "expires_at": "2027-03-25T00:00:00.000Z",
            "disclosable": ["sub", "prova_de_vida", "unica_no_servico"] } } }

// atestado_humano no check_details: não emitido (aprovação manual)
{ "module": "atestado_humano", "passed": null, "outcome": "pending", "score": 0,
  "data": { "attestation": { "issued": false, "reason": "manual_decision" } } }
```

Os motivos de não emissão em `reason`: `verification_not_approved` (a verificação não foi aprovada automaticamente), `manual_decision` (a aprovação veio da revisão manual), `attestation_sandbox_not_issued` (sessão de sandbox), `liveness_not_approved` (a prova de vida não aprovou), `attestation_signer_not_configured` (a emissão está temporariamente indisponível do nosso lado) e `attestation_issue_failed` (a emissão falhou e a verificação seguiu sem ela). Com `issued` igual a `false` não há cobrança do módulo. O `attestation_signer_not_configured` também aparece com `issued` igual a `true` e `sd_jwt` nulo, a segunda forma acima.

**O que vai dentro.** Sempre visíveis: o emissor `iss` (`https://unifokal.com`), o tipo `vct` (o mesmo para todo cliente, sem nome de cliente, país ou produto) e a validade em `iat` e `exp`. Cada uma das informações abaixo é divulgável em separado, e a lista `disclosable` diz quais este atestado carrega:

```
"sub": "Y2Vu...43 caracteres"          // identificador aleatório da conta, só neste serviço
"prova_de_vida": { "resultado": true, "data": "2026-09-26" }
"unica_no_servico": true                  // só quando o flow tem face_unica e nenhuma outra conta foi encontrada
"maioridade": { "resultado": true, "metodo": "documento" }   // só com documento lido e rosto aprovado
```

O `sub` é um identificador aleatório, estável para a mesma conta (a sua organização, o ambiente e o `reference_id`) até o pedido de eliminação do titular, e nunca derivado de documento ou de biometria. Dois serviços diferentes recebem identificadores diferentes para a mesma pessoa. A `unica_no_servico` quer dizer "sem outra conta encontrada neste serviço", e só aparece quando o módulo [Detecção de múltiplas contas](https://unifokal.com/docs/modulos/face-unica#modulo-face-unica) foi contratado no flow. A `maioridade` sai da data de nascimento lida do documento, comparada no dia da emissão, e só aparece quando o flow leu documento com data de nascimento e o rosto bateu com ele. Idade estimada pela selfie nunca vira `maioridade`. A data de nascimento em si nunca entra.

**Validade.** 180 dias. O `iat` e o `exp` são arredondados ao começo do dia em UTC, para o horário exato da verificação não ficar gravado no atestado.

**Como conferir sem nos consultar.** O cabeçalho traz `alg` `ES256`, `typ` `dc+sd-jwt` e o `kid` da chave. As chaves públicas de produção vêm fixadas nos SDKs e também estão publicadas em [https://unifokal.com/.well-known/jwt-vc-issuer](https://unifokal.com/.well-known/jwt-vc-issuer) (o documento de metadado do emissor do SD-JWT VC). No SDK TypeScript, `verifyHumanAttestation(sdJwt)`; no SDK Python, `verify_human_attestation(sd_jwt)`. Os dois conferem a assinatura, o emissor, o tipo, a validade e cada divulgação, e recusam por padrão qualquer `kid` que não seja de produção.

**Como mostrar só uma parte.** `presentHumanAttestation(sdJwt, ["prova_de_vida"])` no TypeScript, ou `present_human_attestation(sd_jwt, ["prova_de_vida"])` no Python, devolve o mesmo token só com as divulgações escolhidas. A assinatura continua valendo, e quem recebe não vê as outras informações.

**Baixar pelo painel.** No detalhe da verificação, quem é dono ou administrador da conta baixa o atestado. Cada download é uma emissão nova, com sal e assinatura novos e as mesmas informações, então dois downloads não são o mesmo token.

**O que ele não é.** O atestado não esconde a pessoa da UNIFOKAL: como emissora, a UNIFOKAL consegue ligar um atestado à verificação que o originou. O que ele garante é que dois serviços que recebem atestados não conseguem ligar as contas entre si por meio dele. Esta versão não tem vínculo de chave: quem tem o token consegue apresentá-lo, então guarde-o como guarda uma credencial. Nada emitido em sandbox, nem os vetores de teste dos SDKs, confere contra as chaves publicadas. E o atestado diz o que a verificação encontrou no dia, sem acompanhar o que acontece com a conta depois.

## Reuso de Documento

<https://unifokal.com/docs/modulos/doclink>

### Reuso de Documento

O módulo `doclink` existe para o titular que **já se verificou com você** não precisar fotografar o documento de novo. Ele compara a selfie de agora com a selfie da verificação anterior e, batendo, **reaproveita** a identidade já lida. O titular ainda faz a prova de vida: o que some do fluxo é a captura do documento, nunca a prova de que há uma pessoa ali.

**Disponível desde 11 de setembro de 2026.** Ele aparece na tabela de preços e no `GET /v1/capabilities` com preço e status `available`, e pode ser ligado num flow. Até essa data a venda estava pausada, e a trava nunca foi técnica: o código já estava pronto, e o que faltava era a decisão do nome público e a conta comercial de trocar ticket por conversão. No flow ele **substitui** a Verificação de Identidade e o Face Match, e **exige** o Liveness, que é o que impede reusar uma identidade com a foto de uma foto.

```
// doclink: reuso aprovado. A identidade vem da verificação de ORIGEM.
{ "module": "doclink", "passed": true, "outcome": "approved", "score": 92,
  "data": { "reused": { "full_name": "JOÃO SILVA", "birth_date": "04/11/1992",
                        "document_number": "12345678901" },
            "source": { "verification_id": "ver_2f8c1a90",   // id opaco NOSSO, não é dado do titular
                        "age_days": 34 },                    // idade da verificação de origem
            "similarity": 0.91 } }                           // 0..1 (não é porcentagem)

// o rosto NÃO bateu com o da origem -> failed, e a chave "data" não existe
{ "module": "doclink", "passed": false, "outcome": "failed", "score": 40,
  "reason": "reuse_mismatch" }

// não havia origem para reusar -> pending (desfecho DIFERENTE do de cima), e também sem "data"
{ "module": "doclink", "passed": null, "outcome": "pending", "score": 0,
  "reason": "reuse_source_unavailable" }
```

**O bloco `data` só existe no caminho aprovado, e isso é regra de segurança e não estética.** A identidade reaproveitada só é anexada depois que o rosto bate. Se fosse anexada antes, quem tivesse o `reference_id` de outra pessoa receberia os dados dela de volta sem provar nada. Nos dois caminhos que não aprovam (origem indisponível e rosto que não bateu), o item do `check_details` traz os campos comuns a todo check (`module`, `passed`, `outcome`, `score` e `reason`) e nenhum bloco de dado. Repare que os dois **não** têm o mesmo desfecho: rosto que não bateu é `failed`, e origem indisponível é `pending`, porque no primeiro caso medimos e no segundo não havia o que medir. Os dois terminam em **revisão humana**: nenhum vira recusa automática, e nenhum vira pedido de foto nova.

`similarity` é a similaridade **crua**, de 0 a 1, e o corte deste módulo é **mais duro** que o do Face Match comum, de propósito: aqui o rosto é a única coisa que autoriza reaproveitar um documento que ninguém está reapresentando. `source.age_days` diz quantos dias tem a verificação de origem, e existe para a sua política: você pode aceitar reuso de 30 dias e recusar o de 170, ainda que os dois tenham passado no nosso corte. E repare que `source.verification_id` é um **id nosso**, opaco, não o `reference_id` que você escolheu.

## Validação de canal: e-mail e telefone

<https://unifokal.com/docs/modulos/canal>

### Validação de canal: e-mail e telefone

Três módulos validam o **canal de contato** informado pelo usuário: **Validação de e-mail** (`email_otp`), **Telefone: linha e operadora** (`telefone`) e **Telefone: código por SMS** (`sms_otp`). Eles provam **posse do canal naquele momento** (a pessoa recebe e digita um código, ou o número existe na numeração oficial). **Nenhum deles prova identidade**: quem prova identidade é a biometria e o documento. Use canal como sinal complementar, nunca como autenticador único.

! **Limites honestos.** `telefone` valida o número contra a base oficial de numeração (tipo de linha, DDD, detentora da faixa), mas **não diz se a linha está ativa** nem de quem ela é (portabilidade não é coberta: `range_holder_is_current` vem sempre `false`). `sms_otp` é cobrado **na decisão**, como todo módulo do catálogo, e o critério ali é a **chegada**: o canal entra no preço quando o código é confirmado ou quando o relatório de entrega diz que a mensagem chegou. Mensagem que saiu sem confirmação de entrega e sem código digitado fica fora. Como quem cobra é a decisão, sessão abandonada antes dela não gera cobrança nenhuma, mesmo que o SMS já tenha saído. E confirmar um e-mail prova acesso à caixa de entrada, **não a identidade** de quem digitou.

`email_otp` e `sms_otp` são **interativos**, e o contato dos dois vem do mesmo lugar: **o titular digita no widget**, e-mail e telefone igualmente. Você nunca envia contato na criação da sessão, e não há nada a chamar do seu servidor: o próprio widget envia o código (6 dígitos, validade de 10 minutos, reenvio com intervalo mínimo) e o confirma. O módulo `telefone` também não pede nada: ele roda sozinho no pipeline, sem enviar mensagem.

**Os limites do código**, para você desenhar a sua tela e instruir o seu suporte: ele vale por **10 minutos**, aceita **3 tentativas** erradas e trava na terceira, e o reenvio só libera **60 segundos** depois do envio anterior. Cada sessão comporta no máximo **3 envios no e-mail** e **2 no SMS**, contando o primeiro.

Os dois fins de linha são **diferentes**, e o seu suporte precisa separá-los. Esgotar as **tentativas** encerra aquele código, e o titular pede outro no próprio widget, o que funciona enquanto sobrar envio. Esgotar os **envios** é o fim da linha da sessão: não existe código novo ali, insistir não resolve, e o que resta é uma sessão nova.

No webhook, cada módulo de canal entra em `check_details` com os seus metadados. **Nunca** trafegam o código nem o contato completo: só o mascarado, o domínio e a impressão digital (`fp`, um HMAC do destino, estável para correlacionar sem expor).

```
// email_otp
{ "module": "email_otp", "passed": true, "outcome": "approved", "score": 100,
  "data": { "verified": true, "email_domain": "gmail.com", "email_masked": "j***o@gmail.com", "email_fp": "b3f1c9…", "destination_source": "end_user", "attempts": 1, "sends": 1, "verified_at": "2026-06-17T14:31:40Z" } }

// sms_otp
{ "module": "sms_otp", "passed": true, "outcome": "approved", "score": 100,
  "data": { "verified": true, "phone_masked": "+55 11 9****-**99", "phone_fp": "9f2c1a…", "country_code": "55", "destination_source": "end_user", "attempts": 1, "sends": 1, "verified_at": "2026-06-17T14:31:52Z" } }
```

Os dois são **portões suaves** na decisão: canal não confirmado deixa a verificação em `review`, nunca reprova sozinho. Em sandbox nada é enviado: o código fixo `000000` confirma, e destino terminado em `9` simula um destino recusado, sem cobrança.

## PEP e listas restritivas

<https://unifokal.com/docs/modulos/pep>

### PEP e listas restritivas

O módulo `pep_sancoes` confere o **CPF e o nome lidos do documento** contra listas oficiais de pessoas expostas politicamente e de sanções. Ele exige **Verificação de Identidade + Face Match + Liveness** no mesmo flow: o dado consultado vem do documento apresentado por quem está vivo na frente da câmera, nunca de um CPF digitado. O widget não ganha nenhum passo novo.

! **Este módulo nunca reprova sozinho.** Um resultado positivo é **candidato**, não veredito: nomes brasileiros repetem muito (dentro da própria lista de PEP há um nome com 13 CPFs diferentes). Por isso um sinal leva a verificação para `review`, com a evidência (lista, referência da entrada, score e motivo) no webhook, para a sua análise decidir. Nunca a `denied` automático.

**Cobertura desta fase**, e nada além dela: PEP federal e expulsões da administração federal (CGU: `PEP` e `CEAF`), empresas e pessoas inidôneas ou punidas (CGU: `CEIS` e `CNEP`) e as listas internacionais de sanções da **ONU** (obrigatória no Brasil pela Lei 13.810/2019), do **OFAC** (Tesouro dos EUA) e do **Reino Unido** (UK Sanctions List, do FCDO). **Não** estão incluídos, e não os anunciamos: a lista de sanções da **União Europeia** (a fonte exige uma credencial que ainda não temos, então ela nasce desabilitada e sai marcada como tal em `dataset_versions`), parentes e associados de PEP, PEP estadual/municipal, PEP estrangeiro, improbidade do CNJ e adverse media.

Cada resposta carimba a **idade e o tamanho de cada lista** em `dataset_versions` (`ingested_at`, `age_hours` e `record_count`). Se uma lista que entraria na resposta estiver vencida, o módulo devolve `pending` com `dataset_stale` e a verificação vai para `review`: nós não afirmamos "nada consta" sobre uma base que não conseguimos atualizar, e esse caso **não é cobrado**. Sem CPF legível no documento o resultado também é `pending`.

**A mesma regra vale quando a lista não foi consultada, e não só quando ela envelheceu.** A resposta sai `pending` com `no_coverage` quando nenhuma lista do módulo está disponível, e com `critical_source_disabled` quando uma lista que a resposta não pode dispensar está fora do ar. Os dois casos vão para `review` e **não são cobrados**, pelo mesmo motivo do `dataset_stale`: um veredito de "nada consta" só vale se alguma lista tiver sido de fato consultada. E quando uma lista específica não entrou na consulta, ela aparece em `aggregates.by_list` com `null` em vez de sumir do objeto, para que "não foi olhada" nunca se pareça com "olhada e sem resultado" no seu código.

A política **"aceito clientes que são PEP?"** é sua: por padrão ser PEP **não reprova** (sai como sinalização, que é o que a regulação pede: diligência reforçada, não recusa). Você pode desligá-la no flow, ou por sessão ao criar com `sk_` mandando `{ "policy": { "allow_pep": false } }`. Só o vínculo **atual** reprova; vínculo passado nunca. Chave de política desconhecida é 400 `unknown_policy_key`.

**O resumo pronto de PEP e sanção.** Você não precisa refazer a conta em cada integração. O bloco `pep_status` diz se há função pública **em exercício** (`current`) e se a pessoa **deixou** a função no último ano, nos últimos três ou nos últimos cinco anos (`last_1y`, `last_3y`, `last_5y`), contados do fim do exercício. `roles` traz a descrição da função como a fonte oficial publica, e só quando o casamento foi pelo documento e forte: semelhança de nome nunca traz cargo. Em `aggregates`, `is_pep`, `has_sanctions_br` (lista publicada por órgão brasileiro) e `has_sanctions_intl` (lista publicada por organismo ou governo estrangeiro) resumem os hits, `worst_severity` dá a pior zona entre eles (`no_hit`, `weak` ou `strong`), e `sanction_windows` e `pep_windows` separam as janelas por tipo de lista. `windows` continua no payload como a soma das duas, marcado como depreciado: prefira as separadas.

**Programe contra `source`, não contra `list`.** Em cada hit, `source` é o identificador estável da lista (`cgu_pep`, `cgu_ceis`, `ofac_sdn`) e não muda; `list` é o rótulo legível e pode mudar de texto. Na lista de PEP, `left_at` é o fim do período em que a pessoa é considerada politicamente exposta, e `exercise_end` é o fim do exercício da função; `pep_in_carency` marca quem já deixou a função e ainda está dentro dos cinco anos.

**Cota diária do módulo.** Cada organização tem uma cota diária de sessões novas com este módulo em produção. Passando dela, a criação de sessão responde `429 module_quota_reached` com `Retry-After` até a virada do dia (meia-noite UTC). A renovação de uma sessão não conta de novo. Se o seu volume pede mais, fale com a gente: o ajuste é por organização.

**A lista de hits tem teto, e o corte vem declarado.** Nome comum produz muito candidato, e um payload sem limite viraria incidente de custo e de leitura do seu lado. Passando do teto, `hits` sai cortado e `hits_truncated` vem `true`. Duas garantias tornam o corte seguro de programar: os `aggregates` são calculados sobre o conjunto **completo** (a contagem continua verdadeira mesmo com a lista cortada), e a ordem é determinística **antes** do corte, com hit forte na frente do fraco e casamento por documento na frente do casamento por nome. O que fica de fora é sempre a cauda mais fraca, nunca a evidência que decide.

```
// pep_sancoes: nada consta
{ "module": "pep_sancoes", "passed": true, "outcome": "approved", "score": 95,
  "data": { "pep": false, "flagged": false, "matched_by": "none", "reason": "clean",
            "pep_status": { "current": false, "last_1y": false, "last_3y": false, "last_5y": false,
                            "roles": [] },
            "hits": [], "hits_truncated": false,
            "aggregates": { "total": 0, "pep": 0, "sanctions": 0, "strong": 0,
                            "is_pep": false, "has_sanctions_br": false, "has_sanctions_intl": false,
                            "worst_severity": "no_hit",
                            "pep_windows": { "d30": 0, "d90": 0, "d180": 0, "d365": 0, "d1825": 0, "total": 0 },
                            "sanction_windows": { "d30": 0, "d90": 0, "d180": 0, "d365": 0, "d1825": 0, "total": 0 },
                            "windows": { "d30": 0, "d90": 0, "d180": 0, "d365": 0, "d1825": 0, "total": 0 } },
            "policy": { "allow_pep": true, "source": "flow" },
            "dataset_versions": { "cgu_pep": { "ingested_at": "2026-08-21T03:10:00Z", "age_hours": 9.2,
                                               "record_count": 133880,
                                               "stale": false, "disabled": false } } } }

// pep_sancoes: PEP atual, política do flow permitindo (sinaliza, NÃO reprova)
{ "module": "pep_sancoes", "passed": true, "outcome": "approved", "score": 70,
  "data": { "pep": true, "flagged": true, "matched_by": "document", "reason": "pep_current",
            "pep_status": { "current": true, "last_1y": false, "last_3y": false, "last_5y": false,
                            "roles": [ "DIRETOR" ] },
            "hits": [ { "source": "cgu_pep", "list": "PEP", "matched_by": "document", "strong": true,
                        "similarity": 1, "precision": 1, "listed_at": "2025-02-01", "left_at": null,
                        "current": true, "entry_ref": "cgu_pep:8831",
                        "exercise_end": null, "pep_in_carency": false,
                        "details": { "funcao": "DIRETOR", "orgao": "MINISTERIO X" } } ] } }

// pep_sancoes: deixou a função há dois anos e segue exposta pela carência de cinco anos
{ "module": "pep_sancoes", "passed": true, "outcome": "approved", "score": 70,
  "data": { "pep": true, "flagged": true, "matched_by": "document", "reason": "pep_current",
            "pep_status": { "current": false, "last_1y": false, "last_3y": true, "last_5y": true,
                            "roles": [ "SECRETARIO" ] },
            "hits": [ { "source": "cgu_pep", "list": "PEP", "matched_by": "document", "strong": true,
                        "similarity": 1, "precision": 1, "listed_at": "2019-01-01",
                        "left_at": "2028-12-31", "current": true, "entry_ref": "cgu_pep:9120",
                        "exercise_end": "2023-12-31", "pep_in_carency": true,
                        "details": { "funcao": "SECRETARIO", "orgao": "MINISTERIO X" } } ] } }

// pep_sancoes: semelhança de NOME numa lista internacional (candidato, não veredito)
// repare no que NÃO vem: nome, documento, cargo ou texto livre do terceiro.
{ "module": "pep_sancoes", "passed": true, "outcome": "approved", "score": 60,
  "data": { "pep": false, "flagged": true, "matched_by": "name", "reason": "sanction_name_weak",
            "hits": [ { "source": "ofac_sdn", "list": "OFAC SDN", "matched_by": "name",
                        "strong": false, "similarity": 0.82, "precision": 0.64,
                        "listed_at": "2019-05-10", "left_at": null, "current": true,
                        "entry_ref": "ofac_sdn:4417" } ] } }

// pep_sancoes: lista nossa vencida, pending, review, e NÃO cobrado
// QUAL lista venceu você lê em dataset_versions, na fonte com "stale": true.
{ "module": "pep_sancoes", "passed": null, "outcome": "pending", "score": 0,
  "data": { "pep": false, "flagged": false, "matched_by": "none", "reason": "dataset_stale",
            "hits": [], "hits_truncated": false, "aggregates": null,
            "policy": { "allow_pep": true, "source": "flow" },
            "dataset_versions": { "cgu_pep": { "ingested_at": "2026-08-19T03:10:00Z",
                                               "age_hours": 51.4, "record_count": 133880,
                                               "stale": true, "disabled": false } } } }

// pep_sancoes: nenhuma lista do modulo disponivel, pending, review, e NAO cobrado
// dataset_versions continua vindo inteiro: voce ve exatamente quais listas ficaram de fora.
{ "module": "pep_sancoes", "passed": null, "outcome": "pending", "score": 0,
  "data": { "pep": false, "flagged": false, "matched_by": "none", "reason": "no_coverage",
            "hits": [], "hits_truncated": false, "aggregates": null,
            "policy": { "allow_pep": true, "source": "flow" },
            "dataset_versions": { "cgu_pep": { "ingested_at": "2026-08-21T03:10:00Z",
                                               "age_hours": 9.2, "record_count": 133880,
                                               "stale": false, "disabled": true } } } }

// pep_sancoes: uma lista que a resposta nao pode dispensar esta fora do ar
{ "module": "pep_sancoes", "passed": null, "outcome": "pending", "score": 0,
  "data": { "pep": false, "flagged": false, "matched_by": "none",
            "reason": "critical_source_disabled",
            "hits": [], "hits_truncated": false, "aggregates": null,
            "policy": { "allow_pep": true, "source": "flow" } } }

// pep_sancoes: lista NAO consultada aparece como null em by_list (nunca ausente)
{ "module": "pep_sancoes", "passed": true, "outcome": "approved", "score": 95,
  "data": { "pep": false, "flagged": false, "matched_by": "none", "reason": "clean",
            "aggregates": { "total": 0, "pep": 0, "sanctions": 0, "strong": 0,
                            "by_list": { "TCU": null },
                            "windows": { "d30": 0, "d90": 0, "d180": 0, "d365": 0, "total": 0 } } } }
```

Em sandbox nada é consultado: o desfecho vem do sufixo do documento, como no resto do ambiente de testes. `66` devolve PEP atual com a política recusando, `77` PEP atual com a política permitindo, `44` PEP que deixou a função há dois anos e segue na carência, `88` sanção por documento no CEIS e `99` um homônimo na OFAC com semelhança 0,82. O `pep_status` e os agregados do sandbox saem da mesma conta da produção.

## Consulta cadastral de CPF e de CNPJ

<https://unifokal.com/docs/modulos/cadastrais>

### Consulta cadastral de CPF e de CNPJ

Sete módulos consultam a **fonte cadastral** a partir do documento já validado: `cpf_receita` (situação na Receita e óbito), `cpf_contatos` (telefones e e-mails), `cpf_enderecos`, `cpf_empresas` (empresas no nome do titular), `cnpj_cadastro`, `cnpj_receita` (cadastro em tempo real, com CNAE, porte e quadro societário) e `cnpj_participacoes` (o QSA detalhado, com percentual de cada sócio). Todos exigem **Documento, Face Match e Liveness** no mesmo flow, e a razão é a de sempre: o CPF ou CNPJ consultado vem do **documento apresentado** por quem está vivo na frente da câmera, nunca de um número digitado. Sem esse portão, a plataforma viraria uma ferramenta de consulta cadastral de terceiros.

! **Aqui o `data` é o dossiê da fonte, e não um objeto desenhado por nós.** Tiramos exatamente **três** campos, que são de conta e não do titular (`pacoteUsado`, `saldo` e `consultaID`), e o resto passa como a fonte mandou. Isso é decisão de produto, não descuido: remapear campo de fonte externa cria um contrato nosso que envelhece calado quando a fonte muda. A consequência para você é direta: **o conjunto de chaves varia por pacote e por consulta**, e pode ganhar campo sem release nosso. Os exemplos abaixo são **a forma observada**, não uma lista fechada. Leia por chave, com valor ausente tratado como ausente, e nunca escreva um parser estrito sobre estes quatro blocos.

**Duas armadilhas de leitura, e as duas já custaram integração alheia.** A primeira: `status` **não é a situação cadastral do titular**, é o indicador de sucesso da chamada à fonte (`1` quer dizer "a consulta deu certo"). A situação cadastral é `situacao`, e ela vem como **texto** no CPF (`"Regular"`, `"Titular Falecido"`) e como **objeto** no CNPJ (`{ "id": 2, "nome": "Ativa", … }`). A segunda: as datas do dossiê vêm no formato brasileiro `DD/MM/AAAA`, e não em ISO, com uma exceção que é justamente onde você não espera (veja `data_entrada` logo abaixo).

**A chave `data` se comporta de dois jeitos diferentes**, e vale saber qual é qual. Nos quatro tiers de **CPF** a chave **sempre existe** e vem `null` quando não houve enriquecimento (fonte fora, documento não encontrado, ou o portão de identidade não liberou). Nos três tiers de **CNPJ** a chave é **omitida** quando não há nem dossiê nem screening de sócios. Um parser que assume "`data` sempre presente" erra no CNPJ; um que assume "`data` ausente significa nada consta" erra nos dois.

```
// cpf_receita: situação na Receita e o marcador de óbito
{ "module": "cpf_receita", "passed": true, "outcome": "approved", "score": 95,
  "data": { "status": 1,                   // sucesso da CONSULTA, não situação do titular
            "cpf": "12345678901", "nome": "TITULAR MOCK DA SILVA",
            "nascimento": "15/03/1990",    // DD/MM/AAAA
            "mae": "MAE MOCK DE SOUZA", "genero": "M",
            "situacao": "Regular",         // <- ESTA é a situação cadastral
            "situacaoInscricao": "anterior a 10/11/1990", "situacaoDigito": "00",
            "situacaoMotivo": null, "situacaoAnoObito": null,
            "situacaoComprovante": "1A1A.2B2B.3C3C.4D4D",
            "situacaoComprovanteEmissao": "29/06/2026 19:08:44" } }

// cpf_enderecos: o endereço principal vem SOLTO no topo, e o histórico em "enderecos"
{ "module": "cpf_enderecos", "passed": true, "outcome": "approved", "score": 95,
  "data": { "status": 1, "cpf": "12345678901", "nome": "TITULAR MOCK DA SILVA",
            "nascimento": "15/03/1990", "situacao": "Regular",
            "endereco": "Rua Mock", "numero": "100 B", "complemento": "Apto 03",
            "bairro": "Centro", "cep": "99999123", "cidade": "Sao Paulo",
            "uf": "SP", "ibge": "1234567",
            "enderecos": [ { "endereco": "Rua Mock Antiga", "numero": "200",
                             "bairro": "Centro", "cep": "99999123",
                             "cidade": "Sao Paulo", "uf": "SP", "ibge": "1234567" } ] } }

// cpf_contatos: telefones, WhatsApp e e-mails, cada um como LISTA (pode vir vazia)
{ "module": "cpf_contatos", "passed": true, "outcome": "approved", "score": 95,
  "data": { "status": 1, "cpf": "12345678901", "nome": "TITULAR MOCK DA SILVA",
            "telefones": [ "11999999999", "3188888888" ],
            "whatsapp": [ "11999999999" ],   // subconjunto de "telefones"
            "emails": [ "titular@exemplo.com" ] } }
// este tier NÃO traz "situacao": ele fala de contato, não da situação cadastral do CPF.

// cpf_empresas: as empresas em que o titular consta como sócio
{ "module": "cpf_empresas", "passed": true, "outcome": "approved", "score": 95,
  "data": { "status": 1, "cpf": "12345678901", "nome": "TITULAR MOCK DA SILVA",
            "empresas": [ { "cnpj": "77888999000110", "razao": "LOJA MOCK LTDA",
                            "fantasia": "MINHA LOJA", "dataSociedade": "01/02/2018",
                            "qualificacao": "SOCIO-ADMINISTRADOR", "situacao": "Ativa" } ] } }
// este tier NÃO traz "situacao" do titular no topo: ele fala das empresas, não do CPF.

// fonte fora do ar -> pending, e a chave "data" EXISTE, vinda null
{ "module": "cpf_receita", "passed": null, "outcome": "pending", "score": 0,
  "reason": "gov_unavailable", "data": null }

// documento inexistente na base oficial -> este caminho REPROVA, e o data também vem null
{ "module": "cpf_receita", "passed": false, "outcome": "failed", "score": 10, "data": null }
```

**Manutenção programada da fonte oficial.** Quando um órgão para, de forma programada, um serviço que estes módulos consultam, as consultas que dependem daquela fonte seguem o caminho de fonte fora do ar mostrado acima.

Nos **tiers de CNPJ** o dossiê vem aninhado em `company`, e ao lado dele pode viajar `partner_screening`, que é o [screening do quadro societário](https://unifokal.com/docs/modulos/screening-socios#screening-socios) descrito na próxima seção. Os três tiers entregam **conjuntos bem diferentes**: `cnpj_cadastro` é o cadastro básico (sem CNAE, sem porte e **sem sócios**), `cnpj_receita` é o mais completo (acrescenta natureza jurídica, CNAE, porte, Simples Nacional, regimes tributários e o QSA) e `cnpj_participacoes` é o mais **magro** de todos, porque o produto dele é uma coisa só: o `percentual` de cada sócio.

! **O mesmo campo do sócio muda de tipo entre dois tiers.** `qualificacao_socio` é um **objeto** `{ "id": 49, "descricao": "Sócio-Administrador" }` em `cnpj_receita` e `cnpj_socios`, e uma **string** crua em `cnpj_participacoes`. E `data_entrada` vem em **ISO** (`"2015-06-01"`) no primeiro caso e no formato **brasileiro** (`"14/11/2018"`) no segundo. Um parser único sobre "o sócio" quebra quando você ligar o segundo tier. Isso é a fonte falando, não nós: os dois pacotes são produtos diferentes dela.

```
// cnpj_receita: o tier mais completo (recorte legível do dossiê)
{ "module": "cnpj_receita", "passed": true, "outcome": "approved", "score": 95,
  "data": { "company": {
      "status": 1, "cnpj": "11222333000181", "tipo": "Matriz",
      "razao": "EMPRESA MOCK LTDA", "fantasia": "MOCK",
      "capitalSocial": 100000, "inicioAtividade": "01/06/2015",
      "situacao": { "id": 2, "nome": "Ativa",        // OBJETO no CNPJ (string no CPF)
                    "data": "01/06/2015", "motivo": null },
      "naturezaJuridica": { "codigo": "2062", "descricao": "Sociedade Empresaria Limitada" },
      "cnae": { "fiscal": "6201501", "subClasse": "6201-5/01",
                "descricao": "Desenvolvimento de programas de computador sob encomenda" },
      "porte": { "id": "03", "descricao": "Empresa de Pequeno Porte" },
      "simplesNacional": { "optante": "Não", "inicio": "17/01/2020", "fim": "01/01/2023" },
      "socios": [ { "cpf_cnpj_socio": "111.222.333-44", "nome": "SOCIO MOCK UM",
                    "tipo": "Pessoa Física",
                    "data_entrada": "2015-06-01",              // ISO neste tier
                    "qualificacao_socio": { "id": 49,          // OBJETO neste tier
                                            "descricao": "Sócio-Administrador" } } ] } } }

// cnpj_participacoes: o QSA detalhado. O produto dele é o "percentual".
{ "module": "cnpj_participacoes", "passed": true, "outcome": "approved", "score": 95,
  "data": { "company": {
      "status": 1, "cnpj": "11222333000181", "razao": "EMPRESA MOCK LTDA", "fantasia": "MOCK",
      "socios": [ { "cpf_cnpj_socio": "111.222.333-44", "nome": "SOCIO MOCK UM",
                    "qualificacao_socio": "ADMINISTRADOR",   // STRING neste tier
                    "data_entrada": "14/11/2018",            // DD/MM/AAAA neste tier
                    "percentual": 60 },
                  { "cpf_cnpj_socio": "22.333.444/0001-81",
                    "nome": "HOLDING MOCK PARTICIPACOES LTDA",
                    "qualificacao_socio": "SOCIO PESSOA JURIDICA",
                    "data_entrada": "17/09/2015", "percentual": 40 } ] } } }
// este tier NÃO traz situação, CNAE nem endereço: não o use para inferir situação cadastral.

// cnpj_cadastro: o cadastro básico. Sem CNAE, sem porte e SEM sócios,
// e por isso ele nunca recebe o bloco "partner_screening".
{ "module": "cnpj_cadastro", "passed": true, "outcome": "approved", "score": 95,
  "data": { "company": {
      "status": 1, "cnpj": "11222333000181", "tipo": "Matriz",
      "razao": "EMPRESA MOCK LTDA", "fantasia": "MOCK",
      "capitalSocial": 100000, "inicioAtividade": "01/06/2015",
      "email": "contato@mock.com.br",
      "matrizEndereco": { "cep": "39400-000", "tipo": "Rua", "logradouro": "Rua Mock",
                          "numero": "100", "complemento": null, "bairro": "Centro",
                          "cidade": "Montes Claros", "uf": "MG" },
      "telefones": [ { "ddd": "11", "numero": "22334454" } ],
      "situacao": { "id": 2, "nome": "Ativa", "data": "01/06/2015", "motivo": null } } } }
```

**Quadro societário grande é o caso em que o corpo do webhook estoura o teto.** Quando o corpo passa de **256 KB**, a primeira coisa que podamos é justamente `company.socios`, e o corte é **declarado**: o par `truncated` mais `truncated_fields` no topo diz exatamente o que saiu, como explicado em [Webhooks](https://unifokal.com/docs/webhooks#webhooks). A decisão nunca é podada, e o detalhe completo continua no painel.

Em sandbox nada é consultado e o dossiê vem pronto, escolhido pelo **sufixo do documento**: `03` devolve a situação suspensa ou inapta e `99` o documento inexistente. Só no sandbox o dossiê carrega a chave `"mock": true`, que **produção nunca emite**: se você ramificar por ela, o código quebra na virada de ambiente.

## OCR do documento de empresa

<https://unifokal.com/docs/modulos/cnpj-ocr>

### OCR do documento de empresa

O módulo `cnpj_ocr` lê o **documento societário** que a empresa enviou (cartão CNPJ, CCMEI, contrato social, alteração contratual registrada na Junta, certidão, estatuto ou ata) e extrai os campos **impressos nele**. Diferente do documento de pessoa, aqui **não há câmera**: é envio de arquivo, e o documento pode ter várias páginas.

! **Tudo aqui é o que o PAPEL diz, na data em que o papel foi emitido.** Inclusive `situacao_cadastral` e `cnae_principal`: eles são **leitura do documento**, e não consulta à Receita. Para a situação cadastral **vigente** você precisa de `cnpj_cadastro` ou `cnpj_receita`, que perguntam à fonte. Tratar o campo lido do papel como situação atual é a confusão mais cara desta seção, porque uma certidão de 2019 diz "ATIVA" para sempre.

**Campo que o documento não trazia simplesmente não aparece no objeto.** Ele não vem `null`: a chave não existe. A única que sempre existe é `cnpj`, e ela pode vir **string vazia**, o que é um resultado **legítimo** e não uma falha: contrato de constituição original não traz CNPJ, porque a empresa ainda não tinha um. Vazio também é o que sai quando o documento cita mais de um CNPJ sem rótulo que desempate, porque **preferimos não responder a chutar** entre a empresa titular e uma sócia pessoa jurídica. Quando resolvemos, o número vem com **14 posições e dígito verificador conferido** (inclusive no formato alfanumérico da Receita), nunca um número que não fecha.

```
// cnpj_ocr no check_details: o que foi LIDO do documento societário
{ "module": "cnpj_ocr", "passed": true, "outcome": "approved", "score": 90,
  "data": { "cnpj": "11222333000181",
            "razao_social": "EMPRESA MOCK LTDA",
            "nome_fantasia": "MOCK",
            "nire": "31201234567",              // registro na Junta, NÃO é o CNPJ
            "tipo_documento": "cartao_cnpj",    // cartao_cnpj | ccmei | contrato_social |
                                                // alteracao_contratual | certidao |
                                                // estatuto_ata | requerimento_empresario | outro
            "situacao_cadastral": "ATIVA",      // IMPRESSA no papel, não consultada
            "cnae_principal": "6201-5/01 Desenvolvimento de programas de computador sob encomenda",
            "data_abertura": "01/06/2015",
            "socios": [ { "nome": "SOCIO MOCK UM", "qualificacao": "Administrador",
                          "participacao": "60%" } ] } }
// contrato de constituição original, que ainda não tem CNPJ: resultado VÁLIDO
{ "module": "cnpj_ocr", "passed": true, "outcome": "approved", "score": 90,
  "data": { "cnpj": "", "razao_social": "EMPRESA NOVA LTDA",
            "tipo_documento": "contrato_social", "data_abertura": "12/08/2026" } }
```

! **O sandbox deste módulo emite os mesmos nomes de campo da produção**, então dá para fixar o shape contra ele. O que muda entre os dois ambientes é só o conteúdo: no sandbox os valores são fixos e nenhum documento é lido de verdade. Continua valendo a regra de cima: campo que o documento não trazia **não aparece** no objeto, então programe por presença de chave e não por posição.

## Dados cadastrais entregues

<https://unifokal.com/docs/modulos/dados-cadastrais>

### Dados cadastrais entregues

O módulo `dados_cadastrais` é diferente de todos os anteriores: ele **não faz consulta nenhuma**. Ele pega o que os tiers `cpf_*` do mesmo flow já trouxeram e entrega **quatro campos, sempre os mesmos quatro**, com o registro de finalidade do tratamento ao lado. Sem um tier `cpf_*` no flow não há o que entregar. É o único módulo desta família com **contrato fechado**: aqui você pode escrever um parser estrito.

```
// dados_cadastrais: quatro campos, e o registro de finalidade
{ "module": "dados_cadastrais", "passed": true, "outcome": "approved", "score": 100,
  "data": {
    "delivered": { "name": "JOÃO SILVA",
                   "birth_date": "1992-11-04",        // ISO aqui (DD/MM/AAAA nos tiers cpf_*)
                   "registration_status": "regular",  // valor CRU da fonte, não um enum nosso
                   "deceased": false },               // null = NÃO MEDIDO, nunca "vivo"
    "purpose": { "finalidade": "identity_verification",
                 "controller": "client", "operator": "unifokal",
                 "legal_basis": "controller_art_7", "minimization": "art_6_iii",
                 "delivered_fields": ["name","birth_date","registration_status","deceased"],
                 "withheld_fields": ["filiacao","genero","endereco","contatos","empresas","obito_ano"],
                 "policy_version": "cadastral-delivery@1" },
    // QUAIS tiers sustentaram os campos acima: prova de diligência, nunca dado do titular
    "coverage": ["cpf_receita"] } }

// nenhum tier sustentou nenhum dos quatro campos: NÃO entregue, e o módulo sai do preço
{ "module": "dados_cadastrais", "passed": null, "outcome": "pending", "score": 0,
  "data": { "delivered": null, "purpose": { "…": "idêntico ao acima" }, "coverage": [] } }
```

**Três leituras que evitam erro.** Primeira: `deceased: null` não é `false`. `null` quer dizer que o tier comprado não carregava o campo de óbito, ou seja **não medimos**, e nunca "confirmamos que está vivo". Segunda: `registration_status` é o valor **cru da fonte**, com a capitalização dela (`Regular`, `Suspensa`, `Titular Falecido`), e vem `null` quando o tier comprado não traz situação, que é o caso do `cpf_empresas`. Terceira: o bloco `purpose` viaja **mesmo quando `delivered` é `null`**, porque ele é o registro de **finalidade e minimização** do tratamento, não a prova de que houve entrega. Ele é constante por construção, então não o leia como configuração da sua conta: `withheld_fields` é a lista do que decidimos **não** entregar neste módulo, e ela é a mesma para todo mundo.

Cada um dos quatro campos é resolvido **independentemente**, varrendo os tiers do flow e ficando com o primeiro valor não nulo. Por isso `coverage` pode listar mais de um módulo: não existe um tier "vencedor" único, existe o primeiro que respondeu **por campo**. E `coverage` traz nome de **módulo**, nunca dado do titular.

## Benefícios do governo

<https://unifokal.com/docs/modulos/beneficios-gov>

### Benefícios do governo

O módulo `beneficios_gov` confere, na fonte oficial da CGU, se o titular verificado consta como **beneficiário de programa social federal**. Ele é **informacional** e não pede captura nova.

! **Este módulo não pode ser usado para negar serviço por condição socioeconômica.** Ele é informacional por construção: `passed` só assume `true` ou `null`, **nunca `false`**, então não existe desfecho em que constar num programa reprove a verificação. Discriminação por dado sensível é vedada pela LGPD no princípio da **não discriminação** (Lei 13.709/2018, Art. 6º, IX), e o próprio payload carrega essa finalidade escrita no campo `purpose`. Repare que o enquadramento é esse, e não o de dado sensível: constar num programa social **não** é dado sensível pelo rol do Art. 5º, II, e nós não o tratamos como tal.

**Venda pausada hoje.** Ele aparece na tabela de preços e no `GET /v1/capabilities` com preço e status `coming_soon`, e não pode ser ligado num flow enquanto a credencial da fonte não existir. O contrato abaixo é o que já está implementado.

**A cobertura é de quatro programas, e só quatro**: `novo_bolsa_familia`, `seguro_defeso`, `garantia_safra` e `peti`. São os que a fonte expõe por pessoa. BPC e Auxílio Emergencial **não** estão incluídos, e não os anunciamos. A lista viaja em `programs_covered` em toda resposta, justamente para você nunca ter que adivinhar o que foi olhado.

```
// beneficios_gov: consta em um programa (valores em CENTAVOS)
{ "module": "beneficios_gov", "passed": true, "outcome": "approved", "score": 95,
  "data": {
    "programs_covered": ["novo_bolsa_familia","seguro_defeso","garantia_safra","peti"],
    "partial": false, "programs_degraded": [],
    "benefits": {
      "has_any_benefit": true,
      "programs": ["novo_bolsa_familia"],
      "months_on_record": 12,
      "total_received_cents": 720000,     // CENTAVOS (o crédito usa reais; aqui é centavo)
      "records": [ { "program": "novo_bolsa_familia", "months_on_record": 12,
                     "total_cents": 720000,
                     "first_reference": "202509", "last_reference": "202608",  // AAAAMM
                     "history_truncated": false,
                     "amounts_unreadable": 0,
                     "payments": [ { "reference": "202509", "amount_cents": 60000 } ] } ],
      "raw": { "…": "a resposta do órgão, como ela veio" } },
    "purpose": "Confirmar, na fonte oficial da CGU, se o titular verificado consta como beneficiario de programa social federal. Informacional: nao reprova a verificacao e nao pode ser usado para negar servico por condicao socioeconomica (LGPD art. 6, IX)." } }

// PARCIAL: um dos programas da cobertura não respondeu. Lista vazia aqui NÃO é "nada consta".
// Repare que o desfecho continua "approved": a fonte respondeu, só que não sobre tudo.
{ "module": "beneficios_gov", "passed": true, "outcome": "approved", "score": 95,
  "data": {
    "programs_covered": ["novo_bolsa_familia","seguro_defeso","garantia_safra","peti"],
    "partial": true,
    "programs_degraded": ["peti"],       // ESTE não foi consultado nesta passagem
    "benefits": { "has_any_benefit": false, "programs": [], "months_on_record": 0,
                  "total_received_cents": 0, "records": [], "raw": null },
    "purpose": "…" } }
```

**O campo que carrega o módulo é `partial`.** Quando ele vem `true`, algum programa da cobertura **não respondeu**, e a lista vazia ao lado significa "não perguntei a esse", jamais "perguntei e não consta". `programs_degraded` nomeia quais. Ler `has_any_benefit: false` sem olhar `partial` é transformar uma falha de consulta em atestado negativo.

Dois números pedem cuidado. `total_received_cents` e `total_cents` podem estar **subestimados** quando `amounts_unreadable` é maior que zero: parcela cujo valor a fonte não publicou legível entra como zero na soma, e o contador ao lado declara quantas foram. E `months_on_record` só conta o histórico que recebemos: com `history_truncated: true`, ele é um piso e não o total. Quando a fonte não é consultada de forma nenhuma (credencial ausente, portão de identidade fechado), a chave `data` **não vem**, e isso é deliberado: nenhum payload deste módulo pode ser lido como "nada consta" quando nada foi perguntado.

## Screening do quadro societário

<https://unifokal.com/docs/modulos/screening-socios>

### Screening do quadro societário

Quando o flow tem um módulo de **quadro societário** (`cnpj_socios`, `cnpj_participacoes` ou `cnpj_receita`), cada sócio que veio na consulta é conferido contra as **mesmas listas** do módulo `pep_sancoes`. Não é um módulo novo, não entra no flow e **não custa nada a mais**: os sócios já vinham dentro da consulta que você já paga, e o cruzamento roda na nossa base. O resultado sai no `partner_screening`, dentro do `data` do próprio módulo que trouxe o quadro, ao lado de `company`.

! **Isto é um nível do quadro, não beneficiário final.** A consulta devolve os sócios **diretos** do CNPJ apresentado. Se um deles for pessoa jurídica, este enriquecimento **não sobe a cadeia** para achar quem está atrás dela: aquele ramo fica em aberto, e nós dizemos isso na resposta em vez de deixar parecer concluído. Quem sobe é um **módulo próprio**, a [Cadeia societária](https://unifokal.com/docs/modulos/ubo-profundo#modulo-ubo-profundo) (`ubo_profundo`), que percorre nível a nível até as pessoas naturais e é cobrado por empresa subida, com teto definido por você.

**Cobertura deste enriquecimento**, e nada além dela: **um nível** do quadro societário, com o sócio conferido por **documento** (CPF ou CNPJ, inclusive o CPF mascarado que a Receita publica) e por **nome**. **Cadeia de vários níveis e participação indireta** são o produto do módulo [Cadeia societária](https://unifokal.com/docs/modulos/ubo-profundo#modulo-ubo-profundo), que você liga no flow quando precisa deles. Continuam **fora** de qualquer um dos dois, e não os anunciamos: controle por acordo de acionistas, sócio no exterior que não publica CNPJ e pessoa que controla sem constar do quadro. O **percentual** de participação só existe quando o flow comprou `cnpj_participacoes`: sem ele, nenhuma regra de 25% pode ser calculada, e o campo sai `null` em vez de zero. Acima de **50 sócios** o excedente sai declarado em `partners_truncated`, nunca em silêncio.

O bloco `ubo_coverage` existe para essa honestidade ser **legível por código**, e não só por quem lê esta página: `chain_complete` só é `true` quando **todo** sócio do quadro é pessoa natural identificada, e `unresolved_legal_entities` conta os ramos que ficaram em aberto. Se o seu processo exige beneficiário final, é esse par que diz quando a análise humana ainda precisa continuar.

! **Nenhum sócio reprova a sua verificação.** O quadro é **evidência**: o único efeito possível na decisão é levar de `approved` para `review`, e só no caso de **sanção forte casada por documento**. Semelhança de **nome** nunca move a decisão: o quadro societário não publica data de nascimento, então o desempate que existe para o titular não existe para o sócio, e homônimo é o caso comum. Nunca `denied` automático.

Se uma lista nossa estiver **vencida**, o bloco sai `unavailable` com `dataset_stale` e **cada sócio marcado como indeterminado**: nós não afirmamos "nada consta" sobre um quadro que não conseguimos conferir, e esse caso **não muda a decisão**. O mesmo bloco, com os mesmos sócios indeterminados, sai com `no_coverage` quando nenhuma lista do módulo está disponível e com `critical_source_disabled` quando uma lista que a resposta não pode dispensar está fora do ar. São três motivos para a mesma regra: um veredito sobre o quadro só vale se alguma lista tiver sido de fato consultada. E quando uma lista específica não entrou na consulta, ela aparece no `aggregates.by_list` de **cada sócio** com `null` em vez de sumir do objeto, exatamente como no bloco do titular: "não foi olhada" nunca se parece com "olhada e sem resultado" no seu código. O mesmo vale para sócio sem nome e sem documento na fonte. O **documento do sócio não viaja** dentro de `partner_screening`: ele já está em `company.socios`, no mesmo check, sob a mesma cifra e o mesmo direito de exclusão.

```
// cnpj_socios: quadro conferido, um sócio sancionado (evidência; só vai a review se você ligar)
{ "module": "cnpj_socios", "passed": true, "outcome": "approved", "score": 95,
  "data": {
    "company": { "cnpj": "46157128000164", "razao_social": "EXEMPLO LTDA", "socios": [ /* ... */ ] },
    "partner_screening": {
      "status": "flagged", "reason": "partner_sanction_document", "review": true,
      "partners_total": 2, "partners_screened": 2, "partners_flagged": 1,
      "partners_indeterminate": 0, "partners_truncated": false,
      "partners": [
        { "name": "JOSE CARLOS DA SILVA", "qualification": "Sócio-Administrador",
          "entity_type": "person", "percentual": null, "matchable": true,
          "status": "flagged", "reason": "sanction_document", "matched_by": "document",
          "hits": [ { "source": "cgu_ceis", "list": "CEIS", "matched_by": "document",
                      "strong": true, "similarity": 1, "entry_ref": "cgu_ceis:1182" } ] },
        { "name": "SOCIO LIMPO", "qualification": "Sócio", "entity_type": "person",
          "status": "clear", "reason": "clean", "matched_by": "none", "hits": [] }
      ],
      // o que dá e o que NÃO dá para afirmar sobre beneficiario final
      "ubo_coverage": { "level": 1, "chain_complete": true, "percentual_available": false,
                        "unresolved_legal_entities": 0, "presumption_threshold_pct": 25,
                        "candidates_over_threshold": null },
      "model_version": "partner-screening-v1" } } }

// cnpj_socios: sócio pessoa jurídica no quadro (a cadeia NÃO se fecha, e a resposta diz isso)
{ "module": "cnpj_socios", "passed": true, "outcome": "approved", "score": 95,
  "data": { "partner_screening": {
    "status": "clear", "reason": "clean", "review": false,
    "ubo_coverage": { "level": 1, "chain_complete": false, "unresolved_legal_entities": 1 } } } }

// cnpj_socios: lista nossa vencida (indeterminado explícito, nunca "limpo")
{ "module": "cnpj_socios", "passed": true, "outcome": "approved", "score": 95,
  "data": { "partner_screening": {
    "status": "unavailable", "reason": "dataset_stale", "review": false,
    "stale_sources": ["cgu_ceis"],
    "partners": [ { "name": "JOSE CARLOS DA SILVA", "status": "indeterminate",
                    "reason": "dataset_stale", "hits": [] } ] } } }

// cnpj_socios: nenhuma lista do modulo disponivel (mesmo bloco, outro motivo).
// disabled_sources traz o catalogo INTEIRO do modulo neste caso; o exemplo mostra so duas entradas.
{ "module": "cnpj_socios", "passed": true, "outcome": "approved", "score": 95,
  "data": { "partner_screening": {
    "status": "unavailable", "reason": "no_coverage", "review": false,
    "disabled_sources": ["cgu_ceis", "cgu_pep"],
    "partners": [ { "name": "JOSE CARLOS DA SILVA", "status": "indeterminate",
                    "reason": "no_coverage", "hits": [] } ] } } }
```

## Impedidos de apostar

<https://unifokal.com/docs/modulos/impedidos-apostar>

### Impedidos de apostar

O módulo `impedidos_apostar` confere o **CPF e o nome lidos do documento** contra as listas públicas de pessoas vedadas de apostar pela **Lei 14.790/2023 (Art. 26)**, a obrigação legal do operador de apostas de quota fixa perante a SPA/MF. Ele exige **Verificação de Identidade + Face Match + Liveness** no mesmo flow: o dado consultado vem do documento apresentado por quem está vivo na frente da câmera, nunca de um CPF digitado. O widget não ganha nenhum passo novo.

**Cobertura deste módulo**, fonte a fonte, e nada além dela: **agentes públicos** dos órgãos de regulação e fiscalização do setor de apostas (download de servidores do Portal da Transparência, filtrado por lotação). A base é **mensal** e o órgão publica com até dois meses de defasagem, então o competente que entrou no cargo no mês passado ainda não está nela: `dataset_versions` devolve a data em que nós ingerimos, não a data de referência do arquivo do órgão. **Não** estão incluídos, e não os anunciamos: cônjuge e parentes (o vínculo familiar do Art. 26 exige bureau e entra como produto separado), dirigentes de clube, agentes do próprio operador, intermediários e outros esportes. E este módulo **não substitui o SIGAP**: a consulta ao bloco de proteção (menores, beneficiários de programas sociais e autoexcluídos) é uma API do Serpro liberada só para o operador licenciado, que a sua operação já é obrigada a consultar diretamente.

! **Atleta e árbitro NÃO entram, e preferimos dizer isso a listar sem consultar.** A CBF não publica base para conferência automática: o Boletim Informativo Diário é uma página de consulta um a um e os quadros de arbitragem não têm arquivo. Ler aquilo por raspagem de página produziria um "nada consta" falso no dia em que a página mudasse de formato, que é justamente o resultado que gera multa para o operador. As duas fontes aparecem na resposta como `disabled` em `dataset_versions`: você vê, por fonte, o que foi consultado e o que não foi.

! **Este módulo nunca reprova sozinho.** O casamento é por **CPF parcial** (o Portal publica `***.NNN.NNN-**`) mais o nome, ou seja, homônimo continua sendo possível. Um sinal leva a verificação para `review` com a evidência no webhook, para a sua análise decidir. Nunca a `denied` automático.

Cada resposta carimba as fontes que a sustentaram em `coverage` e a idade de cada base em `dataset_versions`: é a sua **prova de diligência** ("na data do cadastro checamos estas bases nestas versões"). Se uma fonte que entraria na resposta estiver vencida, ou ainda sem carga, o módulo devolve `pending` com `dataset_stale` e a verificação vai para `review`: nós não afirmamos "nada consta" sobre base que não conseguimos atualizar, e esse caso **não é cobrado**.

A política **"impedimento reprova?"** é sua: por padrão a restrição **não reprova**, sai como sinalização com evidência e você decide no seu backoffice. Para que a restrição derrube o check, desligue no flow ou por sessão ao criar com `sk_` mandando `{ "policy": { "allow_betting_ban": false } }`. Chave de política desconhecida é 400 `unknown_policy_key`. Flows com este módulo não permitem renovação de sessão pelo widget: a renovação é sempre pelo seu servidor.

**A lista de restrições tem teto de 20, e o corte vem declarado.** O casamento por CPF parcial mais nome produz homônimo em volume, e um payload sem limite viraria incidente de custo e de leitura do seu lado. Passando de 20, `restrictions` sai cortado e `restrictions_truncated` vem `true`. O que continua verdadeiro no corte: `restrictions_total` declara o número **real**, `aggregates` é calculado sobre o conjunto **completo** (então `is_restricted` e `strongest_match` nunca mentem por causa do corte), e a ordenação é determinística **antes** dele: documento, depois documento parcial, depois nome, e maior similaridade primeiro.

**Coincidência de CPF parcial sem o nome confirmar tem resposta própria.** Parte das listas publica o CPF **mascarado**. Quando a máscara do titular coincide com uma linha da lista e o nome do documento **não** confirma, o módulo não devolve `clear`: o `reason` vem `mask_unconfirmed` (ou `mask_unconfirmed_allowed`, se a sua política aceita impedido), `flagged` vem `true` e `mask_unconfirmed_total` diz quantas linhas coincidiram. `restrictions` continua **vazio** e `is_restricted` continua `false`: não há restrição provada, e a linha da pessoa não confirmada nunca viaja. No resumo da verificação esse caso aparece como `inconclusive`, que é diferente de `restricted` e de `clear`. Os quatro valores possíveis em `checks.impedidos_apostar` são `clear`, `restricted`, `inconclusive` e `pending`.

```
// impedidos_apostar: nada consta (fonte fresca)
{ "module": "impedidos_apostar", "passed": true, "outcome": "approved", "score": 95,
  "data": { "is_restricted": false, "flagged": false, "matched_by": "none", "reason": "clear",
            "restrictions": [], "restrictions_total": 0, "restrictions_truncated": false,
            "mask_unconfirmed_total": 0,
            "aggregates": { "is_restricted": false, "restriction_types": [], "strongest_match": null },
            "policy": { "allow_betting_ban": true, "source": "flow" },
            "coverage": ["ptransp_servidores_reg"],
            "coverage_degraded": [],
            // as fontes da CBF viajam SEMPRE, marcadas como nao consultadas
            "dataset_versions": { "ptransp_servidores_reg": { "ingested_at": "2026-08-29T05:41:24Z",
                                                              "age_hours": 4, "stale": false,
                                                              "disabled": false },
                                  "cbf_bid":      { "ingested_at": null, "age_hours": null,
                                                    "stale": false, "disabled": true },
                                  "cbf_arbitros": { "ingested_at": null, "age_hours": null,
                                                    "stale": false, "disabled": true } },
            "name_source": "ocr" } }

// impedidos_apostar: agente publico casado por CPF PARCIAL + nome, com a politica reprovando -> review
{ "module": "impedidos_apostar", "passed": false, "outcome": "failed", "score": 20,
  "data": { "is_restricted": true, "flagged": true, "matched_by": "document_partial",
            "reason": "restricted_document_partial",
            "restrictions": [ { "type": "agente_publico", "link_level": 0, "sport": null,
                                "entity": "MINISTERIO DA FAZENDA",
                                "source": "ptransp_servidores_reg",
                                "matched_by": "document_partial", "similarity": 100,
                                "birth_date_mismatch": false,
                                "entry_ref": "ptransp_servidores_reg:e_a8954835",
                                "details": { "orgao": "MINISTERIO DA FAZENDA",
                                             "cargo": "ANALISTA TRIBUTARIO REC FEDERAL BRASIL" } } ],
            "restrictions_total": 1,
            "aggregates": { "is_restricted": true, "restriction_types": ["agente_publico"],
                            "strongest_match": "document_partial" },
            "policy": { "allow_betting_ban": false, "source": "session" } } }

// impedidos_apostar: a mascara publicada coincidiu e o nome NAO confirmou -> review, sem restricao
{ "module": "impedidos_apostar", "passed": false, "outcome": "failed", "score": 45,
  "data": { "is_restricted": false, "flagged": true, "matched_by": "none",
            "reason": "mask_unconfirmed",
            "restrictions": [], "restrictions_total": 0, "mask_unconfirmed_total": 1,
            "policy": { "allow_betting_ban": false, "source": "session" } } }

// impedidos_apostar: fonte vencida -> pending, review, e NAO cobrado
{ "module": "impedidos_apostar", "passed": null, "outcome": "pending", "score": 0,
  "data": { "reason": "dataset_stale", "coverage": [],
            "coverage_degraded": ["ptransp_servidores_reg"] } }
```

Em sandbox nada é consultado: o desfecho vem do sufixo do documento. `02` devolve um agente público casado por CPF parcial, `01` o caminho de fonte vencida (pendente, não cobrado) e `33` um homônimo abaixo do corte, que aprova.

## Mídia adversa

<https://unifokal.com/docs/modulos/midia-adversa>

### Mídia adversa

O módulo `midia_adversa` confere o **nome lido do documento** contra um **corpus aberto de notícias** (negative news) mantido e atualizado por nós, construído sobre o GDELT Project (dados do Global Knowledge Graph, uso comercial livre, com atribuição). Ele exige **Verificação de Identidade + Face Match + Liveness** no mesmo flow: o nome consultado vem do documento apresentado por quem está vivo na frente da câmera, nunca de um nome digitado. O widget não ganha nenhum passo novo.

! **Este módulo nunca reprova sozinho.** Notícia não publica CPF, então todo casamento é por **nome**, e homônimo é o caso comum. Um hit forte leva a verificação para `review` com a evidência no webhook (fonte, data e link), para a sua análise humana decidir. Nunca a `denied` automático: uma coincidência de nome não pode destruir o cadastro de um inocente.

**O grau de confiança do casamento vem sempre declarado.** Cada hit carrega `name_match` (`strong` ou `possible`) e a `similarity` de 0 a 100; a resposta agrega o grau mais forte no topo. Um **hit forte** vem com a evidência completa: a fonte, a **data de publicação** e o **link da notícia**, que é o que o seu revisor abre para julgar. Um **possível homônimo** (zona cinzenta) apenas sinaliza: sai o grau, a similaridade e uma referência opaca, **sem link, sem veículo e sem data**, porque provável terceiro não ganha dossiê. Divergência de data de nascimento, quando existe nos dois lados, rebaixa o grau e fica marcada em `birth_date_mismatch`, nunca esconde o hit.

**A lista de hits tem teto de 20, e o corte vem declarado.** Homônimo comum produz dezenas de notícias, e um payload sem limite viraria incidente de custo e de leitura do seu lado. Passando de 20, `hits` sai cortado e `hits_truncated` vem `true`. O que continua verdadeiro no corte: `hits_total` declara o número **real** de hits, `aggregates` é calculado sobre o conjunto **completo**, e a ordenação é determinística **antes** do corte (hit forte na frente do possível, depois maior similaridade). Sem essa ordem, um homônimo com 50 hits fracos empurraria para fora da lista justamente o único hit forte, que é o que o seu revisor precisa ver primeiro.

Cada resposta carimba a fonte que a sustentou em `coverage` e a idade da base em `dataset_versions`. Se a base estiver vencida, ou ainda sem carga, o módulo devolve `pending` com `dataset_stale` e a verificação vai para `review`: o resultado é **indeterminado**, nós não afirmamos "nada consta" sobre base que não conseguimos atualizar, e esse caso **não é cobrado**.

```
// midia_adversa: nada consta (base fresca)
{ "module": "midia_adversa", "passed": true, "outcome": "approved", "score": 95,
  "data": { "has_adverse_media": false, "flagged": false, "name_match": "none", "reason": "clear",
            "hits": [], "hits_total": 0, "hits_truncated": false,
            "aggregates": { "has_adverse_media": false, "possible_matches": 0, "strongest_match": null },
            "coverage": ["gdelt_gkg"], "coverage_degraded": [],
            "dataset_versions": { "gdelt_gkg": { "ingested_at": "2026-08-27T03:00:00Z", "age_hours": 4,
                                                 "stale": false, "disabled": false } },
            "name_source": "ocr" } }

// midia_adversa: hit FORTE -> review humano com a evidência (fonte + data + link)
{ "module": "midia_adversa", "passed": false, "outcome": "failed", "score": 40,
  "data": { "has_adverse_media": true, "flagged": true, "name_match": "strong",
            "reason": "adverse_media_strong",
            "hits": [ { "source": "gdelt_gkg", "name_match": "strong", "similarity": 94,
                        "url": "https://noticia.example.com/materia",
                        "outlet": "noticia.example.com", "published_at": "2025-11-20",
                        "birth_date_mismatch": false, "entry_ref": "gdelt_gkg:e_7a31d2" } ],
            "hits_total": 1,
            "aggregates": { "has_adverse_media": true, "possible_matches": 0,
                            "strongest_match": "strong" } } }

// midia_adversa: possível homônimo -> aprova com sinalização, SEM link e SEM data (minimização)
{ "module": "midia_adversa", "passed": true, "outcome": "approved", "score": 70,
  "data": { "has_adverse_media": false, "flagged": true, "name_match": "possible",
            "reason": "possible_match",
            "hits": [ { "source": "gdelt_gkg", "name_match": "possible", "similarity": 78,
                        "url": null, "outlet": null, "published_at": null,
                        "birth_date_mismatch": false, "entry_ref": "gdelt_gkg:e_2c19aa" } ],
            "hits_total": 1 } }

// midia_adversa: base sem carga/vencida -> pending (indeterminado), review, e NÃO cobrado
{ "module": "midia_adversa", "passed": null, "outcome": "pending", "score": 0,
  "data": { "reason": "dataset_stale", "coverage": [], "coverage_degraded": ["gdelt_gkg"] } }
```

Em sandbox nada é consultado: o desfecho vem do sufixo do documento. `02` devolve um hit forte com a evidência completa, `33` um possível homônimo que aprova com sinalização e `01` o caminho de base vencida (pendente, indeterminado, não cobrado). Flows com este módulo não permitem renovação de sessão pelo widget: a renovação é sempre pelo seu servidor.

## Coerência cadastral

<https://unifokal.com/docs/modulos/coerencia-cadastral>

### Coerência cadastral

O módulo `coerencia_cadastral` responde uma pergunta que hoje você teria que responder sozinho: **o que o documento diz bate com o que o cadastro oficial diz?** Ele compara o **nome** e o **nascimento** lidos do documento pelo `cpf_ocr` com os que a **Validação CPF - Receita e Óbito** (`cpf_receita`) do mesmo flow já trouxe, e devolve o veredito **campo a campo**. Ele também responde se o cadastro registra o titular como **falecido**, que é a única situação da Receita que contradiz a prova de vida que acabou de passar.

**Ele não faz consulta nenhuma.** Não há chamada nova, não há fonte nova e não há passo novo no widget: o módulo lê o que o seu flow já comprou. É por isso que ele custa uma fração do tier que o alimenta, e é por isso que ele **exige** a Validação CPF - Receita e Óbito no mesmo flow, além de **Verificação de Identidade + Face Match + Liveness** (o dado comparado vem do documento apresentado por quem está vivo na frente da câmera).

**O que você recebe é a resposta, não o dado.** O payload deste módulo não carrega nome, data nem situação cadastral: carrega `match`, `mismatch` ou `not_checked` por campo. O dado oficial continua chegando pelo check do tier que você comprou, no lugar de sempre. Se o que você quer é só a conferência, este módulo é a forma de tê-la sem espalhar cadastro de terceiro pelo seu sistema.

! **Este módulo nunca reprova ninguém.** Divergência de nome tem causa legítima e comum no Brasil: casamento, divórcio e, desde a Lei 14.382/2022, mudança de prenome extrajudicial na maioridade. Divergência de nascimento costuma ser leitura de documento antigo. O módulo fica **fora** do pass/fail da verificação: ele informa, e quem decide o que fazer é você.

**O que ele não confere, por decisão nossa:** **filiação** (nome da mãe é fator de autenticação em banco e telecom, e devolver o veredito sobre ela municia engenharia social contra o próprio titular), **gênero** (a divergência entre o sexo do documento e o gênero do cadastro é, na prática, um detector de transição de gênero, e a LGPD veda tratamento para fins discriminatórios no art. 6, IX), **endereço** e **contatos** (seriam revenda de bureau, que os nossos Termos vedam). Não é limitação técnica: os campos existem na resposta que o tier já traz. É escopo, e ele não vai mudar sem revisão jurídica.

Sobre o **nome**: o comparador é o mesmo do screening de PEP, então grafia diferente já casa (SOUZA e SOUSA, LUIZ e LUIS). Entre "bate" e "não bate" existe uma **faixa em que não afirmamos nada**, e ela é intencional: é onde caem o nome de casada, o nome social e o sobrenome composto lido pela metade. Nessa faixa o campo sai `not_checked`, e o `name_score` contínuo vai no payload para você aplicar a sua própria régua se quiser.

**Cobrança:** o módulo só é cobrado quando entrega veredito. Se a fonte cadastral não respondeu, se o portão de identidade bloqueou a consulta ou se o tier comprado não trouxe nenhum campo comparável, o resultado é `indeterminate` e o módulo **sai do preço** daquela verificação. `fields_checked` diz sobre quantos campos a resposta se sustenta e `coverage` diz quais tiers a sustentaram.

```
// coerencia_cadastral: documento e cadastro batem
{ "module": "coerencia_cadastral", "passed": true, "outcome": "approved", "score": 100,
  "data": { "verdict": "coherent",
            "fields": { "name": "match", "birth_date": "match", "alive": "match" },
            "fields_checked": 3, "name_score": 0.98,
            "coverage": ["cpf_receita"],
            "calibration": { "name_match_min": 0.85, "name_mismatch_max": 0.4 },
            "reason": null } }

// coerencia_cadastral: o nome do documento não bate com o do cadastro
// a verificação NÃO é reprovada por isso: o veredito é informativo.
{ "module": "coerencia_cadastral", "passed": false, "outcome": "failed", "score": 40,
  "data": { "verdict": "divergent",
            "fields": { "name": "mismatch", "birth_date": "match", "alive": "match" },
            "fields_checked": 3, "name_score": 0.21,
            "coverage": ["cpf_receita"],
            "calibration": { "name_match_min": 0.85, "name_mismatch_max": 0.4 },
            "reason": null } }

// coerencia_cadastral: não houve campo comparável -> NÃO cobrado
{ "module": "coerencia_cadastral", "passed": null, "outcome": "pending", "score": 0,
  "data": { "verdict": "indeterminate", "fields": null, "fields_checked": 0,
            "name_score": null, "coverage": [], "reason": "no_comparable_field" } }
```

No resumo `checks` o módulo aparece com vocabulário próprio: `coherent`, `divergent` ou `indeterminate`. Em sandbox nada é comparado e o desfecho vem do sufixo do documento: `02` devolve o caminho divergente, `01` o indeterminado (não cobrado) e `33` um coerente com cobertura parcial, que é o que você vê quando o tier comprado traz o nome mas não o nascimento.

## Forense de documento

<https://unifokal.com/docs/modulos/doc-forense>

### Forense de documento

O módulo `doc_forense` faz a **perícia do arquivo** da foto do documento que a Verificação de Identidade já capturou. Ele **não pede foto nova** e **não lê o conteúdo do documento**: quem lê é o `cpf_ocr`. O que ele analisa é o que o arquivo declara sobre si mesmo (a ferramenta que o gerou, as datas, as revisões anexadas de um PDF, os metadados da imagem) e o que a compressão revela: recaptura de tela, sinais de recorte e colagem, e a presença de um manifesto declarando mídia gerada por modelo.

O que ele **não é**: documentoscopia. Marca d'água, microtexto e holograma são elementos do documento **físico**, e este módulo não os confere. Um achado forte **nunca reprova sozinho**: leva a verificação para `review` com o laudo, e o laudo diz o que cada sinal prova **e o que ele não prova**. Editar um PDF num site de juntar páginas é comum e legítimo, e aparece como contexto, não como acusação.

```
// check_details do doc_forense: o laudo, não um score solto
{ "module": "doc_forense", "passed": false, "outcome": "failed", "score": 35,
  "data": { "verdict": "suspect",
            "media_type": "image",
            "bands": { "recapture": "strong", "splice": "weak", "provenance": "none" },
            "evidence": ["screen_recapture", "exif_stripped"],
            "findings": [ { "tag": "screen_recapture", "proves": "…", "does_not_prove": "…" } ],
            "regions": [ { "x": 0.12, "y": 0.41, "w": 0.22, "h": 0.09 } ],
            "measurable_facets": 4,
            "thresholds": { "suspect_min": 0.55 } } }
```

`regions` vem em coordenadas **normalizadas** (0 a 1), para você desenhar sobre a mesma imagem na sua tela de revisão. `measurable_facets` é o que separa **limpo** de **não consegui medir**: arquivo sem metadado nenhum não é arquivo inocente nem culpado, é arquivo sem evidência, e o laudo diz isso. Os blocos brutos de calibração não viajam no webhook de propósito: eles são ruído nosso, não decisão sua.

**Origem assinada.** Quando o arquivo carrega um manifesto de proveniência assinada, no padrão aberto C2PA (versão 2.4 da especificação), o laudo diz o estado dele em `evidence` e em `findings`: `c2pa_trusted_origin` (o manifesto está íntegro e a assinatura encadeia até a lista oficial de confiança da C2PA), `c2pa_signature_invalid` (o arquivo mudou depois de assinado, ou a assinatura não confere) e `c2pa_manifest_present` (há manifesto, sem cadeia até a lista oficial, e ele vale como ausente). Origem confiável prova de onde o **arquivo** veio, não que o documento fotografado é verdadeiro; e recorte, redimensionamento e reenvio por aplicativo também quebram a assinatura, por isso o estado é evidência para quem revisa, nunca uma acusação. O manifesto que declara mídia gerada ou editada por modelo generativo sai como `c2pa_ai_generated_declared` ou `c2pa_ai_composite_declared`.

## Detecção de rede de fraude

<https://unifokal.com/docs/modulos/fraud-network>

### Detecção de rede de fraude

O módulo `fraud_network` responde uma pergunta só: **esta pessoa está ligada a outras contas suas?** A ligação é procurada por aparelho, rede, e-mail, telefone, rosto (quando a Detecção de Múltiplas Contas está no mesmo flow) e pela conta que você informa no `reference_id`. Ele **não pede foto nem passo novo** ao titular.

! **A rede é sempre a sua.** Nenhum dado seu alimenta a rede de outro cliente, e você nunca vê dado de cliente nenhum. E **ligação não é prova de fraude**: família no mesmo wi-fi, escritório de contabilidade com dezenas de empresas no mesmo IP e casal com um telefone só continuam `approved`, com a ligação visível para você. Só quando há evidência de **pessoa** (o mesmo rosto ou a mesma conta em documentos diferentes) a verificação vai para `review`. Nunca reprova sozinho.

O veredito sai em `decision`, com **cinco** valores possíveis, e ele depende das **classes** de evidência, nunca do número de chaves. `clean`: componente de um titular só, ou uma classe única (a família no mesmo wi-fi cai aqui). `linked_weak`: duas classes ou mais, **nenhuma** delas de pessoa, e a decisão não muda (é aqui que cai o escritório de contabilidade). `linked`: duas classes ou mais com ao menos uma de **pessoa**, e vai para revisão. `ring`: o componente alcança um titular já rotulado como fraude confirmada **e** há aresta de pessoa no caminho, também para revisão. `insufficient_keys`: havia menos de duas chaves utilizáveis, então não olhamos rede nenhuma, e esse caso **não é cobrado**. As classes são exatamente três, `local` (IP, aparelho), `contato` (e-mail, telefone) e `pessoa` (rosto, conta), e `subject_resolution` diz sobre **quem** o laudo falou: `subject` (o documento lido), `reference` (o `reference_id` que você mandou) ou `verification` (não havia nem um nem outro).

```
// check_details do fraud_network: o TIPO da ligação, nunca o valor da chave
// "linked" sai como passed:null / pending (indeterminado, vai a revisão), NUNCA como recusa
{ "module": "fraud_network", "passed": null, "outcome": "pending", "score": 50,
  "data": { "decision": "linked",
            "component_size": 3, "hops": 1,
            "evidence_classes": ["local", "pessoa"],   // local | contato | pessoa
            "key_kinds": ["account", "ip"],            // os tipos que LIGARAM
            "keys_observed": ["account", "ip"],        // os tipos que foram OLHADOS
            "linked_subjects_by_kind": { "ip": 3, "account": 2 },
            "link_meanings": {
              "account": "A mesma conta do seu sistema (reference_id) apareceu com documentos de titulares diferentes. Vinculo de PESSOA: indica reciclagem de conta.",
              "ip": "Mais de um titular usou o mesmo endereco de rede na janela. Vinculo de LOCAL: casas, escritorios e redes moveis compartilham IP legitimamente." },
            "linked_references": ["acc_401", "acc_402"],
            "linked_confirmed_fraud": false,
            "subject_resolution": "subject",
            "hub_keys_skipped": 0,
            "window_days": 30,
            "thresholds": { "min_subjects": 3, "hub_degree": 50,
                            "max_hops": 3, "window_days": 30 },
            "network_risk": null,     // camada em SOMBRA (ver abaixo): null = NÃO MEDIDO
            "sync_burst": { "shadow": true, "weight": 0, "subjects": 1, "by_kind": {},
                            "suspect": false, "window_minutes": 60, "min_subjects": 3 } } }

// decision "ring": o componente alcança fraude JÁ confirmada, com aresta de pessoa no caminho.
// Os dois sinais em sombra aparecem preenchidos, e MESMO ASSIM não mudam o veredito.
// É o ÚNICO passed:false do módulo, e mesmo ele só pede revisão humana.
{ "module": "fraud_network", "passed": false, "outcome": "failed", "score": 25,
  "data": { "decision": "ring",
            "component_size": 4, "hops": 2,
            "evidence_classes": ["local", "pessoa"],
            "key_kinds": ["device", "face"],
            "keys_observed": ["account", "device", "face", "ip"],
            "linked_subjects_by_kind": { "device": 4, "face": 3 },
            "link_meanings": {
              "device": "Mais de um titular usou o mesmo aparelho na janela. Vinculo de LOCAL: celular emprestado na mesma casa e causa legitima comum.",
              "face": "O mesmo rosto apareceu em documentos de titulares diferentes. Vinculo de PESSOA: nao ha causa legitima cotidiana." },
            "linked_references": ["acc_918", "acc_a22", "acc_c07"],
            "linked_confirmed_fraud": true,
            "subject_resolution": "subject",
            "hub_keys_skipped": 1, "window_days": 30,
            "thresholds": { "min_subjects": 3, "hub_degree": 50,
                            "max_hops": 3, "window_days": 30 },
            "network_risk": 62,       // 1..100, exposição propagada. PESO 0 na decisão.
            "sync_burst": { "shadow": true, "weight": 0, "subjects": 3,
                            "by_kind": { "device": 3 }, "suspect": true,
                            "window_minutes": 60, "min_subjects": 3 } } }
```

`link_meanings` traz, em texto, **o que cada vínculo significa e o que ele não prova**, e só para os tipos que de fato ligaram. Ele existe porque a leitura errada ("ligado, logo fraudador") é o falso positivo número um deste módulo, e o operador que abre o webhook não deve precisar desta página aberta do lado. `linked_subjects_by_kind` responde a outra metade da pergunta, **quantos titulares por qual vínculo**: é ela que deixa você ler por que a família no mesmo wi-fi não foi acusada. Ela sai também no `clean`; já `linked_references` vem vazia em `clean` e `insufficient_keys`, de propósito, porque listar contas sem veredito seria entregar vizinhança de rede sem afirmação nenhuma.

**Dois campos são sinais em sombra, e sombra aqui significa peso zero.** `network_risk` é a exposição do titular a fraude confirmada dentro do **seu próprio** grafo, propagada a partir dos rótulos que você mesmo confirmou (de 1 a 100, decaindo com a distância e com a idade da aresta). `sync_burst` mede **sincronia**: quantos titulares distintos passaram pelas mesmas chaves na última hora (`subjects` inclui o próprio titular, e `by_kind` só lista tipo em que houve ao menos um outro). Os dois viajam para dar contexto à sua revisão e para você medir prevalência antes de confiar neles. Nenhum dos dois entra na decisão: `suspect: true` ou `network_risk: 90` não alteram `decision`, `score`, `passed` nem `outcome`. E `null` nos dois significa **não medido**, jamais "medido e limpo": um zero fabricado seria a afirmação errada.

O que **não sai**, e é o coração do módulo: **o valor de nenhuma chave**. Nem IP, nem e-mail, nem telefone, nem documento, **e nem o hash deles**. Sai o **tipo** (`key_kinds`) e a **classe** da evidência. Devolver o hash pareceria inofensivo e não é: ele é determinístico, e dois payloads bastariam para você **confirmar** que dois titulares compartilham o mesmo e-mail sem nunca ter visto o e-mail.

`keys_observed` viaja ao lado de `key_kinds` porque as duas respondem perguntas opostas: **não achei ligação por rosto** e **não havia rosto para olhar** (flow sem `face_unica`) não podem sair iguais. `linked_references` são os `reference_id` das contas **do seu próprio tenant**: sem eles você leria "há ligação" e não conseguiria agir.

## Cadastro de dispositivo Pix

<https://unifokal.com/docs/modulos/pix-device>

### Cadastro de dispositivo Pix

O módulo `pix_device` transforma o **cadastro de dispositivo de acesso** que o Regulamento do Pix (art. 89, §6º a §9º, incluídos pela Resolução BCB nº 403/2024) obriga o PSP a manter num cadastro **verificado por biometria**. No ato de cadastrar um aparelho novo, o titular passa pelo face match e pela prova de vida do mesmo fluxo, que são o segundo fator que a Instrução Normativa BCB nº 491/2024 aceita, e o aparelho só é vinculado ao titular quando a verificação inteira aprova.

! **Somos fornecedor da tecnologia do cadastro, não participante do Pix.** O registro regulatório do dispositivo, o motor de limites e a resposta ao Banco Central continuam sendo do PSP. O que entregamos são três coisas: o fator biométrico do cadastro, o vínculo verificado entre aparelho e titular com a verificação que o aprovou como evidência, e o aviso assinado quando um aparelho é revogado.

! **Em breve.** A venda deste módulo está pausada: ele aparece na [tabela de preços](https://unifokal.com/precos) com preço e com o selo "Em breve", e o `create` de flow o recusa até a abertura. O campo de identidade do aparelho na criação de sessão também ainda não está publicado. O contrato de resposta abaixo é o que o backend já emite, para você planejar a integração.

O módulo exige `face` e `liveness` no mesmo flow, e a exigência vem da norma, não de preferência nossa: sem eles, o vínculo seria com quem **digitou**, e não com quem **é**. A identidade do aparelho chega pelo seu servidor, com a chave secreta, nunca pelo navegador do titular: quem está do outro lado da câmera é o adversário do produto e não pode escolher o próprio identificador de dispositivo.

```
// pix_device no check_details: o cadastro verificado do aparelho
{ "module": "pix_device", "passed": true, "outcome": "approved", "score": 92,
  "data": { "device_id": "pxd_01H…",              // id estável do aparelho, e a chave da revogação
            "status": "active",                   // active | pending_verification | verification_failed
            "platform": "ios",                    // o que você declarou na criação da sessão
            "activated_at": "2026-06-17T14:31:52.114Z",  // só quando ativou
            "limits_advisory": { "unregistered_max_per_tx_cents": 20000,
                                 "unregistered_max_daily_cents": 100000 } } }
```

O `limits_advisory` carrega os limites da IN BCB nº 491/2024, art. 9º, para dispositivo **não** cadastrado (duzentos reais por transação e mil reais por dia), em centavos. Ele é **informativo por contrato**: quem aplica limite é o seu motor, nunca o nosso. O campo existe para o seu motor carregar os números certos sem alguém ter que reescrevê-los à mão a cada mudança de norma.

No resumo `checks` o módulo sai como `"pix_device": "active"` (o vínculo nasceu), `"review"` (há uma pergunta esperando gente) ou `"pending"` (ainda sem desfecho do aparelho). **Nunca** leia `pending` como "cadastrado".

! **Aparelho compartilhado nunca reprova sozinho.** Um celular já ativo para outro titular vira `review` com a evidência, e não recusa automática: família dividindo um aparelho é comum e legítimo, e a decisão dura sobre o dispositivo é sempre do PSP. O titular, na tela, vê apenas "revisão manual", nunca "este aparelho já pertence a outra pessoa".

**O que nunca sai no payload:** a impressão digital do aparelho (você já tem o valor que enviou, e devolver o nosso derivado dele só serviria para alguém testar palpites contra ele) e o apelido do aparelho, que é dado do usuário final, guardado cifrado em repouso e nunca devolvido em resposta de API.

**Revogação.** Quando um aparelho é revogado, no bloqueio reversível ou na exclusão definitiva da IN BCB nº 491/2024 (art. 7º, com o recadastro obrigatório da IN BCB nº 594/2025), o seu backend recebe no mesmo endpoint de webhook de sempre, com a mesma assinatura, um evento `pix_device.revoked`. É por ele que o seu motor de limites fica sabendo que um aparelho roubado saiu do cadastro. A exclusão é terminal: recadastrar exige uma sessão de verificação nova. **Quem revoga é o painel**, em Dispositivos PIX: é lá que se bloqueia, desbloqueia ou exclui um aparelho, e o evento acima sai desse ato.

```
// evento pix_device.revoked no seu endpoint de webhook
{ "id": "evt_pxd_01H…_pix_device_revoked_1",
  "schema_version": 1,
  "event": "pix_device.revoked",
  "livemode": true,
  "created": "2026-06-18T09:02:44.120Z",
  "data": { "object": "pix_device",
            "device_id": "pxd_01H…",
            "verification_id": "ver_01H…",   // pode ser null: revogado antes de a verificação decidir
            "reference_id": "usr_1207",
            "status": "blocked",             // blocked (reversível) | removed (terminal)
            "reason": "stolen",              // stolen | compromised | user_request | admin
            "revoked_at": "2026-06-18T09:02:43.900Z" } }
```

## Monitoramento de sessão

<https://unifokal.com/docs/modulos/sessao-monitor>

### Monitoramento de sessão

! **Ainda não está aberto para venda.** O módulo aparece na [tabela de preços](https://unifokal.com/precos) com preço e com o selo "Em breve", e o `create` de flow o recusa até a abertura. O endpoint que recebe os eventos do navegador também ainda não está publicado. O contrato de resposta abaixo é o que o backend já emite, para você planejar a integração.

O módulo `sessao_monitor` observa o que acontece **depois** da aprovação. Na sessão já logada do seu site, ele acompanha ações de negócio (depósito, saque, aposta), um sinal de vida a cada trinta minutos, a localização que o navegador conceder e o aparelho. Regras determinísticas rodam no nosso servidor e, quando uma fecha, você recebe um alerta no mesmo webhook assinado de sempre, com o nome da regra, a severidade e a evidência em número.

**A janela, o sinal de vida e os eventos são grátis: você paga só pelo achado.** É o oposto do modelo por chamada: uma sessão de catorze eventos custa zero até encontrarmos alguma coisa. Cada alerta chega no webhook com a regra e a evidência em número.

```
// sessao_monitor no check_details: o alerta de sessão
{ "module": "sessao_monitor", "passed": null, "outcome": "pending", "score": 50,
  "data": { "monitoring": {
      "kind": "session_monitor_alert",       // distingue este evento de uma verificação de onboarding
      "alert_id": "mal_01H…",                // id opaco do achado
      "rule": "impossible_travel_session",   // qual regra fechou
      "severity": "high",                    // high | medium (não existe low)
      "evidence": { "distance_km": 412.7,    // a evidência é NUMÉRICA, nunca a coordenada
                    "interval_min": 18,
                    "implied_kmh": 1375.7 } } } }
```

No resumo `checks` o módulo sai com o **nome da regra** que fechou, ou `"clean"` quando a sessão foi observada e nada fechou. As regras respondem perguntas diferentes e você aciona coisas diferentes em cada uma, então um booleano ali esconderia a informação inteira. As chaves de `evidence` mudam conforme a `rule`, e são sempre números do achado.

! **O alerta nunca recusa ninguém, e isso é estrutural.** O dado vem do navegador do usuário final e é falsificável por construção, então o pior desfecho possível é `review`: não existe caminho de recusa automática neste módulo, em nenhuma severidade e com nenhuma calibração. **E este evento não substitui a verificação de onboarding**: se o seu sistema guarda "a última verificação por `reference_id`", use o `kind` para não sobrescrever o resultado do KYC com um alerta de sessão.

**O que nunca sai no payload:** a coordenada (o que viaja é distância, intervalo e velocidade implícita), o endereço de rede e o identificador do aparelho: os dois só existem no nosso banco como derivados por chave secreta, e devolvê-los serviria apenas para alguém testar palpites contra eles.

**Não confunda com o `monitoring_aml`**, que reconsulta listas de compliance sobre a **pessoa** e cobra por mês. Este observa **uma sessão** no tempo e cobra por achado.

## Sinais do aparelho

<https://unifokal.com/docs/modulos/device-intel>

### Sinais do aparelho

! **Ainda não está aberto para venda.** O módulo aparece na [tabela de preços](https://unifokal.com/precos) com preço e com o selo "Em breve", e o `create` de flow o recusa até a abertura. A trava não é técnica: é a base legal da coleta, que exige o teste de balanceamento documentado, transparência ao titular e um mecanismo de oposição que funcione. Enquanto isso, a coleta fica desligada no servidor. O contrato de resposta abaixo é o que o backend já emite, para você planejar a integração.

O módulo `device_intel` faz **três perguntas ao navegador do titular** durante a captura, e agrega a pior observação da sessão num veredito explicável: se o navegador está sendo controlado por um programa de automação (o sinalizador padronizado que Selenium, Playwright e Puppeteer acendem), se as funções nativas dele foram reescritas por algum script, e se um aparelho que se anuncia como celular admite não ter nenhum ponto de toque. São **três perguntas em quatro campos**: o quarto campo que o widget envia não é um sinal novo, ele registra apenas se a primeira pergunta pôde sequer ser feita naquele navegador. Você recebe os três sinais nomeados, com a cobertura da leitura declarada ao lado, e não um score fechado que você teria que aceitar sem entender.

```
// device_intel no check_details: os três sinais, nomeados
{ "module": "device_intel", "passed": true, "outcome": "approved", "score": 90,
  "data": { "device": {
      "automation": false,   // o navegador declarou estar sob controle de um programa
      "tampered": false,     // alguma função nativa amostrada foi reescrita por script
      "incoherent": false,   // user-agent de celular declarando zero ponto de toque
      "quality": "present",  // present = os três medidos; partial = só parte deles
      "measured": 3 } } }
```

**Os três campos têm três valores, não dois:** `true` significa que medimos e o sinal acusou, `false` significa que medimos e não acusou, e `null` significa que **ninguém mediu**. O terceiro valor existe porque "não medi" e "está limpo" são coisas opostas, e tratá-las como iguais seria a única forma de este módulo mentir. Quando nada foi medido, a verificação sai com `"pending"` no resumo `checks` e **o módulo não é cobrado**: você não paga por uma pergunta que não chegou a ser feita.

! **O sinal nunca recusa ninguém sozinho, e isso é estrutural.** Tudo aqui é declaração do próprio navegador, e quem controla a máquina controla a declaração: o pior desfecho possível é `review`, em qualquer combinação dos três sinais e com qualquer calibração. Só a automação declarada leva sozinha à revisão; os outros dois precisam aparecer juntos, porque função nativa reescrita é o efeito comum de uma extensão de privacidade e reprovar por isso seria punir quem se protege.

**O que este módulo não faz.** Ele **não identifica o aparelho**: as três respostas são de sim ou não e nenhuma delas distingue um celular de outro, então isso não serve para reconhecer quem volta amanhã, e nenhum identificador de aparelho sai no payload. Ele **roda inteiro no navegador do titular**, sem instalar nada no aparelho dele: os três sinais nomeados acima são o escopo completo da leitura, e é sobre eles que você recebe resposta.

**Navegador que esconde essas leituras não é suspeito.** Os navegadores modernos estão fechando essa janela de propósito, e isso é bom: o Safari passou a bloquear parte dessas leituras para scripts conhecidos de rastreamento e a injetar ruído em canvas, áudio e WebGL, e o Firefox particiona o estado por site. Por isso leitura ausente é sempre neutra aqui, o campo `quality` diz quanto foi medido, e o módulo diz em voz alta quando não mediu.

## Assinatura eletrônica

<https://unifokal.com/docs/modulos/assinatura>

### Assinatura eletrônica

! **Ainda não está aberto para venda.** O módulo aparece na [tabela de preços](https://unifokal.com/precos) com preço e com o selo "Em breve", e o `create` de flow o recusa até a abertura. O contrato de resposta abaixo é o que o backend já emite, para você planejar a integração.

O módulo `assinatura` transforma a verificação aprovada em **assinatura eletrônica avançada** do documento que você indicar. Você cria a sessão com o hash SHA-256 do documento; o titular passa pela verificação de identidade completa (documento, face match e prova de vida, que o flow exige junto); e, se a verificação aprovar, a plataforma emite o **dossiê de assinatura**: um manifesto que amarra o hash do documento à verificação aprovada e ao instante, assinado com a chave Ed25519 da plataforma. **O documento em si nunca é enviado**: você continua guardando os bytes, e o hash não revela o conteúdo.

```
// criação de sessão com o bloco assinatura (obrigatório quando o flow tem o módulo)
POST /v1/verification-sessions
{ "flow_id": "flow_...", "reference_id": "contrato-8841",
  "assinatura": { "document_sha256": "f2ca1bb6c7e907d06dafe4687e579fce76b37e4e93b7605022da52e6ccc26fd2" } }
```

**Por link, um a um ou em lote.** O link de verificação hospedado aceita o mesmo bloco `assinatura` em `POST /v1/verification-links`, e o hash fica fixo no link desde a emissão. No painel, uma planilha com `reference_id` e `document_sha256` por linha gera um link por linha; o arquivo com os links sai uma única vez. Quem avisa o signatário é você. Para acompanhar cada etapa, o webhook do flow recebe `verification_link.claimed` quando o signatário abre o link e começa a verificação (um aviso por link, com `link_id`, `session_id` e o seu `reference_id`), e `verification.completed` com o dossiê quando ele assina.

```
// assinatura no check_details: o dossiê completo
{ "module": "assinatura", "passed": true, "outcome": "approved",
  "data": { "assinatura": {
      "signed": true,
      "document_sha256": "f2ca1bb6...cc26fd2",   // o hash que você mandou na criação
      "algorithm": "Ed25519",
      "key_id": "1f60c078f27b",                   // identifica a chave da plataforma
      "public_key": "SGVsbG8t...",                // chave pública, base64 (32 bytes)
      "signed_at": "2026-09-07T12:00:00.000Z",
      "manifest_json": "{\"v\":1,\"type\":\"unifokal/assinatura-avancada@1\",...}",
      "signature": "kqYw3...==" } } }             // assinatura do manifest_json, base64
```

**Qualquer pessoa confere o dossiê, sem nos consultar.** A âncora é a lista de chaves públicas da UNIFOKAL logo abaixo: a chave que viaja no dossiê (`public_key`, com o `key_id`) só vale se for igual a uma chave da lista, já em vigor no instante `signed_at`. A assinatura cobre exatamente os bytes UTF-8 de `manifest_json` (use a string literal recebida, nunca uma re-serialização sua: outra ordem de campos muda os bytes). Depois, o `document_sha256` do manifesto tem de ser o SHA-256 do seu contrato: se qualquer byte do documento mudar depois, o hash muda e o dossiê deixa de casar com ele. É essa a detecção de alteração posterior que a lei exige.

```
// chaves públicas da assinatura (Ed25519, 32 bytes em base64). A âncora é ESTA lista:
// a chave que viaja no dossiê só vale se for igual a uma destas, já em vigor em signed_at.
ASSINATURA_PUBLIC_KEYS = []
// A chave de produção entra nesta lista antes da abertura da venda.
```

```
// Node 20+ (só node:crypto, sem rede)
const { createPublicKey, verify, createHash } = require("node:crypto");
const SPKI = Buffer.from("302a300506032b6570032100", "hex");

function confereDossie(dossie, contrato, pinadas) {
  const k = pinadas.find((p) => p.key_id === dossie.key_id && p.public_key === dossie.public_key &&
    p.environment === "production" && dossie.signed_at >= p.not_before);
  if (!k) return false; // chave fora da lista: o dossiê não foi emitido pela UNIFOKAL
  const chave = createPublicKey({ key: Buffer.concat([SPKI, Buffer.from(k.public_key, "base64")]), format: "der", type: "spki" });
  if (!verify(null, Buffer.from(dossie.manifest_json, "utf8"), chave, Buffer.from(dossie.signature, "base64"))) return false;
  const m = JSON.parse(dossie.manifest_json);
  return m.type === "unifokal/assinatura-avancada@1" && m.decision === "approved" &&
    m.signed_at === dossie.signed_at && m.document_sha256 === dossie.document_sha256 &&
    m.document_sha256 === createHash("sha256").update(contrato).digest("hex");
}
```

! **Avançada, não qualificada.** É assinatura eletrônica AVANÇADA nos termos da MP 2.200-2 (art. 10, § 2º) e da Lei 14.063 (art. 4º, II): vale entre particulares quando as partes a aceitam, e a associação unívoca ao signatário é a verificação biométrica. Atos que exigem assinatura QUALIFICADA com certificado ICP-Brasil, como nota fiscal eletrônica e transferência de imóvel, precisam de outro instrumento. Não emitimos assinatura eletrônica qualificada. Não emitimos carimbo de tempo RFC 3161 nesta fase.

**Consignado do trabalhador.** Desde 25 de julho de 2025, as instituições consignatárias devem adotar **verificação biométrica da identidade do trabalhador** nas operações de crédito consignado feitas por plataforma digital (Lei 10.820/2003, art. 2º-I, incluído pela Lei 15.179/2025), com **prova de vida** (Decreto 12.564/2025, art. 2º, I). O contrato digital pode ser firmado por assinatura qualificada ou por assinatura **avançada**, e a avançada precisa cumprir, junto com a Lei 14.063, dois requisitos: **autenticação biométrica com prova de vida no ato da assinatura** e **geração de evidências técnicas** que comprovem a autenticação e a integridade do ato, utilizáveis em processo administrativo ou judicial (Lei 10.820, art. 2º-I, § 3º, I e II; Decreto 12.564, art. 3º, II, alíneas a e b). Os dois valem para quem contrata daqui em diante por assinatura avançada que não estava homologada em 25 de julho de 2025: o § 4º do mesmo artigo considera adequadas também a avançada já homologada pelo Poder Executivo federal ou pelo Poder Judiciário naquela data e a assinatura digital nos termos do regulamento (Decreto 12.564, art. 3º, III).

O módulo entrega as duas coisas no mesmo fluxo: a verificação com documento, face match e prova de vida acontece no ato em que o trabalhador assina, e o dossiê que amarra o hash do contrato à identidade verificada e ao instante é a evidência técnica, que qualquer pessoa confere com a chave pública publicada pela UNIFOKAL, sem nos consultar. A guarda do dossiê é da consignatária: ele chega inteiro no webhook para você arquivar pelo prazo do seu contrato. Não emitimos assinatura eletrônica qualificada.

**Quando não cobra.** O dossiê só existe se a verificação aprovar. Verificação recusada ou em revisão devolve `"signed": false` com o motivo nomeado (`verification_not_approved`) e o módulo sai do preço daquela verificação. O bloco `assinatura` da criação é obrigatório quando o flow contém o módulo (422 `assinatura_document_required`) e recusado quando não contém (422 `assinatura_not_supported`): nunca aceito e ignorado em silêncio. O link hospedado não leva o documento: a emissão de link para um flow com o módulo é recusada com o mesmo 422 `assinatura_document_required`, e a sessão desse flow nasce pela API.

## Custódia da autorização de consulta

<https://unifokal.com/docs/modulos/custodia-autorizacao>

### Custódia da autorização de consulta

! **Ainda não está aberto para venda.** O módulo aparece na [tabela de preços](https://unifokal.com/precos) com preço e com o selo "Em breve", e o `create` de flow o recusa até a abertura. O contrato abaixo é o que o backend já emite, para você planejar a integração.

Quem consulta o SCR do Banco Central precisa guardar a autorização do cliente **por cinco anos contados da data da última consulta**, num meio que permita comprovar a autenticidade dela (Resolução CMN 5.037/2022, art. 12, § 3º). O módulo `custodia_autorizacao` faz essa guarda por você: amarra a autorização à identidade verificada e ao consentimento da sessão, assina cada ato com Ed25519 e entrega um pacote de prova que qualquer pessoa confere **sem consultar a UNIFOKAL**.

**O texto da autorização nunca passa por nós.** Você cria a sessão com o SHA-256 do texto que o titular leu, a versão desse texto no seu sistema e o escopo. O CPF lido do documento entra no registro só como compromisso criptográfico, com um sal aleatório próprio de cada autorização que não viaja no pacote de prova: quem tem só o pacote não chega ao CPF, e quem tem o CPF confere o titular com a abertura que o painel entrega a quem informa esse mesmo CPF.

```
// criação de sessão com o bloco consultation_authorization (obrigatório quando o flow tem o módulo)
POST /v1/verification-sessions
{ "flow_id": "flow_...", "reference_id": "proposta-8841",
  "consultation_authorization": {
    "text_sha256": "f2ca1bb6c7e907d06dafe4687e579fce76b37e4e93b7605022da52e6ccc26fd2",
    "text_version": "autorizacao-scr-v3",
    "scope": "scr" } }
```

Se a verificação aprovar, o registro é emitido na mesma transação da decisão e o `check_details` traz o identificador que você usa no painel:

```
// custodia_autorizacao no check_details: registro emitido
{ "module": "custodia_autorizacao", "passed": true, "outcome": "approved",
  "data": { "consultation_authorization": {
      "issued": true,
      "id": "cau_01J8Z6XK4M9QHDEMSANDBXXXX1",
      "scope": "scr",
      "collection_mode": "declarada_pelo_cliente",   // ou "assinada_pelo_titular"
      "text_version": "autorizacao-scr-v3",
      "issued_at": "2026-09-23T12:00:00.000Z",
      "retention_years": 5,
      "retained_until": "2031-09-23",                // cresce a cada consulta registrada
      "key_id": "prd-..." } } }
```

```
// custodia_autorizacao no check_details: registro NÃO emitido (e não cobrado)
{ "module": "custodia_autorizacao", "passed": null, "outcome": "pending",
  "data": { "consultation_authorization": {
      "issued": false,
      "reason": "verification_not_approved" } } }
```

O `collection_mode` diz como o titular aceitou o texto: `assinada_pelo_titular` quando o mesmo flow tem o módulo `assinatura` e o documento assinado é o próprio texto da autorização (mesmo hash); `declarada_pelo_cliente` quando o texto foi mostrado na sua interface e você declarou o hash. Os motivos de não emissão são nomeados: `verification_not_approved`, `consent_missing` (a sessão não registrou o consentimento), `subject_document_missing` (sem CPF lido do documento) e `consultation_authorization_missing`.

**O relógio conta da última consulta.** A cada consulta ao SCR feita com a autorização, registre o dia no painel, em Conformidade, um por vez ou em lote (até 500 por envio, a partir de um arquivo CSV lido no seu navegador). O prazo passa a ser a data da consulta mais cinco anos, contados pelo Código Civil (art. 132, § 3º: 29 de fevereiro mais cinco anos vence em 1º de março). O prazo nunca diminui: registrar uma data anterior à última fica no histórico e não encurta nada, a mesma data de novo não duplica, data futura ou anterior à emissão é recusada, e autorização revogada só aceita o registro de consulta feita até o dia da revogação (a guarda do que já foi consultado continua). O prazo padrão é de cinco anos e você pode ampliá-lo até vinte no painel; salvar um prazo leva a ele as autorizações já emitidas que têm prazo menor, e nenhuma guarda encurta.

**A prova, verificável fora da UNIFOKAL.** No painel, a exportação devolve um arquivo JSON no formato `unifokal/custodia-autorizacao@1`: a cadeia de eventos (registro, consultas, revogação), cada um com o texto canônico exato que foi assinado, o SHA-256 dele, o do evento anterior e a assinatura; e o estado atual, assinado no instante da exportação. Esse arquivo não carrega dado pessoal em claro e pode ser entregue a um auditor. Para provar **de quem** é a autorização, informe no painel o CPF do titular: se ele casar com o compromisso assinado, você recebe a **abertura do titular** (formato `unifokal/custodia-autorizacao-titular@1`), com o sal do compromisso. Junto com o pacote, a abertura identifica o titular: entregue as duas só a quem já tem o CPF.

O caminho de referência é a função dos SDKs, que confere tudo sem rede e devolve o motivo de cada recusa: cada evento assinado por chave da lista abaixo, do mesmo ambiente e já em vigor no instante do evento; o SHA-256 de cada texto canônico, a sequência e o encadeamento; o estado apontando para o último evento, sem prometer prazo menor do que a cadeia prova; depois de uma revogação, só o registro de consulta feita até o dia dela; e, com o CPF e a abertura, o titular contra o compromisso **assinado** no registro, nunca contra a cópia de leitura do envelope.

```
// TypeScript (SDK oficial, só node:crypto, sem rede)
import { readFileSync } from "node:fs";
import { verifyConsultationAuthorizationProof } from "unifokal";

const pacote = JSON.parse(readFileSync("custodia-cau_....json", "utf8"));
const abertura = JSON.parse(readFileSync("custodia-cau_...-titular.json", "utf8"));
const r = verifyConsultationAuthorizationProof(pacote, { cpf: "529.982.247-25", subjectSalt: abertura.subject.salt });
// r.valid, r.errors (o motivo de cada recusa), r.subject_matches, r.retained_until, r.revoked
```

```
# Python (SDK oficial, extra "unifokal[crypto]")
import json
from unifokal import verify_consultation_authorization_proof

pacote = json.load(open("custodia-cau_....json"))
abertura = json.load(open("custodia-cau_...-titular.json"))
r = verify_consultation_authorization_proof(pacote, cpf="529.982.247-25", subject_salt=abertura["subject"]["salt"])
# r["valid"], r["errors"], r["subject_matches"], r["retained_until"], r["revoked"]
```

```
// chaves públicas de custódia (Ed25519, 32 bytes em base64). A âncora é ESTA lista:
// nunca confie na chave que viaja dentro do arquivo exportado.
[
  { "key_id": "sbx-86d06e439b84",
    "public_key": "vKO9oHr/kojzQ4Pyvk+LG8yN0Dm1fE0YfCm9T+5O3Rk=",
    "environment": "sandbox",
    "not_before": "2026-09-23T00:00:00.000Z" }
]
// A chave de produção entra nesta lista antes da abertura da venda.
```

Sem o SDK, os dois trechos abaixo fazem a mesma conferência, com a lista de chaves acima em `pinadas`. A comparação de datas é de texto: o formato ISO-8601 ordena igual ao tempo.

```
// Node 20+ (só node:crypto, sem rede)
const { createPublicKey, verify, createHash, createHmac } = require("node:crypto");
const SPKI = Buffer.from("302a300506032b6570032100", "hex");
const assinou = (k, texto, sig) => verify(null, Buffer.from(texto, "utf8"),
  createPublicKey({ key: Buffer.concat([SPKI, Buffer.from(k.public_key, "base64")]), format: "der", type: "spki" }),
  Buffer.from(String(sig), "base64"));
const pinada = (pinadas, amb, id, quando) =>
  pinadas.find((p) => p.key_id === id && p.environment === amb && typeof quando === "string" && quando >= p.not_before);

function confere(pacote, pinadas) {
  try {
    const amb = pacote.environment;
    if (pacote.format !== "unifokal/custodia-autorizacao@1" || pacote.algorithm !== "Ed25519") return false;
    let anterior = "0".repeat(64), registro = null, guarda = null, revogadaEm = null;
    for (const [i, ev] of pacote.events.entries()) {
      const c = JSON.parse(ev.canonical);
      const k = pinada(pinadas, amb, ev.key_id, c.occurred_at);
      if (!k || !assinou(k, ev.canonical, ev.signature)) return false;
      if (createHash("sha256").update(ev.canonical, "utf8").digest("hex") !== ev.entry_sha256) return false;
      if (c.seq !== i + 1 || ev.seq !== i + 1 || c.authorization_id !== pacote.authorization_id || c.prev_sha256 !== anterior) return false;
      if (revogadaEm && !(c.kind === "consulted" && c.consulted_on <= revogadaEm)) return false;
      if (i === 0) {
        if (c.kind !== "registered" || c.registered.environment !== amb) return false;
        registro = c.registered; guarda = registro.retained_until;
      } else if (c.kind === "consulted") {
        if (c.retained_until_after < guarda) return false;
        guarda = c.retained_until_after;
      } else if (c.kind === "revoked" && !revogadaEm && c.revoked_on) {
        revogadaEm = c.revoked_on;
      } else return false;
      anterior = ev.entry_sha256;
    }
    const e = JSON.parse(pacote.state.canonical);
    const k = pinada(pinadas, amb, pacote.state.key_id, e.exported_at);
    return !!k && assinou(k, pacote.state.canonical, pacote.state.signature) &&
      e.authorization_id === pacote.authorization_id && e.environment === amb &&
      e.chain_head_sha256 === anterior && e.chain_length === pacote.events.length &&
      e.retained_until >= guarda && e.retention_years >= registro.retention_years &&
      (e.revoked_at !== null) === (revogadaEm !== null) &&
      pacote.subject.commitment === registro.subject_commitment;
  } catch {
    return false;
  }
}

// o titular: o CPF (11 dígitos) e o sal da abertura, contra o compromisso ASSINADO no registro
const titular = (pacote, cpf, sal) =>
  createHmac("sha256", Buffer.from(sal, "base64")).update("unifokal/custodia/v1|cpf|" + cpf, "utf8").digest("hex") ===
  JSON.parse(pacote.events[0].canonical).registered.subject_commitment;
```

```
# Python 3.10+ (pip install cryptography)
import base64, hashlib, hmac, json
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

def assinou(k, texto, sig):
    try:
        Ed25519PublicKey.from_public_bytes(base64.b64decode(k["public_key"])).verify(base64.b64decode(sig), texto.encode("utf-8"))
        return True
    except Exception:
        return False

def pinada(pinadas, amb, key_id, quando):
    return next((p for p in pinadas if p["key_id"] == key_id and p["environment"] == amb
                 and isinstance(quando, str) and quando >= p["not_before"]), None)

def confere(pacote, pinadas):
    try:
        amb = pacote["environment"]
        if pacote["format"] != "unifokal/custodia-autorizacao@1" or pacote["algorithm"] != "Ed25519":
            return False
        anterior, registro, guarda, revogada_em = "0" * 64, None, None, None
        for i, ev in enumerate(pacote["events"]):
            c = json.loads(ev["canonical"])
            k = pinada(pinadas, amb, ev["key_id"], c["occurred_at"])
            if k is None or not assinou(k, ev["canonical"], ev["signature"]):
                return False
            if hashlib.sha256(ev["canonical"].encode("utf-8")).hexdigest() != ev["entry_sha256"]:
                return False
            if c["seq"] != i + 1 or ev["seq"] != i + 1 or c["authorization_id"] != pacote["authorization_id"] or c["prev_sha256"] != anterior:
                return False
            if revogada_em and not (c["kind"] == "consulted" and c["consulted_on"] <= revogada_em):
                return False
            if i == 0:
                if c["kind"] != "registered" or c["registered"]["environment"] != amb:
                    return False
                registro = c["registered"]
                guarda = registro["retained_until"]
            elif c["kind"] == "consulted":
                if c["retained_until_after"] < guarda:
                    return False
                guarda = c["retained_until_after"]
            elif c["kind"] == "revoked" and not revogada_em and c["revoked_on"]:
                revogada_em = c["revoked_on"]
            else:
                return False
            anterior = ev["entry_sha256"]
        e = json.loads(pacote["state"]["canonical"])
        k = pinada(pinadas, amb, pacote["state"]["key_id"], e["exported_at"])
        return (k is not None and assinou(k, pacote["state"]["canonical"], pacote["state"]["signature"])
                and e["authorization_id"] == pacote["authorization_id"] and e["environment"] == amb
                and e["chain_head_sha256"] == anterior and e["chain_length"] == len(pacote["events"])
                and e["retained_until"] >= guarda and e["retention_years"] >= registro["retention_years"]
                and (e["revoked_at"] is not None) == (revogada_em is not None)
                and pacote["subject"]["commitment"] == registro["subject_commitment"])
    except Exception:
        return False

# o titular: o CPF (11 dígitos) e o sal da abertura, contra o compromisso ASSINADO no registro
def titular(pacote, cpf, sal):
    calculado = hmac.new(base64.b64decode(sal), ("unifokal/custodia/v1|cpf|" + cpf).encode("utf-8"), hashlib.sha256).hexdigest()
    return calculado == json.loads(pacote["events"][0]["canonical"])["registered"]["subject_commitment"]
```

```
# openssl 3 (um evento por vez; os três arquivos saem do pacote exportado)
printf '%s' "$CANONICO" > evento.txt                       # o texto canônico, byte a byte
printf '%s' "$ASSINATURA_B64" | base64 -d > evento.sig      # 64 bytes
( printf '302a300506032b6570032100' | xxd -r -p; printf '%s' "$CHAVE_B64" | base64 -d ) > chave.der
openssl pkeyutl -verify -pubin -keyform DER -inkey chave.der -rawin -in evento.txt -sigfile evento.sig
# "Signature Verified Successfully"
```

**Quando não cobra.** O registro só existe se a verificação aprovar, com o texto carimbado na criação da sessão, o consentimento registrado e o CPF lido do documento. Sem isso o módulo devolve `"issued": false` com o motivo e sai do preço daquela verificação. Registrar consultas, exportar a prova e ampliar o prazo não custam nada. O bloco `consultation_authorization` da criação é obrigatório quando o flow contém o módulo (422 `consultation_authorization_required`), recusado quando não contém (422 `consultation_authorization_not_supported`), e chave desconhecida dentro dele é 400 `unknown_consultation_authorization_key`: nunca aceito e ignorado em silêncio. O link hospedado não leva esse bloco: a emissão de link para um flow com o módulo é recusada com o mesmo 422 `consultation_authorization_required`, e a sessão desse flow nasce pela API.

## Documento de viagem estrangeiro (MRZ)

<https://unifokal.com/docs/modulos/doc-global>

### Documento de viagem estrangeiro (MRZ)

O módulo `doc_global` lê a **zona de leitura mecânica** (MRZ) de passaportes e de documentos de identidade em cartão, de qualquer país, no padrão internacional **ICAO 9303**. Ele **substitui a Verificação de Identidade** no flow (os dois leem a mesma foto do documento e são o mesmo passo de captura), e por isso não convivem no mesmo fluxo.

A prova que ele entrega é **aritmética**: cada dígito verificador impresso é recalculado, inclusive o **composto**, que é o que revela um documento com um campo alterado. Se a foto cortou a zona de leitura ou os dígitos não fecham por reflexo, **o titular reenvia a foto**: ninguém é reprovado por foto ruim. Se a zona não fecha consigo mesma, a verificação vai para `review` com o laudo do que bateu e do que não bateu, **nunca para recusa automática**. Documento vencido não reprova: a validade volta no resultado como informação, e o que fazer com um documento fora da validade é decisão da sua política.

```
// check_details do doc_global: o dado do documento COM a prova ao lado
{ "module": "doc_global", "passed": true, "outcome": "approved", "score": 95,
  "data": { "name": "ANA MARIA SOUZA", "surname": "SOUZA", "given_names": "ANA MARIA",
            "birth_date": "1990-04-12",
            "document": { "type": "P", "number": "FR1234567",
                          "valid_until": "2031-04-11", "issued_at": "2021-04-12",
                          "sex": "F",
                          "issuing_country": { "alpha3": "FRA", "name": "França" },
                          "nationality":     { "alpha3": "FRA", "name": "França" },
                          "mrz_format": "TD3", "mrz_valid": true,
                          "mrz_checks": { "document_number": true, "birth_date": true,
                                          "expiry_date": true, "composite": true },
                          "expired": false } } }
```

`expired` tem **três** estados de propósito: `true` (vencido), `false` (válido) e `null` (não deu para afirmar). `null` nunca é `false`: "não sei" e "está válido" são respostas diferentes. O **nome** vem da leitura visual e não é coberto por dígito verificador nenhum, então a MRZ nunca é autoridade sobre ele. O módulo **não lê o chip** do passaporte e **não confere elementos de segurança** do documento físico.

No sandbox, escolha o cenário pelos dois últimos dígitos do campo `document` do submit. Como este flow não lê CPF, é esse campo que carrega o sufixo: `00` aprova, `01` pede nova foto, `02`, `41` e `42` vão para `review`, `43` pede outro arquivo e `45` aprova com o passaporte vencido (`expired: true`). A lista completa está em Sandbox.

## Cadeia societária até o beneficiário final

<https://unifokal.com/docs/modulos/ubo-profundo>

### Cadeia societária até o beneficiário final

O módulo `ubo_profundo` é o que **sobe a cadeia** quando um sócio da empresa é outra empresa: nível a nível, até as pessoas naturais que de fato a controlam, com o **percentual acumulado** de cada uma pelo caminho. É a pergunta da **IN RFB 2.119/2022** e da **Circular BCB 3.978/2020**: quem detém 25% do capital, direta ou indiretamente, e quem exerce o controle. Ele exige a **Participação Societária** no mesmo flow, que é de onde vem o percentual de cada sócio.

! **Cobrança por empresa efetivamente subida, com teto que é seu.** O preço unitário multiplica o número de empresas que precisaram ser consultadas, até o teto que você define no flow (o custo máximo aparece na tela antes de salvar, e nunca é ultrapassado). Empresa sem sócio pessoa jurídica não sobe nada e não custa nada, e empresa que a fonte não conseguiu responder também não entra na conta. Por sessão, a chave `policy.ubo_max_paid_nodes` só **aperta** esse teto: pedir mais que o do flow é `422 policy_ubo_cap_above_flow`.

```
// check_details do ubo_profundo: a árvore, e os quatro campos de dinheiro
{ "module": "ubo_profundo", "passed": true, "outcome": "approved", "score": 80,
  "data": { "ubo_tree": { "status": "complete", "chain_complete": true,
                          "ubos": [ { "name": "…", "document": "***.456.789-**",
                                      "effective_percent": 51.0, "path": ["…", "…"] } ],
                          "candidates_over_threshold": 1,
                          "cycles": [], "reason": null },
            "nodes_charged": 2, "unit_cents": 1390, "charged_cents": 2780, "cap": 3 } }

// cadeia que NÃO fechou: "parcial" é dito como parcial, nunca como "não há beneficiário final"
{ "module": "ubo_profundo", "passed": null, "outcome": "pending", "score": 0,
  "data": { "ubo_tree": { "status": "partial", "chain_complete": false, "ubos": [],
                          "candidates_over_threshold": null,
                          "reason": "ubo_node_budget_exhausted" },
            "nodes_charged": 3, "unit_cents": 1390, "charged_cents": 4170, "cap": 3 } }
```

Os campos de dinheiro não são decoração: o motivo `ubo_node_budget_exhausted` (**o seu teto mordeu**, e você sabe exatamente quanto custaria subir mais) e o motivo `ubo_source_unavailable` (**a fonte caiu**, não cobra, tente de novo) leriam igual sem `cap` e `nodes_charged` ao lado. Quando a cadeia não fecha, a resposta **diz por quê**: teto atingido, ciclo societário, sócia estrangeira sem CNPJ publicado ou fonte fora do ar. `ubos: []` com `status: "partial"` lê-se **não consegui subir**, jamais **não há beneficiário final**, e `candidates_over_threshold: null` (nunca `0` por omissão) é a outra metade da mesma honestidade. O módulo **informa e não reprova ninguém**: estrutura societária opaca é um fato sobre o registro público, não uma acusação contra quem está se verificando.

## Inscrição estadual

<https://unifokal.com/docs/modulos/inscricao-estadual>

### Inscrição estadual

! **Ainda não está aberto para venda.** O módulo aparece na [tabela de preços](https://unifokal.com/precos) com preço e com o selo "Em breve". O contrato de resposta abaixo é o que o backend já emite, para você planejar a integração.

O módulo `inscricao_estadual` consulta a **inscrição estadual** da empresa no cadastro de contribuintes do ICMS da **UF da matriz**, a partir do CNPJ lido do documento societário. A UF vem do dado cadastral da empresa no mesmo flow, então o módulo pede o `cnpj_ocr` e **um** dos três dados da empresa (`cnpj_cadastro`, `cnpj_socios` ou `cnpj_receita`): qualquer um deles basta, e o catálogo de `/v1/capabilities` declara isso em `requires_any_of`.

```
// inscricao_estadual no check_details
{ "module": "inscricao_estadual", "passed": true, "outcome": "approved",
  "data": {
    "uf_consultada": "MG",
    "consultado_em": "2026-09-23T12:00:00.000Z",
    "encontrada": true,
    "habilitada": true,              // alguma inscrição habilitada; null quando não há inscrição
    "cobertura": "uf_da_matriz",     // filial em outra UF tem inscrição própria
    "inscricoes": [
      { "ie": "0012345670081", "uf_ie": "MG", "situacao_ie": "HABILITADO",
        "data_inicio": "01/06/2015", "regime_tributacao": "NORMAL",
        "razao_social": "EMPRESA EXEMPLO LTDA", "municipio_descricao": "MONTES CLAROS" } ],
    "purpose": "Informar a inscricao estadual da empresa verificada, na UF da matriz, ..." } }
```

**Como ler.** `encontrada: false` diz que a UF da matriz não tem inscrição para aquele CNPJ, o que é normal para empresa que não é contribuinte do ICMS (muitos prestadores de serviço). `habilitada: false` diz que há inscrição, mas nenhuma habilitada (baixada, suspensa ou inapta): o resumo da verificação marca `inactive`. As situações e os demais campos de cada inscrição vêm exatamente como o cadastro estadual publica. O módulo é informativo: ele entrega o dado e não reprova a identidade de ninguém.

**Quando não cobra.** Só é cobrado quando a consulta responde, com ou sem inscrição. Sem resposta da fonte, ou sem a UF da matriz no flow, o check sai `pending` sem bloco `data` e o módulo sai do preço daquela verificação. Consulta repetida para o mesmo CNPJ e a mesma UF em pouco tempo reaproveita a resposta anterior.

## Certidão trabalhista (CNDT)

<https://unifokal.com/docs/modulos/cndt>

### Certidão trabalhista (CNDT)

! **Ainda não está aberto para venda.** O módulo aparece na [tabela de preços](https://unifokal.com/precos) com preço e com o selo "Em breve". O contrato de resposta abaixo é o que o backend já emite, para você planejar a integração.

O módulo `cndt` consulta a **Certidão Negativa de Débitos Trabalhistas** da empresa, emitida pela Justiça do Trabalho a partir do Banco Nacional de Devedores Trabalhistas (CLT, art. 642-A), usando o CNPJ lido do documento societário. A certidão vale para **todos os estabelecimentos** da empresa e tem **validade de 180 dias** a partir da emissão.

```
// cndt no check_details
{ "module": "cndt", "passed": true, "outcome": "approved",
  "data": {
    "situacao": "negativa",               // "positiva" ou "positiva_com_efeito_de_negativa"
    "nada_consta": true,                  // true na negativa e na positiva com efeito de negativa
    "validade_dias": 180,
    "cobertura": "todos_os_estabelecimentos",
    "consultado_em": "2026-09-23T12:00:00.000Z",
    "certidao": {
      "numero": "12345678/2026",
      "emitida_em": "2026-09-23",
      "valida_ate": "2027-03-22",
      "raw": { } },                        // a resposta da fonte, como ela é
    "purpose": "Informar a situacao da empresa verificada no Banco Nacional de Devedores Trabalhistas, ..." } }
```

**Como ler.** `positiva` significa débito trabalhista inadimplido registrado no banco nacional: o check sai com sinal para revisão e o resumo da verificação marca `flagged`. `positiva_com_efeito_de_negativa` é débito garantido ou com exigibilidade suspensa, que a lei equipara à negativa: `nada_consta` sai `true`. O módulo é informativo: ele não reprova a identidade de ninguém.

**Quando não cobra.** Só é cobrado quando a certidão é emitida. Sem resposta da Justiça do Trabalho, o check sai `pending` sem bloco `data` e o módulo sai do preço daquela verificação.

## Representante vinculado à empresa

<https://unifokal.com/docs/modulos/representante-pj>

### Representante vinculado à empresa

! **Ainda não está aberto para venda.** O módulo aparece na [tabela de preços](https://unifokal.com/precos) com preço e com o selo "Em breve". O contrato de resposta abaixo é o que o backend já emite, para você planejar a integração.

O módulo `representante_pj` confere o CPF do representante lido no documento dele (mesmo OCR de pessoa da verificação de identidade) contra o **quadro societário** da empresa que um dos módulos de CNPJ do mesmo fluxo já trouxe. A pergunta que ele responde é uma só: a pessoa que está assinando pela empresa consta no quadro societário publicado, e com qual qualificação.

```
// representante_pj no check_details
{ "module": "representante_pj", "passed": true, "outcome": "approved",
  "data": {
    "outcome": "consta_qsa_administracao",  // ou "consta_qsa_sem_administracao", "fora_do_qsa"
    "basis": "cpf",                         // ou "mascara_e_nome", ou null quando nao verificado
    "qualification": "Sócio-Administrador", // a qualificacao PUBLICA do quadro societario, ou null
    "pep_sancoes": { "outcome": "approved", "passed": true },   // ou null quando nao rodou no fluxo
    "powers_verified": false } }
```

**Como ler.** `consta_qsa_administracao` significa que o CPF do representante bate com um sócio do quadro que exerce função de administração. `consta_qsa_sem_administracao` significa que o CPF bate com o quadro, mas sem função de administração declarada. `fora_do_qsa` significa que o CPF não foi encontrado no quadro: o check vai para **revisão humana** com a evidência, e nunca reprova a verificação sozinho. Quando nada foi conferido, o resumo da verificação marca `indeterminate`.

**O que o módulo não diz.** Nenhum dos três desfechos confirma que a pessoa tem **poderes** para o ato específico que ela está praticando: o quadro societário publica a qualificação da pessoa na empresa, não o alcance dos poderes de representação dela para aquele contrato ou aquela operação. Se o ato exigir um poder específico, confirme com o próprio cliente ou peça a procuração, mesmo com o desfecho `consta_qsa_administracao`.

## Empresa estrangeira

<https://unifokal.com/docs/modulos/kyb-estrangeira>

### Empresa estrangeira

! **Ainda não está aberto para venda.** O módulo aparece na [tabela de preços](https://unifokal.com/precos) com preço e com o selo "Em breve". O contrato abaixo é o que o backend já emite, para você planejar a integração.

O módulo `kyb_estrangeira` verifica a empresa de fora do Brasil que não tem CNPJ. A empresa entra na criação da sessão pela API, no bloco `foreign_entity`: pelo identificador de entidade legal (LEI, o código de 20 caracteres da norma ISO 17442) ou, sem ele, pelo nome e pelo país. O país vem da mesma lista fechada do painel.

**Erros do bloco.** Flow com o módulo e sem o bloco responde 422 `foreign_entity_required`; o bloco num flow sem o módulo, 422 `foreign_entity_not_supported`; LEI com dígito de controle errado, 422 `foreign_entity_lei_invalid`; beneficiários acima do teto, 422 `foreign_entity_too_many_owners`. Chave desconhecida no bloco responde 400 `unknown_foreign_entity_key`, e num beneficiário, 400 `unknown_beneficial_owner_key`. O link hospedado não leva a empresa: o flow com este módulo só cria sessão pela API.

```
// POST /v1/verification-sessions
{ "flow_id": "flow_...", "reference_id": "cliente-123",
  "foreign_entity": {
    "lei": "5493001KJTIIGC8Y1R12",            // ou "name" + "country" (ex.: "GB"), nunca os dois
    "beneficial_owners": [ { "name": "Nome da pessoa" } ] } }   // opcional, até 20 pessoas
```

```
// kyb_estrangeira no check_details
{ "module": "kyb_estrangeira", "passed": true, "outcome": "approved",
  "data": {
    "outcome": "encontrada",                  // ou "nao_encontrada_no_indice", "candidatos"
    "lookup_by": "lei",                       // ou "name"
    "index_published_at": "2026-09-26T00:00:00Z",   // data da publicação do índice que respondeu
    "reason": "foreign_entity_not_in_index",  // só quando vai para revisão
    "entity": { "lei": "...", "legal_name": "...", "country": "GB",
                "registration_status": "ISSUED", "entity_status": "ACTIVE",
                "parent_is_beneficial_owner": false,
                "direct_accounting_parent": { "kind": "entity", "lei": "...", "legal_name": "..." },
                "ultimate_accounting_parent": { "kind": "reporting_exception", "reason": "NATURAL_PERSONS" } },
    "candidates": [ { "lei": "...", "legal_name": "...", "country": "GB" } ],  // só na busca por nome
    "candidates_total": 3,                    // quantos o índice achou (a lista traz os primeiros)
    "declared_beneficial_owners_count": 1,
    "declared_beneficial_owners": { ... },    // a triagem nas listas, quando houve declaração
    "sanctions_coverage": { "consulted": [ ... ], "not_consulted": [ ... ] } } }
```

**Como ler.** `encontrada` com registro emitido e entidade ativa aprova. Registro vencido, candidatos por nome e beneficiário declarado com alerta vão para **revisão humana** com a evidência, e nunca reprovam sozinhos. `nao_encontrada_no_indice` também vai para revisão: nem toda empresa tem LEI, e não constar no índice não quer dizer que a empresa não existe. Sem LEI, a sua equipe escolhe o candidato certo ou pede o identificador ao cliente. Quando o índice não responde, o módulo fica pendente, a verificação vai para revisão e o módulo não é cobrado.

**O que o módulo não diz.** As controladoras direta e final vêm da consolidação **contábil** que a própria empresa declara ao índice, e controladora contábil não é beneficiário final: quando o controle é de pessoas naturais, o índice registra uma exceção de reporte, não os nomes. Por isso os beneficiários finais pessoa natural entram pelo campo `beneficial_owners`, declarados por você, e passam pelas listas de sanções e de pessoas expostas politicamente. O bloco `sanctions_coverage` diz quais listas foram consultadas e quais não foram.

## Motor Antifraude

<https://unifokal.com/docs/modulos/fraud-ai>

### Motor Antifraude

O módulo `fraud_ai` não olha o documento nem o rosto: ele olha a **sessão**. Rede e IP, aparelho, velocidade de tentativas, o e-mail informado, a sua lista de bloqueio e o reuso na **sua própria base** entram num motor de regras ponderadas que devolve um risco de 0 a 100 e o motivo de cada ponto. Não há modelo treinado no caminho: são regras determinísticas de peso fixo, e o `model_version` que chega no seu webhook diz isso na cara (`fraud-rules-1`, `fraud-rules-2`). Ele **não pede passo novo ao titular**, mas **faz consulta a fornecedor externo pago**: a reputação do IP, do e-mail e do telefone vem de uma consulta que nós fazemos por você, com cache e com teto de chamadas por verificação. O resto dos sinais já existe na verificação e no seu histórico. Quando esse fornecedor não está configurado, o motor continua rodando com os sinais próprios e os que dependem dele saem como não medidos, nunca como se fossem limpos.

! **Este é o único módulo cujo bloco não se chama `data`.** No `check_details` ele vem em `fraud_assessment`. Um parser que procura `data` em todo item lê `undefined` exatamente neste, e é o erro de integração mais caro da página, porque ele passa despercebido até o dia em que você for auditar por que o score não bate.

**Atenção à direção da escala, que é invertida em relação ao resto do payload.** `fraud_score` é **risco**: 0 é ótimo e 100 é péssimo. O `score` do próprio check, ao lado dele, segue a convenção do resto da API (alto é bom). Em produção os dois são exatamente complementares, `score = 100 menos fraud_score`, então ver `"score": 92` junto de `"fraud_score": 8` não é contradição, é a mesma medida em dois sentidos. Ramifique por `level` ou por `decision`, que não têm essa ambiguidade.

`level` tem três valores, `low`, `medium` e `high`, e é ele que determina o desfecho do check: `low` aprova (`passed: true`), `medium` fica indeterminado (`passed: null`, `outcome: "pending"`) e `high` reprova o **módulo** (`passed: false`). O campo `decision` é a mesma coisa na língua da decisão: `approve`, `review` e `decline`. **Reprovar o módulo não é reprovar a verificação**: o `fraud_ai` é um **portão suave**, então `decision: "decline"` aqui dentro não recusa nada sozinho, entra ponderado com os outros módulos. Quem decide a verificação é o `status` no topo do webhook, nunca este campo.

```
// fraud_ai no check_details: repare no "fraud_assessment" no lugar do "data"
{ "module": "fraud_ai", "passed": true, "outcome": "approved", "score": 92,
  "fraud_assessment": {
    "fraud_score": 8,           // RISCO 0..100 (baixo = bom). É o complemento do "score" acima.
    "level": "low",             // low | medium | high
    "decision": "approve",      // approve | review | decline (o módulo, não a verificação)
    "signals": {
      // TODA categoria abaixo pode sair null, e null significa NÃO MEDIDA: o vocabulário de
      // cada uma tem três valores, nunca dois. Um flow sem telefone não é um telefone limpo.
      "device": "trusted",      // trusted | flagged | null (aparelho reusado ou na sua blocklist)
      "device_reuse_count": 0,  // a CONTAGEM crua que o motor usou (null = sem vetor de features)
      "velocity": "normal",     // normal | high | null (tentativas na janela)
      "ip_risk": "normal",      // normal | elevated | null
      "ip_reuse_count": 1,
      "geo": "ok",              // ok | mismatch | null (inclui viagem impossível)
      "email": "ok",            // ok | disposable | null
      "phone": null,            // ok | flagged | null
      "company": null,          // ok | flagged | null (domínio declarado pela empresa)
      "identity_coherence": null, // ok | flagged | null (nome, nascimento, filiação, óbito)
      "device_intel": { "automation": null, "tampered": null, "incoherent": null },
      "recurrence": { "passages": 0, "prior_declines": 0, "prior_approvals": 1 },
      // Com quanto do vetor esta decisão foi tomada. "sufficient": false explica um
      // "decision": "review" com "fraud_score" baixo.
      "coverage": { "measured": 41, "total": 55, "pct": 75, "sufficient": true },
      "reasons": [],
      "reasons_text": [],
      // Explicabilidade POR REGRA: a família e o peso que cada razão contribuiu, e a soma já
      // cortada no teto por família (a soma delas é o "fraud_score"). Enquanto o motor v1 decide,
      // "applied_rules" e "families" saem null: o v1 é soma plana e não conhece família.
      "applied_rules": null,    // [{ code, family, weight, texto }] | null
      "families": null,         // { email: 10, rede: 15, ... } | null
      "engine_version": "fraud-rules-1" } } }

// fraud_ai com sinal: o mesmo aparelho em várias identidades, e o e-mail descartável
{ "module": "fraud_ai", "passed": null, "outcome": "pending", "score": 55,
  "fraud_assessment": {
    "fraud_score": 45, "level": "medium", "decision": "review",
    "signals": {
      "device": "flagged", "device_reuse_count": 4,
      "velocity": "high", "ip_risk": "elevated", "ip_reuse_count": 7,
      "geo": "ok", "email": "disposable",
      "phone": "flagged", "company": null, "identity_coherence": null,
      "device_intel": { "automation": null, "tampered": null, "incoherent": null },
      "recurrence": { "passages": 3, "prior_declines": 1, "prior_approvals": 0 },
      "coverage": { "measured": 26, "total": 55, "pct": 47, "sufficient": true },
      // códigos ESTÁVEIS de regra: ramifique por eles, nunca pelo texto do painel
      "reasons": ["device_reuse", "velocity", "disposable_email", "phone_voip"],
      // os MESMOS códigos com texto pronto para tela. O código é o contrato; o texto, não.
      "reasons_text": [
        { "code": "device_reuse", "texto": "O mesmo aparelho foi visto com várias identidades diferentes" },
        { "code": "phone_voip", "texto": "O telefone é de voz sobre IP, não de operadora móvel" } ],
      "applied_rules": null, "families": null, "engine_version": "fraud-rules-1" } } }

// cota do fornecedor estourada: o motor mediu pouco e NÃO afirma que passou
{ "module": "fraud_ai", "passed": null, "outcome": "approved", "score": 100,
  "fraud_assessment": {
    "fraud_score": 0, "level": "low", "decision": "review",
    "signals": {
      "coverage": { "measured": 6, "total": 55, "pct": 11, "sufficient": false },
      "reasons": [] } } }
```

**Há uma diferença entre "olhamos e está limpo" e "não conseguimos olhar", e o bloco diz qual dos dois foi.** Cada categoria (`device`, `velocity`, `geo`, `email`, `ip_risk`, `phone`, `company`, `identity_coherence`, `device_intel`) sai `null` quando ninguém a mediu naquela verificação, e nunca `"ok"` por omissão: um fluxo sem telefone não é um telefone verificado. O campo `coverage` fecha a conta, dizendo quantos dos 55 sinais foram medidos. Quando `"sufficient"` vem `false`, o motor mediu pouco demais para afirmar que está limpo: nesse caso `passed` sai `null` e `decision` sai `review` mesmo com `fraud_score` baixo, e é isso que o terceiro exemplo acima mostra. Isso não reprova ninguém e não muda a decisão da verificação: é o módulo se recusando a assinar embaixo de um vetor que ele não conseguiu levantar.

`reasons_text` traz as mesmas razões com texto pronto para tela. **Ramifique sempre por `reasons`, nunca pelo texto**: o código é o contrato e é estável, o texto pode mudar de redação.

`applied_rules` abre o `fraud_score` por regra: cada razão com a família a que ela pertence e o peso que ela contribuiu, e `families` traz a soma de cada família já cortada no teto dela (a soma das famílias é o `fraud_score`). `engine_version` diz qual motor produziu aquelas razões, para você conseguir comparar duas verificações separadas no tempo. **Os dois primeiros saem `null` enquanto o motor de soma plana estiver decidindo**, porque ele não conhece família nem peso por regra. As chaves já saem hoje para que a troca de motor não mude a forma da resposta.

O bloco `signals` traz duas coisas de propósito: o **estado por categoria**, que é legível e curto, e a **contagem crua** que o motor usou (`device_reuse_count`, `ip_reuse_count`). Sem a contagem você leria `"device": "flagged"` sem saber se foram dois ou quarenta cadastros no mesmo aparelho, que é a diferença entre um celular de família e uma fazenda de contas. `null` na contagem significa **não medido** naquela verificação, nunca zero. Já `reasons` é a lista de **códigos de regra** que dispararam, um vocabulário estável (`device_reuse`, `velocity`, `impossible_travel`, `blocklist_hit`, `disposable_email` e outros) do qual as categorias acima são derivadas. Ele pode ganhar códigos novos: trate como lista aberta.

**O que não está aqui, e é deliberado.** Detecção de mídia sintética não vive neste módulo nem em nenhum outro hoje: não existe modelo de deepfake com licença comercial de ponta a ponta, e não publicamos campo sem medição por trás. E nenhum valor de identificador viaja no bloco: nem IP, nem e-mail, nem o _fingerprint_ do aparelho, nem o hash de nenhum deles. Sai a **categoria** e sai a **contagem**, que é o que sustenta a sua decisão sem transformar o webhook num oráculo sobre terceiros.

**Em sandbox este módulo é o menos realista da API, e é melhor você saber por quê.** Os sinais de risco vêm do **seu histórico real** (aparelhos, IPs e tentativas da sua organização), e no ambiente de testes esse histórico não existe. Então o bloco `fraud_assessment` sai sempre no caminho limpo, com `level: "low"`, `fraud_score` baixo e `reasons: []`, enquanto `passed`, `outcome` e `score` continuam seguindo o **sufixo do documento**, como no resto do sandbox. Ou seja: lá, e só lá, dá para ver `decision: "decline"` ao lado de `level: "low"`, e a relação `score = 100 menos fraud_score` não vale. Não programe a sua conciliação contra o par que o sandbox mostra; programe contra os campos, que são os mesmos nos dois ambientes.

## Gate transacional

<https://unifokal.com/docs/modulos/transacao>

### Gate transacional

O módulo `transacao` avalia a **transação que você nos envia** e devolve `allow`, `step_up` ou `deny`, com as razões e os pontos de risco de cada sinal que disparou. Ele olha valor fora do padrão do próprio titular, cadência, contraparte nova ou concentradora, troca de aparelho, horário, e se aquele titular já foi verificado por você aqui. Roda sobre o **seu** histórico, sem consórcio com outros clientes.

! **Quem decide liberar o pagamento é você.** Nós não liquidamos, não acessamos o DICT nem o MED, e não somos o arranjo Pix. `deny` é a nossa **recomendação**, não um bloqueio: o módulo é portão suave e nunca recusa a verificação sozinho.

**Disponível desde 11 de setembro de 2026.** Ele aparece na tabela de preços e no `GET /v1/capabilities` com preço e status `available`, pode ser ligado num flow, e a ingestão de transações deixou de responder `422 transaction_not_supported` em flow que o contenha. Até essa data as duas travas eram o preço e o registro do módulo como consumidor de transação, e elas caíram juntas: abrir só uma teria posto o módulo à venda com a porta de entrada ainda fechada.

**Num flow que contenha o gate, o lote não é aceito: `transactions[]` responde `422 batch_not_supported_for_gate`**, e só a forma unitária `transaction` é avaliada. A razão é de produto: um gate síncrono decide **um** pagamento, e um lote não teria veredito. Isso não quebra integração nenhuma, e o argumento é verificável: até esta data nenhum flow podia conter o módulo, porque a criação de flow o recusava, então a regra nasce junto com a possibilidade.

**Só o movimento que o próprio titular iniciou, e que não falhou, recebe veredito.** São `deposit`, `withdraw`, `transfer`, `payment` e `bet`, com `status` `confirmed` ou `pending`. `settlement` e `reversal` são o ciclo de vida de um pagamento que já foi julgado, `bet_profit` e `bet_loss` são o resultado que a casa apurou, `status: failed` é dinheiro que não se moveu, e o evento de backfill que chega fora da janela é um pagamento que você já liquidou. Nenhum deles tem uma pergunta em aberto: todos continuam sendo ingeridos normalmente, entram na trilha, na retenção e no monitoramento, e simplesmente não geram verificação nem cobrança.

**Duas escalas convivem aqui, e elas apontam para lados opostos.** `risk_score` vai de 0 a 100 com **100 sendo o pior**, e é o eixo do gate. O `score` do check ao lado é o de negócio, e é **ternário e fixo**: `allow` vale 100, `step_up` vale 40 e `deny` vale 0. Um não é o complemento do outro, e a conta `100 menos risk_score` **não** reproduz o segundo.

```
// transacao: step_up. As contribuições dizem quantos pontos de risco cada sinal que
// disparou somou. Os números deste exemplo são ilustrativos.
{ "module": "transacao", "passed": null, "outcome": "pending", "score": 40,
  "data": { "transaction": {
      "verdict": "step_up",              // allow | step_up | deny
      "risk_score": 70,                  // 0..100 (100 é o PIOR) = a SOMA das contributions, com teto em 100
      "reasons": ["amount_above_profile", "new_counterparty", "night_window"],
      // ordenadas por pontos desc; empate desempata pelo nome do sinal, em ordem alfabética
      "contributions": [ { "signal": "amount_above_profile", "points": 31 },
                         { "signal": "new_counterparty", "points": 22 },
                         { "signal": "night_window", "points": 17 } ],
      "confidence": 0.3,                 // 0..1, e NÃO é probabilidade de fraude
      "feature_set_version": "tx-fs_90adbec7ef08",
      "calibration_version": "tx-cal_9f2c1a4b7e03" } } }

// deny: é PORTÃO determinístico, não soma de pesos. Por isso "contributions" vem vazio
// e o risk_score 100 é carimbo, não cálculo.
{ "module": "transacao", "passed": false, "outcome": "failed", "score": 0,
  "data": { "transaction": { "verdict": "deny", "risk_score": 100,
                             "reasons": ["blocklist_hit"], "contributions": [],
                             "confidence": 0.3,
                             "feature_set_version": "tx-fs_90adbec7ef08",
                             "calibration_version": "tx-cal_9f2c1a4b7e03" } } }

// allow, perfil maduro e payload completo -> confidence alta
{ "module": "transacao", "passed": true, "outcome": "approved", "score": 100,
  "data": { "transaction": { "verdict": "allow", "risk_score": 0,
                             "reasons": [], "contributions": [], "confidence": 1,
                             "feature_set_version": "tx-fs_90adbec7ef08",
                             "calibration_version": "tx-cal_9f2c1a4b7e03" } } }
```

**A resposta síncrona sempre vem, e para a mesma pergunta ela é sempre a mesma.** Num flow com o gate, todo evento avaliável recebe `verdict` na própria chamada. Isso inclui o seu **retry**: repetir o mesmo `external_id` devolve o veredito que já foi dado na primeira vez, sem reavaliar e **sem uma segunda cobrança**. É o comportamento que um caminho de pagamento precisa, porque o retry de rede ali é rotina e não exceção. O `external_id` é único por organização e ambiente, e o veredito dele também: se o mesmo `external_id` for enviado por outro flow seu, a resposta é o veredito que já foi dado, sem nova avaliação e sem nova cobrança.

**No sandbox o gate também decide**, com a mesma forma de resposta da produção e sem gravar nada. O veredito de sandbox é determinístico e você escolhe o ramo pelos **dois últimos dígitos** de `amount_cents`. Ele sai só do valor: no sandbox o gate não consulta perfil nem lista de bloqueio, porque ali ele existe para você exercitar os três ramos do seu código, e não para julgar titular.

```
// SANDBOX: o veredito sai do valor, para você exercitar os três ramos do seu código.
// ...13  -> deny      (ex.: amount_cents 10013, 4513, 13)
// ...07  -> step_up   (ex.: amount_cents 10007, 4507, 7)
// resto  -> allow

POST /v1/verification-sessions   (chave sk_test_)
{ "flow_id": "flow_...", "reference_id": "cliente-4821",
  "transaction": { "type": "payment", "amount_cents": 10007, "external_id": "pay-1" } }

201
{ "id": "ing_...", "status": "consumed",
  "ingest": { "batch_id": "ing_...", "accepted": 1, "duplicated": 0, "batch_size": 1,
              "first_seen_at": "2026-09-20T12:00:00.000Z" },
  "transacao": { "verdict": "step_up" } }
```

**Trate `step_up` como o caminho normal, não como o raro.** Ele é a categoria que pede uma prova a mais antes de liberar o dinheiro, e é sempre a resposta quando o gate prefere não afirmar. Uma integração que só trata `allow` e `deny` está com um ramo em aberto.

**Step-up com prova de humano (em breve).** Quando o flow do gate tem um flow de prova configurado (`step_up_flow_id`, com um flow de reserva opcional em `step_up_fallback_flow_id`) e o veredito é `step_up`, o bloco `transacao` passa a trazer `step_up`. Com `available: true`, `session_id` é a sessão de PROVA, válida por 600 segundos: monte o widget com ela, como na criação de sessão, e o titular aprova o pagamento com a passkey da conta dele ou com a reautenticação facial, conforme o fator que a conta tem (`factor`). O resultado chega no webhook da sessão de prova, com `data.step_up_of`: `source` é o gate que pediu a prova, `verification_id` é a verificação do pagamento e `event_type` é o tipo do evento. É por ele que você liga o desfecho da prova ao pagamento que estava esperando. A verificação do pagamento não muda por causa da prova. Com `available: false`, `reason` diz por quê (por exemplo `no_factor_enrolled` ou `insufficient_credit`) e você aplica o seu próprio desafio. Repetir o mesmo `external_id` devolve o mesmo step-up enquanto o pedido estiver aberto, e `step_up_closed` depois. No sandbox a ingestão não abre sessão de prova: o bloco vem com `step_up_sandbox`, na mesma forma da produção. Os módulos de prova estão com a venda pausada, então a configuração só fica disponível quando eles abrirem.

**A sua lista de bloqueio vale aqui, em produção.** Quando o `reference_id` da transação está na lista, o veredito é `deny`, com `blocklist_hit` em `reasons`. Envie sempre o `reference_id` do titular junto da transação: é por ele que a sua lista é consultada. No sandbox, como o veredito vem do valor, use o gatilho de `amount_cents` para exercitar o ramo de `deny` do seu código.

**O `reference_id` e o `counterparty_ref` são o **sujeito** e o **destino**, e nos dois a maiúscula não muda quem é quem.** `User_42`, `user_42` e `USER_42` são a mesma pessoa, e o mesmo vale para o destino: o histórico, a janela de análise e a sua lista de bloqueio enxergam um titular só. Isso importa porque um id que muda de grafia entre dois caminhos do seu backend (um derivado de e-mail, outro digitado num formulário) partiria o histórico em dois pedaços pequenos, e histórico curto derruba a `confidence` do veredito. O `external_id` é o oposto e de propósito: ele é o token do **evento**, comparado byte a byte, então `TX-1` e `tx-1` são duas transações diferentes.

**A janela de análise é contada pelo relógio do nosso servidor.** O `occurred_at` que você manda continua valendo, e é ele que descreve quando o pagamento aconteceu no seu sistema; quem decide em que janela o evento entra é o momento em que ele chega aqui. Consequência prática para a sua integração: se você faz carga retroativa, mande-a de uma vez e não espere que ela reescreva a análise de semanas passadas, porque a análise é do que chegou.

**`reasons` pode conter razão que não pontua, e isso é de propósito.** Uma razão pode declarar **contexto** sobre a avaliação sem somar risco, e nesse caso ela aparece em `reasons` e não em `contributions`: nada é tratado como "limpo por omissão". Quem somar o tamanho de `reasons` como se fosse risco erra. A conta de verdade é `contributions[].points`: os pontos de risco de cada sinal que disparou, e a soma deles, com teto em 100, é o `risk_score`.

**`confidence` não é probabilidade de fraude.** É o quanto o veredito merece crédito, composto de maturidade do perfil daquele titular, completude do payload que você mandou e nitidez da contraparte. Perfil novo derruba a confiança mesmo com veredito `allow`. Use os dois juntos: `step_up` com `confidence` baixa é "desconfio, mas sei pouco", e merece tratamento diferente de `step_up` com confiança alta.

**O que não sai daqui, e é deliberado:** o limiar que separa as bandas, e o valor, a contraparte e o documento da transação. Publicar o limiar vigente transformaria o payload num oráculo contra o próprio gate. Pela mesma razão, a resposta **síncrona** da ingestão devolve o `verdict` e nada mais: as razões e as contribuições viajam só aqui, no webhook, que é superfície assinada e auditável. E `calibration_version` é o rastro dessa calibração: `tx-cal_` mais doze caracteres em produção, e a string literal `"sandbox"` no ambiente de testes.

**`feature_set_version` e `calibration_version` são strings **opacas**: guarde, nunca interprete.** Elas servem para uma coisa só, e é uma coisa valiosa: reler um veredito de meses atrás sabendo que ele saiu sob exatamente aquela política. O prefixo é estável (`tx-fs_` e `tx-cal_`); o que vem depois muda sempre que a política muda, e não carrega significado que você possa decodificar. Os valores que aparecem nos exemplos acima são **ilustrativos**: não compare o seu payload com eles, não os fixe em teste e não condicione comportamento a um valor específico. O que você deve fazer é guardá-los junto do desfecho, e comparar um veredito com outro veredito.

**O contrato do evento: o ciclo de vida.** Um movimento que você nos manda com `status` `confirmed` (o padrão) é um movimento que aconteceu. `settlement` liquida um evento seu que chegou `pending`, e quem decide a liquidação é o banco de dados, nunca a ordem de chegada: a segunda liquidação do mesmo alvo responde `409 already_settled`, e uma liquidação cujo `settles` não aponta um evento seu, no mesmo ambiente, responde `422 unknown_settles_reference`. Liquidação e reversão nunca chegam pendentes: `status: pending` em uma delas é `422 settlement_status_invalid`. `reversal` nunca altera o evento revertido, que continua contando como o movimento que de fato aconteceu; a reversão é um evento próprio, e ela conta na próxima avaliação daquele titular. Em qualquer um dos dois, `settles` é o `external_id` do alvo. E `status: pending` só entra em flow cujo módulo aceite o ciclo pendente; nos demais ele responde `422 pending_lifecycle_not_enabled`. Hoje nenhum dos módulos que consomem transação aceita o ciclo pendente: mande o evento já confirmado e, se ele for desfeito, mande a reversão.

```
// Reversão: um evento próprio, que aponta o alvo por settles (o external_id do alvo).
// O alvo não muda; a reversão conta na próxima avaliação deste titular.
POST /v1/verification-sessions
{ "flow_id": "flow_...", "reference_id": "cliente-4821",
  "transaction": { "type": "payment", "amount_cents": 64000, "external_id": "pay-77",
                   "counterparty_ref": "loja-903" } }

{ "flow_id": "flow_...", "reference_id": "cliente-4821",
  "transaction": { "type": "reversal", "amount_cents": 64000, "external_id": "pay-77-rev",
                   "settles": "pay-77" } }

// Liquidação: só de um alvo que chegou pending, em flow cujo módulo aceite o ciclo pendente.
{ "flow_id": "flow_...", "reference_id": "cliente-4821",
  "transaction": { "type": "settlement", "amount_cents": 64000, "external_id": "dep-12-liq",
                   "settles": "dep-12" } }
// segunda liquidação ou reversão do mesmo alvo -> 409 already_settled
// settles sem evento seu no mesmo ambiente     -> 422 unknown_settles_reference
// liquidação ou reversão com status pending    -> 422 settlement_status_invalid
```

**Os três ids são opacos, e cada um tem um papel.** `reference_id` é o **titular**, e precisa ser estável por pessoa: é por ele que o histórico, o perfil e a sua lista de bloqueio são lidos. `external_id` é o token de idempotência do **evento**, comparado byte a byte: é ele que faz o seu retry devolver o mesmo veredito sem uma segunda cobrança, e ele é obrigatório (sem ele, `422 external_id_required`). `counterparty_ref` é o **destino**. Nenhum dos três aceita dado pessoal: um caractere fora do conjunto aceito é `422 reference_id_charset` (a mensagem nomeia o campo recusado), e um valor com forma de dado pessoal é `422 pii_shaped_value`. Número de pessoa (CPF, telefone) nunca é referência, nem escrito sem máscara: use um id do seu sistema. O corpo do erro nunca devolve o valor recusado, só o campo e a posição no lote.

**`device` é minimizado antes de ser guardado.** Do IP fica só o prefixo de rede (/24 em IPv4, /48 em IPv6) e um derivado comparável; do fingerprint, só o derivado. Nem o IP nem o fingerprint ficam em claro, e a telemetria de aparelho tem prazo próprio, mais curto que o do evento. Mande os dois quando tiver: eles alimentam os sinais de aparelho da avaliação e nada além disso.

**`direction` diz para que lado o dinheiro foi, do ponto de vista do titular.** `in` entrou na conta dele, `out` saiu. O campo é opcional e, ausente, fica registrado como não informado: nós nunca deduzimos a direção do `type`, porque uma `transfer` pode ser as duas coisas. Qualquer outro valor é `400 validation_error`. No monitoramento de PLD/FT, uma `transfer` ou um `payment` sem `direction` conta como saída: mande `in` quando o titular recebe.

**`instrument` identifica o meio de pagamento do titular, e nunca envie o número do cartão.** O bloco tem `kind` (`card`, `account`, `wallet` ou `pix_key`) e `ref`, um valor opaco seu: o token que o seu PSP já devolve, ou um HMAC que você calcula. Guardamos só o tipo e um derivado comparável do `ref`, nunca o valor que chegou. Um `ref` com forma de número de cartão (13 a 19 dígitos que fecham o dígito verificador, com ou sem separador), de CPF, CNPJ, e-mail, telefone ou chave Pix é `422 pii_shaped_value`, sem o valor no corpo do erro. Não existe campo de código de segurança nem de validade, e mandar um é `400 validation_error`. Se o seu token do PSP for só de dígitos, prefira mandar um HMAC dele: uma sequência numérica pode fechar o dígito verificador por acaso e ser recusada.

**`cash` diz que a operação foi em espécie**, dinheiro vivo. É booleano, o padrão é `false`, e não existe outro jeito de marcar espécie: o `method` continua sendo o meio eletrônico. Mande `true` só quando a operação foi mesmo em espécie, porque é ele que as regras de espécie da [PLD/FT pela regra da norma](https://unifokal.com/docs/pld-ft#pld-ft) leem.

```
{ "flow_id": "flow_...", "reference_id": "cliente-4821",
  "transaction": { "type": "transfer", "amount_cents": 64000, "external_id": "tr-91",
                   "direction": "out", "cash": false,
                   "instrument": { "kind": "card", "ref": "tok_1Nv0aB2eZvKYlo2C" } } }
```

**Os tetos, cada um com o seu código.** `amount_cents` acima do teto de fábrica é `422 amount_too_large`; moeda diferente de `BRL` é `422 currency_not_supported`; `occurred_at` além da janela de importação é `422 event_too_old` (dentro dela, o evento antigo entra normalmente e não recebe veredito, como dito acima); e o teto diário de eventos por organização é `429 daily_ingest_cap_reached`, sem `Retry-After` porque o balde é o dia: pause, retome no dia seguinte, e saiba que o lote recusado não gravou nada. Num flow que contenha o gate, a chamada **sem** o bloco `transaction` responde `422 transaction_required`, tanto na criação de sessão quanto na emissão de link hospedado: esse flow só recebe transação, e uma sessão de widget não teria o que perguntar a ele.

**O flow do gate é um flow só dele.** `POST /v1/flows` e `PATCH /v1/flows/{id}` recusam com `422 sync_gate_module_exclusive` uma composição que junte o gate a qualquer outro módulo. A razão é a mesma que faz o gate decidir na própria chamada: a requisição que carrega o bloco `transaction` responde na hora e não abre jornada de captura, então um módulo de documento ou de biometria nesse flow nunca teria por onde rodar. Mantenha o seu KYC no flow que você já tem e crie um flow separado só com o gate. Os dois flows convivem no mesmo `reference_id`: é por ele que o gate encontra a identidade aprovada do titular.

```
// Os doze códigos da ingestão de transação, além dos da criação de sessão:
//   422 transaction_required            flow com o gate e chamada sem o bloco (sessão e link hospedado)
//   422 external_id_required            evento sem o token de idempotência
//   422 amount_too_large                acima do teto de fábrica
//   422 currency_not_supported          só BRL
//   422 event_too_old                   occurred_at além da janela de importação
//   422 reference_id_charset            id opaco com caractere fora do conjunto aceito (a mensagem nomeia o campo)
//   422 pii_shaped_value                id opaco com forma de dado pessoal
//   422 pending_lifecycle_not_enabled   status pending em flow cujo módulo não aceita o ciclo pendente
//   422 settlement_status_invalid       liquidação ou reversão com status pending
//   422 unknown_settles_reference       settles sem evento seu no mesmo ambiente
//   409 already_settled                 segunda liquidação ou reversão do mesmo alvo
//   429 daily_ingest_cap_reached        teto diário por organização; sem Retry-After, retome no dia seguinte
```

## Monitoramento transacional

<https://unifokal.com/docs/modulos/transacao-monitor>

### Monitoramento transacional

! **A cobrança é por alerta emitido, e não pela varredura.** São 30 centavos por **alerta**: varrer as suas transações não custa nada, e alerta suprimido por repetição também não. É o que sustenta cobrar pelo achado em vez de cobrar pela busca.

O módulo `transacao_monitor` faz uma **varredura retrospectiva sobre as transações que você já nos mandou** pela rota de ingestão, procurando **padrões que uma transação sozinha não revela**. Ele roda no nosso relógio, sobre uma janela fechada, e ninguém fica esperando por ele: quando um padrão fecha, você recebe um **alerta** no mesmo webhook assinado de sempre, com a janela e a evidência em número.

**Disponível desde 11 de setembro de 2026.** Ele aparece na tabela de preços e no `GET /v1/capabilities` com preço e status `available`, e pode ser ligado num flow. Até essa data as duas travas eram comerciais, não técnicas: a **unidade** de cobrança e o **número**. As duas foram decididas, e a unidade é o alerta emitido, não a assinatura mensal. O **alerta nunca reprova ninguém**: o pior desfecho é revisão humana.

! **Este não é o módulo da seção acima.** O `transacao` é um **gate síncrono**: você tem um pagamento parado esperando um veredito, e a resposta vem na hora. O `transacao_monitor` é **observação no tempo**: nada está parado, a pergunta é sobre o conjunto, e o desfecho nunca é "não pague". São produtos diferentes, com preços diferentes e payloads diferentes. Ter um não dá o outro.

O campo `pattern` diz qual padrão fechou, e o seu código pode receber estes valores: `structuring`, `payee_concentration_growth`, `device_farm`, `post_approval_blocklist`, `dormant_reactivation`, `velocity_escalation` e `unverified_volume`. Receptores devem tolerar valor novo.

**A severidade tem dois valores, e só dois:** `high` e `medium`. Não existe `low`, e a ausência é decisão de produto: alerta que não muda o comportamento de ninguém é ruído cobrado.

```
// transacao_monitor no check_details: um alerta de fracionamento
{ "module": "transacao_monitor", "passed": null, "outcome": "pending", "score": 60,
  "data": {
    "transaction_monitor": {
      "pattern": "structuring",
      "severity": "high",
      "window": { "from": "2026-08-01T00:00:00Z", "to": "2026-08-08T00:00:00Z" },
      "evidence": { "tx_count": 7, "sum_cents": 6900000, "max_single_cents": 990000 }
    }
  } }
```

**Repare em `passed` e `outcome`: eles são sempre esses.** `passed` é `null` e `outcome` é `"pending"` nas **duas** severidades, sempre. A severidade viaja em `data.transaction_monitor.severity` e **nunca** em `passed`. Quem programar lendo `passed === false` como "reprovado" nunca vai entrar nesse ramo, e é exatamente essa a intenção: um `passed: false` aqui faria a verificação sair `denied` no webhook assinado, e um cliente que automatizou "denied = bloquear conta" bloquearia o titular sozinho. Leia o `pattern` e a `severity`, e trate o resto como evidência.

O campo `evidence` muda de chaves conforme o `pattern`, e ele **só carrega número**: contagem, soma em centavos e intervalo em dias. Nunca documento, nome, e-mail, chave Pix em claro nem endereço IP. O sujeito do alerta, que chega em `reference_id`, depende do `pattern`: pode ser o titular, a conta de destino ou o aparelho que o padrão observou.

! **O alerta nunca reprova ninguém, e isso é estrutural.** O pior desfecho que este módulo emite é **revisar**, em qualquer padrão e nas duas severidades: `high` e `medium` diferem no que você **lê**, não no que o motor **decide**. Quem aplica qualquer consequência sobre a conta é você.

**O que este módulo não faz.** Ele **não bloqueia nada**, como acabou de ser dito, e essa é a primeira. Ele **não consulta o DICT, o MED, bureau nem dado de consórcio entre instituições**: o universo da varredura é **só o que você nos mandou**, sob o seu próprio tenant, e nunca o histórico de outro cliente. E ele **não é o Gate transacional**, que é a seção logo acima.

**A varredura é grátis: você paga pelo alerta emitido.** Uma janela em que nenhum padrão fecha não gera verificação, não gera `check` e não custa nada, e por isso não existe um estado "limpo" neste payload: ele só nasce quando um padrão fecha. Alerta suprimido por repetição não é cobrado.

**A conta fica no painel.** Em Monitoramento você vê, no período que escolher, quantos alertas por 1.000 eventos recebidos o módulo produziu, quantos foram entregues e quantos ficaram **retidos sem cobrança**, com o motivo de cada retenção em palavras.

**Alerta de severidade alta nunca é retido.** Nem por volume, nem por falta de saldo: ele é entregue sempre, e quando sai acima do volume contratado ou sem saldo chega **sem cobrança**, com `billing.waived` dizendo o porquê: `above_volume` ou `no_credit`. Alerta cobrado normalmente não traz o bloco `billing`.

```
// data do alerta high entregue sem cobrança
{ "object": "verification", "billing": { "waived": "above_volume" },
  "check_details": [ { "module": "transacao_monitor", "passed": null, "outcome": "pending" } ] }
```

## Proteção de conta

<https://unifokal.com/docs/modulos/conta>

### Proteção de conta

! **Ainda não está aberto para venda.** O módulo aparece na [tabela de preços](https://unifokal.com/precos) com preço e com o selo "Em breve", e o `create` de flow o recusa até a abertura. A trava não é técnica: é **jurídica**, e é a mesma do módulo **Sinais do aparelho**. Os sinais que dependem de `device.fingerprint` precisam do teste de balanceamento do legítimo interesse (art. 7º IX da LGPD), da transparência ao titular e de um mecanismo real de oposição antes de poderem pesar. O contrato de resposta abaixo é o que o backend já emite, para você planejar a integração.

O módulo `conta` é um **gate síncrono de risco sobre um evento de conta**: seu backend nos manda o evento e recebe **na hora** um veredito explicável, para decidir se pede uma prova a mais antes de deixar a ação seguir. **São sete tipos de evento**: login, login falhado, cadastro, troca de senha, recuperação de acesso, troca de e-mail e ação sensível.

! **Este não é o módulo da seção acima, nem o da seção antes dela.** O `transacao` avalia um **pagamento** parado esperando liberação. O `transacao_monitor` varre a **janela** de transações depois, sem ninguém esperando. O `conta` avalia um **acesso**, no instante em que ele acontece. E ele também não é o `sessao_monitor`: aquele observa a sessão **já logada** ao longo do tempo; este responde a um **evento pontual** e termina ali. São quatro produtos diferentes, com preços diferentes e payloads diferentes.

**Os sinais da v1 são publicados pelo nome** porque um gate que não diz o que olha é uma caixa preta: `impossible_travel` (dois acessos separados por uma distância que não dá para vencer no tempo entre eles), `failed_login_burst` (rajada de logins falhados), `new_country` (país que nunca apareceu para aquele titular), `recent_credential_change` (a credencial mudou há pouco), `hosting_asn` (o IP é de rede de hospedagem, não de acesso residencial ou móvel), `ip_risk` (o risco do próprio IP), `no_verified_identity` (o titular nunca fez onboarding com você aqui), `dormant_reactivation` (conta parada há muito tempo que volta a se mexer), `new_isp` (a operadora de rede nunca apareceu para aquele titular), `odd_hour` (horário sem nenhum acesso anterior no histórico dele), `ip_shared` (o mesmo IP falhando contra várias contas suas na mesma janela) e `disposable_email` (você nos declarou que o e-mail da conta é de domínio descartável).

`disposable_email` é o único que depende de você: mande `account_event.disposable_email` como `true` ou `false` quando souber. Sem o campo, o sinal não vira "e-mail limpo": ele volta em `reasons` como `disposable_email_unknown`, que é como declaramos todo sinal que não foi possível medir.

A comparação é **com o histórico do próprio titular na sua base**: países, ASNs, horários e a velocidade entre eventos consecutivos, tudo isolado por cliente. Não há consórcio: o histórico de outro cliente nunca entra na conta. **Não compramos reputação de IP de terceiro**: o país e o ASN saem da nossa base GeoIP local, e o resto sai do que você mesmo nos mandou.

```
// conta no check_details: um login pedindo prova adicional
{ "module": "conta", "passed": null, "outcome": "pending", "score": 40,
  "data": {
    "account": {
      "verdict": "step_up",
      "risk_score": 60,
      "reasons": ["new_country", "impossible_travel"],
      "type": "login",
      "step_up_session_id": "vs_..."
    }
  } }
```

**Repare que o `data` é aninhado sob `account`.** É a mesma razão do `sessao_monitor` e do `transacao_monitor`: o evento chega no **mesmo** `verification.completed` do onboarding, e um integrador que guarda "a última verificação por `reference_id`" sobrescreveria o KYC daquele titular com um veredito de login. Com a chave própria, o KYC e o acesso nunca disputam o mesmo lugar no seu banco.

**São três vereditos, e nenhum deles bloqueia.** `allow` é "nada anormal". `step_up` é "peça mais uma prova antes de deixar entrar", nunca um não, e ele **jamais** vira `approved`: `step_up_session_id` vem junto e é a sessão de verificação que você pode usar para pedir essa prova. `deny` é o corte mais alto, e mesmo ele **sai do nosso motor como revisão**. O pior desfecho que emitimos é revisar, e quem aplica qualquer consequência sobre a conta é você.

! **O campo `device.fingerprint` é opcional.** Ele é aceito no contrato de entrada e está descrito na referência da API, e o módulo decide com ou sem ele: a avaliação do evento de conta não depende desse campo para sair completa. É a mesma trava jurídica do módulo **Sinais do aparelho**, que continua pausado enquanto o pacote do legítimo interesse não fechar (o teste de balanceamento do art. 7º IX, a transparência e o mecanismo de oposição). Qualquer mudança no que o campo passa a valer é anunciada no changelog, nunca silenciosa.

**O que este módulo não faz.** Ele **não bloqueia ninguém**, como acabou de ser dito. Ele **não autentica** e **não emite segundo fator**: o `step_up` é uma recomendação, e quem pede a prova, escolhe qual é e opera o próprio login continua sendo você. Ele **não consulta bureau nem compra reputação de IP**. E ele **não observa a sessão depois**: terminado o evento, o módulo não tem mais nada a dizer sobre aquele titular até o próximo evento chegar.

**A unidade cobrada é o evento de conta avaliado**, e não a verificação nem o titular por mês. Um mesmo titular que faz vinte logins no mês gera vinte eventos avaliados, e a [tabela de preços](https://unifokal.com/precos) imprime a unidade ao lado do valor para a conta fechar antes da integração.

**Tentativa de login com falha não é cobrada.** O evento `login_failed` é avaliado, entra no histórico do titular e alimenta a rajada de logins falhados, mas sai do preço: ele é o rastro do ataque que a sua base sofre, e cobrar por ele faria o atacante gastar o seu saldo. A verificação dele sai com `billable: false`. E um 402 por saldo insuficiente num fluxo com este módulo nunca aciona a recarga automática do seu cartão.

## Risco de IP e de e-mail

<https://unifokal.com/docs/modulos/risco>

### Risco de IP e de e-mail

Dois módulos avaliam o **contexto** da verificação, e não a identidade: **Verificação de IP** (`ip_risk`) e **Risco de e-mail** (`email_risk`). Nenhum dos dois pede passo novo ao usuário, e nenhum dos dois reprova sozinho: são **portões suaves**, como os módulos de canal. Um sinal aqui leva a verificação para `review` com a evidência no webhook, nunca a `denied` automático.

! **Limites honestos.** VPN corporativa, rede de hotel, CGNAT de operadora móvel, domínio próprio recém registrado e caixa de função de um MEI são o cotidiano de gente legítima: trate estes dois como **agravantes em conjunto**, nunca como veredito. `ip_risk` não diz quem está por trás do IP, e `email_risk` não prova que a caixa é da pessoa (quem prova posse é a Validação de e-mail).

Dois campos do payload merecem leitura antes de você programar contra ele. `provider_verdict` diz se a camada de reputação de rede/endereço estava disponível para aquela consulta: quando vem `not_cached`, os campos que dependem dela chegam `null` e isso significa **não medido**, jamais "limpo". No `ip_risk` existe ainda o `network_measured`: quando ele vem `false`, nenhuma camada de rede respondeu e o módulo sai `pending` em vez de dizer que está limpo (e nesse caso ele **não é cobrado**). E `scope`, no `email_risk`, diz o que foi de fato analisado: `mailbox` quando o flow também tem `email_otp` (é dele que o endereço vem, digitado pelo titular no widget) e `domain` quando só o domínio existia. No escopo `domain`, `role_based` e `plus_alias` vêm `null`: não havia caixa para olhar. **Combine sempre `email_risk` com `email_otp` no mesmo flow**: sem ele o endereço nunca chega ao produto (você não envia contato na criação), o módulo sai `pending` com `reason: "email_not_provided"`, a verificação vai para revisão e o módulo **não é cobrado**.

O `ip_risk` devolve **derivados** do endereço, e não o endereço: país, ASN, organização do ASN e a classe da conexão. O IP, a cidade e a coordenada **não trafegam**. No `email_risk` vale a mesma regra dos módulos de canal: só o domínio, a máscara e o `email_fp` (o mesmo HMAC que a Validação de e-mail publica para o mesmo endereço, para você correlacionar sem receber o dado em claro).

No `ip_risk`, **proxy e VPN saem separados** porque a pergunta que você faz sobre cada um é diferente, e `anonymizer` é apenas o "ou" dos dois. Ao lado deles viaja o **comportamento recente** do endereço, como o fornecedor o declara: `recent_abuse` (houve abuso conhecido a partir dali), `bot` (tráfego automatizado) e `abuse_velocity`, um ordinal fechado em `none`, `low`, `medium` e `high`. Valor fora desse vocabulário vira `null`, nunca um veredito por acidente. E vale para todos eles a regra do `provider_verdict`: `null` significa que o fornecedor não respondeu sobre aquele ponto, jamais que respondeu que está limpo.

No `email_risk`, `disposable_listed` e `forwarder_listed` declaram **de onde veio a afirmação**: eles são a nossa lista curada de domínios descartáveis e de encaminhadores mascarados, que **soma** ao veredito do fornecedor em vez de substituí-lo. Assim `disposable: true` com `disposable_listed: false` quer dizer "quem afirmou foi o fornecedor", e a nossa lista, que é curta por desenho, não transforma o próprio `false` em "medimos e está limpo". Outros dois campos olham o **formato** do endereço, e só existem no escopo `mailbox`: `digits_heavy` marca o local-part dominado por dígitos (uma corrida de cinco ou mais, ou mais da metade dos caracteres), o padrão do endereço fabricado em massa, calibrado de propósito para **não** pegar o apelido com ano de nascimento; e `name_email_score` mede, de 0 a 1, quanto do local-part é coberto pelo nome lido no documento. Este último é **informativo e não pontua**: apelido legítimo é comum demais para virar agravante, e ele vem `null` quando não há caixa, não há OCR na verificação, ou o local-part não tem nenhum token com cara de nome.

```
// ip_risk: rede residencial, nada a apontar
{ "module": "ip_risk", "passed": true, "outcome": "approved", "score": 100,
  "data": { "risk_score": 0, "level": "low", "provider_verdict": "clean", "network_measured": true,
            "country": "BR", "asn": 28573, "asn_org": "OPERADORA X S.A.",
            "connection": "residential", "tor": false, "datacenter": false, "anonymizer": false,
            // proxy e vpn SEPARADOS: o anonymizer acima é só o "ou" dos dois.
            "proxy": false, "vpn": false,
            // comportamento RECENTE do endereço, como o fornecedor o declara
            "recent_abuse": false, "bot": false, "abuse_velocity": "none",
            "geo_mismatch": false, "timezone_mismatch": false, "asn_incoherent": false,
            "ip_reuse_count": 1, "reasons": [] } }

// ip_risk: saída Tor, fora do país, fuso do navegador incoerente (review, NUNCA decline)
{ "module": "ip_risk", "passed": false, "outcome": "failed", "score": 40,
  "data": { "risk_score": 60, "level": "high", "provider_verdict": "dirty", "network_measured": true,
            "country": "NL", "asn": 60068, "asn_org": "DATACAMP LIMITED",
            "connection": "anonymizer", "tor": true, "datacenter": false, "anonymizer": true,
            "proxy": false, "vpn": true,
            "recent_abuse": true, "bot": false, "abuse_velocity": "medium",
            "geo_mismatch": true, "timezone_mismatch": true, "asn_incoherent": false,
            "ip_reuse_count": 4,
            "reasons": ["ip_tor", "ip_geo_mismatch", "ip_timezone_mismatch"] } }

// ip_risk: nenhuma camada de rede respondeu -> pending, e o módulo NÃO é cobrado
{ "module": "ip_risk", "passed": null, "outcome": "pending", "score": 0,
  "data": { "provider_verdict": "not_cached", "network_measured": false,
            "reason": "ip_intel_unavailable", "country": null, "asn": null,
            "tor": null, "datacenter": null, "anonymizer": null,
            // null nos SEIS: "o fornecedor não disse", jamais "disse que está limpo".
            "proxy": null, "vpn": null,
            "recent_abuse": null, "bot": null, "abuse_velocity": null } }

// email_risk: caixa analisada (o flow tem email_otp), endereço descartável
{ "module": "email_risk", "passed": false, "outcome": "failed", "score": 38,
  "data": { "scope": "mailbox", "risk_score": 62, "level": "high",
            "provider_verdict": "disposable", "email_domain": "tempmail.xyz",
            "email_masked": "u***r@tempmail.xyz", "email_fp": "7c14ab…",
            "disposable": true, "undeliverable": false, "role_based": false, "plus_alias": false,
            "typosquat": false, "homoglyph": false, "masked_forwarder": false,
            "suspicious_lexicon": true, "domain_age_days": 12,
            // DE ONDE veio a afirmação: true = a NOSSA lista curada também listou o domínio
            "disposable_listed": true, "forwarder_listed": false,
            // padrão do endereço e coerência com o nome do documento (0..1, INFORMATIVA)
            "digits_heavy": true, "name_email_score": 0.12,
            "reasons": ["email_disposable", "email_suspicious_lexicon", "email_domain_recent"] } }

// email_risk: só o domínio existia (flow sem email_otp) -> o que NÃO foi olhado vem null
{ "module": "email_risk", "passed": null, "outcome": "pending", "score": 0,
  "data": { "scope": "domain", "provider_verdict": "not_cached",
            "email_domain": "empresa.com.br", "email_masked": null, "email_fp": null,
            "role_based": null, "plus_alias": null, "disposable": null,
            // as listas NOSSAS respondem sobre o DOMÍNIO, então continuam medindo aqui
            "disposable_listed": false, "forwarder_listed": false,
            // estes DOIS dependem da caixa: sem local-part não há o que medir
            "digits_heavy": null, "name_email_score": null,
            "reason": "email_not_provided" } }
```

Em sandbox nada é consultado: o desfecho vem do **sufixo do documento**, como no resto do ambiente. Para o `ip_risk`: `33` datacenter, `44` saída Tor fora do país e `55` reputação indisponível (`not_cached`). Para o `email_risk`: `33` domínio descartável, `44` caixa de função com alias e `55` escopo de domínio.

A mesma dupla tem uma versão **avançada**, com um terceiro módulo de telefone ao lado: **Verificação avançada de IP** (`ip_risk_plus`), **Risco avançado de e-mail** (`email_risk_plus`) e **Risco de telefone** (`telefone_risco`). Os dois primeiros devolvem os MESMOS campos dos módulos de origem acima, com uma diferença de **entrega**: aqui o veredito nunca chega como não medido. Sem resposta da consulta o módulo **defere**, e o deferimento tem forma própria no payload: o item de `check_details` continua vindo **com** `data`, e é dentro dele que o deferimento se declara, com `provider_verdict: "unavailable"`, `reason: "provider_unavailable"` e `null` em tudo que não foi medido. Nessa passagem **o módulo não é cobrado**: sai da conta só ele, e o resto do flow continua sendo cobrado normalmente. Nenhum campo é completado com "limpo".

O `telefone_risco` exige `sms_otp` no mesmo flow: o número analisado é o que o titular já provou controlar pelo código por SMS, nunca um número solto. Ele confirma a **linha**, não a pessoa, e não substitui a prova de posse por SMS nem a biometria. O número completo não trafega: só a máscara e o `phone_fp`, o mesmo pseudônimo que o `sms_otp` publica para o mesmo número. `line_class` é vocabulário fechado nosso (`mobile`, `landline`, `voip`, `toll_free`, `premium`, `satellite`, `pager`, `unknown`), nunca a string crua do fornecedor, e `high_risk` é derivado por nós: não há score cru de fornecedor no payload. Os três são **portões suaves**: um sinal alto leva a verificação para `review` com a evidência no webhook, nunca a `denied` automático.

```
// ip_risk_plus: mesma rede do ip_risk, agora com o veredito garantido (nunca not_cached)
{ "module": "ip_risk_plus", "passed": true, "outcome": "approved", "score": 100,
  "data": { "risk_score": 0, "level": "low", "provider_verdict": "clean", "network_measured": true,
            "country": "BR", "asn": 28573, "asn_org": "OPERADORA X S.A.",
            "connection": "residential", "tor": false, "datacenter": false, "anonymizer": false,
            "proxy": false, "vpn": false,
            "recent_abuse": false, "bot": false, "abuse_velocity": "none",
            "geo_mismatch": false, "timezone_mismatch": false, "asn_incoherent": false,
            "ip_reuse_count": 1, "reasons": [], "reason": null } }

// ip_risk_plus: saída Tor, fora do país, abuso recente (review, NUNCA decline)
{ "module": "ip_risk_plus", "passed": false, "outcome": "failed", "score": 20,
  "data": { "risk_score": 80, "level": "high", "provider_verdict": "dirty", "network_measured": true,
            "country": "NL", "asn": 60068, "asn_org": "DATACAMP LIMITED",
            "connection": "anonymizer", "tor": true, "datacenter": false, "anonymizer": true,
            "proxy": false, "vpn": true,
            "recent_abuse": true, "bot": false, "abuse_velocity": "high",
            "geo_mismatch": true, "timezone_mismatch": true, "asn_incoherent": false,
            "ip_reuse_count": 1,
            // toda razão que pontuou aparece aqui, e "score" é sempre 100 menos "risk_score"
            "reasons": ["ip_tor", "ip_geo_mismatch", "ip_timezone_mismatch",
                        "ip_recent_abuse", "ip_abuse_velocity_high"], "reason": null } }

// ip_risk_plus: sem veredito do fornecedor -> DEFERE. O "data" VEM, com null no que ninguém mediu,
// e só o MÓDULO sai da cobrança (o resto do flow segue cobrando)
{ "module": "ip_risk_plus", "passed": null, "outcome": "pending", "score": 0,
  "data": { "risk_score": null, "level": null, "provider_verdict": "unavailable",
            "network_measured": false,
            "country": null, "asn": null, "asn_org": null, "connection": null,
            "tor": null, "datacenter": null, "anonymizer": null, "proxy": null, "vpn": null,
            "recent_abuse": null, "bot": null, "abuse_velocity": null,
            "geo_mismatch": null, "timezone_mismatch": null, "asn_incoherent": null,
            "ip_reuse_count": null, "reasons": [], "reason": "provider_unavailable" } }

// email_risk_plus: caixa analisada, entregabilidade real e idade do domínio garantidas
{ "module": "email_risk_plus", "passed": true, "outcome": "approved", "score": 100,
  "data": { "scope": "mailbox", "risk_score": 0, "level": "low", "provider_verdict": "deliverable",
            "email_domain": "gmail.com", "email_masked": "j***o@gmail.com", "email_fp": "b3f1c9…",
            "disposable": false, "disposable_listed": false, "undeliverable": false,
            "role_based": false, "plus_alias": false, "typosquat": false, "homoglyph": false,
            "masked_forwarder": false, "forwarder_listed": false, "suspicious_lexicon": false,
            "domain_age_days": 8200, "digits_heavy": false, "name_email_score": 0.82,
            "reasons": [], "reason": null } }

// email_risk_plus: caixa NÃO entrega, domínio descartável e recente (review, NUNCA decline)
{ "module": "email_risk_plus", "passed": false, "outcome": "failed", "score": 8,
  "data": { "scope": "mailbox", "risk_score": 92, "level": "high", "provider_verdict": "disposable",
            "email_domain": "tempmail.xyz", "email_masked": "u***r@tempmail.xyz", "email_fp": "7c14ab…",
            "disposable": true, "disposable_listed": true, "undeliverable": true,
            "role_based": false, "plus_alias": false, "typosquat": false, "homoglyph": false,
            "masked_forwarder": false, "forwarder_listed": false, "suspicious_lexicon": true,
            "domain_age_days": 9, "digits_heavy": false, "name_email_score": 0.05,
            "reasons": ["email_disposable", "email_undeliverable", "email_suspicious_lexicon",
                        "email_domain_recent"], "reason": null } }

// email_risk_plus: sem veredito do fornecedor -> DEFERE. O "data" VEM com null no que ninguém mediu:
// sobra o "scope" (o que teria sido analisado, caixa ou domínio) e nada do endereço, porque o
// deferimento não publica máscara, domínio nem pseudônimo. Só o MÓDULO sai da cobrança
{ "module": "email_risk_plus", "passed": null, "outcome": "pending", "score": 0,
  "data": { "scope": "mailbox", "risk_score": null, "level": null,
            "provider_verdict": "unavailable",
            "email_domain": null, "email_masked": null, "email_fp": null,
            "disposable": null, "disposable_listed": null, "undeliverable": null,
            "role_based": null, "plus_alias": null, "typosquat": null, "homoglyph": null,
            "masked_forwarder": null, "forwarder_listed": null, "suspicious_lexicon": null,
            "domain_age_days": null, "digits_heavy": null, "name_email_score": null,
            "reasons": [], "reason": "provider_unavailable" } }

// telefone_risco: linha móvel ativa, sem indício de abuso
{ "module": "telefone_risco", "passed": true, "outcome": "approved", "score": 100,
  "data": { "risk_score": 0, "level": "low", "provider_verdict": "phone_clean",
            "phone_masked": "+55 11 9****-**99", "phone_fp": "9f2c1a…", "country_code": "+55",
            "valid": true, "active": true, "line_class": "mobile", "carrier": "OPERADORA X S.A.",
            "voip": false, "prepaid": false, "risky": false, "recent_abuse": false,
            "high_risk": false, "reasons": [], "reason": null } }

// telefone_risco: linha VOIP com abuso recente e reputação de risco (review, NUNCA decline)
{ "module": "telefone_risco", "passed": false, "outcome": "failed", "score": 35,
  "data": { "risk_score": 65, "level": "high", "provider_verdict": "phone_risky",
            "phone_masked": "+55 11 9****-**21", "phone_fp": "4ab8e2…", "country_code": "+55",
            "valid": true, "active": true, "line_class": "voip", "carrier": "OPERADORA VIRTUAL LTDA",
            "voip": true, "prepaid": false, "risky": true, "recent_abuse": true,
            "high_risk": false,
            "reasons": ["phone_risky", "phone_recent_abuse", "phone_voip"], "reason": null } }

// telefone_risco: sem veredito do fornecedor -> DEFERE. O "data" VEM: máscara, pseudônimo e código
// do país continuam (eles são nossos, não do fornecedor), o resto sai null, e só o MÓDULO sai da
// cobrança
{ "module": "telefone_risco", "passed": null, "outcome": "pending", "score": 0,
  "data": { "risk_score": null, "level": null, "provider_verdict": "unavailable",
            "phone_masked": "+55 11 9****-**99", "phone_fp": "9f2c1a…", "country_code": "+55",
            "valid": null, "active": null, "line_class": null, "carrier": null,
            "voip": null, "prepaid": null, "risky": null, "recent_abuse": null,
            "high_risk": null, "reasons": [], "reason": "provider_unavailable" } }
```

Em sandbox, o mesmo sufixo do documento decide o caminho, e não há veredito ausente: o ambiente de testes não encena queda do fornecedor, do mesmo jeito que não encena queda da nossa própria infraestrutura. Aqui o sufixo troca o **dado devolvido**, e não o desfecho: `passed`, `outcome` e `score` continuam saindo da [tabela de sufixos do sandbox](https://unifokal.com/docs/ambientes#sandbox) (`00`, `01` e `02`), e qualquer outro sufixo aprova. Para o `ip_risk_plus`: `33` responde com rede de datacenter e `44` com saída Tor fora do país. Para o `email_risk_plus`: `33` domínio descartável, `44` caixa de função com alias e `55` escopo de domínio, com o veredito medido. Para o `telefone_risco`: `33` abuso recente, `44` linha VOIP e `55` número fora da faixa atribuída pela base. Ou seja: em sandbox dá para ver um payload de risco alto junto de `outcome: "approved"`, o que não acontece em produção. Programe contra os campos, nunca contra o par que o sandbox mostra.

## Análise de crédito

<https://unifokal.com/docs/modulos/credito>

### Análise de crédito

Cinco módulos consultam a situação de crédito do **CPF lido do documento**: `credito_dividas` (dívidas e negativações), `credito_scr` (SCR do Banco Central), `credito_boavista` (Boa Vista SCPC), `credito_protestos` (protestos Cenprot) e `credito_cadin` (CADIN). Exigem Verificação de Identidade, Face Match e Liveness, pela mesma razão dos cadastrais: o CPF consultado vem do documento, e o documento tem que pertencer a quem está vivo na frente da câmera.

O sexto, `credito_pgfn` (Dívida Ativa da União), é de **empresa**: ele consulta o **CNPJ lido do documento societário** e por isso exige apenas o `cnpj_ocr` no flow, sem biometria, porque empresa não tem rosto. A fonte dele não é fornecedor: é o **dado aberto oficial** que a PGFN publica e atualiza **trimestralmente**, ingerido na nossa base. Cada resposta carimba a **competência publicada** do arquivo que respondeu, para você saber a que mês ela se refere.

**Venda pausada hoje**: os **cinco** de CPF aparecem na tabela de preços e no `GET /v1/capabilities` com preço e status `coming_soon`, e não podem ser ligados num flow enquanto a fonte não for liberada na conta do fornecedor. O `credito_pgfn` saiu dessa fila em 11 de setembro de 2026 e viaja como `available`. O estado vem do catálogo vivo, e esta página acompanha.

**Os cinco de CPF pedem finalidade, relação e consulente**, como a Lei 12.414/2011 manda para dado de crédito. A **finalidade** é do flow: ao montá-lo no painel você declara `credit_purpose`, `analise_risco_credito` ou `concessao_credito` (art. 7º). A **relação** com o titular vai em cada sessão, no campo `credit_relationship` do `POST /v1/verification-sessions`: `mantem` ou `pretende_manter` (art. 15). O **consulente** é a sua empresa, e por isso a conta precisa ter o CNPJ cadastrado e verificado. A consulta só sai com a identidade aprovada, e cada uma fica registrada com a finalidade, a relação e o consulente. O `credito_pgfn` não passa por esse portão.

```
// sessão num flow com módulo de crédito
{ "flow_id": "flow_...", "reference_id": "cliente-4821", "credit_relationship": "mantem" }

// os 422 do portão, cada um com a prosa na página de erros:
//   credit_relationship_required       flow com crédito e a chamada sem credit_relationship
//   credit_relationship_not_supported  credit_relationship num flow sem módulo de crédito
//   credit_purpose_required            flow com crédito que não declara a finalidade
//   credit_consulente_unverified       a conta ainda sem CNPJ cadastrado e verificado
```

São **informacionais**: ter dívida **não reprova a identidade** (o pass/fail é 100% biometria). Os cinco de CPF são **assíncronos** na fonte: o dossiê pode chegar num `verification.completed` posterior, quando a consulta conclui. Enquanto processa, o módulo aparece como `pending` **sem a chave** `data`; concluído, o `data` traz o dossiê do pacote. Se a fonte não concluir na janela, o módulo termina como indisponível e **não é cobrado**.

O `credito_pgfn` responde da **nossa base**, na mesma verificação, e não tem esses dois tempos. Em troca ele carrega a **competência** do arquivo do governo que respondeu, em `competencia` e `atualizado_em`, mais a `cobertura` por fonte. Quando a base está vencida ou incompleta, ele sai como `pending` com o motivo e **não é cobrado**: um **nada consta** só é emitido com a base inteira dentro do prazo. Achado positivo, ao contrário, é devolvido mesmo com cobertura incompleta, porque **consta** continua verdadeiro.

**Sem dossiê, a chave `data` não vem `null`: ela não existe.** Vale enquanto a fonte processa, quando ela não responde na janela e quando o documento não é encontrado. Um leitor que testa `check.data === null` não pega nenhum desses casos: teste a **presença** da chave, e ramifique por `outcome`.

Aqui o `data` **é** o dossiê da fonte, sem envelope nenhum: não há `flagged`, não há `matched_by`, não há bloco nosso em volta. Valem as mesmas duas regras da [consulta cadastral](https://unifokal.com/docs/modulos/cadastrais#modulos-cadastrais): tiramos só `pacoteUsado`, `saldo` e `consultaID`, e **o conjunto de chaves é o do pacote**, não uma lista fechada nossa. Todo dossiê traz o bloco comum `status`, `estado`, `cpf` e `nome`, e os dois primeiros enganam: `status` é o indicador de sucesso da **chamada** (nunca a situação do titular) e `estado` é o estágio do **processamento** (`"concluido"`; a primeira chamada da fonte real pode responder `"processando"`, que tratamos como indisponibilidade temporária). Os valores em dinheiro vêm em **reais com decimais**, e não em centavos como os saldos do topo do webhook.

As duas regras do parágrafo acima valem para os **cinco** de CPF, que repassam o dossiê do fornecedor. O `credito_pgfn` é a exceção nas duas: o payload dele é nosso, então não existe o bloco comum `status`, `estado`, `cpf` e `nome`, e o dinheiro sai em **centavos inteiros**, em `valor_total_centavos`.

```
// credito_dividas: dossiê concluído (informacional, não reprova)
{ "module": "credito_dividas", "passed": true, "outcome": "approved", "score": 100,
  "data": { "score": 742, "classe": "B", "renda_presumida": 4200.5, "possui_debitos": true,
            "total_negativado": 1234.56,
            "negativacoes": [ { "credor": "BANCO EXEMPLO S.A.", "valor": 1234.56,
                                "data": "2026-03-10", "tipo": "Pendencia financeira" } ] } }

// credito_pgfn: Dívida Ativa da União do CNPJ, da NOSSA base do dado aberto da PGFN.
// Os três papéis vêm separados: corresponsável costuma ser dívida de OUTRA empresa.
// Valor em CENTAVOS aqui (só este módulo), e sem número de inscrição: a base agrega
// por documento e por papel. "competencia" é o mês do arquivo publicado pelo governo,
// e é a MAIS ANTIGA entre as fontes que responderam: o carimbo é o do elo mais velho.
{ "module": "credito_pgfn", "passed": true, "outcome": "approved", "score": 100,
  "data": { "fonte": "pgfn_dados_abertos", "documento": "cnpj",
            "fonte_oficial": "Divida Ativa da Uniao, dado aberto oficial da PGFN",
            "competencia": "202606", "atualizado_em": "2026-09-02T03:14:00.000Z",
            // uma entrada POR LIVRO da PGFN. "status" é um destes sete: "consultada",
            // "desabilitada", "nunca_ingerida", "ingestao_vencida", "competencia_vencida",
            // "vazia" (ingeriu e veio sem linha) e "ausente" (o livro nem está na base).
            // SÓ "consultada" conta como olhada: qualquer outro degrada a resposta para
            // pending, com o motivo, e a checagem não é cobrada. "ingerida_em" é quando
            // NÓS ingerimos aquele livro, e não a competência dele: os dois são diferentes
            // e confundi-los é ler frescor de ingestão como frescor de dado.
            // A cobertura vem ORDENADA POR fonte, sempre, e nao na ordem de tamanho dos livros:
            // ordem estavel e o que permite comparar duas respostas sem reordenar.
            "cobertura": [ { "fonte": "pgfn_fgts", "livro": "FGTS", "status": "consultada",
                             "competencia": "202606",
                             "ingerida_em": "2026-09-02T01:12:00.000Z", "registros": 541698 },
                           { "fonte": "pgfn_prev", "livro": "PREV", "status": "consultada",
                             "competencia": "202606",
                             "ingerida_em": "2026-09-02T01:40:00.000Z", "registros": 3606529 },
                           { "fonte": "pgfn_sida", "livro": "SIDA", "status": "consultada",
                             "competencia": "202606",
                             "ingerida_em": "2026-09-02T03:14:00.000Z", "registros": 45553971 } ],
            // "situacoes" conta a SITUACAO_INSCRICAO (INSCRITA, AJUIZADA, PARCELADA...) e
            // "tipos_situacao" conta o TIPO_SITUACAO_INSCRICAO ("Em cobranca", "Garantia",
            // "Suspenso por decisao judicial", "Em negociacao", "Beneficio Fiscal"). São
            // eixos diferentes: exigibilidade suspensa por juiz não é inadimplência, e
            // reportar as duas do mesmo jeito seria menos honesto. "livros" diz de quais
            // livros da PGFN as inscrições deste papel vieram.
            "principal": { "consta": true, "inscricoes": 4, "valor_total_centavos": 345000,
                           "ajuizadas": 1,
                           "situacoes": { "INSCRITA": 3, "AJUIZADA": 1 },
                           "tipos_situacao": { "Em cobranca": 3, "Garantia": 1 },
                           "primeira_inscricao": "2019-04-18",
                           "ultima_inscricao": "2025-11-30",
                           "ufs": ["SP"], "livros": ["PREV", "SIDA"] },
            // corresponsavel e solidario têm a MESMA forma do principal, zerados quando não
            // consta: as duas datas vêm null, nunca ausentes e nunca com data sentinela.
            "corresponsavel": { "consta": false, "inscricoes": 0, "valor_total_centavos": 0,
                                "ajuizadas": 0, "situacoes": {}, "tipos_situacao": {},
                                "primeira_inscricao": null, "ultima_inscricao": null,
                                "ufs": [], "livros": [] },
            "solidario": { "consta": false, "inscricoes": 0, "valor_total_centavos": 0,
                           "ajuizadas": 0, "situacoes": {}, "tipos_situacao": {},
                           "primeira_inscricao": null, "ultima_inscricao": null,
                           "ufs": [], "livros": [] } } }

// credito_scr: o retrato do SCR do Banco Central
{ "module": "credito_scr", "passed": true, "outcome": "approved", "score": 100,
  "data": { "status": 1, "estado": "concluido",       // sucesso da chamada / estágio do processamento
            "cpf": "12345678900", "nome": "TITULAR MOCK DA SILVA",
            "data_base": "31/07/2026",                // a competência do retrato
            "instituicoes": 3, "operacoes": 5,
            "carteira_credito_total": 18500.0,        // REAIS com decimais, não centavos
            "vencido_ate_90": 0, "prejuizo": 0 } }

// credito_protestos: protestos em cartório (Cenprot)
{ "module": "credito_protestos", "passed": true, "outcome": "approved", "score": 100,
  "data": { "status": 1, "estado": "concluido",
            "cpf": "12345678900", "nome": "TITULAR MOCK DA SILVA",
            "total_protestos": 1,
            "protestos": [ { "cartorio": "2º Tabelionato de Protesto", "uf": "SP",
                             "valor": 980.0, "data": "05/01/2026" } ] } }

// credito_cadin: inscrição no CADIN. "inscrito": false com "registros": [] é nada consta.
{ "module": "credito_cadin", "passed": true, "outcome": "approved", "score": 100,
  "data": { "status": 1, "estado": "concluido",
            "cpf": "12345678900", "nome": "TITULAR MOCK DA SILVA",
            "inscrito": false, "registros": [] } }

// credito_boavista: score e pendências da Boa Vista SCPC
{ "module": "credito_boavista", "passed": true, "outcome": "approved", "score": 100,
  "data": { "status": 1, "estado": "concluido",
            "cpf": "12345678900", "nome": "TITULAR MOCK DA SILVA",
            "score_boavista": 688, "possui_pendencias": false, "pendencias": [],
            "consultas_ultimos_90d": 2 } }

// ainda processando, ou fonte que não respondeu -> pending, e repare: NÃO existe a chave "data"
{ "module": "credito_scr", "passed": null, "outcome": "pending", "score": 0 }
```

Em sandbox o dossiê vem pronto (sem os dois tempos da fonte real): o desfecho segue o sufixo do documento, como no resto do ambiente de testes.

## Monitoramento contínuo

<https://unifokal.com/docs/modulos/monitoring-aml>

### Monitoramento contínuo

O módulo `monitoring_aml` é o único que **não roda durante uma verificação**. Depois que o titular foi aprovado, ele fica sob vigilância nas mesmas listas de PEP e de sanções do `pep_sancoes`, e quando o nome dele **aparece numa lista em que não constava no onboarding**, nós criamos uma verificação de acompanhamento e mandamos o webhook. Nenhuma captura nova, nenhum passo para o titular. A cobrança é **por titular monitorado por mês**, e os alertas entre as reconciliações já estão inclusos.

**Disponível desde 11 de setembro de 2026**, a 10 centavos por titular por mês. Ele aparece na tabela de preços e no `GET /v1/capabilities` com preço e status `available`.

**Atenção a como ele se liga, porque não é como os outros: ele não é item do array `modules` de um flow**, e pedir isso continua sendo recusado. Quem liga o monitoramento é o campo `monitoring_enabled` do flow, em `POST /v1/flows` e em `PATCH /v1/flows/{id}`, ou o bloco `monitoring` da criação da sessão. Esses campos deixaram de responder `422 monitoring_unavailable`: agora são aceitos. A razão de ele ficar fora da lista de módulos é que ele não é etapa da verificação: nada dele roda enquanto o titular está na jornada.

! **O alerta chega num evento próprio, `verification.monitoring`, e não em `verification.completed`.** Ele é gerado pelo nosso vigia, não por uma jornada que alguém percorreu. Se o seu handler só trata `verification.completed`, o alerta passa despercebido; e se ele guarda "a última verificação por `reference_id`", o alerta sobrescreve o resultado do onboarding. Trate o evento pelo nome.

```
// monitoring_aml: o titular apareceu numa lista DEPOIS do onboarding
{ "module": "monitoring_aml", "passed": false, "outcome": "failed", "score": 40,
  "data": { "monitoring": {
      "flagged": true,
      "reason": "monitoring_new_listing",     // monitoring_new_listing | monitoring_clear
      "trigger": "delta",                     // delta (lista mudou) | reconcile (varredura periódica)
      // SÓ o que MUDOU nesta passagem. Não é a lista de hits do titular.
      "changes": [ { "source": "ofac_sdn", "list": "OFAC_SDN", "matched_by": "name",
                     "strong": false, "similarity": 0.82, "precision": 0.82,
                     "listed_at": "2026-01-02", "left_at": null, "current": true,
                     "entry_ref": "ofac_sdn:8f31c2a0" } ],
      "hits_total": 1,                        // o total da triagem, novos E antigos
      "origin_verification_id": "ver_2f8c1a90" } } }   // a verificação que o inscreveu
// repare no que NÃO está aqui: "dataset_versions". No trilho "delta" a chave não viaja.

// o MESMO alerta, achado pela varredura periódica. Aí sim a chave de frescor vem junto.
{ "module": "monitoring_aml", "passed": false, "outcome": "failed", "score": 40,
  "data": { "monitoring": {
      "flagged": true, "reason": "monitoring_new_listing", "trigger": "reconcile",
      "changes": [ { "source": "cgu_pep", "list": "PEP", "matched_by": "document",
                     "strong": true, "similarity": 1, "precision": 1,
                     "listed_at": "2026-08-30", "left_at": null, "current": true,
                     "entry_ref": "cgu_pep:8831" } ],
      "hits_total": 2,
      "origin_verification_id": "ver_2f8c1a90",
      "dataset_versions": { "cgu_pep": { "ingested_at": "2026-09-05T03:00:00Z",
                                         "age_hours": 6, "stale": false,
                                         "disabled": false } } } } }
```

**`changes` é o delta, e `hits_total` é o total.** Os dois quase nunca batem, e isso é o desenho: o alerta existe para dizer **o que mudou**, não para reenviar a triagem inteira toda vez. Um titular com três hits antigos e um novo chega com `changes` de tamanho 1 e `hits_total` 4. `origin_verification_id` é a verificação de onboarding que inscreveu o titular, e é por ela que você liga o alerta ao cadastro no seu lado. `dataset_versions` é **opcional de verdade**: a chave só aparece na varredura periódica, e some no alerta em tempo real.

**Você só recebe evento quando algo mudou.** A varredura que não encontra novidade nenhuma não emite webhook: ela registra internamente que o titular continua limpo e segue. Ou seja, não existe um "pulso" periódico de tranquilidade chegando no seu endpoint, e silêncio aqui significa **nada mudou**. Se você precisa provar diligência continuada numa data específica, a fonte disso é o painel, não a ausência de webhook.

**Avisos de ciclo de vida.** Além do alerta, o mesmo `verification.monitoring` carrega os seis avisos de que a assinatura do titular mudou de estado. Eles chegam como verificação `failed`, **sem checks e sem cobrança**, e o que os distingue é o `decision_reason`: `monitor_paused_no_credits` e `monitor_paused_source_unavailable` (a varredura parou, e retoma sozinha), `monitor_resumed` (voltou), `monitor_ended_window` (a janela acabou), `monitor_ended_erased` (o titular foi apagado a pedido dele) e `monitor_ended_client` (você encerrou). Tratar esses seis como alerta faria a sua fila de revisão encher de eventos que não são achado nenhum.

**A carteira, a janela de cada titular e o encerramento ficam no painel**, em Monitoramento: é lá que você vê quem está sendo verificado, até quando, quando foi a última varredura de cada um, e é de lá que se encerra o monitoramento de alguém.

O alerta **nunca decide**: ele sai sempre como revisão, com a evidência minimizada, e quem julga é você. E vale a mesma minimização do `pep_sancoes`: hit por nome traz fonte, lista, scores, datas e uma referência opaca, sem nome, documento ou texto livre do terceiro.

## Monitoramento PLD/FT

<https://unifokal.com/docs/modulos/pld-monitor>

### Monitoramento PLD/FT

! **Ainda não está aberto para venda.** O módulo aparece na [tabela de preços](https://unifokal.com/precos) com preço e com o selo "Em breve". O contrato abaixo é o que o backend já emite, para você planejar a integração.

! **A cobrança é por pessoa monitorada no mês.** Quem teve evento enviado no mês civil entra uma vez na conta, não importa quantas transações ou alertas gerou. A fila, o caso, o dossiê e o rascunho da comunicação não têm custo extra.

O módulo `pld_monitor` seleciona **operações e situações pela relação da norma do seu setor**, sobre as transações que você envia pela rota de ingestão, e cada alerta nasce com o artigo, o prazo legal do seu regime e, quando a norma tem um, o código de enquadramento do Siscoaf. As regras ligadas e os parâmetros são os da **política que a sua organização aprovou**. Como ligar, os campos do evento e o perfil do cliente estão em [PLD/FT pela regra da norma](https://unifokal.com/docs/pld-ft#pld-ft).

**O alerta não é uma verificação.** Ele é informação sigilosa (Lei 9.613/1998, art. 11, II) e mora no painel de PLD/FT, lido só por owner e admin, com cada leitura registrada. Por isso ele não aparece em `GET /v1/verifications` nem no `verification.completed`: o seu sistema pode ser avisado pelo evento `pld.alert.created`, com carga mínima, quando a sua política liga o aviso por webhook (ele nasce desligado), e o caso se trabalha no painel.

**O flow é exclusivo e não abre sessão.** Um flow com `pld_monitor` não aceita outro módulo e não monta widget: criar sessão de verificação nele é `422 session_not_supported`. O flow só recebe eventos, e o webhook dele é o destino do aviso de alerta.

**O único check deste módulo é o do faturamento.** Uma vez por dia, quando há pessoa nova na conta do mês, nasce uma verificação de sistema, sem titular e sem webhook, com o check abaixo: a janela, quantas pessoas entraram na conta e o preço unitário. É ela que aparece no seu extrato.

```
// pld_monitor no check_details da verificação diária de faturamento
{ "module": "pld_monitor", "passed": true, "outcome": "approved", "score": 100,
  "data": {
    "pld_monitor": {
      "window": { "from": "2026-09-23T03:00:00.000Z", "to": "2026-09-24T03:00:00.000Z" },
      "subjects_charged": 128,
      "unit_cents": 5,
      "charged_cents": 640
    }
  } }
```

**Sem saldo, o monitoramento não para.** A seleção é dever legal seu e continua rodando; a cobrança do dia fica devida e é debitada assim que o saldo voltar, sem cobrar a mesma pessoa duas vezes no mês.

## Classificação de risco PLD/FT

<https://unifokal.com/docs/modulos/pld-risco>

### Classificação de risco PLD/FT

! **Ainda não está aberto para venda.** O módulo aparece na [tabela de preços](https://unifokal.com/precos) com preço e com o selo "Em breve". O contrato abaixo é o que o backend já emite, para você planejar a integração.

O módulo `pld_risco` classifica o **risco do cliente na própria verificação**, pela matriz da sua política, e devolve a faixa (`baixo`, `medio` ou `alto`), os fatores que pesaram, o que ficou sem avaliar e as **medidas de diligência reforçada** que a norma do seu regime pede, cada uma com o artigo. A data da próxima revisão cadastral já sai marcada. O perfil do cliente que você envia em `pld_profile` entra na conta; veja [PLD/FT pela regra da norma](https://unifokal.com/docs/pld-ft#pld-ft).

**A classificação nunca reprova ninguém.** Faixa alta leva a verificação para revisão com o motivo `pld_edd_required` e as medidas na mão do seu analista, e nunca para `denied`. O bloco nunca chega ao titular: o motivo público que o widget mostra é o de revisão genérica.

```
// pld_risco no check_details: faixa alta, com diligência reforçada
{ "module": "pld_risco", "passed": null, "outcome": "pending", "score": 40,
  "data": {
    "pld_risco": {
      "classified": true,
      "reason": "edd_required",
      "risk_class": "alto",
      "risk_label": "Alto",
      "edd_required": true,
      "factors": [{ "code": "pep", "origin": "verificacao" }],
      "factors_not_evaluated": [
        { "code": "midia_adversa", "reason": "modulo_fora_do_flow" },
        { "code": "atividade_de_risco", "reason": "perfil_nao_informado" }
      ],
      "measures": [
        { "code": "bcb_avaliacao_interesse_nivel_superior",
          "basis": "Circular BCB 3.978/2020, art. 19, par. 2, III, e par. 3" },
        { "code": "bcb_informacoes_adicionais",
          "basis": "Circular BCB 3.978/2020, art. 18, par. 3" }
      ],
      "measures_by_policy": false,
      "pep_family_definition": null,
      "review_months": 12,
      "next_review_due_at": "2027-09-23T12:00:00.000Z",
      "policy_version": 1,
      "regime": "circular_3978"
    }
  } }
```

**Quando não há classificação a entregar.** Se a triagem de listas da mesma verificação ainda não respondeu, o módulo devolve `classified: false` com o motivo, sem faixa, e **não é cobrado** naquela verificação.

```
// pld_risco sem classificação: a triagem de listas da verificação ainda não respondeu
{ "module": "pld_risco", "passed": null, "outcome": "pending", "score": 0,
  "data": {
    "pld_risco": {
      "classified": false, "reason": "screening_unresolved", "risk_class": null, "risk_label": null,
      "edd_required": false, "factors": [], "factors_not_evaluated": [], "measures": [],
      "measures_by_policy": false, "pep_family_definition": null, "review_months": null,
      "next_review_due_at": null, "policy_version": null, "regime": null
    }
  } }
```

O módulo exige o `pep_sancoes` no mesmo flow: sem a triagem de PEP e de sanções não existe classificação honesta a fazer.

## Vínculos, listas restritivas e processos

<https://unifokal.com/docs/modulos/compliance-vinculos>

### Vínculos, listas restritivas e processos

Seis módulos de **compliance** aprofundam o screening além do titular: `pep_parentes` (parentes de pessoa exposta politicamente), `impedidos_vinculos` (vínculo familiar com impedido de apostar, a fase 2 da Lei 14.790/2023, Art. 26), `pep_lista_restritiva` (listas restritivas), `antecedentes_estaduais`, `processos_judiciais` e `scr_bacen` (retrato de endividamento no SCR). Todos partem do **CPF lido do documento**, e exigem Documento, Face Match e Liveness no mesmo flow pela mesma razão do `pep_sancoes`: nada aqui é consultado a partir de um número digitado. A consulta à fonte é **pelo CPF**; o **nome** só entra no `impedidos_vinculos`, e ainda assim como nome do **parente**, dentro do nosso motor local de vedação.

**Venda pausada hoje, os seis.** Eles aparecem na tabela de preços e no `GET /v1/capabilities` com preço e status `coming_soon`, e não podem ser ligados num flow enquanto a credencial do fornecedor não existir na conta. O estado vem do catálogo vivo, e esta página acompanha. O payload abaixo é o contrato que já está implementado e que passa a valer no dia da abertura.

! **Nunca leia só `flagged` nestes módulos.** Quando a fonte não é chamada (credencial ausente, portão de identidade fechado, consulta adiada), o check sai `pending` **e ainda assim traz `data`**, preenchido com os valores neutros: `flagged: false`, `matched_by: "none"`, `aggregates: null` e o bloco da fonte `null`. Isso é "não perguntamos", e é indistinguível de "nada consta" se você olhar só a flag. Ramifique por `outcome` primeiro, sempre.

**Outro campo que promete mais do que entrega:** nestes módulos `matched_by: "document"` é **derivado de `flagged`**, não é a prova de que o casamento se deu por CPF. Ele diz "houve resultado", e não "casou pelo documento". A única exceção é o `impedidos_vinculos`, onde ele é medido de verdade e distingue `document`, `document_partial` e `name`. E ele **nem existe** em dois dos seis: `antecedentes_estaduais` e `scr_bacen` não emitem a chave. Onde ela aparece fora do `impedidos_vinculos`, trate-a como sinônimo de `flagged`.

```
// pep_parentes: parente PEP encontrado. "parentes" é o dossiê da fonte passando por nós.
{ "module": "pep_parentes", "passed": false, "outcome": "failed", "score": 40,
  "data": { "flagged": true,
            "matched_by": "document",   // derivado de flagged, NÃO é prova de match por CPF
            "reason": null,
            "parentes": { "parentescosPEP": [
              { "nome": "MARIA SILVA", "cpf": "***456789**", "grauParentesco": "MAE",
                "pep": { "nome": "JOSE SILVA", "funcao": "DEPUTADO FEDERAL",
                         "orgao": "CAMARA MOCK", "nivel": "FEDERAL" } } ] } } }

// pep_lista_restritiva: o bloco "listas" é a resposta da fonte, como ela manda.
{ "module": "pep_lista_restritiva", "passed": false, "outcome": "failed", "score": 40,
  "data": { "flagged": true, "matched_by": "document",
            "listas": { "listas": [ { "lista": "LISTA RESTRITIVA MOCK", "nome": "JOAO SILVA",
                                      "origem": "MOCK", "dataInclusao": "2025-03-01" } ] } } }

// antecedentes_estaduais: repare no tri-estado de "nada_consta" e na cobertura FIXA de UFs.
{ "module": "antecedentes_estaduais", "passed": false, "outcome": "failed", "score": 40,
  "data": { "nada_consta": false,      // true | false | null. null = a fonte NÃO afirmou nada.
            "flagged": true,
            "cobertura_uf": ["CE", "MG", "MT", "RS"],   // as UFs cobertas hoje, e só elas
            "antecedentes": { "nadaConsta": false,
                              "ocorrencias": [ { "uf": "MG", "tribunal": "TJMG",
                                                 "classe": "Acao Penal", "ano": 2024 } ] } } }

// processos_judiciais: "encontrados" é contado por nós sobre a resposta, não é campo da fonte.
{ "module": "processos_judiciais", "passed": false, "outcome": "failed", "score": 40,
  "data": { "flagged": true, "matched_by": "document", "encontrados": 2,
            "processos": { "totalProcessos": 2,
                           "processos": [ { "numero": "0001234-56.2024.8.13.0000",
                                            "tribunal": "TJMG", "classe": "Execucao de Titulo",
                                            "polo": "passivo", "status": "ativo" } ] } } }

// scr_bacen: INFORMACIONAL. O dossiê é o produto, e "flagged" é sempre false.
{ "module": "scr_bacen", "passed": true, "outcome": "approved", "score": 100,
  "data": { "flagged": false,
            "scr": { "dataBase": "2026-07", "quantidadeInstituicoes": 1,
                     "quantidadeOperacoes": 2,
                     "carteira": { "vencido": 0, "aVencer": 8765.43 } } } }
```

Duas leituras que evitam conclusão errada. `nada_consta`, no `antecedentes_estaduais`, tem **três** estados e não dois: `false` quando há ocorrência, `true` só quando a fonte **afirma** que nada consta, e `null` quando não houve ocorrência e a fonte também não afirmou nada. Campo ausente na resposta **nunca** vira atestado nosso. E `scr_bacen.flagged` é **sempre `false`**, por construção: o módulo é informacional, o produto dele é o retrato de endividamento, e não existe caminho no código que o marque. Não escreva alerta em cima dessa flag.

O `impedidos_vinculos` é o mais denso dos seis, porque ele precisa provar **duas** coberturas ao mesmo tempo: de onde veio o **grafo familiar** (`graph_coverage`, hoje sempre `"pep_relatives"`, ou seja a fonte de parentesco de PEP e mais nada) e quais **bases de vedação** sustentaram a triagem de cada parente (`coverage` e `dataset_versions`, as mesmas do módulo [Impedidos de apostar](https://unifokal.com/docs/modulos/impedidos-apostar#modulo-impedidos-apostar)). E ele conta os parentes de forma auditável: `relatives_total` é quanto a fonte devolveu, `relatives_screened` é quanto o motor de fato respondeu, e `relatives_unscreened` agrupa **por motivo** quem ficou de fora. Essa lista tem vocabulário fechado de seis valores: `degree_unknown`, `degree_out_of_scope`, `document_missing`, `name_missing`, `screening_indeterminate` e `over_cap`. Repare que `relatives_total` menos `relatives_screened` **não** é o tamanho dessa lista: ela é agrupada, e cada item traz o próprio `count`.

```
// impedidos_vinculos: parente de 1o grau na base de impedidos -> review COM evidência
{ "module": "impedidos_vinculos", "passed": false, "outcome": "failed", "score": 40,
  "data": { "is_restricted": true, "flagged": true,
            "matched_by": "name",            // aqui ele é MEDIDO: document | document_partial | name
            "reason": "family_link_restricted",
            "restrictions": [ { "type": "vinculo_familiar_1g", "link_level": 1, "sport": null,
                                "entity": null, "source": "ptransp_servidores_reg",
                                "matched_by": "name",
                                "similarity": 91,       // 0..100, NÃO 0..1
                                "listed_at": null, "left_at": null,
                                "birth_date_mismatch": false,
                                "entry_ref": "ptransp_servidores_reg:e_a8954835" } ],
            "restrictions_total": 1, "restrictions_truncated": false,
            "aggregates": { "is_restricted": true,
                            "restriction_types": ["vinculo_familiar_1g"],
                            "strongest_match": "name" },
            "graph_coverage": "pep_relatives",
            "relatives_total": 2, "relatives_screened": 2, "relatives_unscreened": [],
            "coverage": ["ptransp_servidores_reg"], "coverage_degraded": [],
            "dataset_versions": { "ptransp_servidores_reg": { "ingested_at": "2026-09-01T03:00:00Z",
                                                              "age_hours": 4, "stale": false,
                                                              "disabled": false },
                                  "cbf_bid":      { "ingested_at": null, "age_hours": null,
                                                    "stale": false, "disabled": true },
                                  "cbf_arbitros": { "ingested_at": null, "age_hours": null,
                                                    "stale": false, "disabled": true } },
            "name_source": "ocr" } }

// o caminho que MAIS importa: o parente existe e NÃO foi rastreável.
// Não é "nada consta": é "não deu para perguntar", e a contagem diz por quê.
{ "module": "impedidos_vinculos", "passed": null, "outcome": "pending", "score": 0,
  "data": { "is_restricted": false, "flagged": false, "matched_by": "none",
            "reason": "relatives_unscreenable",
            "restrictions": [], "restrictions_total": 0, "restrictions_truncated": false,
            "aggregates": null,
            "graph_coverage": "pep_relatives",
            "relatives_total": 1, "relatives_screened": 0,
            "relatives_unscreened": [ { "reason": "degree_unknown", "count": 1 } ],
            // vazios porque o motor local NEM FOI CHAMADO: sem grau de parentesco não há o
            // que triar, então não há cobertura a declarar. Não confunda com base vencida.
            "coverage": [], "coverage_degraded": [], "dataset_versions": null,
            "name_source": null } }   // null porque nem chegamos a usar um nome
```

**Nenhum dos seis reprova sozinho**, pela mesma regra do `pep_sancoes`: um resultado é candidato, vai para `review` com a evidência no webhook, e a sua análise decide. E vale a mesma minimização: o que sai do `impedidos_vinculos` sobre o parente é **contagem e restrição minimizada**, nunca nome, CPF ou datas de terceiro. O nome e o documento do parente entram na consulta e morrem lá.

! **No sandbox, o desfecho destes módulos não acompanha o resultado, e isso é deliberado do ambiente de testes.** Os sufixos `88` (hit) e `77` (indeterminado, só no `impedidos_vinculos`) escolhem o **dado**, mas o `outcome` continua vindo da tabela universal do sandbox, onde só `00`, `01` e `02` mudam o desfecho. Ou seja: em sandbox você vê `outcome: "approved"` com `flagged: true`. Em produção o mesmo hit sai `failed` e a verificação vai a revisão. Use os sufixos para exercitar o **parser**, e `02` para exercitar o desfecho.

## Background check e sanções

<https://unifokal.com/docs/modulos/background>

### Background check e sanções

Três módulos de **compliance** consultam o **CPF lido do documento** em fontes de sanção e antecedentes: `ofac_realtime` (lista OFAC SDN de sanções internacionais, com atestação datada da consulta e sem certidão em PDF), `antecedentes_cac` (Certidão de Antecedentes Criminais CAC/SINIC) e `mandados_interpol` (mandados de busca e apreensão BNMP/CNJ e alertas Interpol). Exigem Verificação de Identidade, Face Match e Liveness, pela mesma razão do PEP: o CPF consultado vem do documento, nunca digitado.

**Venda pausada hoje**: os três aparecem na tabela de preços e no `GET /v1/capabilities` com preço e status `coming_soon`, e não podem ser ligados num flow enquanto a entrega da fonte não for provada em produção. O estado vem do catálogo vivo, e esta página acompanha.

Como o `pep_sancoes`, **nunca reprovam sozinhos**: um hit sinaliza com evidência para **revisão humana** (nunca decline automático). Se a fonte não responder (indisponível), o módulo fica `pending` e **não é cobrado** (o`ofac_realtime` cobra sempre que a fonte responde, inclusive em nada consta, que é o produto: a evidência auditável).

**Sobre a certidão em PDF, e leia isto antes de programar contra o campo.** A certidão do `antecedentes_cac` e do `mandados_interpol` vive no S3, e o payload **nunca** leva o conteúdo em base64 nem a chave do objeto no bucket. O que ele leva, no campo `pdf_media_id`, é o **identificador da mídia** da certidão na verificação. Para abrir o PDF, chame `GET /v1/verifications/{id}/media` com a sessão do painel (papel owner ou admin): a lista devolve o item com esse mesmo `id`, `kind` igual a `compliance_certificate` e uma URL pré-assinada de vida curta, e o acesso fica registrado como toda visualização de mídia. A certidão segue o prazo de retenção da mídia da verificação e é apagada junto com ela, no pedido do titular, no vencimento do prazo e no encerramento da conta. `null` significa que não veio PDF da fonte, ou que o envio ao S3 falhou sem derrubar o veredito, que já está em `nada_consta`.

O `ofac_realtime` é um **add-on** que se soma ao `pep_sancoes` (traz a atestação datada da consulta e a busca por documento/passaporte mundial), não o substitui: em um flow com os dois, a afirmação OFAC aparece nas duas camadas.

```
// ofac_realtime: nada consta (aprovado, cobrado como evidência auditável)
{ "module": "ofac_realtime", "passed": true, "outcome": "approved", "score": 100,
  "data": { "sancionado": false, "flagged": false, "matched_by": "none",
            "encontrados": 0, "resultado": [], "lista_atualizada_em": "2026-06-15" } }

// ofac_realtime: correspondência na SDN (review COM evidência, NUNCA decline)
{ "module": "ofac_realtime", "passed": false, "outcome": "failed", "score": 40,
  "data": { "sancionado": true, "flagged": true, "matched_by": "document", "encontrados": 1,
            "resultado": [ { "uid": "SDN-12345", "nome": "FULANO DE TAL", "tipo": "Individual",
                             "lista": "SDN List", "programas": ["SDGT"], "nacionalidade": "BR" } ],
            "lista_atualizada_em": "2026-06-15" } }

// antecedentes_cac: certidão em PDF no S3 (pdf_media_id = o id da mídia, NUNCA base64)
{ "module": "antecedentes_cac", "passed": true, "outcome": "approved", "score": 100,
  "data": { "nada_consta": true, "flagged": false, "nr_protocolo": "SINIC-2026-000123",
            "pdf_media_id": "med_4Tz8" } }

// mandados_interpol: nada consta (aprovado)
{ "module": "mandados_interpol", "passed": true, "outcome": "approved", "score": 100,
  "data": { "mandados": [], "interpol": [], "nada_consta": true, "flagged": false,
            "nr_protocolo": "BNMP-2026-000456",
            "pdf_media_id": "med_9Qx2" } }

// mandados_interpol: mandado ATIVO no BNMP -> review COM evidência, NUNCA decline
// os arrays "mandados" e "interpol" são passthrough da fonte: as chaves de cada item
// vêm como o BNMP/CNJ e a Interpol as publicam, e podem variar entre registros.
{ "module": "mandados_interpol", "passed": false, "outcome": "failed", "score": 40,
  "data": { "mandados": [ { "id": "bnmp-8831", "tribunal": "TJSP",
                            "tipoPeca": "Mandado de Prisão", "status": "ativo",
                            "dataExpedicao": "2025-05-01", "recaptura": false } ],
            "interpol": [], "nada_consta": false, "flagged": true,
            "nr_protocolo": "BNMP-2026-000456", "pdf_media_id": null } }

// mandados_interpol: fonte indisponível -> pending, sem cobrança e SEM data
{ "module": "mandados_interpol", "passed": null, "outcome": "pending", "score": null, "data": {} }
```

Em sandbox o desfecho segue o sufixo do documento, como no resto do ambiente de testes, e a fonte paga nunca é chamada.

MUDANÇAS

## Changelog da API

<https://unifokal.com/docs/changelog>

Toda mudança do contrato público, da mais recente para a mais antiga, classificada em quatro classes e nada além delas. O que define cada classe está na [política de versão](https://unifokal.com/docs/versionamento). Última mudança publicada: **2026-09-29**.

**A API ainda não está em produção.** Enquanto o primeiro ambiente produtivo não sobe, a data de cada item é o dia em que a mudança passou a fazer parte do contrato, e não o dia em que entrou no ar. A [página de status](https://unifokal.com/status) mostra o mesmo fato pelo outro lado: não há medição sendo publicada ainda.

Existe também o mesmo conteúdo legível por máquina em [`/docs/changelog.json`](https://unifokal.com/docs/changelog.json), para você observar mudança sem raspar esta página.

Mudança que quebra

8

Adição compatível

56

Correção

12

Depreciação

2

i **Como datamos.** A data de cada item vem da migration que aplicou a mudança ou do documento interno que registra a entrega, e o item mostra qual das duas. Onde o número da migration usa uma faixa reservada de numeração, que não é data de nada, a data vem do documento. Nenhuma data é estimada: item sem prova não entra nesta lista.

### 2026-09-29

1 mudança

Adição compatível API REST e webhook

#### PLD/FT: transfer e payment seguem a direction da ingestão

No monitoramento de PLD/FT, uma transfer ou um payment com direction in conta como entrada na conta do titular, e com direction out, como saída. Sem direction, os dois continuam contando como saída, como antes. Os outros tipos têm direção própria e o type prevalece: um deposit é sempre entrada, mesmo com direction out. Mande direction nas transferências que o seu cliente recebe, para que as regras de PLD/FT e o rascunho da comunicação leiam cada valor do lado certo.

### 2026-09-28

1 mudança

Adição compatível API REST e webhook

#### Prova de humano: recusas do ato no contrato, data.attestation e o step-up nos SDKs

A criação de sessão lista as recusas que o bloco act e a passkey já davam: act_requires_passkey, passkey_not_enrolled, passkey_bind_needs_authentication, passkey_bind_needs_reproof, act_hostile_char, act_kind_invalid, act_summary_invalid, act_counterparty_invalid, act_amount_invalid e act_currency_invalid; a emissão do link lista as seis do ato, reference_id_charset e pii_shaped_value. Chave desconhecida no bloco act passa a responder 400 unknown_act_key, como já estava publicado, em vez de validation_error. Na sessão com ato, a chave de idempotência é o act.external_id na forma canônica, a mesma que fica carimbada: o mesmo ato com caractere invisível ou espaço nas pontas não abre uma segunda sessão. O verification.completed de um flow com atestado_humano documenta data.attestation, com issued, sd_jwt no formato dc+sd-jwt e o motivo quando não há emissão. Os SDKs TypeScript e Python conhecem data.attestation, os novos códigos e o step_up da resposta de ingestão, com session_id, expires_at, factor e reason.

### 2026-09-27

5 mudanças

Adição compatível API REST e webhook

#### Step-up com prova de humano: data.step_up_of no webhook da sessão de prova

O verification.completed da sessão de prova que o gate transacao abriu traz data.step_up_of, também no modo minimal, com source (o gate que pediu a prova), verification_id (a verificação do pagamento) e event_type (o tipo do evento). É por ele que você liga o desfecho da prova ao pagamento que estava esperando. A verificação do pagamento não muda por causa da prova, e toda outra verificação sai como antes. Os SDKs TypeScript e Python conhecem o campo.

Adição compatível API REST e webhook

#### Comprovante de endereço ganha cep_consistency

No módulo endereco_ocr, o data do check ganha cep_consistency: o CEP lido do comprovante conferido contra uma base de endereços, com ok (o CEP está na base, na mesma UF), uf_divergente (está na base, em outra UF), cep_fora_da_base ou unknown (não houve conferência). Fora da base não quer dizer inexistente. É informação para a sua política e nunca muda o desfecho da verificação.

Adição compatível API REST e webhook

#### Monitoramento transacional: alerta grave nunca retido, com billing.waived

O alerta de severidade alta do transacao_monitor nunca é retido por volume nem por falta de saldo. Quando ele sai acima do volume contratado ou sem saldo, chega sem cobrança, e o data do webhook traz billing com waived igual a above_volume ou no_credit. Alerta cobrado normalmente não traz o bloco. Os SDKs TypeScript e Python conhecem o campo.

Adição compatível API REST e webhook

#### PEP e sanções: resumo pronto no check e cota diária do módulo

O bloco data do check pep_sancoes ganha pep_status (função em exercício e saída da função no último ano, em três ou em cinco anos, com o cargo como a fonte publica só no casamento por documento) e, em aggregates, is_pep, has_sanctions_br, has_sanctions_intl, worst_severity e as janelas separadas sanction_windows e pep_windows. windows continua no payload como a soma das duas, marcado como depreciado. Cada hit de PEP traz exercise_end, o fim do exercício da função, e pep_in_carency passa a contar dele: antes contava do fim da carência e marcava o contrário. Programe contra hits\[\].source, o identificador estável da lista. A criação de sessão em produção pode recusar com 429 module_quota_reached quando a cota diária do módulo da organização acaba, com Retry-After até a meia-noite UTC; a renovação de uma sessão não conta.

Adição compatível API REST e webhook

#### PLD/FT: pld_profile aceita net_worth_cents e declared_at

O bloco opcional pld_profile, na criação de sessão e na ingestão, aceita net_worth_cents, o patrimônio declarado pelo seu cliente em centavos, com o mesmo teto da capacidade financeira, e declared_at, a data AAAA-MM-DD em que ele fez a declaração. Sem declared_at vale a data do recebimento; data no futuro é recusada com 422 pld_profile_invalid, com o campo na mensagem e nunca o valor. Cada declaração recebida fica registrada com a data, o recebimento e a chave que a enviou, e sai na exportação de dados da conta. Os SDKs TypeScript e Python conhecem os campos.

### 2026-09-26

10 mudanças

Adição compatível API REST e webhook

#### Passkey: eventos passkey.bound, passkey.revoked e act.rejected, e o step_up do gate transacional

Módulo novo passkey, com a venda pausada (Em breve). O webhook ganha três tipos de evento: passkey.bound (a chave passou a valer para o titular), passkey.revoked (a chave deixou de valer, inclusive pelo painel e no apagamento) e act.rejected (o titular disse que não reconhece o pedido). Os dois da chave chegam no webhook do flow da verificação que a vinculou, e o act.rejected no da sessão de prova, com corpo mínimo, a mesma assinatura e o mesmo replay. No gate transacional, quando o flow configura o step-up e o veredito é step_up, a resposta traz o bloco step_up: com available true, session_id é a sessão de prova (monte o widget com ela), válida por 600 segundos, e factor diz o fator (passkey, face_reauth ou os dois); com available false, reason diz por quê e você aplica o seu próprio desafio. O resultado da prova chega no webhook da sessão de prova.

Adição compatível API REST e webhook

#### Atestado de pessoa verificada: data.attestation no webhook e o metadado do emissor

Módulo novo atestado_humano, com a venda pausada (Em breve). O webhook ganha data.attestation, também no modo minimal, com issued e, quando emitido, id, format (dc+sd-jwt), sd_jwt (o SD-JWT VC compacto), vct, kid, issued_at, expires_at e disclosable (as informações que podem ser mostradas em separado: sub, prova_de_vida, unica_no_servico e maioridade). O bloco do módulo em check_details traz os mesmos dados sem o token. Não emitido traz reason: verification_not_approved, manual_decision, attestation_sandbox_not_issued, attestation_signer_not_configured, liveness_not_approved ou attestation_issue_failed. As chaves públicas de produção ficam em https://unifokal.com/.well-known/jwt-vc-issuer e nos SDKs (verifyHumanAttestation e presentHumanAttestation no TypeScript, verify_human_attestation e present_human_attestation no Python). O painel ganha POST /v1/dashboard/verifications/{id}/attestation/reissue, que devolve uma emissão nova do mesmo atestado, com os erros attestation_not_issued e attestation_signer_not_configured.

Adição compatível API REST e webhook

#### Confirmação fora de banda: o link com purpose confirmation e o ato

POST /v1/verification-links aceita purpose (verification, o padrão, ou confirmation) e o bloco act (kind, summary, amount_cents, currency, counterparty e external_id, este obrigatório pela API). O link de confirmação vale no máximo 600 segundos, exige um flow com passkey ou face_reauth e a pessoa com o fator, fica um aberto por pessoa (o novo revoga o anterior) e tem teto de cinco por pessoa por hora. A view do link ganha purpose, act_kind e act_digest, e o painel lê o estado ao vivo por GET /v1/verification-links/{id}. O resgate devolve purpose igual a confirmation, e verification.completed ganha data.purpose e data.act com kind, digest e external_id, o mesmo digest da criação. Códigos novos: act_required, act_not_accepted, act_external_id_required, act_hostile_char, act_kind_invalid, act_summary_invalid, act_counterparty_invalid, act_amount_invalid, act_currency_invalid, unknown_act_key, expires_in_too_long_for_confirmation, confirmation_requires_human_proof, no_factor_enrolled, confirmation_rate_limited e confirmation_unavailable; e o link comum passa a recusar na emissão, com os códigos da criação de sessão, o flow de passkey que o resgate recusaria (passkey_not_enrolled, passkey_bind_needs_authentication e passkey_bind_needs_reproof).

Adição compatível API REST e webhook

#### Criação de sessão: 429 attempt_limit_reached para tentativas repetidas da mesma referência

A criação de sessão pode recusar com 429 attempt_limit_reached quando a mesma referência já tentou vezes demais num período recente. A recusa não traz Retry-After, não cria sessão e não cobra. Não repita automaticamente: trate como fim da tentativa daquela pessoa e siga pelo seu atendimento. Os SDKs oficiais já tratam o código como teto que não se repete.

Adição compatível API REST e webhook

#### Novo no sandbox: a resposta parcial do cadastro de CNPJ

No sandbox, o CNPJ de teste terminado em 84 simula a base que responde sem os dados da empresa nos módulos cnpj_socios, cnpj_cadastro, cnpj_receita e cnpj_participacoes. O módulo aprova e o item de check_details sai com data.company null e completeness partial, o mesmo formato que a produção entrega quando a resposta vem incompleta.

Depreciação Widget

#### A handoff_url passa a levar o token no fragmento da URL

A handoff_url devolvida por POST /v1/verification-sessions/{id}/handoff e o QR que o widget mostra passam a levar os parâmetros t e b depois de # (h.html#t=ho\_...&b=...), e não mais depois de ?. O navegador não envia o fragmento ao servidor nem o coloca no Referer, então o token de uso único deixa de passar por log e proxy no caminho. Abra a URL como ela chega, sem remontar. A forma antiga (h.html?t=ho\_...) continua aceita até a data de saída e, nesse período, a página responde com os cabeçalhos Deprecation e Sunset. O SDK React Native já lê as duas formas.

**Sai do ar em 2026-12-31.**

Adição compatível Painel

#### Consulta em lote por planilha, com resultado por arquivo

A consulta avulsa do painel ganha a aba Em lote: um CSV com reference_id,document roda uma consulta por linha, com o mesmo preço, a mesma finalidade e os mesmos limites diários da consulta de um documento. O arquivo só entra se couber no que resta do dia (429 com o código da consulta avulsa) e no saldo (402 insufficient_credit), e o envio pede o segundo fator. O resultado sai num CSV por link de 5 minutos, uma vez, e a guarda de fórmula de todo CSV do painel passa a cobrir também a quebra de linha no começo da célula.

Adição compatível API REST e webhook

#### Comprovante de endereço ganha declared_address_match, e a mídia do painel diz o tipo apurado

No módulo endereco_ocr, o data do check ganha declared_address_match, o CEP e a UF do comprovante contra os endereços que a consulta cadastral de endereços do mesmo fluxo trouxe: cep_match, uf_match, mismatch ou unknown quando falta um dos lados. É informação para você, nunca reprova a verificação e nunca aparece para a pessoa. A rota GET /v1/verifications/{id}/media passa a devolver content_type, o tipo do arquivo apurado pelos bytes na confirmação do upload (null quando não foi apurado).

Adição compatível API REST e webhook

#### Novo módulo representante_pj, pausado, e o motivo representative_review

O módulo representante_pj confere o CPF do representante lido no documento contra o quadro societário que um dos módulos de CNPJ do mesmo fluxo já trouxe, e devolve consta_qsa_administracao, consta_qsa_sem_administracao ou fora_do_qsa em check_details, com indeterminate no resumo quando nada foi conferido. fora_do_qsa nunca reprova sozinho: vai para revisão humana com o motivo novo representative_review. Nenhum desfecho confirma poderes de representação para o ato específico, só a qualificação que o quadro societário publica. O módulo nasce pausado, aparece na tabela de preços com o selo Em breve e o create ou update de flow recusa até a migration de reabertura.

Correção API REST e webhook

#### O 402 da criação de sessão passa a conferir o máximo da verificação

POST /v1/verification-sessions em produção passa a conferir o saldo contra o máximo que a verificação pode custar: o preço do flow mais, quando o flow cobra por sócio ou por empresa da cadeia, essas parcelas no teto. Antes conferia só o preço do flow, e a verificação podia debitar além do saldo. A mensagem do 402 insufficient_credit passa a dizer quanto falta. No módulo Proteção de conta, o evento login_failed deixa de ser cobrado, e um 402 de flow com esse módulo nunca aciona a recarga automática.

### 2026-09-25

8 mudanças

Adição compatível API REST e webhook

#### Documento de identidade: document.expired e a conferência com a MRZ no cpf_ocr

O data do cpf_ocr ganha document.expired, com três estados: true (vencido), false (vigente) e null (não deu para afirmar). Documento vencido é aceito e nunca reprova. Na CIN e no passaporte, o nome, o nascimento e o número impressos são conferidos com a MRZ: quando não batem, a verificação vai para review com decision_reason identity_document_mrz_review, o motivo do módulo é mrz_visual_mismatch e os campos lidos não vêm. No sandbox, o sufixo 41 simula a divergência e o 45 o documento vencido, que também vale no doc_global.

Adição compatível API REST e webhook

#### Teto diário de operações no orçamento, com 429 volume_cap_reached

O orçamento do painel ganha o teto de operações cobradas por dia, para a organização ou para uma chave. Ao alcançá-lo, POST /v1/verification-sessions responde 429 volume_cap_reached com Retry-After até 00:00 UTC. O gasto das sessões criadas por uma chave passa a contar nela também quando a decisão sai depois, e a renovação e o link hospedado herdam a chave. O funil do painel ganha a etapa de verificação exibida ao titular.

Adição compatível API REST e webhook

#### Ingestão de transação aceita direction e instrument

O evento de transação aceita direction (in ou out, do ponto de vista do titular; ausente fica registrado como não informado) e instrument, com kind (card, account, wallet ou pix_key) e ref, um valor opaco seu, como o token do seu PSP. Nunca envie o número do cartão: ref com forma de cartão, CPF, CNPJ, e-mail, telefone ou chave Pix é 422 pii_shaped_value, sem eco do valor. Os dois campos estão no openapi.json e nos tipos dos SDKs TypeScript e Python.

Adição compatível API REST e webhook

#### Novo no sandbox: o desfecho que muda depois, com o segundo webhook

No sandbox, o CPF de teste terminado em 04 termina em review e, cerca de um minuto depois, é decidido de novo como approved; o terminado em 05, como denied. Chegam dois verification.completed, o segundo com um id de evento novo terminado em \_r\<número>, para você testar o webhook de atualização sem esperar um caso real. Se alguém decidir pelo painel antes, vale a decisão da pessoa. O -01 termina em review também em flow com face e liveness, e a doc passou a dizer que o CPF de teste vai no campo document do submit pela API, porque o widget submete sem ele.

Mudança que quebra API REST e webhook

#### O campo account_event.document_hash deixa de ser aceito

POST /v1/verification-sessions com o bloco account_event passa a recusar o campo document_hash com 400 que nomeia o campo e nunca devolve o valor, com hash ou com documento. O campo nao mudava o resultado do gate de conta e deixa de ser guardado. O código account_event_document_not_hashed sai do contrato. Os SDKs TypeScript e Python deixam de oferecer o campo. Para bloquear um titular no gate de conta, use a lista de bloqueio pelo reference_id.

Adição compatível API REST e webhook

#### Bloco de módulo com resposta parcial passa a chegar, marcado com completeness

Quando um módulo de consulta respondeu e parte do dado não veio, o item de check_details traz o bloco data com o que veio, null no que faltou, e o campo completeness com o valor partial. Antes o bloco saía vazio, igual ao de um módulo que nem rodou. Bloco completo não traz o campo. Vale para os módulos de CNPJ, cnpj_ocr, ubo_profundo, beneficios_gov, doclink, pix_device, inscricao_estadual e cndt. E módulo que falhou por dentro passa a sair sem bloco de dado em cpf_ocr, doc_global, fraud_ai e nos níveis de validação de CPF, como o resto do catálogo.

Correção API REST e webhook

#### A resposta 201 da criação de sessão documenta os blocos decision e policy

POST /v1/verification-sessions já devolvia, só quando o flow tem o módulo que os produz, o bloco decision (flow com conta: verdict, risk_score de 0 a 100 em faixas de 20, reasons, verification_id e step_up) e o bloco policy (flow com pep_sancoes ou impedidos_apostar: a política efetiva e a origem de cada valor). Os dois passam a constar no openapi.json e nos tipos dos SDKs TypeScript e Python. reasons é uma lista aberta de códigos: trate o que não conhecer como informativo.

Correção Painel

#### Editar um flow não mata mais em silêncio o link hospedado já enviado

A edição de flow que tornaria impossível abrir um link de verificação ainda válido (por exemplo, trocar os módulos pelo gate transacional) responde 409 flow_has_active_links, com a contagem dos links. O editor oferece revogar os links ativos e salvar em seguida. Link consumido, revogado ou vencido não conta, e a edição que não afeta o resgate segue igual.

### 2026-09-24

4 mudanças

Adição compatível Painel

#### Lista de permissão: menos atrito na prova de vida para o titular que a conta já aprovou

No painel, o proprietário, ou um administrador a quem ele deu a capacidade, concede permissão a partir de uma verificação aprovada, para a conta inteira ou só para um flow, com validade obrigatória de até 180 dias e segundo fator em toda concessão. O efeito é um só: na prova de vida adaptativa, a sessão daquele titular recebe menos atrito. A permissão nunca dispensa identidade: rosto, prova de vida, documento, sanções e lista de bloqueio decidem como sempre, e a lista de bloqueio vence. Nenhuma rota de API cria ou lê a lista: uma chave vazada não consegue permitir ninguém. Na mesma entrega, o painel ganhou a simulação de política sobre o próprio histórico, em Motor, que mostra o que teria mudado nas verificações já decididas antes de qualquer ajuste no flow.

Adição compatível API REST e webhook

#### PLD/FT: módulos pld_monitor e pld_risco, campos cash e counterparty_country, bloco pld_profile

Dois módulos contratáveis de 5 centavos: pld_monitor, que seleciona operações e situações pela relação da norma do seu setor, cobrado por pessoa monitorada no mês, e pld_risco, que classifica o risco do cliente na verificação, cobrado só quando classifica. O evento de transação aceita cash (booleano, padrão false) e counterparty_country (ISO 3166-1 alfa-2 de lista fechada), e a criação de sessão e a ingestão aceitam o bloco opcional pld_profile. Dois códigos 422 entram no contrato: counterparty_country_invalid e pld_profile_invalid, com o índice ou o campo na mensagem e nunca o valor. O pld_monitor é exclusivo no flow e não abre sessão de widget. Os SDKs TypeScript e Python conhecem os campos, o bloco e os códigos.

Adição compatível API REST e webhook

#### Evento pld.alert.created no webhook do flow com pld_monitor

Quando o monitoramento de PLD/FT seleciona uma operação ou situação, o webhook do flow recebe pld.alert.created, com a mesma assinatura, os mesmos headers e as mesmas retentativas dos eventos de verificação. O corpo é mínimo e sigiloso: o id do alerta, a sua reference_id, a severidade, o tipo, os itens da norma, o fundamento, a data da seleção, os vencimentos e o caminho do alerta no painel. Nenhuma evidência, valor ou operação. Não é evento de verificação: o envelope verification.\* não muda, e GET /v1/webhook-events passa a listar pld_alert_created quando a entrega não chegou. Os SDKs ganharam o tipo do aviso e o isPldAlertEvent.

Correção API REST e webhook

#### Link hospedado recusado para flow com assinatura ou custódia da autorização

POST /v1/verification-links responde 422 assinatura_document_required quando o flow tem o módulo assinatura, e 422 consultation_authorization_required quando tem o módulo custodia_autorizacao. Os dois exigem, na criação da sessão, um bloco que o link não carrega (o hash do documento, ou o hash, a versão e o escopo do texto da autorização): a emissão era aceita e todo titular que abria o link recebia erro. A sessão desses flows nasce por POST /v1/verification-sessions, com o bloco. Os códigos já existiam na criação de sessão.

### 2026-09-23

5 mudanças

Adição compatível API REST e webhook

#### Origem da jornada: origin_tag na criação de sessão, ecoado no webhook

POST /v1/verification-sessions aceita o campo opcional origin_tag, um rótulo seu para a origem da jornada (a campanha, o canal, a tela de onde a pessoa veio), de 1 a 64 caracteres entre letras, dígitos, ponto, sublinhado, dois-pontos e hífen. Ele volta exato no 201 da criação e no data.origin_tag do webhook da verificação, e a sessão renovada pelo widget herda o valor. Sem o campo, nenhuma resposta muda. Junto do bloco de transação ele é recusado com 422 origin_tag_not_supported.

Adição compatível API REST e webhook

#### Bloco consultation_authorization na criação de sessão e requires_any_of em capabilities

POST /v1/verification-sessions aceita o bloco consultation_authorization (text_sha256, text_version e scope), obrigatório quando o flow tem o módulo custodia_autorizacao (422 consultation_authorization_required), recusado quando não tem (422 consultation_authorization_not_supported), e fechado: chave desconhecida dentro dele é 400 unknown_consultation_authorization_key. GET /v1/capabilities passa a trazer requires_any_of no módulo que exige ao menos um de um grupo de alternativas no mesmo flow, como a inscrição estadual, que precisa de um dos níveis de consulta de CNPJ. Os SDKs TypeScript e Python conhecem os códigos e o campo. O módulo custodia_autorizacao ainda não está à venda.

Adição compatível API REST e webhook

#### Orçamento diário de gasto configurável no painel, com 429 spend_cap_reached na criação de sessão

POST /v1/verification-sessions passa a declarar 429 spend_cap_reached, com Retry-After em segundos até 00:00 UTC, quando o orçamento diário que a conta configurou no painel (Operação, Orçamento) para a organização ou para a chave acabou no dia. O teto nasce desligado: quem não configurou nada nunca recebe o código. Diminuir vale na hora; aumentar ou desligar vale na virada do dia (UTC). O painel ganhou também o funil de conversão por etapa e por flow, e o registro de requisições da API por 30 dias, só com metadado: identificador, credencial, rota padrão, status e duração, nunca o corpo. Os SDKs TypeScript e Python conhecem o código novo.

Adição compatível API REST e webhook

#### pep_sancoes devolve pending quando a lista não foi consultada

Além de dataset_stale, o módulo passa a responder pending com no_coverage (nenhuma lista do módulo disponível) e com critical_source_disabled (uma lista que a resposta não pode dispensar está fora do ar). Os dois vão para review e não são cobrados, como o dataset_stale já ia. Em cnpj_socios os mesmos motivos aparecem no bloco partner_screening com status unavailable. dataset_versions ganhou record_count, e uma lista não consultada aparece em aggregates.by_list com null em vez de sumir do objeto.

Correção API REST e webhook

#### Flow com o gate transacional é exclusivo: a composição mista é recusada na criação

POST /v1/flows e PATCH /v1/flows/{id} respondem 422 sync_gate_module_exclusive quando o módulo transacao aparece junto de qualquer outro módulo. A composição mista era aceita e não rodava por caminho nenhum: a chamada com o bloco transaction responde na hora e não abre jornada de captura, então os módulos de documento e de biometria daquele flow ficavam parados sem erro. Mantenha o seu KYC no flow que você já tem e crie um flow separado só com o gate; os dois se encontram pelo mesmo reference_id.

### 2026-09-22

1 mudança

Adição compatível API REST e webhook

#### Ingestão de transações: dois códigos de erro entram no contrato e o flow com gate exige o bloco

POST /v1/verification-sessions passa a declarar 422 settlement_status_invalid (liquidação ou reversão enviada com status pending) e 429 daily_ingest_cap_reached (teto diário de eventos por organização, sem Retry-After: o balde é o dia). Num flow que contenha o gate transacional, a chamada sem o bloco transaction responde 422 transaction_required, na criação de sessão e na emissão de link hospedado. Nenhum campo mudou de forma; os SDKs conhecem os dois códigos novos.

### 2026-09-19

3 mudanças

Correção API REST e webhook

#### Endereço de webhook protegido nas respostas de leitura

Em GET /v1/webhook-events e no detalhe de uma entrega, target_url traz o host do destino. O endereço completo, que pode carregar credencial do seu receptor, aparece só para dono e administrador, na tela de endpoints. O campo e o tipo continuam os mesmos.

Correção Painel

#### Webhooks e Flows mostram o host do destino para quem não administra endpoints

Perfis de desenvolvimento e de leitura continuam escolhendo o webhook de um flow normalmente, agora pelo host. Dono e administrador seguem vendo o endereço completo.

Adição compatível API REST e webhook

#### Header x-idsaas-delivery-id em toda entrega de webhook

Cada POST leva o identificador da entrega. Ele se repete em todas as tentativas e no replay, então você correlaciona as tentativas sem abrir o corpo.

### 2026-09-18

4 mudanças

Adição compatível Painel

#### Sessões: bloquear e desbloquear um titular pela sua referência

Em Sessões, quem é dono ou administrador bloqueia um titular pelo reference_id, na conta inteira ou em um flow, e desfaz o bloqueio na mesma tela. Serve para quem abandonou a verificação antes de existir documento.

Correção API REST e webhook

#### Gate transacao: o veredito deny considera o reference_id da transação

No módulo transacao, quando o reference_id enviado está na sua lista de bloqueio o veredito é deny, com blocklist_hit em reasons. Envie sempre o reference_id do titular junto da transação: é por ele que a sua lista é consultada.

Adição compatível API REST e webhook

#### Lista de bloqueio: o reference_id passa a barrar a verificação

O titular que você bloqueou pelo reference_id termina a próxima verificação em blocked, sem cobrança, e o webhook verification.blocked chega com reason igual a reference_blocklisted. É um valor a mais em reason, que a política desta página já obriga a tolerar.

Correção API REST e webhook

#### Flow exclusivo do monitoramento transacional deixa de aceitar sessão e link

Criar sessão ou link num flow que só recebe alertas do monitoramento transacional passa a responder 422 session_not_supported. Antes a sessão era aceita e produzia um resultado em revisão sem análise nenhuma. Nada muda para quem envia eventos com o bloco transaction, nem para os seus flows de verificação.

### 2026-09-17

4 mudanças

Correção API REST e webhook

#### Gate transacao: só movimento do titular que não falhou recebe veredito e cobrança

No módulo transacao, a verificação e a cobrança passam a sair só para o movimento que o próprio titular iniciou e que não falhou: deposit, withdraw, transfer, payment e bet, com status confirmed ou pending. Os eventos settlement e reversal (que são o ciclo de vida de um pagamento já julgado), bet_profit e bet_loss (que são o resultado apurado pela casa), qualquer evento com status failed e o backfill que chega fora da janela normal continuam sendo ingeridos, entram na trilha, na retenção e no monitoramento, e deixam de gerar verificação, webhook de verificação e débito. Antes cada um deles produzia uma verificação cobrada, o que na prática cobrava duas vezes pelo mesmo pagamento. Se a sua integração contava uma verificação por evento enviado, passe a contar por evento avaliável.

Correção API REST e webhook

#### Gate transacao: mandar só device.ip deixa de zerar a penalidade de dispositivo

Quem já tinha mandado device.fingerprint antes para o mesmo titular e para de mandar passa a receber device_data_dropped em reasons, mesmo quando o device.ip continua indo na chamada. Antes um device.ip sozinho contava como dispositivo presente e zerava a penalidade inteira. Nada muda para quem sempre mandou os dois campos, e nada muda na forma do corpo: é uma razão a mais em reasons, que a política desta mesma página já obriga a tolerar.

Mudança que quebra Widget

#### Marca do widget: os textos voltam vazios quando a conta não escreveu os seus

A resposta de marca lida com o segredo da sessão passou a trazer título, subtítulo e texto do botão APENAS quando a conta escreveu os seus. Conta que nunca customizou recebe esses três campos nulos, e o widget usa o texto do próprio catálogo, no idioma da sessão. Cores, raio e fonte não mudaram. Saiu também o idioma padrão da conta, que é configuração do painel e não tem uso no widget. Quem monta a própria tela a partir dessa resposta deve tratar os três textos como opcionais.

Mudança que quebra Painel

#### Consulta avulsa: a resposta passou a ter um conjunto fechado de campos

O corpo de POST /v1/lookup-queries e os itens de GET /v1/lookup-queries passaram a trazer apenas o que descreve a SUA consulta: identificador, ambiente, módulo, tipo de documento, finalidade, situação, se a fonte encontrou o documento, o preço, se foi cobrada, quem pediu e quando foi pedida. Saíram três campos que descreviam o funcionamento interno da plataforma e não a sua consulta: o estado do cache, a impressão digital do documento e o carimbo de conclusão. Quem lia algum deles passa a ler ausente. Para saber se uma consulta custou, use price_cents e billed, que continuam no mesmo lugar e respondem exatamente isso. Nenhum campo mudou de nome, de tipo ou de significado, e nada foi acrescentado.

### 2026-09-11

8 mudanças

Adição compatível API REST e webhook

#### Módulo doclink contratável: Reuso de Documento

O módulo doclink passou a ser contratável: em GET /v1/capabilities ele sai com status available em vez de coming_soon, e POST /v1/flows passa a aceitá-lo. Ele dispensa a foto do documento de quem você já aprovou antes: o titular faz a prova de vida e a selfie, o módulo compara essa selfie com a da verificação anterior da mesma conta na SUA base e reaproveita os dados que já tinham sido lidos do documento naquela vez. No flow ele SUBSTITUI a Verificação de Identidade e o Face Match, e exige o Liveness, que é o que impede reusar uma identidade com a foto de uma foto. Quem nunca foi verificado por você não tem o que reusar, e a verificação vai para revisão em vez de ser recusada. Nada muda no formato de check_details: o bloco do módulo é o mesmo que já estava documentado durante a pausa. O preço é de 25 centavos por verificação.

Adição compatível API REST e webhook

#### Módulo transacao_monitor contratável: monitoramento transacional cobrado por alerta

O módulo transacao_monitor passou a ser contratável: em GET /v1/capabilities ele sai com status available em vez de coming_soon, e POST /v1/flows passa a aceitá-lo. Ele varre, sem ninguém esperando, as transações que você já mandou pela rota de ingestão, e quando um padrão fecha o alerta chega no mesmo webhook assinado de sempre, com a janela e a evidência em número. O alerta nunca reprova ninguém: o pior desfecho é revisão humana. A cobrança é por ALERTA EMITIDO, 30 centavos: a varredura é grátis e alerta suprimido por repetição não custa nada. Ele é exclusivo no flow, a mesma regra do monitoramento de sessão: um flow que o contenha não aceita nenhum outro módulo, e pedir isso responde 422 system_module_exclusive. Nada muda no formato de check_details.

Adição compatível API REST e webhook

#### Módulo monitoring_aml contratável: monitoramento contínuo por titular e por mês

O Monitoramento Contínuo passou a ser contratável, a 10 centavos por titular monitorado por mês. Atenção ao que ele NÃO é, porque isso decide a sua integração: ele não é item do array modules de um flow, e pedir isso continua sendo recusado. Quem liga o monitoramento é o campo monitoring_enabled do flow, em POST /v1/flows e em PATCH /v1/flows/{id}, ou o bloco monitoring da criação da sessão, e esses campos deixaram de responder 422 monitoring_unavailable: agora são aceitos. Depois da aprovação o titular fica sob vigilância contínua nas mesmas listas oficiais do PEP e Listas Restritivas, sem nenhuma captura nova, e quando ele passa a constar de uma lista em que não constava no onboarding você recebe o webhook com a evidência minimizada e uma verificação de acompanhamento em revisão. O alerta nunca decide sozinho: quem revisa é você.

Adição compatível API REST e webhook

#### Módulo transacao contratável: gate transacional, e o lote recusado no flow que o contém

O Gate Transacional passou a ser contratável, a 12 centavos por transação avaliada: em GET /v1/capabilities ele sai com status available em vez de coming_soon, POST /v1/flows passa a aceitá-lo e a ingestão de transações deixou de responder 422 transaction_not_supported em flow que o contenha. A consequência que você precisa ler ANTES de integrar: num flow que contenha o gate, enviar transactions\[\], que é o lote, passa a responder 422 batch_not_supported_for_gate, e só a forma unitária transaction é aceita. A razão é de produto e não de implementação: um gate síncrono decide UM pagamento, e um lote não teria veredito. Isso é adição compatível e não mudança que quebra, e o argumento é verificável: até esta data nenhum flow podia conter o módulo, porque a criação de flow o recusava, então não existe integração afetada. A regra nasce junto com a possibilidade.

Adição compatível API REST e webhook

#### PGFN Dívida Ativa virou consulta de empresa, e o data do módulo mudou de forma

O módulo credito_pgfn passou a ser contratável e trocou de produto dentro do mesmo nome. Ele deixou de consultar o CPF e passou a consultar o CNPJ, então a dependência no flow deixou de ser Verificação de Identidade, Face Match e Liveness e passou a ser apenas o OCR do documento de empresa: empresa não tem rosto, e cobrar biometria aqui seria cobrar por uma prova que não prova nada. A fonte deixou de ser certidão comprada de fornecedor e passou a ser o dado aberto oficial da Dívida Ativa da União, publicado e atualizado mensalmente pela PGFN e servido da nossa base. E o campo data do módulo MUDOU DE FORMA, dito aqui em voz alta: antes era o repasse do dossiê do fornecedor, com a lista de inscrições e o número de cada uma; agora traz principal, corresponsavel e solidario separados, mais competencia, atualizado_em e cobertura. Os três papéis vêm separados porque corresponsável costuma ser dívida de outra empresa pela qual a consultada responde, e somar tudo acusaria de devedor quem não é. Não há número de inscrição: a base agrega por documento e por papel. Quando ela está vencida ou incompleta o módulo responde pendente com o motivo e não é cobrado, em vez de dizer que nada consta sobre uma cópia velha. Mudar a forma de um campo é adição compatível SÓ porque este módulo nunca esteve à venda: ele saiu de coming_soon direto para available, e não existe integração lendo o formato antigo.

Adição compatível API REST e webhook

#### Preço do módulo credito_pgfn de R$ 17,90 para R$ 0,45

A consulta de Dívida Ativa da União passou a custar 45 centavos, contra os 1790 centavos que o catálogo publicava desde que o módulo nasceu. A queda de 97,5% não é promoção e não tem prazo: ela é a consequência de a fonte ter deixado de ser uma certidão comprada por consulta e passado a ser a nossa base do dado aberto oficial da PGFN, sem fornecedor no caminho. O valor novo já viaja em GET /v1/pricing-catalog/public, que continua sendo a única fonte de preço que a nossa vitrine e a sua integração devem ler.

Adição compatível API REST e webhook

#### reason_code separa a evidência do desfecho, e agrupa as razões

O bloco reason_code do corpo do webhook ganhou dois campos. O primeiro é reasons, uma lista em que cada razão separa o que foi visto do que isso causou: code é a evidência, no mesmo vocabulário que decision_reason já usava, e outcome_effect diz se ela bloqueou, mandou para revisão ou apenas ficou registrada. Cada razão também traz o módulo de origem, o grupo a que ela pertence e dois textos prontos em português, display_pt para a sua tela e action_pt com o que fazer a seguir. O segundo campo é aspects, o índice desses códigos por cinco grupos, documento, biometria, validação de dado, sinais de fraude e canal, sempre com as cinco chaves, mesmo vazias. A primeira razão da lista é sempre a que puxou a decisão e o código dela é idêntico ao decision_reason, então nada de novo passa a ser dito: o que muda é a forma. Nenhum campo saiu e nenhum mudou de valor, os textos display_pt e action_pt são para quem integra e não para o titular, que continua recebendo apenas subject_message, e schema_version segue valendo 1, como a política desta mesma página prevê para adição de campo.

Adição compatível API REST e webhook

#### Verificação de idade voltou a ser vendável

O módulo idade voltou ao catálogo como contratável: em GET /v1/capabilities ele sai com status available em vez de coming_soon, e POST /v1/flows passa a aceitá-lo. A pausa, anunciada em 21 de agosto, era por licença do modelo que estima a idade, não por defeito: o peso anterior tinha origem de dados restrita a pesquisa e foi retirado do produto. O que roda agora tem cadeia de licença aberta e verificável, e a atribuição exigida por ela está publicada na página de segurança. Nada muda no formato da resposta nem nos campos de check_details: o contrato do módulo é o mesmo que já estava documentado durante a pausa. O preço é de 50 centavos por verificação.

### 2026-08-29

1 mudança

Adição compatível API REST e webhook

#### impedidos_apostar: a base entrou no ar, e a cobertura anunciada encolheu para o que ela é

O módulo deixou de sair pendente por falta de base: a lista de agentes públicos do setor de apostas do Portal da Transparência está carregada e o módulo passa a devolver veredito, com coverage e dataset_versions como prova de diligência. Na mesma entrega a cobertura anunciada encolheu, e é a parte que importa para quem já integrou: atleta e árbitro SAÍRAM. A CBF não publica base para conferência automática, e as duas fontes dela passam a viajar em dataset_versions como disabled, ou seja, declaradamente não consultadas, em vez de aparecerem como cobertura. Preferimos dizer o que não cobrimos a listar uma fonte que não conseguimos conferir. Nada muda no formato do payload nem no preço; muda o que a resposta afirma.

### 2026-08-28

3 mudanças

Adição compatível API REST e webhook

#### Quatro módulos novos: forense de documento, rede de fraude, documento de viagem e cadeia societária

Quatro módulos novos no catálogo, cada um com bloco próprio em check_details. Forense de documento pericia o ARQUIVO que a verificação de identidade já capturou (ferramenta que o gerou, datas, revisões de PDF, recaptura de tela, recorte e colagem), sem pedir foto nova. Detecção de rede de fraude mostra se quem está se verificando está ligado a outras contas SUAS, por aparelho, rede, e-mail, telefone, rosto ou pela conta que você informa, e a rede é sempre a sua. Documento de viagem lê a zona de leitura mecânica (MRZ) de passaportes e carteiras estrangeiras no padrão ICAO 9303, com todos os dígitos verificadores, e ocupa o lugar da verificação de identidade no mesmo flow. Cadeia societária sobe o quadro nível a nível quando um sócio é outra empresa, até as pessoas naturais, com o percentual acumulado. Nenhum dos quatro reprova sozinho: um achado leva a verificação para revisão humana com a evidência. A cadeia societária é cobrada por empresa efetivamente subida, e o teto por verificação é definido por você no flow.

Adição compatível API REST e webhook

#### policy.ubo_max_paid_nodes na criação de sessão, e dois códigos de erro novos

A política por sessão ganhou a chave ubo_max_paid_nodes, o teto de empresas da cadeia societária que aquela verificação pode subir. Ela só APERTA o teto do flow: pedir mais que ele responde 422 policy_ubo_cap_above_flow, nunca um corte silencioso, porque gasto só sobe por decisão registrada e auditada no flow. Na mesma passada entrou no spec o 422 policy_module_not_in_flow, que já existia e não estava publicado: política para um módulo que o flow não tem é recusada com nome, nunca aceita e ignorada. As duas regras valem igual na criação de sessão e na emissão de link hospedado.

Adição compatível API REST e webhook

#### Link de verificação hospedado publicado no contrato

POST /v1/verification-links passa a constar do spec OpenAPI e da documentação. A rota já estava no ar e é a quarta da superfície da chave secreta: para quem não vai montar o widget, nós hospedamos a página e você entrega a url ao titular. O link vive horas, a sessão só nasce no resgate e vive 15 minutos, o token do link volta em claro uma vez só e não é reexibido, e emitir link não cobra nada: quem cobra é a verificação que nascer do resgate. Nada mudou no comportamento; o que mudou é que o contrato público parou de omitir a rota.

### 2026-08-27

1 mudança

Adição compatível API REST e webhook

#### Módulo midia_adversa: mídia adversa (negative news)

Módulo novo no catálogo (40 centavos), com bloco próprio em check_details. Confere o nome lido do documento contra um corpus aberto de notícias mantido por nós. Todo casamento é por nome e a resposta sempre declara o grau de confiança (name_match forte ou possível homônimo) e a similaridade. Um hit forte nunca reprova sozinho: leva a verificação para revisão humana com a evidência (fonte, data e link). Exige Verificação de Identidade, Face Match e Liveness no mesmo flow, e desliga a renovação de sessão pelo widget. Enquanto o corpus não estiver carregado, toda checagem sai pendente (indeterminado), vai a review e não é cobrada, nunca um falso nada consta.

### 2026-08-25

1 mudança

Adição compatível API REST e webhook

#### Módulo impedidos_apostar: vedação de apostas (Lei 14.790/2023)

Módulo novo no catálogo (45 centavos), com bloco próprio em check_details e a chave opcional policy.allow_betting_ban na criação da sessão com sk\_. Confere o CPF e o nome do documento contra as listas públicas de impedidos (CBF BID, arbitragem, servidores do setor) e devolve coverage e dataset_versions como prova de diligência. Exige Verificação de Identidade, Face Match e Liveness no mesmo flow, e desliga a renovação de sessão pelo widget. Nunca reprova sozinho: um sinal leva a verificação para review. Enquanto o feed das fontes não estiver carregado, toda checagem sai pendente, vai a review e não é cobrada.

### 2026-08-22

1 mudança

Adição compatível API REST e webhook

#### schema_version no corpo do webhook, e a garantia de entrega publicada

O envelope de todo evento passa a carregar schema_version, a versão da FORMA do corpo. Hoje ela vale 1 e não sobe por adição compatível: campo novo, módulo novo em check_details e valor novo em enum de saída continuam com schema_version 1. Ela só muda se um campo mudar de tipo ou de significado, e isso já exigiria uma versão nova de caminho. É campo NOVO na resposta, ou seja, adição compatível pela regra desta mesma política. Junto vai para a documentação, por escrito, a garantia de entrega que já praticávamos: pelo menos uma vez, com repetição possível, e o id do evento como chave de deduplicação do seu lado.

### 2026-08-21

8 mudanças

Adição compatível API REST e webhook

#### Módulo pep_sancoes: PEP e listas restritivas

Módulo novo no catálogo (40 centavos), com bloco próprio em check_details e a chave opcional policy.allow_pep na criação da sessão com sk\_. Exige Verificação de Identidade, Face Match e Liveness no mesmo flow. Nunca reprova sozinho: um sinal leva a verificação para review. Chave de política desconhecida é 400, que é a validação nova valendo só para um parâmetro novo.

Adição compatível API REST e webhook

#### Módulo email_otp e as duas rotas de OTP da sessão

Validação de e-mail por código (10 centavos). Entram POST /v1/verification-sessions/{id}/otp e POST /v1/verification-sessions/{id}/otp/verify, autenticadas pela própria sessão do widget, e o bloco email_otp em check_details. Nenhuma rota nova de sk\_.

Adição compatível API REST e webhook

#### Módulos telefone e sms_otp

Validação de telefone contra a base oficial de numeração, sem envio (15 centavos), e validação por SMS (115 centavos). O sms_otp nasce INATIVO no catálogo, à espera do contrato de SMS: até o flip por migration nova ele não aparece no catálogo público nem entra em flow.

Correção API REST e webhook

#### Cobrança do sms_otp unificada no fecho da verificação

O sms_otp debitava no despacho da mensagem, fora do caminho por onde o resto do catálogo cobra. Passou a cobrar no fecho, como todos os outros módulos. Nenhum campo de resposta mudou: o que muda é o momento do débito no seu saldo.

Adição compatível Widget

#### Widget em português, inglês e espanhol

O texto que a pessoa vê passa a existir em pt-BR, en-US e es-ES. O idioma sai da dica do host (mount({ locale: 'en' }) ou data-locale), senão do navegador, senão pt-BR. O widget já tolera um campo de idioma vindo do servidor, que ainda NÃO existe na API: quando ele nascer, o bundle antigo continua funcionando, e idioma desconhecido mantém a língua que já estava.

Adição compatível Painel

#### Recuperação de senha do painel

Telas /esqueci-senha e /redefinir-senha, com link por e-mail de uso único e validade curta. A resposta é sempre a mesma, exista a conta ou não, para a tela não virar consulta de quem tem cadastro. Nada muda na API REST nem no widget.

Adição compatível Site público

#### Preços e comparativos públicos

As páginas /precos e /comparar passam a existir, servidas do catálogo público de preços, não de uma tabela escrita à mão. Módulo que não está ativo no catálogo aparece como Em breve, nunca com preço de venda.

Depreciação API REST e webhook

#### Venda do módulo idade pausada

O módulo idade saiu do catálogo público e deixou de ser vendável, à espera de decisão do dono sobre religar a venda. A saída foi no mesmo dia do aviso, sem os 90 dias que a política de versão passa a exigir daqui em diante, porque não havia nenhuma integração ativa para avisar. A partir desta publicação, saída de módulo do catálogo cumpre o prazo.

**Sai do ar em 2026-08-21.**

### 2026-08-09

1 mudança

Mudança que quebra API REST e webhook

#### Módulo ocr renomeado para cpf_ocr

A leitura de documento de pessoa passou a se chamar cpf_ocr, por simetria com o cnpj_ocr do KYB. Quem lia check_details procurando module igual a ocr deixou de encontrar o bloco. O histórico foi renomeado junto, então verificações antigas também aparecem como cpf_ocr.

### 2026-08-06

1 mudança

Adição compatível API REST e webhook

#### Módulos idade e face_unica

Verificação de idade e detecção de múltiplas contas, ambos rodando sobre a selfie já capturada: zero passo novo no widget. Entram como valores novos no catálogo de módulos e como blocos novos em check_details.

### 2026-08-05

1 mudança

Mudança que quebra API REST e webhook

#### Módulo socios extinto, cnpj vira cnpj_socios

O quadro societário já vinha na mesma resposta do módulo de CNPJ, então o módulo socios era redundante e foi removido; o módulo cnpj passou a se chamar cnpj_socios. Flow que pedia socios perdeu o módulo, e o preço do flow foi recalculado pela soma dos módulos restantes.

### 2026-08-02

1 mudança

Mudança que quebra API REST e webhook

#### Módulos de validação de CPF renomeados

cpf virou cpf_contatos, cpf_k virou cpf_enderecos, cpf_e virou cpf_receita e cpf_i virou cpf_empresas. Os nomes antigos eram códigos de pacote do nosso fornecedor de dados, sem significado para quem integra. O histórico de verificações foi renomeado junto.

### 2026-07-28

1 mudança

Mudança que quebra API REST e webhook

#### Combos de preço removidos: preço do flow é a soma dos módulos

As tabelas de combo saíram e o preço de um flow passou a ser sempre a soma dos preços unitários dos módulos escolhidos. Além de simplificar, isso corrigiu preço gravado errado: quando a lista de módulos casava inteira com uma chave de combo, o combo vencia a soma e cobrava mais caro. Os preços dos flows existentes foram recalculados.

### 2026-07-27

1 mudança

Adição compatível API REST e webhook

#### Módulo cnpj_ocr: leitura do documento da empresa

OCR do comprovante de CNPJ, separado do OCR de documento de pessoa. KYC e KYB viram módulos independentes e podem conviver no mesmo flow.

### 2026-07-01

1 mudança

Adição compatível API REST e webhook

#### Status review nas verificações

O enum de status ganhou review, para separar revisão humana de pending, que significa em processamento. Este é o exemplo canônico de adição compatível: valor novo em enum de saída. Integração que tratava status desconhecido como erro precisou tolerar o valor novo.

### 2026-06-29

1 mudança

Mudança que quebra API REST e webhook

#### Widget autentica por client_secret da sessão, fim da chave publicável

O widget passou a autenticar pelo segredo escopado a UMA sessão, em vez de uma chave pública de conta. A chave publicável pk\_ saiu do produto: hoje ela não parseia e devolve 401. Trocar o segredo global pelo segredo de sessão foi a mudança que tirou credencial de conta do navegador.

### 2026-06-24

1 mudança

Adição compatível API REST e webhook

#### Publicação da API sob /v1

Primeira versão do contrato HTTP, montada no caminho /v1. É o marco zero deste changelog: tudo acima aconteceu dentro desta mesma versão de caminho.

Não vê aqui uma mudança que você percebeu na integração? Isso é um defeito nosso, e a gente quer saber: fale com o suporte pelo painel. Changelog incompleto vale menos que changelog nenhum.

CONTRATO

## Política de versão

<https://unifokal.com/docs/versionamento>

Quatro perguntas, respondidas por escrito: o que consideramos mudança que quebra, quanto tempo uma versão fica no ar, como avisamos uma depreciação e o que assumimos como compromisso. O que já mudou de fato está no [changelog](https://unifokal.com/docs/changelog).

Cada item desta página é um compromisso que cumprimos, e a última seção diz como a versão é escolhida e quanto tempo ela convive com a seguinte.

### Onde vive a versão

A versão do contrato HTTP está no **caminho**: `/v1`. Não existe cabeçalho de versão, e a sua integração não escolhe versão por requisição. Enquanto a URL começar com `/v1`, valem as regras desta página.

### A versão do envelope do evento

O caminho versiona a **API**. O corpo do webhook versiona a si mesmo: todo evento sai com `schema_version`, hoje `1`, logo depois do `id`. Ele descreve a **forma** do envelope, não o conteúdo da verificação.

Esse número **não** sobe por adição compatível. Campo novo no corpo, módulo novo em `check_details` e valor novo num enum de saída continuam com `schema_version: 1`, porque a lista logo acima já obriga a sua integração a tolerar essas três coisas. Ele sobe quando um campo muda de tipo ou de significado, e isso, pela lista de baixo, também exige `/v2`: os dois marcadores andam juntos de propósito, e é por isso que você nunca vai precisar decidir qual dos dois seguir.

Na prática, para quem integra: leia o campo, registre o valor no seu log e trate um número maior que o esperado como sinal de que existe uma versão nova de caminho para migrar. Não recuse o evento por causa dele: enquanto o número for `1`, o corpo é o mesmo que você já sabe ler.

### Como entregamos: pelo menos uma vez

Isto não é uma promessa nova, é o registro por escrito do que a plataforma sempre fez. **Você vai receber o mesmo evento mais de uma vez** em algum momento, e isso não é defeito: é o preço de nunca perder um desfecho. O que garantimos é a entrega, não a unicidade dela, e é por isso que o `id` do evento é estável, para servir de chave de deduplicação no seu lado.

- → Entregamos pelo menos uma vez. O mesmo evento pode chegar mais de uma vez, e o seu endpoint precisa ser idempotente.
- → Deduplique pelo campo id do evento (evt\_...). Ele é estável por verificação e por tipo de evento, então toda retentativa do mesmo evento repete o mesmo id.
- → Reemissão manual (decisão revista no painel, e também a re-decisão automática quando uma análise continua e muda o resultado) chega com id NOVO de propósito: é evento novo, e você deve tratar como upsert pelo verification_id.
- → O replay self-service é o oposto, e por desenho: ele reenvia a MESMA entrega que falhou, byte a byte, com o MESMO id do evento original. É o mesmo evento chegando de novo, não um evento novo: a dedup por id continua valendo e nada é processado duas vezes.
- → Uma entrega só é considerada aceita quando o seu endpoint responde 2xx. Timeout, erro de rede ou 5xx contam como falha e entram na retentativa.
- → São até 3 tentativas, com espera crescente entre elas (cerca de 15 minutos do primeiro disparo à última). Depois disso o resgate é o replay, que você dispara quando o seu sistema voltar.
- → Resposta 4xx DE CONTRATO (400, 401, 403, 404, 405, 410 e 422) é lida como recusa definitiva daquele destino e não gera novas tentativas: o corpo não vai mudar se o problema é o pedido. Corrija e use o replay.
- → As duas exceções entre os 4xx são as que um receptor sob carga devolve: 408 e 429 contam como falha TRANSITÓRIA e entram na retentativa normalmente. Se você limita taxa no seu endpoint, prefira 429 a 403.
- → Resposta 3xx NÃO é entrega e NÃO é seguida: nós fazemos um POST na URL cadastrada e paramos ali. Redirecionar o destino (inclusive de http para https, ou de com barra final para sem) faz o evento parar de chegar, e a tentativa fica registrada com o status 301 ou 302 para você ver em GET /v1/webhook-events. Quem conserta isso é o cadastro da URL, não o retry.
- → A entrega repetida é assinada NA HORA do envio, com timestamp novo sobre o mesmo corpo, então a sua validação de assinatura e a sua janela anti-replay continuam passando.
- → O corpo do evento carrega schema_version, a versão da forma do envelope. Campo novo não muda esse número; mudança de tipo ou de significado muda, e nesse caso já existiria uma versão nova de caminho.

A consequência prática é uma só: **o seu endpoint precisa ser idempotente**. Guarde o `id` já processado e ignore a repetição, ou trate todo evento como um upsert pelo `verification_id`. Quem processa webhook sem isso credita duas vezes, aprova duas vezes, ou dispara dois e-mails, no primeiro dia em que a rede engasgar.

### O que é adição compatível

Estas mudanças entram em `/v1` a qualquer momento, sem aviso prévio. **A sua integração é obrigada a tolerá-las.** Na prática isso significa: não quebre em campo desconhecido no JSON, não trate valor de enum desconhecido como erro fatal, não valide a resposta contra um esquema fechado, e não dependa da ordem das propriedades de um objeto.

- \+ Endpoint novo, ou módulo novo no catálogo.
- \+ Campo novo na resposta ou no corpo do webhook.
- \+ Parâmetro opcional novo no request.
- \+ Tipo de evento novo no webhook.
- \+ Valor novo em um enum de SAÍDA (um status novo, um módulo novo em check_details, um motivo novo).
- \+ Item novo dentro de um array já existente.
- \+ Reordenação das propriedades de um objeto JSON.
- \+ Mudança de tamanho ou de formato de uma string opaca (id, token, prefixo de chave).

### O que é mudança que quebra

Estas **nunca** entram em `/v1`. Se um dia forem necessárias, elas exigem um caminho novo, `/v2`, e a versão antiga continua respondendo do jeito que sempre respondeu enquanto estiver no ar.

- ! Remover ou renomear endpoint, parâmetro, header, campo de resposta ou módulo.
- ! Mudar o tipo de um campo.
- ! Exigir campo novo no request, ou criar validação que recusa um request antes válido.
- ! Mudar o código de status HTTP de um caso já documentado.
- ! Mudar o SIGNIFICADO de um campo mantendo o nome dele.
- ! Remover um valor aceito em um enum de ENTRADA.
- ! Mudar a exigência de autenticação de uma rota.

Duas exceções, pelo mesmo motivo que o mercado inteiro as abre: mudança em respostas `5xx` e em `404` de recurso inexistente não conta como quebra, porque são justamente os casos em que o contrato não estava sendo cumprido.

### Quanto tempo uma versão fica no ar

Aviso de **90 dias** antes de uma versão nova entrar no ar, e **12 meses** de convivência entre a nova e a antiga contados a partir do lançamento. Passado esse prazo, a versão antiga responde `410`, e não um erro genérico: você recebe uma resposta que diz exatamente o que aconteceu.

| Se a /v2 entrasse no ar em | Então |
| --- | --- |
| 2026-10-03 | o aviso já estaria publicado no changelog, 90 dias antes. |
| 2027-01-01 | a `/v2` entra no ar e a `/v1` continua funcionando igual. |
| 2028-01-01 | a `/v1` sai do ar e passa a responder `410`. |

### Como avisamos uma depreciação

Depreciação é anúncio com **data de saída**. Ela aparece no [changelog](https://unifokal.com/docs/changelog) classificada como Depreciação, com o dia em que o comportamento antigo sai do ar, e no feed [`/docs/changelog.json`](https://unifokal.com/docs/changelog.json) no campo `sunset_on` daquele item, para o seu monitoramento enxergar sem depender de alguém ler esta página. O painel também traz o aviso, mas o registro que vale é o changelog: o painel é lido por uma pessoa, e integração não é gente.

### A exceção de segurança

Correção de vulnerabilidade entra em `/v1` **na hora**, sem janela de aviso, mesmo que altere comportamento observável. Ela é registrada no changelog como Correção logo em seguida. Essa exceção existe para a promessa desta página não virar algema no meio de um incidente: entre cumprir o prazo e fechar um buraco que expõe dado de cliente, a gente fecha o buraco.

### O que assumimos

- ✓ Mudança que quebra nunca entra em /v1. Ela exige um caminho novo, /v2.
- ✓ Antes de /v2 entrar no ar, o aviso sai aqui no changelog com 90 dias de antecedência.
- ✓ /v1 e /v2 convivem por 12 meses contados do dia em que /v2 entra no ar. Depois disso, /v1 responde 410.
- ✓ Depreciação é anunciada nesta página com a data de saída, nunca só por e-mail.
- ✓ Correção de vulnerabilidade entra em /v1 na hora, sem janela de aviso, e é registrada aqui como Correção logo em seguida.
- ✓ Toda entrada deste changelog só entra com a prova da data conferida no nosso repositório. Esta página publica o que mudou, não o mapa do nosso código.

### Como a versão é escolhida

Uma regra só, pensada para o seu código não mudar de comportamento sem você mudar a URL.

- \- A versão é escolhida pelo caminho da URL, nunca por cabeçalho: a mesma URL devolve sempre a mesma forma de contrato, e um cabeçalho esquecido nunca muda a resposta que você recebe.
- \- Existe uma versão de caminho no ar por vez, sem versão nomeada por mês nem canal de preview: o que está em /v1 é o contrato inteiro, e o changelog registra cada mudança dele com a data.
- \- Quando uma versão nova de caminho entrar no ar, a anterior convive com ela por 12 meses, com o aviso publicado 90 dias antes.

### Onde isso aparece no contrato

A cláusula 2.4 dos [Termos de Uso](https://unifokal.com/termos) fixa **30 dias** de antecedência para mudança que altere de forma relevante o comportamento observável da API, anunciada neste changelog público e por aviso no painel, e diz que mudança que quebra integração existente não entra em `/v1`. São 30 dias porque é o mesmo prazo que os Termos já usam para alteração de preço de módulo e para o plano gratuito. Os 90 dias desta página são um prazo maior, e valem para o caso mais pesado de todos: colocar uma versão nova no ar.
