Pular para o conteúdo

Webhooks

Webhooks permitem que o Sentinex notifique sua aplicação quando eventos relevantes acontecem: alerta criado, KYC concluído, cliente atualizado. Substitui polling por push.

O catálogo abaixo é o conjunto completo aceito na criação de uma subscription. Um event_type fora desta lista é recusado com 400.

Event type Família
customer.created Clientes
customer.updated Clientes
customer.deleted Clientes
wallet.linked Carteiras
kyc.completed KYC
kyc.provider_status_changed KYC
kyc.provider_decision_conflict KYC
kyc.provider_evidence_refreshed KYC
kyc.assessment_finalized KYC
kyc.decision_proposal_created KYC
kyc.decision_approval_recorded KYC
kyc.decision_finalized KYC
kyc.rekyc_scheduled Re-KYC
kyc.rekyc_started Re-KYC
kyc.rekyc_failed Re-KYC
kyc.partner_admitted Parceiros
kyc.partner_relationship_ended Parceiros
kyc.actor_invitation.expired Convites
kyc.actor_invitation.reminder_rotated Convites
alert.created Alertas
alert.escalated Alertas
alert.resolved Alertas

Além do catálogo, existe o event_type reservado webhook.test, entregue apenas quando você dispara a entrega sintética pelo console. Ele não pode ser assinado e não indica efeito de negócio.

Wildcard: events: [] (array vazio) na subscription recebe todos os eventos do catálogo.

O secret HMAC é gerado por você e enviado no corpo da requisição. O servidor guarda apenas o hash SHA-256 e o ciphertext, e nunca devolve o secret: não existe endpoint que o recupere nem que o rotacione. Perdeu o secret, crie outra subscription.

Terminal window
curl -X POST https://api.crypto.sentinexrisk.com/api/v1/vasp/webhooks \
-H "X-API-Key: $SENTINEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"target_url": "https://hooks.suaempresa.com.br/sentinex",
"events": ["alert.created", "alert.escalated", "kyc.completed"],
"description": "Pager + dashboard ops",
"secret": "<32 a 128 bytes gerados por voce, ex: openssl rand -hex 32>"
}'

Requer o escopo vasp:webhooks:manage. A superfície de API key também expõe GET /api/v1/vasp/webhooks e DELETE /api/v1/vasp/webhooks/:id.

Campo Regra medida no servidor
target_url HTTPS obrigatório, sem userinfo, sem query string e sem fragmento. Qualquer violação retorna 400 target_url must be valid HTTPS URL
secret 32 a 128 bytes. Fora da faixa retorna 400 secret must contain 32 to 128 bytes
events Cada item precisa estar no catálogo acima. Array vazio significa todos
description Opcional, texto livre

Resposta 201, sem envelope data e sem eco do secret:

{
"success": true,
"id": "3f0d1e42-8f8e-4a1c-9b7a-2c5d7e9f1a3b",
"target_url": "https://hooks.suaempresa.com.br/sentinex",
"events": ["alert.created", "alert.escalated", "kyc.completed"]
}

Equivalentes no console, autenticados por JWT com papel TENANT_ADMIN e MFA recente:

Terminal window
POST /api/v1/admin/outbound-webhooks/subscriptions
GET /api/v1/admin/outbound-webhooks/subscriptions
DELETE /api/v1/admin/outbound-webhooks/subscriptions/{id}

Toda requisição de entrega chega assim:

POST /sentinex HTTP/1.1
Content-Type: application/json
User-Agent: Sentinex-Webhook/1.0
X-Sentinex-Signature: 5d3b7a9e1c4f2d6b8a7e9c3d5f1b2a4e6c8d7f9b1a3e5c7d9b2a4f6e8c1d3b5a
X-Sentinex-Timestamp: 1747584000
X-Sentinex-Event-Type: alert.created
X-Sentinex-Event-Id: 7c9e6679-7425-40de-944b-e07fc1f90ae7
X-Sentinex-Delivery-Id: 0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d
{"event_type":"alert.created","event_id":"7c9e6679-7425-40de-944b-e07fc1f90ae7","delivery_id":"0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d","subscription_id":"3f0d1e42-8f8e-4a1c-9b7a-2c5d7e9f1a3b","tenant_id":"a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d","attempt":1,"payload":{}}
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 da 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

