Recebendo Resultados
O desfecho de uma verificação chega até você por um de três canais. Todos os três são assinados com seu webhookSecret, e GET /v1/sessions/{sessionId} é sempre o registro oficial por trás deles.
| Canal | Quando se aplica |
|---|---|
| Webhook | delivery.mode é webhook (o padrão) |
| Redirect assinado | delivery.mode é redirect |
| Notificação de resultado | O resultado de uma sessão de redirect, um resultado server-to-server que muda depois, ou qualquer decisão tomada a posteriori |
Modo Webhook
A Inyo faz um POST do resultado normalizado para o seu webhookUrl configurado:
POST /kyc-result HTTP/1.1 Content-Type: application/json X-Inyo-Signature: t=1704829200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Verifique a Assinatura
Este é o mesmo formato de cabeçalho usado pela API de Remessas, então um único verificador atende aos dois produtos.
| Componente | Significado |
|---|---|
t=<segundos> | Timestamp Unix de quando a assinatura foi calculada — use-o para rejeitar replays fora de uma janela de tolerância |
v1=<hex> | HMAC-SHA256 codificado em hexadecimal, com a chave sendo seu webhookSecret. v1 é a versão atual do esquema |
A sequência de bytes assinada é a concatenação ASCII:
<timestamp> + "." + <corpo bruto da requisição>
Use os dígitos de t= exatamente como aparecem — não faça parse e reformatação.
Verifique contra os bytes brutos, antes de fazer o parse do JSON. Fazer parse e reserializar altera espaços em branco e ordem de chaves, o que altera os bytes, o que quebra a assinatura. Esta é a falha de integração mais comum de todas. Compare em tempo constante — nunca com
==em strings.
Três regras que evitarão uma migração no futuro:
- Faça split do cabeçalho em
,e busque a sua própria versão. Um esquema futuro será entregue comot=…,v1=…,v2=…durante uma janela de transição, então um verificador que procura porv1continua funcionando enquanto você migra. Um verificador que assume que o cabeçalho é um único valor quebra na vírgula. - Rejeite um timestamp fora da sua tolerância. 300 segundos é uma janela razoável. Sem essa checagem o timestamp é decoração e uma entrega capturada pode ser reproduzida para sempre.
- Rejeite um cabeçalho que repita uma versão. Nunca enviamos um assim, mas o HTTP permite que proxies juntem cabeçalhos duplicados com vírgulas — e como este formato é delimitado por vírgula, escolher um deles silenciosamente faria a verificação depender da ordem.
Node.js (Express):
import express from "express";
import crypto from "node:crypto";
const app = express();
// corpo bruto, não express.json()
app.post(
"/kyc-result",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("X-Inyo-Signature") ?? "";
// faz split da lista e rejeita chave repetida em vez de escolher uma
const parts = new Map();
for (const entry of header.split(",")) {
const [key, value] = entry.trim().split("=");
if (!key || !value) continue;
if (parts.has(key)) return res.sendStatus(401);
parts.set(key, value);
}
const timestamp = parts.get("t");
const signature = parts.get("v1");
if (!timestamp || !signature) return res.sendStatus(401);
// rejeita replays: sem isso o timestamp não serve para nada
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.sendStatus(401);
}
const expected = crypto
.createHmac("sha256", process.env.KYC_WEBHOOK_SECRET)
.update(`${timestamp}.`)
.update(req.body)
.digest("hex");
const expectedBuf = Buffer.from(expected, "utf8");
const signatureBuf = Buffer.from(signature, "utf8");
if (
expectedBuf.length !== signatureBuf.length ||
!crypto.timingSafeEqual(expectedBuf, signatureBuf)
) {
return res.sendStatus(401);
}
const result = JSON.parse(req.body.toString("utf8"));
handleResult(result);
res.sendStatus(200);
}
);
Python (FastAPI):
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
TOLERANCE_SECONDS = 300
def parse_signature(header: str) -> dict[str, str]:
"""Retorna {} quando uma chave se repete, em vez de escolher uma silenciosamente."""
parts: dict[str, str] = {}
for entry in header.split(","):
key, _, value = entry.strip().partition("=")
if not key or not value:
continue
if key in parts:
return {}
parts[key] = value
return parts
@app.post("/kyc-result")
async def kyc_result(request: Request, x_inyo_signature: str = Header(default="")):
raw = await request.body()
parts = parse_signature(x_inyo_signature)
timestamp, signature = parts.get("t"), parts.get("v1")
if not timestamp or not signature:
raise HTTPException(status_code=401, detail="invalid signature")
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
raise HTTPException(status_code=401, detail="signature timestamp outside tolerance")
expected = hmac.new(
os.environ["KYC_WEBHOOK_SECRET"].encode(),
f"{timestamp}.".encode() + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(status_code=401, detail="invalid signature")
handle_result(json.loads(raw))
return {"ok": True}
crypto.timingSafeEqual lança exceção quando os comprimentos diferem, então o exemplo em Node verifica o comprimento primeiro; hmac.compare_digest trata isso internamente.
Rotacionando seu Webhook Secret
Um secret pode ser rotacionado — fale com seu contato na Inyo. A rotação vale imediatamente e não é gradual: entregas assinadas com o secret anterior param no instante em que o novo é emitido, então planeje uma janela curta em que seu verificador aceite qualquer um dos dois valores e descarte o antigo quando parar de vê-lo. A rotação também invalida qualquer URL de redirect já emitida para uma sessão em andamento, porque a assinatura foi calculada com o secret aposentado. Rotacione entre jornadas de clientes sempre que possível.
Garantias de Entrega
| Propriedade | Comportamento |
|---|---|
| Confirmação | Qualquer resposta 2xx. Qualquer outra coisa conta como falha |
| Retries | Até 3 tentativas com backoff (aproximadamente 1s, 4s, 10s) |
| Após 3 falhas | A entrega é descartada e não é tentada novamente depois |
| Timeout | 15 segundos por tentativa |
A entrega é best-effort. Responda 2xx rapidamente e processe de forma assíncrona — um handler lento consome o timeout e transforma uma verificação bem-sucedida em uma entrega descartada. Se você precisa de uma garantia em vez de um push, faça polling em GET /v1/sessions/{sessionId}.
Torne seu handler idempotente. O resultado da mesma sessão pode legitimamente chegar mais de uma vez: um retry depois que seu 2xx se perdeu no caminho, ou um desfecho genuinamente mais novo (veja ordenação).
Modo Redirect
Quando a sessão foi criada com delivery.mode: "redirect", o cliente é devolvido à sua redirectUrl com o desfecho na query string:
https://you.example/kyc-done?sessionId=9f1c8e42…&status=approved&sig=7b3e1d…&sigVersion=v1
| Parâmetro | Descrição |
|---|---|
sessionId | A sessão que foi concluída |
status | approved, declined, expired ou in_review |
sig | Hex do HMAC-SHA256 da string "<sessionId>.<status>", com a chave sendo seu webhookSecret |
sigVersion | O esquema com que sig foi calculado — atualmente sempre v1 |
O redirect não é assinado como o cabeçalho do webhook. sig é um digest hexadecimal puro de 64 caracteres, sem timestamp, e a versão viaja como parâmetro próprio em vez de dentro do valor. Os dois diferem porque um redirect é uma URL que o navegador do seu cliente segue, não um corpo de requisição que nós controlamos: manter sig puro faz o verificador que você já escreveu continuar funcionando, e sigVersion dá a um esquema futuro onde se anunciar sem um dia de virada.
Verifique a Assinatura do Redirect
import hashlib
import hmac
def valid_redirect(session_id: str, status: str, sig: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(), f"{session_id}.{status}".encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, sig)
Leia sigVersion e rejeite um valor que seu código não implementa — é isso que o torna útil. Tratar uma versão desconhecida como v1 verificaria um digest futuro contra o esquema errado e falharia de um jeito que parece adulteração. Não há timestamp para checar, então limite o acesso por conta própria: um redirect só faz sentido para uma sessão que você está esperando naquele momento.
Como status faz parte da assinatura, um cliente não pode reescrever declined como approved, e uma sessão retida não pode ser reproduzida como aprovação. Uma assinatura que não valida significa que os parâmetros foram adulterados — rejeite o acesso por completo.
O status em um Redirect Pode Ser in_review
Uma sessão retida ainda devolve seu cliente a você em vez de abandoná-lo em uma tela de "em análise". Sua página de destino deve tratar in_review como um estado pendente — nem como falha, nem como aprovação. A decisão final do analista não estará no redirect com o qual o cliente chegou; ela chega até você por uma notificação de resultado ou por polling.
Notificações de Resultado
Um redirect viaja de volta no navegador do seu cliente, e uma verificação server-to-server responde em um corpo de resposta. Nenhum dos dois sobrevive a uma mudança posterior — e desfechos mudam, quando um analista decide uma sessão enfileirada ou uma decisão de controle de qualidade reverte uma sessão concluída.
Então, quando você tem um webhookUrl configurado, os resultados também são enviados via POST para ele — assinados exatamente como na entrega em modo webhook — para sessões cuja entrega voltada ao cliente é outra:
| Situação | O que você recebe |
|---|---|
| Sessão em modo redirect é concluída | O resultado inicial, incluindo um in_review não terminal que o próprio redirect não consegue carregar |
| Qualquer mudança posterior | O resultado atualizado, carregando result.manualReview |
A resposta original de POST /v1/verifications | Nada — o 201 foi a entrega |
Sessões em modo webhook não são afetadas: ali o webhook é a entrega, não uma notificação sobre ela.
Ordenando Notificações Concorrentes
Toda notificação carrega um timestamp notifiedAt:
{
"sessionId": "9f1c8e42…",
"status": "approved",
"autoStatus": "declined",
"manualReview": { "action": "review", "reviewer": "analyst-7", "reason": "…", "autoStatus": "declined" },
"notifiedAt": "2026-07-31T14:41:09.882Z"
}
Uma sessão pode ter duas entregas em trânsito ao mesmo tempo — uma ainda em retry enquanto uma decisão mais nova é despachada — então elas podem chegar fora de ordem.
Aplique o maior
notifiedAte ignore qualquer coisa mais antiga. Não confie na ordem de chegada.
notifiedAt carimba a entrega, não o resultado armazenado, então ele está ausente do resultado que você lê via GET /v1/sessions/{sessionId} — esse endpoint sempre retorna o estado atual, então não há nada a ordenar.
Desativando
As notificações de resultado vêm ativadas por padrão. Elas podem ser desativadas para o seu tenant, deixando o redirect do navegador ou a resposta síncrona como seu único canal — fale com seu contato na Inyo. Ativar notificações sem um webhookUrl é rejeitado em vez de ignorado silenciosamente.
Escolhendo uma Estratégia
| Sua situação | Abordagem recomendada |
|---|---|
| Fluxo de onboarding server-side | Modo webhook, com verificação de assinatura e um handler idempotente |
| Fluxo web em que o cliente deve continuar imediatamente | Modo redirect para o cliente, mais notificações para o registro de verdade |
| UI de captura própria | Server-to-server, mais um webhookUrl para que decisões posteriores cheguem até você |
| Crítico para conformidade, não pode perder um desfecho | Qualquer uma das opções acima mais um job de reconciliação fazendo polling em GET /v1/sessions/{sessionId} para sessões sem status terminal |
Próximos Passos
- Verificações e Decisões — interpretando o payload que você acabou de verificar
- Revisão Manual — o que
in_reviewemanualReviewsignificam
