Tratamento dos resultados
Existem duas formas de receber um veredito — polling e webhooks — e ambas rodam no seu servidor.
O evento veridia:complete do widget não é uma delas. Ele dispara no navegador no momento em que a API aceita o envio e carrega apenas { verificationId, status }. Use-o para mostrar um spinner e para registrar a qual dos seus usuários este verificationId pertence. O veredito vem do seu backend.
| Método | Quando usar | Latência | Observações |
|---|---|---|---|
| Polling | Protótipos, ou quando você não tem um endpoint HTTPS público | Segundos; você controla o intervalo | Simples, mas você precisa estar rodando para ver o resultado |
| Webhooks | Produção | Segundos após a conclusão | Com reintentos; também entrega vereditos alterados depois por um revisor humano |
Para produção, use webhooks. Há uma coisa que o polling estruturalmente não consegue te dar: quando um caso em review é depois aprovado ou rejeitado por uma pessoa no painel, essa decisão chega como webhook. Se você fez o polling uma vez, viu review e parou, nunca vai saber o desfecho.
Leia isto antes de escrever qualquer lógica de decisão
status e verdict são dois eixos diferentes, e confundi-los é o erro mais caro que esta API permite.
| Campo | Pergunta que ele responde | Valores |
|---|---|---|
status | O pipeline terminou de rodar? | queued, processing, completed, failed |
verdict | A pessoa passou? | approved, review, rejected |
status: "completed" significa que a maquinaria rodou até o fim. Não diz nada sobre o candidato ser quem afirma ser — uma verificação rejected também está completed.
// ERRADO — isso faz o onboarding de todo candidato rejeitado.
if (result.status === 'completed') {
enableUserAccount(userId);
}
// CERTO — status diz que o resultado está pronto; verdict diz qual ele é.
if (result.status === 'completed') {
onVerdict(result);
}
Três armadilhas relacionadas:
verdictpode chegar comonull. Enquanto o pipeline roda, a chave está presente com valor nulo — ela não está ausente.'verdict' in dataé verdadeiro já no primeiro polling, então não use a presença da chave como sinal de conclusão. Decida pelostatus.status: "failed"não tem veredito nenhum. O pipeline deu erro. Isso não é uma rejeição; é a ausência de um resultado. Trate à parte — normalmente pedindo ao usuário que refaça o fluxo.reviewé um veredito final, não um estado transitório. Fazer polling em loop esperando quereviewvire outra coisa espera para sempre. Ele se resolve quando um humano decide, e isso chega até você por webhook.
Método 1 — Polling em GET /v1/verify/{id}
Aqui você precisa da chave secreta
Este endpoint exige uma chave da família secreta. Uma chave publicável retorna HTTP 401 com error: "secret_key_required".
Chaves secretas de modo de teste têm o prefixo qv_sect_; as de produção são qv_sec_. Se você fez os passos 1 e 2 com uma chave qv_pubt_, sua chave secreta correspondente é qv_sect_..., da mesma seção API keys do painel. Não existe chave qv_sec_ em ambiente de teste, e não existe a forma qv_pub_test_ de nenhum dos prefixos.
Sua chave publicável está visível no código-fonte da sua página. Se a leitura de vereditos a aceitasse, qualquer pessoa poderia abrir o DevTools e extrair o resultado de KYC de qualquer verificação. É por isso que a restrição existe — mantenha a chave secreta no seu servidor.
curl
curl https://api.xxuxe.online/v1/verify/vf_AG07CDWRRFQV4T05ZXG2 \
-H "Authorization: Bearer qv_sect_SUA_CHAVE_SECRETA_DE_TESTE"
Enquanto ainda está rodando:
{
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"status": "processing",
"verdict": null,
"confidence": null,
"scores": null,
"flags": null,
"submittedAt": "2026-05-01T18:39:05Z",
"completedAt": null
}
Note que todas as chaves já estão ali, contendo null. Nada está faltando enquanto o pipeline roda — e é por isso que a presença de chaves é inútil como teste de conclusão.
Depois de pronto:
{
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"status": "completed",
"verdict": "approved",
"confidence": 91.4,
"scores": {
"ocr_confidence": 78.0,
"face_match": 96.2,
"liveness": 91.5,
"doc_quality": 85.0,
"mrz_valid": 100.0,
"name_match": 88.3
},
"flags": [
{ "level": "ok", "text": "auto_approved_all_checks_passed" }
],
"submittedAt": "2026-05-01T18:39:05Z",
"completedAt": "2026-05-01T18:39:08Z"
}
O objeto scores
Seis chaves, todas em snake_case:
| Chave | Significado | Faixa |
|---|---|---|
ocr_confidence | Quão bem o texto foi lido do documento | 0–100 |
face_match | Selfie contra a foto do documento | 0–100 |
liveness | Sinal de prova de vida | 0–100, ou null |
doc_quality | Qualidade de imagem do documento | 0–100 |
mrz_valid | Validade do checksum da zona de leitura mecânica | 0–100 |
name_match | submitted-full-name contra o nome no documento | 0–100 |
Dois modos de falha a evitar:
As chaves não são camelCase. scores.faceMatch é undefined. Isso falha de forma silenciosa e perigosa: undefined < 80 avalia como false, então uma checagem de limiar como if (scores.faceMatch < 80) reject() nunca dispara, e um controle de segurança que você acredita ter escrito fica permanentemente desativado sem nenhum erro.
liveness pode ser null — quando não houve sinal de prova de vida, ou quando a etapa de liveness deu erro. É a única pontuação anulável, e é justamente a que as pessoas mais costumam jogar dentro de contas. Verifique antes de usar:
const liveness = result.scores.liveness;
if (liveness !== null && liveness < 70) { /* ... */ }
O array flags
Uma lista de objetos, não de strings: { level, text }. Existem exatamente três níveis:
| Nível | Significado |
|---|---|
ok | Uma checagem passou. Sinal positivo |
warn | Algo foi notado, mas não é desqualificante |
err | Uma falha grave |
ok é o que pega as pessoas de surpresa. Toda verificação aprovada carrega { "level": "ok", "text": "auto_approved_all_checks_passed" }, então um resultado aprovado nunca tem um array flags vazio. Se você tratar "qualquer flag" como "um problema", vai mandar 100% das suas aprovações para revisão manual, porque o marcador de sucesso parece um aviso.
Filtre por nível, e note que o nível de uma falha grave de verdade é err:
const hardFailures = result.flags.filter(f => f.level === 'err');
Textos de flag com os quais você provavelmente vai se importar incluem possible_screen_capture (foto de uma tela em vez de um documento), mrz_viz_mismatch (a MRZ discorda dos dados impressos), active_liveness_spoof e aml_sanctions_match / aml_possible_match. Trate o texto como uma string opaca com a qual você faz correspondência, não como algo para mostrar ao usuário final — dizer a um fraudador qual checagem o pegou é feedback de calibração de graça.
JavaScript / Node.js
async function pollVerification(verificationId, maxAttempts = 30) {
for (let i = 0; i < maxAttempts; i++) {
const response = await fetch(
`https://api.xxuxe.online/v1/verify/${verificationId}`,
{ headers: { Authorization: `Bearer ${process.env.VERIDIA_SECRET_KEY}` } }
);
if (!response.ok) {
const err = await response.json();
// secret_key_required significa que você enviou uma chave publicável.
throw new Error(`${err.error}: ${err.message} (requestId ${err.requestId})`);
}
const data = await response.json();
// status é sobre o pipeline, não sobre a pessoa.
if (data.status === 'completed') return data;
if (data.status === 'failed') {
throw new Error(`Verification ${verificationId} failed to process`);
}
await new Promise(r => setTimeout(r, 1000));
}
throw new Error('Verification did not complete in time');
}
const result = await pollVerification('vf_AG07CDWRRFQV4T05ZXG2');
onVerdict(result); // decida por result.verdict, nunca por result.status
Python
import os
import time
import requests
def poll_verification(verification_id, max_attempts=30):
headers = {"Authorization": f"Bearer {os.environ['VERIDIA_SECRET_KEY']}"}
for _ in range(max_attempts):
r = requests.get(
f"https://api.xxuxe.online/v1/verify/{verification_id}",
headers=headers,
timeout=10,
)
r.raise_for_status()
data = r.json()
if data["status"] == "completed":
return data
if data["status"] == "failed":
raise RuntimeError(f"Verification {verification_id} failed to process")
time.sleep(1)
raise TimeoutError("Verification did not complete in time")
result = poll_verification("vf_AG07CDWRRFQV4T05ZXG2")
on_verdict(result["verdict"], result["scores"], result["flags"])
PHP
<?php
function pollVerification(string $verificationId, int $maxAttempts = 30): array {
$secretKey = $_ENV['VERIDIA_SECRET_KEY'];
for ($i = 0; $i < $maxAttempts; $i++) {
$ch = curl_init("https://api.xxuxe.online/v1/verify/$verificationId");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $secretKey"]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Verifique o status antes de confiar no corpo. Sem isso, um 401 —
// facilmente alcancado ao consultar com uma chave publicavel, que este
// endpoint recusa — deixa $data['status'] indefinido, o loop gasta suas
// 30 tentativas e entao reporta timeout. O diagnostico apontaria para um
// pipeline lento quando o problema real e a chave.
if ($httpCode === 401) {
throw new RuntimeException(
"401 ao ler o veredito. Este endpoint exige uma chave SECRETA "
. "(qv_sec_*); uma publicavel pode iniciar e enviar, mas nao ler resultados."
);
}
if ($httpCode < 200 || $httpCode >= 300) {
throw new RuntimeException("Veridia retornou HTTP $httpCode: $response");
}
$data = json_decode($response, true);
if ($data['status'] === 'completed') {
return $data;
}
if ($data['status'] === 'failed') {
throw new RuntimeException("Verification $verificationId failed to process");
}
sleep(1);
}
throw new RuntimeException("Verification did not complete in time");
}
$result = pollVerification("vf_AG07CDWRRFQV4T05ZXG2");
onVerdict($result);
Uma requisição por segundo durante trinta segundos está bem dentro do limite deste endpoint (600 requisições por minuto por tenant), então esses loops não vão esbarrar no limite de taxa.
Método 2 — Webhooks (recomendado para produção)
Configuração
Há um webhook por tenant, configurado no seu painel em Settings → Webhook. Dois campos:
- URL — seu endpoint. Precisa começar com
https://; o painel recusa qualquer outra coisa. Endereços privados, de loopback, link-local e CGNAT também são rejeitados. - Secret — você escolhe esse valor, mínimo de 24 caracteres. Ele não é gerado para você nem revelado de volta depois; o campo é somente escrita, e deixá-lo em branco mantém o secret atual. Gere algo aleatório, guarde no seu próprio gerenciador de segredos e cole aqui.
Essa é toda a configuração. Não há lista de endpoints nem seleção de eventos: você recebe os três tipos de evento ou nenhum. A página Webhooks do painel é o histórico de entregas, não um lugar para adicionar endpoints.
Como a URL precisa ser HTTPS e apenas endereços públicos são aceitos, você não consegue apontar um webhook para http://localhost:3000. Use ngrok, localtunnel ou Cloudflare Tunnel e registre a URL HTTPS pública que ele te der. Essa é a única forma de testar webhooks localmente.
O que você recebe
O payload é plano — não há um wrapper data:
{
"id": "evt_9f2c1b7a4e5d38c0a1b2c3d4e5f60718",
"type": "verification.review_required",
"createdAt": 1777663148,
"tenantId": "tn_default_demo",
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"verdict": "review",
"confidence": 77.85,
"userRef": "customer-12345",
"scores": {
"ocr_confidence": 20.0,
"face_match": 99.5,
"liveness": 88.0,
"doc_quality": 75.7,
"mrz_valid": 0.0,
"name_match": 61.2
},
"flags": [
{ "level": "warn", "text": "heavy_glare" }
],
"fieldsExtracted": {
"full_name": "MARIA GONZALEZ",
"document_number": "1234567",
"date_of_birth": "1990-04-12",
"nationality": "PRY",
"document_type": "dni"
},
"latencyMs": 2140
}
| Campo | Observações |
|---|---|
id | evt_ + hex. A chave de deduplicação. Estável entre reintentos do mesmo evento |
type | O discriminador em que você faz o switch. Não é event |
createdAt | Tempo Unix em segundos, um inteiro — não uma string ISO |
tenantId, verificationId | Identificadores |
verdict, confidence | O desfecho |
userRef | O que você passou como user-ref. Pode ser null se você nunca o definiu |
scores | As mesmas seis chaves em snake_case de antes; liveness pode ser null |
flags | Os mesmos objetos { level, text }, os mesmos níveis ok / warn / err |
fieldsExtracted | Dados de identidade lidos do documento — veja o aviso abaixo |
latencyMs | Tempo do pipeline. null quando um humano tomou a decisão |
O evento não inclui metadata, submittedAt nem completedAt. Se você precisa de timestamps de envio, leia-os em GET /v1/verify/{id}.
fieldsExtracted é dado pessoalTodo evento carrega o nome completo, o número do documento e a data de nascimento da pessoa. Seu endpoint de webhook é, portanto, um sistema que processa dados de identidade, e tudo o que estiver a jusante dele também é. Em particular: não registre o corpo bruto da requisição em um serviço de log de uso geral, e não o encaminhe para rastreadores de erro de terceiros, sem decidir isso deliberadamente. A maioria dos times descobre isso depois que os dados pessoais já estão no índice de logs, onde apagá-los dá muito mais trabalho do que nunca tê-los enviado.
Os três tipos de evento
Exatamente três, e nenhum outro:
verification.approvedverification.rejectedverification.review_required
Não existe verification.created nem verification.expired. Ainda assim, trate tipos desconhecidos de forma graciosa — mas não construa lógica para tipos específicos que não existem.
switch (payload.type) { // `type`, não `event`
case 'verification.approved': return onApproved(payload);
case 'verification.rejected': return onRejected(payload);
case 'verification.review_required': return onReview(payload);
default:
console.warn('Unknown Veridia event type:', payload.type);
}
Verificando a assinatura
Cada entrega carrega dois headers:
Veridia-Signature: t=1777663148,v1=5f8c...e21
Veridia-Event: verification.review_required
O MAC é HMAC-SHA256 sobre os bytes "<t>." + rawBody, com chave igual ao secret do seu webhook. O timestamp fica dentro do header de assinatura — não há um header de timestamp separado, nem variante com prefixo X- de nenhum dos dois.
Verifique contra os bytes brutos da requisição. Fazer o parse do JSON e reserializá-lo muda o digest, e sua verificação vai falhar por mais correto que esteja o resto do seu código. No Express use express.raw(), no Flask request.get_data(), no PHP php://input.
Uma tolerância de 300 segundos em t está correta e você não deve ampliá-la. O despachante reassina a cada reintento, então a sexta tentativa — doze minutos após a primeira — chega com um t novo, não um antigo. Ampliar a janela não traz nada e enfraquece sua proteção contra replay.
Exemplos completos e comentados em quatro linguagens: verificação de assinatura.
Entrega e reintentos
A entrega é ao menos uma vez. As entregas são enfileiradas em um outbox transacional e reenviadas em caso de falha com backoff de 1s, 5s, 30s, 2min, 10min — seis tentativas ao longo de cerca de 12,6 minutos. Depois disso, a entrega fica parada como failed, e um operador pode reenfileirá-la pelo painel.
A causa comum de uma duplicata não é um bug de nenhum dos lados: seu handler processou o evento corretamente, mas demorou mais que o timeout para responder, então o 200 nunca chegou e o despachante tentou de novo. Assuma que isso vai acontecer.
Deduplique pelo id. Ele é o mesmo valor em todos os reintentos de um evento, e é diferente para cada evento — inclusive para dois eventos sobre a mesma verificação, que é exatamente o caso em que uma chave como verificationId + type erra. Uma verificação que volta como review_required e depois é aprovada por um revisor produz dois eventos para um mesmo verificationId; deduplique por qualquer coisa que não seja id e você vai descartar a decisão do humano e deixar aquele usuário pendente para sempre.
const seen = await db.webhookEvents.findUnique({ where: { id: payload.id } });
if (seen) return res.status(200).end(); // já tratado
await db.webhookEvents.create({ data: { id: payload.id } });
Responda 2xx rapidamente e faça o trabalho pesado depois — a entrega expira após 10 segundos.
Agindo com base no veredito
async function onVerdict(payload) {
// Venha por qual caminho vier, primeiro mapeie de volta para o seu usuário.
// De um webhook: payload.userRef (se você definiu user-ref).
// Do polling: sua própria tabela verificationId -> userId, salva quando
// o widget disparou veridia:complete.
const userId = await resolveUser(payload);
switch (payload.verdict) {
case 'approved':
await enableUserAccount(userId);
break;
case 'review':
// Final até um humano decidir. A decisão chega como um segundo webhook.
await queueForReview(userId, payload.verificationId, payload.flags);
break;
case 'rejected':
await blockUserKyc(userId, payload.verificationId);
break;
default:
// verdict era null: o pipeline não terminou. Não aja.
console.error('No verdict yet for', payload.verificationId);
}
}
O que vem a seguir
Início rápido concluído. Daqui em diante, dependendo do que você estiver construindo:
- Documentação do widget — todos os atributos, eventos e opções de estilo
- Referência da API — API REST completa para integrações no servidor e com clientes customizados
- Webhooks — verificação de assinatura, comportamento de reintento, exemplos comentados
- Compliance — retenção de dados e postura regulatória
Precisa de ajuda? Fale com o suporte.