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.
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.
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.// 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;
// }# 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
# raiseO 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.
// 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.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á.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.
// 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.# 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?":
#!/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.
// 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.# 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.Nada se perde. Liste o que não foi entregue e redispare: você recebe o corpo exato que teria recebido, com assinatura nova.
# 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.
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"
}'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>"
}'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.
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"
}'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>"
}'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.
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"
}'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>"
}'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.
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"
}'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>"
}'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.
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.
- Há uma pessoa viva diante da câmera agora? A prova de vida, módulo
liveness(documento, face match e prova de vida). - É 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. - É a credencial que ela vinculou à conta? A passkey do titular, vinculada no onboarding com
passkey_binde conferida pelo módulopasskey(Aprovação de ato com passkey). - Ela aprova este ato, por um canal que o pedido não controla? O bloco
actna sessão, ostep_updo gate transacional e o link de confirmação fora de banda, que você manda pelo canal que já tinha cadastrado. - 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.
| Caso | Embalagem | O que você liga |
|---|---|---|
| Pagamento, troca de chave Pix ou alteração de cadastro pelo seu app ou site | Aprovação verificada | passkey com o bloco act, ou o step_up do transacao |
| Pedido que chegou por chamada, vídeo, mensagem ou e-mail | Confirmação fora de banda | o link com purpose igual a confirmation, sobre passkey ou face_reauth |
| Rede social, relacionamento, avaliações e comunidade | Pessoa real | liveness com atestado_humano, e face_unica para dizer que não há outra conta no seu serviço |
O que volta. Tudo chega no webhook assinado. O bloco do módulo passkey em check_details diz qual chave aprovou (passkey_id), como ela foi vinculada (bound_by e assurance), se ela é sincronizável (backup_eligible e backup_state), se houve verificação do usuário no aparelho (user_verified), qual ato ela assinou (act_digest e act_kind) e a evidência conferível sem nos consultar. Na confirmação, data.purpose e data.act casam a resposta com o seu pedido. No atestado, data.attestation traz o token assinado. Os eventos próprios são passkey.bound, passkey.revoked e act.rejected, este quando o titular diz que não reconhece o pedido.
Os limites que mudam a sua decisão.
- O atestado não é anônimo perante a emissora: a UNIFOKAL, que emite, consegue ligar o atestado à verificação que o originou. O que ele garante é que dois serviços que recebem atestados não ligam as contas entre si por meio dele.
- A passkey sincronizada não fica num aparelho só: ela acompanha o titular nos aparelhos em que ele usa o mesmo gerenciador de senhas. Quando o seu caso exige um aparelho só, ligue no flow a opção "Só credencial de um aparelho", e a chave sincronizável é recusada com
passkey_not_device_bound. Sem a opção, leiabackup_eligibleno webhook. - Só a chave vinculada com rosto e prova de vida (
assuranceigual aidentidade) 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