> ## Documentation Index
> Fetch the complete documentation index at: https://docs.unifokal.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Autenticação e segurança

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

## Autenticação

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

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

```sh theme={null}
# A chave secreta NUNCA sai do seu servidor. Aqui ela vem do ambiente, nunca do arquivo:
#   export UNIFOKAL_SECRET_KEY="unifokal_live_..."     (use unifokal_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.
```

Se uma chave sua aparecer fora do seu servidor ou for usada sem a sua autorização, a UNIFOKAL pode revogá-la na hora e avisa por e-mail os owners e admins da conta (Termos, item 3.4). Chave revogada responde `401 invalid_api_key`: crie outra no painel e troque na integração.

Revogar uma chave, por você ou pela UNIFOKAL, corta também o que ela emitiu e ainda não começou: o link de verificação ainda não usado deixa de abrir e a sessão que o titular ainda não enviou deixa de valer. A verificação já enviada segue, é decidida e chega ao seu webhook. Para trocar a chave sem cancelar nada, use **Rotacionar com prazo** no painel: a chave antiga continua valendo até o prazo (até 7 dias) e o que ela já criou segue até o próprio vencimento.

**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 theme={null}
# 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.

# 400 / 413 -> o corpo nem chegou a ser lido: JSON que nao se le (aspas faltando, byte de controle
#        cru, texto fora de UTF-8) ou corpo acima do teto. Valem em toda rota da chave secreta com corpo.
#   { "error": "invalid_json",      "message": "Invalid JSON body" }
#   { "error": "payload_too_large", "message": "Request body too large" }

# 403 -> a organizacao esta suspensa: TODA chamada com a chave secreta 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 -> a conta esta em revisao: novas operacoes de producao ficam paradas ate o fim da revisao.
#        Leitura e sandbox seguem.
#   { "error": "account_under_review", "message": "Account under review: new production operations are refused until the review ends. Reads and sandbox keep working." }

# 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 a chave secreta (unifokal_live_ ou unifokal_test_).
#   { "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 -> o flow tem um modulo sem o modulo de que ele precisa para decidir (por exemplo face sem
#        documento, cpf_ocr ou doc_global). Edite os modulos do flow; repetir nao muda nada.
#   { "error": "module_dependency_violation", "message": "flow flow_...: module face needs a document module in the same flow (cpf_ocr or doc_global) and cannot reach a decision without it; edit the flow modules before creating sessions" }

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

# 422 -> o reference_id nao e um id do seu sistema. Fora do conjunto (letras, digitos e . _ : @ -, sem
#        espaco nem caractere invisivel) e reference_id_charset; com forma de dado pessoal (CPF, CNPJ,
#        telefone, e-mail) e pii_shaped_value. Use o id do usuario no seu sistema; um UUID serve.
#   { "error": "reference_id_charset", "message": "Field reference_id has characters outside [A-Za-z0-9._:@-] (1..255)." }
#   { "error": "pii_shaped_value", "message": "Field reference_id looks like personal data (CPF/CNPJ/e-mail/phone/Pix key). Send an opaque id." }

# 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 (unifokal_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_sanctions 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)" }

# 400 -> o return_url e https e o host dele nao esta na lista de destinos de retorno do ambiente
#        (painel, Organizacao). Acrescente o host la; a lista de sandbox e a de producao sao separadas.
#   { "error": "return_url_not_allowed", "message": "return_url host is not on the allowlist of this environment: add it in the dashboard (Organization, return destinations)." }

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

# 422 -> "policy.allow_pep" true num flow que recusa PEP, sem a permissao do flow para afrouxar pela sessao.
#   { "error": "pep_override_not_enabled", "message": "Flow does not allow loosening allow_pep per session; enable allow_pep_override_enabled on the flow (owner or admin)." }

# 422 -> o "reference_id" esta na lista de bloqueio da conta (do flow ou da conta inteira). O titular nao
#        passa pela captura; trate a pessoa como barrada no seu sistema.
#   { "error": "reference_blocklisted", "message": "reference_id is on the blocklist of this organization for this flow." }

# 422 -> o bloco "auth" num flow sem a autenticacao do usuario final, ou o flow com ela sem o bloco.
#   { "error": "user_auth_not_enabled", "message": "This flow does not run the end-user authentication: remove `auth`." }
#   { "error": "auth_required", "message": "This flow runs the end-user authentication: send the `auth` block with the PKCE challenge." }

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

> [!WARNING]
>
> 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://docs.unifokal.com/ambientes.md#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 chave secreta (`unifokal_live_` ou `unifokal_test_`), usada no backend para **criar a sessão**, é a única credencial sensível e nunca sai do servidor. A captura e o processamento acontecem no UNIFOKAL; o resultado oficial só trafega no **webhook assinado** para o seu backend.

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

## Acesso da equipe por SSO (SAML 2.0)

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

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

**Formato do identificador (NameID):** o padrão é o de e-mail (*EmailAddress*). No Okta, o aplicativo precisa do *Name ID format* EmailAddress. No Microsoft Entra ID, o identificador padrão é o UPN (*user.userprincipalname*): se ele não for o e-mail da pessoa, troque a fonte para *user.mail* ou informe na tela o atributo de e-mail. A tela também aceita o persistente, o não especificado e o transitório. O persistente e o transitório tiram o e-mail do identificador e por isso pedem o atributo em que o provedor envia o e-mail. O pedido de login não fixa a forma de autenticação: o provedor informa a que a pessoa usou.

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

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

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

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

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

**Papel por grupo do provedor.** Na tela de SSO você informa o atributo em que o seu provedor envia os grupos da pessoa e liga cada grupo a um papel. Quem entra pela primeira vez pelo SSO recebe o papel do grupo mais forte em que está, ou o papel padrão da conexão se nenhum grupo casar. O papel de quem já está na equipe nunca é reescrito pelo grupo, e nenhum grupo dá a titularidade da conta: ela só muda por transferência no painel.

**Saída avisada pelo provedor.** Quando o seu provedor manda o aviso de logout assinado, as sessões abertas por ele para aquela pessoa caem aqui também. A sessão de quem entrou por senha continua de pé, porque o provedor só responde pelo que ele abriu. Aviso sem assinatura válida é recusado.

**Saída pelo provedor ao sair daqui.** Com a opção ligada na tela de SSO, quem entrou pelo provedor e clica em Sair encerra a sessão daqui primeiro e é levado ao provedor para encerrar a sessão por lá, o que pode encerrar também os outros aplicativos da pessoa. A sessão daqui cai sempre, mesmo que o provedor não responda. Antes de ligar, libere no provedor o logout iniciado pelo aplicativo: no Okta, a Single Logout URL é a nossa URL de logout, o SP Issuer é o nosso identificador e o certificado é o nosso certificado de assinatura (deixe Signed Requests desmarcado); no Microsoft Entra ID, a Logout Url é a nossa URL de logout (não ligue Require verification certificates). Vale para quem entrar depois de ligar. Com a opção desligada, Sair encerra só a sessão daqui, e a tela de entrada avisa que a sessão no provedor continua aberta.

O que **ainda não** existe, para você não planejar em cima do que não temos: o **login iniciado pelo provedor** (o acesso começa sempre pela nossa tela de entrada) e o aviso ao provedor quando a sessão daqui cai sem a pessoa clicar em Sair (expiração, remoção pelo SCIM, encerramento pelo dono da conta).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.