O corpo é um envelope de sete campos. O seu evento vem dentro de payload, não de data.

Campo Tipo Nota
event_type string Evento do catálogo
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 muda 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.

A assinatura é hex(HMAC_SHA256(secret, timestamp + "." + raw_body)), calculada sobre os bytes crus do corpo, antes de qualquer parsing. Compare em tempo constante e contra a string hex inteira.

import crypto from 'crypto';
function verifySentinexWebhook(req, secret) {
const signature = req.headers['x-sentinex-signature'];
const timestamp = req.headers['x-sentinex-timestamp'];
const rawBody = req.rawBody; // Buffer ou string, NÃO o JSON parsed
if (typeof signature !== 'string' || typeof timestamp !== 'string') {
throw new Error('Headers de assinatura ausentes');
}
// Anti-replay: rejeita se mais de 5 min de tolerância
if (Math.abs(Date.now() / 1000 - parseInt(timestamp, 10)) > 300) {
throw new Error('Timestamp fora da janela');
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest('hex');
// timingSafeEqual exige mesmo tamanho, então o comprimento é checado antes
const recebida = Buffer.from(signature, 'utf8');
const esperada = Buffer.from(expected, 'utf8');
if (recebida.length !== esperada.length ||
!crypto.timingSafeEqual(recebida, esperada)) {
throw new Error('Assinatura inválida');
}
}

São 3 tentativas no total, e não quatro:

Tentativa Quando
assim que o worker pega a delivery
10s após a falha da 1ª
60s após a falha da 2ª
Após a 3ª falha status vira dead e a delivery vai para a DLQ

Falha significa resposta HTTP fora da faixa 2xx, erro de transporte, ou estouro do timeout de 10 segundos por tentativa.

Após 5 falhas consecutivas na mesma subscription, ela é auto-pausada (active: false) e para de receber entregas. Um sucesso zera o contador. A pausa não gera evento de webhook e não dispara e-mail: acompanhe active e consecutive_failures na listagem de subscriptions.

A DLQ é a listagem de deliveries filtrada por status. Não existe rota /subscriptions/{id}/dlq.

Terminal window
GET /api/v1/admin/outbound-webhooks/deliveries?status=dead
GET /api/v1/admin/outbound-webhooks/deliveries?status=dead&subscription_id={id}&limit=100

Status possíveis: pending, in_progress, delivered, failed, dead. O limit tem default 50 e teto 500.

Replay manual (JWT, TENANT_ADMIN, MFA recente):

Terminal window
POST /api/v1/admin/outbound-webhooks/deliveries/{delivery_id}/replay

Apenas deliveries em failed ou dead são replayáveis. Uma delivery já entregue devolve 409, para não duplicar efeito no seu lado.

Não há endpoint de 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 consumer e só então apague a antiga com DELETE.

  1. Responda 2xx rápido: o timeout do lado do Sentinex é de 10 segundos por tentativa, e processamento pesado deve ir para fila assíncrona
  2. Idempotência por X-Sentinex-Delivery-Id mais X-Sentinex-Event-Id: a entrega é at-least-once e o mesmo evento pode chegar duas vezes
  3. Endpoint HTTPS obrigatório, sem query string e sem fragmento (validado na criação)
  4. Sem autenticação extra: a assinatura HMAC já é a auth
  5. Logue X-Sentinex-Delivery-Id: facilita o suporte cruzar logs

No console, em Webhooks, a ação de teste dispara um webhook.test sintético para uma subscription específica (POST /api/v1/webhooks/endpoints/{id}/test, JWT + TENANT_ADMIN). Ele passa pelo mesmo worker, mesmo retry, mesma assinatura HMAC e mesma proteção contra SSRF das entregas reais, sem causar efeito de negócio.

Para receber em ambiente local, use ngrok ou similar e aponte a subscription para o tunnel HTTPS:

Terminal window
ngrok http 4000
# https://abc123.ngrok.io → seu localhost:4000