Inyo

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.

CanalQuando se aplica
Webhookdelivery.mode é webhook (o padrão)
Redirect assinadodelivery.mode é redirect
Notificação de resultadoO 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.

ComponenteSignificado
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:

  1. Faça split do cabeçalho em , e busque a sua própria versão. Um esquema futuro será entregue como t=…,v1=…,v2=… durante uma janela de transição, então um verificador que procura por v1 continua funcionando enquanto você migra. Um verificador que assume que o cabeçalho é um único valor quebra na vírgula.
  2. 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.
  3. 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

PropriedadeComportamento
ConfirmaçãoQualquer resposta 2xx. Qualquer outra coisa conta como falha
RetriesAté 3 tentativas com backoff (aproximadamente 1s, 4s, 10s)
Após 3 falhasA entrega é descartada e não é tentada novamente depois
Timeout15 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âmetroDescrição
sessionIdA sessão que foi concluída
statusapproved, declined, expired ou in_review
sigHex do HMAC-SHA256 da string "<sessionId>.<status>", com a chave sendo seu webhookSecret
sigVersionO 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çãoO que você recebe
Sessão em modo redirect é concluídaO resultado inicial, incluindo um in_review não terminal que o próprio redirect não consegue carregar
Qualquer mudança posteriorO resultado atualizado, carregando result.manualReview
A resposta original de POST /v1/verificationsNada — 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 notifiedAt e 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çãoAbordagem recomendada
Fluxo de onboarding server-sideModo webhook, com verificação de assinatura e um handler idempotente
Fluxo web em que o cliente deve continuar imediatamenteModo redirect para o cliente, mais notificações para o registro de verdade
UI de captura própriaServer-to-server, mais um webhookUrl para que decisões posteriores cheguem até você
Crítico para conformidade, não pode perder um desfechoQualquer 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