# Análise de crédito

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

credito_dividas (Em breve), credito_scr (Em breve), credito_boavista (Em breve), credito_protestos (Em breve), credito_cadin (Em breve), credito_pgfn, scr_bacen (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) |

## Análise de crédito

<https://unifokal.com/docs/modulos/credito>

### Análise de crédito

Cinco módulos consultam a situação de crédito do **CPF lido do documento**: `credito_dividas` (dívidas e negativações), `credito_scr` (SCR do Banco Central), `credito_boavista` (Boa Vista SCPC), `credito_protestos` (protestos Cenprot) e `credito_cadin` (CADIN). Exigem Verificação de Identidade, Face Match e Liveness, pela mesma razão dos cadastrais: o CPF consultado vem do documento, e o documento tem que pertencer a quem está vivo na frente da câmera.

O sexto, `credito_pgfn` (Dívida Ativa da União), é de **empresa**: ele consulta o **CNPJ lido do documento societário** e por isso exige apenas o `cnpj_ocr` no flow, sem biometria, porque empresa não tem rosto. A fonte dele não é fornecedor: é o **dado aberto oficial** que a PGFN publica e atualiza **trimestralmente**, ingerido na nossa base. Cada resposta carimba a **competência publicada** do arquivo que respondeu, para você saber a que mês ela se refere.

**Venda pausada hoje**: os **cinco** de CPF aparecem na tabela de preços e no `GET /v1/capabilities` com preço e status `coming_soon`, e não podem ser ligados num flow enquanto a fonte não for liberada na conta do fornecedor. O `credito_pgfn` saiu dessa fila em 11 de setembro de 2026 e viaja como `available`. O estado vem do catálogo vivo, e esta página acompanha.

**Os cinco de CPF pedem finalidade, relação e consulente**, como a Lei 12.414/2011 manda para dado de crédito. A **finalidade** é do flow: ao montá-lo no painel você declara `credit_purpose`, `analise_risco_credito` ou `concessao_credito` (art. 7º). A **relação** com o titular vai em cada sessão, no campo `credit_relationship` do `POST /v1/verification-sessions`: `mantem` ou `pretende_manter` (art. 15). O **consulente** é a sua empresa, e por isso a conta precisa ter o CNPJ cadastrado e verificado. A consulta só sai com a identidade aprovada, e cada uma fica registrada com a finalidade, a relação e o consulente. O `credito_pgfn` não passa por esse portão.

```
// sessão num flow com módulo de crédito
{ "flow_id": "flow_...", "reference_id": "cliente-4821", "credit_relationship": "mantem" }

// os 422 do portão, cada um com a prosa na página de erros:
//   credit_relationship_required       flow com crédito e a chamada sem credit_relationship
//   credit_relationship_not_supported  credit_relationship num flow sem módulo de crédito
//   credit_purpose_required            flow com crédito que não declara a finalidade
//   credit_consulente_unverified       a conta ainda sem CNPJ cadastrado e verificado
```

São **informacionais**: ter dívida **não reprova a identidade** (o pass/fail é 100% biometria). Os cinco de CPF são **assíncronos** na fonte: o dossiê pode chegar num `verification.completed` posterior, quando a consulta conclui. Enquanto processa, o módulo aparece como `pending` **sem a chave** `data`; concluído, o `data` traz o dossiê do pacote. Se a fonte não concluir na janela, o módulo termina como indisponível e **não é cobrado**.

O `credito_pgfn` responde da **nossa base**, na mesma verificação, e não tem esses dois tempos. Em troca ele carrega a **competência** do arquivo do governo que respondeu, em `competencia` e `atualizado_em`, mais a `cobertura` por fonte. Quando a base está vencida ou incompleta, ele sai como `pending` com o motivo e **não é cobrado**: um **nada consta** só é emitido com a base inteira dentro do prazo. Achado positivo, ao contrário, é devolvido mesmo com cobertura incompleta, porque **consta** continua verdadeiro.

**Sem dossiê, a chave `data` não vem `null`: ela não existe.** Vale enquanto a fonte processa, quando ela não responde na janela e quando o documento não é encontrado. Um leitor que testa `check.data === null` não pega nenhum desses casos: teste a **presença** da chave, e ramifique por `outcome`.

