Webhooks
Webhooks permitem que a Sentinex Risk avise a sua aplicação quando algo acontece no seu tenant, em vez de você consultar a API em intervalos. A subscription é gerenciada pela própria chave de API, em /vasp/webhooks, com o escopo vasp:webhooks:manage.
Eventos que a integração de KYT recebe
Seção intitulada “Eventos que a integração de KYT recebe”Estes são os eventos que o código de produção emite numa integração de KYT:
| Evento | Quando é emitido |
|---|---|
kyt.screening.completed |
Uma análise de POST /vasp/screenings foi concluída e gravada. A repetição idempotente devolve a análise original e não emite de novo |
kyt.screening.sanction_hit |
A mesma análise teve sanctions.hit verdadeiro. Chega junto do kyt.screening.completed |
kyt.case.client_decision.recorded |
A decisão do cliente foi registrada, por PATCH /vasp/screenings/{id}/client-decision ou pela fila de análise do painel |
customer.created |
POST /vasp/customers criou um cliente |
wallet.linked |
POST /vasp/wallets vinculou uma carteira, nova ou já conhecida do tenant, ou PATCH /vasp/wallets/{id} reativou uma carteira desativada |
wallet.deactivated |
DELETE /vasp/wallets/{id} desativou uma carteira |
alert.created |
Um alerta foi criado para o tenant, qualquer que seja a origem: análise sinalizada, matrícula de carteira em revisão, monitoramento contínuo ou detector agendado. Sai depois que a gravação do alerta foi confirmada, um evento por alerta, e alerta desfeito na mesma operação não gera evento |
alert.resolved |
Um alerta do tenant foi resolvido no painel |
O evento de screening notifica por referência, com payload reduzido do catálogo: a conclusão carrega screening_ref, recommendation, risk_band, sanctions_match e completed_at; o evento de sanção carrega screening_ref, risk_band, match_scope e completed_at. Endereço, hash, ativo, score numérico, regras e customer_ref não são enviados nesses eventos. Use GET /vasp/screenings/{id} para consultar a evidência com autenticação. Não use o evento como substituto da resposta completa do screening.
O payload de alert.created contém alert_ref, category_code, severity, status e created_at. O prazo, score numérico, regra, descrição, metadata, tags e evidências ficam na consulta autenticada de alertas. Use alert_ref para correlacionar com o id de GET /vasp/alerts; não espere alert_id nesse payload de notificação.
Além dos eventos de negócio, existe o webhook.test, entregue apenas quando você chama POST /vasp/webhooks/{id}/test. Ele não pode ser assinado na lista events e não indica efeito de negócio.
Todos os eventos: events: [] (lista vazia) assina todos os nomes aceitos.
Criar a subscription
Seção intitulada “Criar a subscription”O secret HMAC é gerado por você e enviado no corpo. O servidor guarda o secret cifrado e um resumo SHA-256 dele, e nunca o devolve: não existe rota que o recupere nem que o rotacione.
curl -X POST https://crypto-api.sentinexrisk.com/api/v1/vasp/webhooks \ -H "X-API-Key: $SENTINEX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "target_url": "https://hooks.demovasp.com.br/sentinex", "events": ["kyt.screening.completed", "kyt.screening.sanction_hit", "kyt.case.client_decision.recorded"], "description": "Mesa de risco", "secret": "<de 32 a 128 bytes gerados por você, por exemplo: openssl rand -hex 32>" }'| Campo | Regra conferida no servidor |
|---|---|
target_url |
HTTPS obrigatório, com host, sem usuário e senha na URL, sem query string e sem fragmento. Violação responde 400 com target_url must be valid HTTPS URL |
secret |
De 32 a 128 bytes. Fora da faixa responde 400 com secret must contain 32 to 128 bytes |
events |
Cada item precisa estar na lista aceita. Item fora dela responde 400 com invalid event: <nome>. Lista vazia assina todos |
description |
Opcional, texto livre |
Resposta 201, sem envelope data e sem o secret:
{ "success": true, "id": "3f0d1e42-8f8e-4a1c-9b7a-2c5d7e9f1a3b", "target_url": "https://hooks.demovasp.com.br/sentinex", "events": ["kyt.screening.completed", "kyt.screening.sanction_hit", "kyt.case.client_decision.recorded"]}GET /vasp/webhooks lista as suas subscriptions com active, consecutive_failures, last_success_at e last_failure_at, e DELETE /vasp/webhooks/{id} apaga uma delas.
Quem recebe entrega
Seção intitulada “Quem recebe entrega”Os eventos de negócio só são entregues quando o tenant é um cliente em produção: ativo, do tipo cliente, fora de demonstração e de dados sintéticos, no modo live, faturável e com efeitos externos liberados. Num tenant que não atende a essas condições, a entrega é encerrada sem chegar à sua URL e sem contar como falha da subscription. O webhook.test é a exceção: ele é entregue em qualquer tenant, justamente para você validar o receptor antes do tráfego real.
Assinatura HMAC SHA-256
Seção intitulada “Assinatura HMAC SHA-256”Toda entrega chega assim:
POST /sentinex HTTP/1.1Content-Type: application/jsonUser-Agent: Sentinex-Webhook/1.0X-Sentinex-Signature: 5d3b7a9e1c4f2d6b8a7e9c3d5f1b2a4e6c8d7f9b1a3e5c7d9b2a4f6e8c1d3b5aX-Sentinex-Timestamp: 1758808800X-Sentinex-Event-Type: kyt.screening.completedX-Sentinex-Event-Id: 7c9e6679-7425-40de-944b-e07fc1f90ae7X-Sentinex-Delivery-Id: 0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5dX-Sentinex-Attempt: 1
{"attempt":1,"delivery_id":"0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d","event_id":"7c9e6679-7425-40de-944b-e07fc1f90ae7","event_type":"kyt.screening.completed","payload":{},"subscription_id":"3f0d1e42-8f8e-4a1c-9b7a-2c5d7e9f1a3b","tenant_id":"a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"}| Header | Conteúdo |
|---|---|
X-Sentinex-Signature |
HMAC SHA-256 em hex puro, minúsculo, 64 caracteres. Sem prefixo t=, sem v1=, sem lista de versões |
X-Sentinex-Timestamp |
Unix epoch em segundos, gerado no momento de cada tentativa |
X-Sentinex-Event-Type |
Nome do evento, igual ao campo event_type do corpo |
X-Sentinex-Event-Id |
UUID do evento. Estável entre tentativas |
X-Sentinex-Delivery-Id |
UUID da entrega. Estável entre tentativas |
X-Sentinex-Attempt |
Número da tentativa desta entrega. Começa em 1 e aumenta nas retentativas |
Corpo da entrega
Seção intitulada “Corpo da entrega”O corpo é um envelope de sete campos. O conteúdo do evento vem dentro de payload, não de data.
| Campo | Tipo | Nota |
|---|---|---|
event_type |
string | Nome do evento |
event_id |
string (UUID) | Estável entre tentativas |
delivery_id |
string (UUID) | Estável entre tentativas |
subscription_id |
string (UUID) | Subscription que originou a entrega |
tenant_id |
string (UUID) | Seu tenant |
attempt |
inteiro | Começa em 1 e sobe a cada retentativa |
payload |
objeto | Conteúdo do evento |
Deduplique pelo par event_id mais delivery_id. Não deduplique pelo corpo inteiro, porque attempt muda entre tentativas e, com ele, a assinatura.
Verificar a assinatura
Seção intitulada “Verificar a assinatura”A assinatura é hex(HMAC_SHA256(secret, timestamp + "." + corpo_cru)), calculada sobre os bytes crus do corpo, antes de qualquer parsing, com o valor exato do header X-Sentinex-Timestamp. Compare em tempo constante e contra a string hex inteira. O servidor não impõe janela de tempo do seu lado: a tolerância de 5 minutos dos exemplos é uma defesa contra reenvio que você aplica no seu receptor.
import crypto from 'node:crypto';
function verificarWebhookSentinex(headers, corpoCru, secret) { const assinatura = headers['x-sentinex-signature']; const timestamp = headers['x-sentinex-timestamp']; if (typeof assinatura !== 'string' || typeof timestamp !== 'string') { throw new Error('Headers de assinatura ausentes'); } if (!/^[0-9]+$/.test(timestamp) || !Number.isSafeInteger(Number(timestamp)) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { throw new Error('Timestamp fora da janela'); } const esperada = crypto .createHmac('sha256', secret) .update(`${timestamp}.`) .update(corpoCru) // Buffer com os bytes recebidos, não o JSON interpretado .digest('hex'); const recebida = Buffer.from(assinatura, 'utf8'); const calculada = Buffer.from(esperada, 'utf8'); if (recebida.length !== calculada.length || !crypto.timingSafeEqual(recebida, calculada)) { throw new Error('Assinatura inválida'); }}func verificarWebhookSentinex(r *http.Request, secret string) ([]byte, error) { assinatura := r.Header.Get("X-Sentinex-Signature") timestamp := r.Header.Get("X-Sentinex-Timestamp") if assinatura == "" || timestamp == "" { return nil, errors.New("headers de assinatura ausentes") } segundos, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return nil, errors.New("timestamp inválido") } if math.Abs(float64(time.Now().Unix()-segundos)) > 300 { return nil, errors.New("timestamp fora da janela") } corpo, err := io.ReadAll(r.Body) if err != nil { return nil, errors.New("corpo ilegível") } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(timestamp)) mac.Write([]byte(".")) mac.Write(corpo) esperada := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(assinatura), []byte(esperada)) { return nil, errors.New("assinatura inválida") } return corpo, nil}import hashlibimport hmacimport time
def verificar_webhook_sentinex(headers, corpo_cru: bytes, secret: str) -> None: assinatura = headers.get("X-Sentinex-Signature") timestamp = headers.get("X-Sentinex-Timestamp") if not assinatura or not timestamp: raise ValueError("Headers de assinatura ausentes") if abs(time.time() - int(timestamp)) > 300: raise ValueError("Timestamp fora da janela") esperada = hmac.new( secret.encode(), f"{timestamp}.".encode() + corpo_cru, hashlib.sha256, ).hexdigest() if not hmac.compare_digest(assinatura, esperada): raise ValueError("Assinatura inválida")Retentativa e pausa
Seção intitulada “Retentativa e pausa”Uma entrega é considerada falha quando a resposta sai da faixa 2xx, quando há erro de transporte ou quando a sua URL não responde em 10 segundos. São até 10 tentativas no total, com espera crescente depois de cada falha:
| Depois da falha | Próxima tentativa em |
|---|---|
| 1ª | 2 minutos |
| 2ª | 4 minutos |
| 3ª | 8 minutos |
| 4ª | 16 minutos |
| 5ª | 32 minutos |
| 6ª a 9ª | 60 minutos cada |
| 10ª | a entrega vira dead e não é mais tentada |
Depois de 10 falhas consecutivas na mesma subscription, ela é pausada (active: false) e as entregas pendentes dela passam a dead sem novo envio. Um sucesso zera o contador. A pausa não gera evento nem e-mail: acompanhe active e consecutive_failures em GET /vasp/webhooks. Por chave de API não existe reativação: apague a subscription pausada e crie outra depois de corrigir o receptor.
O reenvio de uma entrega em failed ou dead não faz parte do contrato de chave de API. Ele é feito numa sessão do painel, por um administrador do tenant.
Rotação de secret
Seção intitulada “Rotação de secret”Não há rotação nem período de convivência entre secrets. Para trocar o secret, crie uma subscription nova com o secret novo, valide as duas em paralelo no seu receptor e só então apague a antiga com DELETE /vasp/webhooks/{id}.
Testar o receptor
Seção intitulada “Testar o receptor”POST /vasp/webhooks/{id}/test enfileira um webhook.test para uma subscription ativa e responde 202 com success, subscription_id, event_id e status: "pending" na raiz, sem envelope data. Ele passa pelo mesmo mecanismo de entrega, pela mesma retentativa e pela mesma assinatura HMAC das entregas reais. Subscription pausada responde 404.
Para receber no seu ambiente local, exponha a porta por um túnel HTTPS e aponte a subscription para ele.
Boas práticas no receptor
Seção intitulada “Boas práticas no receptor”- Responda 2xx rápido: o limite é de 10 segundos por tentativa, e processamento pesado deve ir para uma fila sua
- Deduplique por
X-Sentinex-Delivery-IdmaisX-Sentinex-Event-Id: a entrega é pelo menos uma vez, e o mesmo evento pode chegar duas vezes - Valide a assinatura em toda entrega, antes de ler o corpo como JSON
- Registre
X-Sentinex-Delivery-Idno seu log, para o suporte cruzar a entrega com o nosso registro
Limite do produto KYC
Seção intitulada “Limite do produto KYC”KYC próprio e eventos kyc.* não estão disponíveis no produto KYT atual. Nas rotas B2B de cases /api/v1/kyc/*, após autenticação e acesso da conta, o gate responde 403 PRODUCT_MODULE_DISABLED. Contratar KYC exige produto habilitado e escopos próprios, sem data prometida. Assinar um nome aceito não habilita seu emissor.