Criar conta grátis

Documentação
Ver em Markdown

Integração ponta a ponta

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 e vira tipos TypeScript com uma linha de npx openapi-typescript.

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.

!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 / NodeCriar a sessãono seu servidor
// 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;
//   }
PythonCriar a sessãono seu servidor
# 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 / NodeA página que o seu usuário abreno seu servidor
// 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, 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.

TypeScript / NodeReceber e validar o webhookno seu servidor
// 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.
PythonReceber e validar o webhookno seu servidor
# 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?":

curlConferir uma assinatura na mãono terminal
#!/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.

statuso que significacobrado?
approvedIdentidade comprovada. Libere o que depende dela.sim
deniedReprovou em um portão (biometria, documento, coerência). Não libere.sim
reviewNem aprovado nem reprovado: alguém seu precisa olhar. Existe justamente para não aprovar no automático o que pede julgamento humano.sim
blockedO documento (ou um sócio) está na sua lista de bloqueio.não
pendingAinda processando, ou uma fonte externa não respondeu. Não é desfecho final: aguarde o próximo evento da mesma verificação.não
failedErro 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 / NodeTratar os sete desfechosno seu servidor
// 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.
PythonTratar os sete desfechosno seu servidor
# 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.

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

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

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

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

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

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

Receitas por jornada

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

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

KYC de pessoa

A jornada completa, com o porquê de cada passo, está em /solucoes/kyc-pessoa.

1. POSTCadastre o webhook de sandboxcom a sessão do painel
curl -sS -X POST https://api.unifokal.com/v1/dashboard/webhook-endpoints \
  -H "Authorization: Bearer $UNIFOKAL_PAINEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "<webhook_url>",
        "secret": "<webhook_secret>",
        "environment": "sandbox"
      }'
2. POSTCrie o flow da jornadacom a sessão do painel
curl -sS -X POST https://api.unifokal.com/v1/flows \
  -H "Authorization: Bearer $UNIFOKAL_PAINEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "KYC de pessoa",
        "subject_type": "person",
        "modules": [
          "cpf_ocr",
          "face",
          "liveness",
          "face_unica",
          "cpf_receita",
          "pep_sancoes"
        ],
        "webhook_endpoint_id": "<webhook_endpoint_id>"
      }'
3. POSTCrie a sessão de verificaçãono seu servidor
curl -sS -X POST https://api.unifokal.com/v1/verification-sessions \
  -H "Authorization: Bearer $UNIFOKAL_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "flow_id": "<flow_id>",
        "reference_id": "<reference_id>"
      }'

KYB de empresa

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

1. POSTCadastre o webhook de sandboxcom a sessão do painel
curl -sS -X POST https://api.unifokal.com/v1/dashboard/webhook-endpoints \
  -H "Authorization: Bearer $UNIFOKAL_PAINEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "<webhook_url>",
        "secret": "<webhook_secret>",
        "environment": "sandbox"
      }'
2. POSTCrie o flow da jornadacom a sessão do painel
curl -sS -X POST https://api.unifokal.com/v1/flows \
  -H "Authorization: Bearer $UNIFOKAL_PAINEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "KYB de empresa",
        "subject_type": "company",
        "modules": [
          "cnpj_ocr",
          "cnpj_cadastro",
          "cnpj_socios",
          "credito_pgfn"
        ],
        "webhook_endpoint_id": "<webhook_endpoint_id>"
      }'
3. POSTCrie a sessão de verificaçãono seu servidor
curl -sS -X POST https://api.unifokal.com/v1/verification-sessions \
  -H "Authorization: Bearer $UNIFOKAL_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "flow_id": "<flow_id>",
        "reference_id": "<reference_id>"
      }'

Antifraude transacional

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

1. POSTCadastre o webhook de sandboxcom a sessão do painel
curl -sS -X POST https://api.unifokal.com/v1/dashboard/webhook-endpoints \
  -H "Authorization: Bearer $UNIFOKAL_PAINEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "<webhook_url>",
        "secret": "<webhook_secret>",
        "environment": "sandbox"
      }'
2. POSTCrie o flow da jornadacom a sessão do painel
curl -sS -X POST https://api.unifokal.com/v1/flows \
  -H "Authorization: Bearer $UNIFOKAL_PAINEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Antifraude transacional",
        "subject_type": "person",
        "modules": [
          "transacao"
        ],
        "webhook_endpoint_id": "<webhook_endpoint_id>"
      }'
3. POSTCrie a sessão de verificaçãono seu servidor
curl -sS -X POST https://api.unifokal.com/v1/verification-sessions \
  -H "Authorization: Bearer $UNIFOKAL_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "flow_id": "<flow_id>",
        "reference_id": "<reference_id>",
        "transaction": {
          "type": "payment",
          "amount_cents": 1900,
          "external_id": "<external_id>"
        }
      }'

Background check

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

1. POSTCadastre o webhook de sandboxcom a sessão do painel
curl -sS -X POST https://api.unifokal.com/v1/dashboard/webhook-endpoints \
  -H "Authorization: Bearer $UNIFOKAL_PAINEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "<webhook_url>",
        "secret": "<webhook_secret>",
        "environment": "sandbox"
      }'
2. POSTCrie o flow da jornadacom a sessão do painel
curl -sS -X POST https://api.unifokal.com/v1/flows \
  -H "Authorization: Bearer $UNIFOKAL_PAINEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Background check",
        "subject_type": "person",
        "modules": [
          "cpf_ocr",
          "face",
          "liveness",
          "pep_sancoes",
          "midia_adversa"
        ],
        "webhook_endpoint_id": "<webhook_endpoint_id>"
      }'
3. POSTCrie a sessão de verificaçãono seu servidor
curl -sS -X POST https://api.unifokal.com/v1/verification-sessions \
  -H "Authorization: Bearer $UNIFOKAL_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "flow_id": "<flow_id>",
        "reference_id": "<reference_id>"
      }'

Humano verificado: qual peça para qual caso

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

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

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

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

Qual embalagem para qual caso.

CasoEmbalagemO que você liga
Pagamento, troca de chave Pix ou alteração de cadastro pelo seu app ou siteAprovação verificadapasskey com o bloco act, ou o step_up do transacao
Pedido que chegou por chamada, vídeo, mensagem ou e-mailConfirmação fora de bandao link com purpose igual a confirmation, sobre passkey ou face_reauth
Rede social, relacionamento, avaliações e comunidadePessoa realliveness com atestado_humano, e face_unica para dizer que não há outra conta no seu serviço

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

Os limites que mudam a sua decisão.

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

Enquanto os módulos estão pausados, integre e teste o resto do fluxo no sandbox, com o mesmo webhook que vai receber estes blocos.

Pronto para integrar? A chave de sandbox sai no painel, logo depois do cadastro. Criar conta grátis