Aqui o `data` **é** o dossiê da fonte, sem envelope nenhum: não há `flagged`, não há `matched_by`, não há bloco nosso em volta. Valem as mesmas duas regras da [consulta cadastral](https://unifokal.com/docs/modulos/cadastrais#modulos-cadastrais): tiramos só `pacoteUsado`, `saldo` e `consultaID`, e **o conjunto de chaves é o do pacote**, não uma lista fechada nossa. Todo dossiê traz o bloco comum `status`, `estado`, `cpf` e `nome`, e os dois primeiros enganam: `status` é o indicador de sucesso da **chamada** (nunca a situação do titular) e `estado` é o estágio do **processamento** (`"concluido"`; a primeira chamada da fonte real pode responder `"processando"`, que tratamos como indisponibilidade temporária). Os valores em dinheiro vêm em **reais com decimais**, e não em centavos como os saldos do topo do webhook.

As duas regras do parágrafo acima valem para os **cinco** de CPF, que repassam o dossiê do fornecedor. O `credito_pgfn` é a exceção nas duas: o payload dele é nosso, então não existe o bloco comum `status`, `estado`, `cpf` e `nome`, e o dinheiro sai em **centavos inteiros**, em `valor_total_centavos`.

```
// credito_dividas: dossiê concluído (informacional, não reprova)
{ "module": "credito_dividas", "passed": true, "outcome": "approved", "score": 100,
  "data": { "score": 742, "classe": "B", "renda_presumida": 4200.5, "possui_debitos": true,
            "total_negativado": 1234.56,
            "negativacoes": [ { "credor": "BANCO EXEMPLO S.A.", "valor": 1234.56,
                                "data": "2026-03-10", "tipo": "Pendencia financeira" } ] } }

// credito_pgfn: Dívida Ativa da União do CNPJ, da NOSSA base do dado aberto da PGFN.
// Os três papéis vêm separados: corresponsável costuma ser dívida de OUTRA empresa.
// Valor em CENTAVOS aqui (só este módulo), e sem número de inscrição: a base agrega
// por documento e por papel. "competencia" é o mês do arquivo publicado pelo governo,
// e é a MAIS ANTIGA entre as fontes que responderam: o carimbo é o do elo mais velho.
{ "module": "credito_pgfn", "passed": true, "outcome": "approved", "score": 100,
  "data": { "fonte": "pgfn_dados_abertos", "documento": "cnpj",
            "fonte_oficial": "Divida Ativa da Uniao, dado aberto oficial da PGFN",
            "competencia": "202606", "atualizado_em": "2026-09-02T03:14:00.000Z",
            // uma entrada POR LIVRO da PGFN. "status" é um destes sete: "consultada",
            // "desabilitada", "nunca_ingerida", "ingestao_vencida", "competencia_vencida",
            // "vazia" (ingeriu e veio sem linha) e "ausente" (o livro nem está na base).
            // SÓ "consultada" conta como olhada: qualquer outro degrada a resposta para
            // pending, com o motivo, e a checagem não é cobrada. "ingerida_em" é quando
            // NÓS ingerimos aquele livro, e não a competência dele: os dois são diferentes
            // e confundi-los é ler frescor de ingestão como frescor de dado.
            // A cobertura vem ORDENADA POR fonte, sempre, e nao na ordem de tamanho dos livros:
            // ordem estavel e o que permite comparar duas respostas sem reordenar.
            "cobertura": [ { "fonte": "pgfn_fgts", "livro": "FGTS", "status": "consultada",
                             "competencia": "202606",
                             "ingerida_em": "2026-09-02T01:12:00.000Z", "registros": 541698 },
                           { "fonte": "pgfn_prev", "livro": "PREV", "status": "consultada",
                             "competencia": "202606",
                             "ingerida_em": "2026-09-02T01:40:00.000Z", "registros": 3606529 },
                           { "fonte": "pgfn_sida", "livro": "SIDA", "status": "consultada",
                             "competencia": "202606",
                             "ingerida_em": "2026-09-02T03:14:00.000Z", "registros": 45553971 } ],
            // "situacoes" conta a SITUACAO_INSCRICAO (INSCRITA, AJUIZADA, PARCELADA...) e
            // "tipos_situacao" conta o TIPO_SITUACAO_INSCRICAO ("Em cobranca", "Garantia",
            // "Suspenso por decisao judicial", "Em negociacao", "Beneficio Fiscal"). São
            // eixos diferentes: exigibilidade suspensa por juiz não é inadimplência, e
            // reportar as duas do mesmo jeito seria menos honesto. "livros" diz de quais
            // livros da PGFN as inscrições deste papel vieram.
            "principal": { "consta": true, "inscricoes": 4, "valor_total_centavos": 345000,
                           "ajuizadas": 1,
                           "situacoes": { "INSCRITA": 3, "AJUIZADA": 1 },
                           "tipos_situacao": { "Em cobranca": 3, "Garantia": 1 },
                           "primeira_inscricao": "2019-04-18",
                           "ultima_inscricao": "2025-11-30",
                           "ufs": ["SP"], "livros": ["PREV", "SIDA"] },
            // corresponsavel e solidario têm a MESMA forma do principal, zerados quando não
            // consta: as duas datas vêm null, nunca ausentes e nunca com data sentinela.
            "corresponsavel": { "consta": false, "inscricoes": 0, "valor_total_centavos": 0,
                                "ajuizadas": 0, "situacoes": {}, "tipos_situacao": {},
                                "primeira_inscricao": null, "ultima_inscricao": null,
                                "ufs": [], "livros": [] },
            "solidario": { "consta": false, "inscricoes": 0, "valor_total_centavos": 0,
                           "ajuizadas": 0, "situacoes": {}, "tipos_situacao": {},
                           "primeira_inscricao": null, "ultima_inscricao": null,
                           "ufs": [], "livros": [] } } }

// credito_scr: o retrato do SCR do Banco Central
{ "module": "credito_scr", "passed": true, "outcome": "approved", "score": 100,
  "data": { "status": 1, "estado": "concluido",       // sucesso da chamada / estágio do processamento
            "cpf": "12345678900", "nome": "TITULAR MOCK DA SILVA",
            "data_base": "31/07/2026",                // a competência do retrato
            "instituicoes": 3, "operacoes": 5,
            "carteira_credito_total": 18500.0,        // REAIS com decimais, não centavos
            "vencido_ate_90": 0, "prejuizo": 0 } }

// credito_protestos: protestos em cartório (Cenprot)
{ "module": "credito_protestos", "passed": true, "outcome": "approved", "score": 100,
  "data": { "status": 1, "estado": "concluido",
            "cpf": "12345678900", "nome": "TITULAR MOCK DA SILVA",
            "total_protestos": 1,
            "protestos": [ { "cartorio": "2º Tabelionato de Protesto", "uf": "SP",
                             "valor": 980.0, "data": "05/01/2026" } ] } }

// credito_cadin: inscrição no CADIN. "inscrito": false com "registros": [] é nada consta.
{ "module": "credito_cadin", "passed": true, "outcome": "approved", "score": 100,
  "data": { "status": 1, "estado": "concluido",
            "cpf": "12345678900", "nome": "TITULAR MOCK DA SILVA",
            "inscrito": false, "registros": [] } }

// credito_boavista: score e pendências da Boa Vista SCPC
{ "module": "credito_boavista", "passed": true, "outcome": "approved", "score": 100,
  "data": { "status": 1, "estado": "concluido",
            "cpf": "12345678900", "nome": "TITULAR MOCK DA SILVA",
            "score_boavista": 688, "possui_pendencias": false, "pendencias": [],
            "consultas_ultimos_90d": 2 } }

// ainda processando, ou fonte que não respondeu -> pending, e repare: NÃO existe a chave "data"
{ "module": "credito_scr", "passed": null, "outcome": "pending", "score": 0 }
```

Em sandbox o dossiê vem pronto (sem os dois tempos da fonte real): o desfecho segue o sufixo do documento, como no resto do ambiente de testes.

## Vínculos, listas restritivas e processos

<https://unifokal.com/docs/modulos/compliance-vinculos>

### Vínculos, listas restritivas e processos

Seis módulos de **compliance** aprofundam o screening além do titular: `pep_parentes` (parentes de pessoa exposta politicamente), `impedidos_vinculos` (vínculo familiar com impedido de apostar, a fase 2 da Lei 14.790/2023, Art. 26), `pep_lista_restritiva` (listas restritivas), `antecedentes_estaduais`, `processos_judiciais` e `scr_bacen` (retrato de endividamento no SCR). Todos partem do **CPF lido do documento**, e exigem Documento, Face Match e Liveness no mesmo flow pela mesma razão do `pep_sancoes`: nada aqui é consultado a partir de um número digitado. A consulta à fonte é **pelo CPF**; o **nome** só entra no `impedidos_vinculos`, e ainda assim como nome do **parente**, dentro do nosso motor local de vedação.

**Venda pausada hoje, os seis.** Eles aparecem na tabela de preços e no `GET /v1/capabilities` com preço e status `coming_soon`, e não podem ser ligados num flow enquanto a credencial do fornecedor não existir na conta. O estado vem do catálogo vivo, e esta página acompanha. O payload abaixo é o contrato que já está implementado e que passa a valer no dia da abertura.

! **Nunca leia só `flagged` nestes módulos.** Quando a fonte não é chamada (credencial ausente, portão de identidade fechado, consulta adiada), o check sai `pending` **e ainda assim traz `data`**, preenchido com os valores neutros: `flagged: false`, `matched_by: "none"`, `aggregates: null` e o bloco da fonte `null`. Isso é "não perguntamos", e é indistinguível de "nada consta" se você olhar só a flag. Ramifique por `outcome` primeiro, sempre.

**Outro campo que promete mais do que entrega:** nestes módulos `matched_by: "document"` é **derivado de `flagged`**, não é a prova de que o casamento se deu por CPF. Ele diz "houve resultado", e não "casou pelo documento". A única exceção é o `impedidos_vinculos`, onde ele é medido de verdade e distingue `document`, `document_partial` e `name`. E ele **nem existe** em dois dos seis: `antecedentes_estaduais` e `scr_bacen` não emitem a chave. Onde ela aparece fora do `impedidos_vinculos`, trate-a como sinônimo de `flagged`.

```
// pep_parentes: parente PEP encontrado. "parentes" é o dossiê da fonte passando por nós.
{ "module": "pep_parentes", "passed": false, "outcome": "failed", "score": 40,
  "data": { "flagged": true,
            "matched_by": "document",   // derivado de flagged, NÃO é prova de match por CPF
            "reason": null,
            "parentes": { "parentescosPEP": [
              { "nome": "MARIA SILVA", "cpf": "***456789**", "grauParentesco": "MAE",
                "pep": { "nome": "JOSE SILVA", "funcao": "DEPUTADO FEDERAL",
                         "orgao": "CAMARA MOCK", "nivel": "FEDERAL" } } ] } } }

// pep_lista_restritiva: o bloco "listas" é a resposta da fonte, como ela manda.
{ "module": "pep_lista_restritiva", "passed": false, "outcome": "failed", "score": 40,
  "data": { "flagged": true, "matched_by": "document",
            "listas": { "listas": [ { "lista": "LISTA RESTRITIVA MOCK", "nome": "JOAO SILVA",
                                      "origem": "MOCK", "dataInclusao": "2025-03-01" } ] } } }

// antecedentes_estaduais: repare no tri-estado de "nada_consta" e na cobertura FIXA de UFs.
{ "module": "antecedentes_estaduais", "passed": false, "outcome": "failed", "score": 40,
  "data": { "nada_consta": false,      // true | false | null. null = a fonte NÃO afirmou nada.
            "flagged": true,
            "cobertura_uf": ["CE", "MG", "MT", "RS"],   // as UFs cobertas hoje, e só elas
            "antecedentes": { "nadaConsta": false,
                              "ocorrencias": [ { "uf": "MG", "tribunal": "TJMG",
                                                 "classe": "Acao Penal", "ano": 2024 } ] } } }

// processos_judiciais: "encontrados" é contado por nós sobre a resposta, não é campo da fonte.
{ "module": "processos_judiciais", "passed": false, "outcome": "failed", "score": 40,
  "data": { "flagged": true, "matched_by": "document", "encontrados": 2,
            "processos": { "totalProcessos": 2,
                           "processos": [ { "numero": "0001234-56.2024.8.13.0000",
                                            "tribunal": "TJMG", "classe": "Execucao de Titulo",
                                            "polo": "passivo", "status": "ativo" } ] } } }

// scr_bacen: INFORMACIONAL. O dossiê é o produto, e "flagged" é sempre false.
{ "module": "scr_bacen", "passed": true, "outcome": "approved", "score": 100,
  "data": { "flagged": false,
            "scr": { "dataBase": "2026-07", "quantidadeInstituicoes": 1,
                     "quantidadeOperacoes": 2,
                     "carteira": { "vencido": 0, "aVencer": 8765.43 } } } }
```

Duas leituras que evitam conclusão errada. `nada_consta`, no `antecedentes_estaduais`, tem **três** estados e não dois: `false` quando há ocorrência, `true` só quando a fonte **afirma** que nada consta, e `null` quando não houve ocorrência e a fonte também não afirmou nada. Campo ausente na resposta **nunca** vira atestado nosso. E `scr_bacen.flagged` é **sempre `false`**, por construção: o módulo é informacional, o produto dele é o retrato de endividamento, e não existe caminho no código que o marque. Não escreva alerta em cima dessa flag.

O `impedidos_vinculos` é o mais denso dos seis, porque ele precisa provar **duas** coberturas ao mesmo tempo: de onde veio o **grafo familiar** (`graph_coverage`, hoje sempre `"pep_relatives"`, ou seja a fonte de parentesco de PEP e mais nada) e quais **bases de vedação** sustentaram a triagem de cada parente (`coverage` e `dataset_versions`, as mesmas do módulo [Impedidos de apostar](https://unifokal.com/docs/modulos/impedidos-apostar#modulo-impedidos-apostar)). E ele conta os parentes de forma auditável: `relatives_total` é quanto a fonte devolveu, `relatives_screened` é quanto o motor de fato respondeu, e `relatives_unscreened` agrupa **por motivo** quem ficou de fora. Essa lista tem vocabulário fechado de seis valores: `degree_unknown`, `degree_out_of_scope`, `document_missing`, `name_missing`, `screening_indeterminate` e `over_cap`. Repare que `relatives_total` menos `relatives_screened` **não** é o tamanho dessa lista: ela é agrupada, e cada item traz o próprio `count`.

```
// impedidos_vinculos: parente de 1o grau na base de impedidos -> review COM evidência
{ "module": "impedidos_vinculos", "passed": false, "outcome": "failed", "score": 40,
  "data": { "is_restricted": true, "flagged": true,
            "matched_by": "name",            // aqui ele é MEDIDO: document | document_partial | name
            "reason": "family_link_restricted",
            "restrictions": [ { "type": "vinculo_familiar_1g", "link_level": 1, "sport": null,
                                "entity": null, "source": "ptransp_servidores_reg",
                                "matched_by": "name",
                                "similarity": 91,       // 0..100, NÃO 0..1
                                "listed_at": null, "left_at": null,
                                "birth_date_mismatch": false,
                                "entry_ref": "ptransp_servidores_reg:e_a8954835" } ],
            "restrictions_total": 1, "restrictions_truncated": false,
            "aggregates": { "is_restricted": true,
                            "restriction_types": ["vinculo_familiar_1g"],
                            "strongest_match": "name" },
            "graph_coverage": "pep_relatives",
            "relatives_total": 2, "relatives_screened": 2, "relatives_unscreened": [],
            "coverage": ["ptransp_servidores_reg"], "coverage_degraded": [],
            "dataset_versions": { "ptransp_servidores_reg": { "ingested_at": "2026-09-01T03:00:00Z",
                                                              "age_hours": 4, "stale": false,
                                                              "disabled": false },
                                  "cbf_bid":      { "ingested_at": null, "age_hours": null,
                                                    "stale": false, "disabled": true },
                                  "cbf_arbitros": { "ingested_at": null, "age_hours": null,
                                                    "stale": false, "disabled": true } },
            "name_source": "ocr" } }

// o caminho que MAIS importa: o parente existe e NÃO foi rastreável.
// Não é "nada consta": é "não deu para perguntar", e a contagem diz por quê.
{ "module": "impedidos_vinculos", "passed": null, "outcome": "pending", "score": 0,
  "data": { "is_restricted": false, "flagged": false, "matched_by": "none",
            "reason": "relatives_unscreenable",
            "restrictions": [], "restrictions_total": 0, "restrictions_truncated": false,
            "aggregates": null,
            "graph_coverage": "pep_relatives",
            "relatives_total": 1, "relatives_screened": 0,
            "relatives_unscreened": [ { "reason": "degree_unknown", "count": 1 } ],
            // vazios porque o motor local NEM FOI CHAMADO: sem grau de parentesco não há o
            // que triar, então não há cobertura a declarar. Não confunda com base vencida.
            "coverage": [], "coverage_degraded": [], "dataset_versions": null,
            "name_source": null } }   // null porque nem chegamos a usar um nome
```

**Nenhum dos seis reprova sozinho**, pela mesma regra do `pep_sancoes`: um resultado é candidato, vai para `review` com a evidência no webhook, e a sua análise decide. E vale a mesma minimização: o que sai do `impedidos_vinculos` sobre o parente é **contagem e restrição minimizada**, nunca nome, CPF ou datas de terceiro. O nome e o documento do parente entram na consulta e morrem lá.

! **No sandbox, o desfecho destes módulos não acompanha o resultado, e isso é deliberado do ambiente de testes.** Os sufixos `88` (hit) e `77` (indeterminado, só no `impedidos_vinculos`) escolhem o **dado**, mas o `outcome` continua vindo da tabela universal do sandbox, onde só `00`, `01` e `02` mudam o desfecho. Ou seja: em sandbox você vê `outcome: "approved"` com `flagged: true`. Em produção o mesmo hit sai `failed` e a verificação vai a revisão. Use os sufixos para exercitar o **parser**, e `02` para exercitar o desfecho.
