# Identidade e biometria

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

cpf_ocr, face, liveness, idade, face_unica, endereco_ocr, doc_global, doclink, face_reauth (Em breve), passkey (Em breve), atestado_humano (Em breve)

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. |

## 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).

## 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".
```

## 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.

### 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)

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

## 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>

### 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).

## 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.

## 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.
