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.
Eventos disponíveis
Seção intitulada “Eventos disponíveis”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.
Configurar subscription
Seção intitulada “Configurar subscription”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.
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:
POST /api/v1/admin/outbound-webhooks/subscriptionsGET /api/v1/admin/outbound-webhooks/subscriptionsDELETE /api/v1/admin/outbound-webhooks/subscriptions/{id}Assinatura HMAC SHA-256
Seção intitulada “Assinatura HMAC SHA-256”Toda requisição de entrega chega assim:
POST /sentinex HTTP/1.1Content-Type: application/jsonUser-Agent: Sentinex-Webhook/1.0X-Sentinex-Signature: 5d3b7a9e1c4f2d6b8a7e9c3d5f1b2a4e6c8d7f9b1a3e5c7d9b2a4f6e8c1d3b5aX-Sentinex-Timestamp: 1747584000X-Sentinex-Event-Type: alert.createdX-Sentinex-Event-Id: 7c9e6679-7425-40de-944b-e07fc1f90ae7X-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 |
Corpo da entrega
Seção intitulada “Corpo da entrega”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.
Verificar assinatura
Seção intitulada “Verificar 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'); }}func verifySentinexWebhook(r *http.Request, secret string) error { sig := r.Header.Get("X-Sentinex-Signature") ts := r.Header.Get("X-Sentinex-Timestamp") if sig == "" || ts == "" { return errors.New("headers de assinatura ausentes") }
tsInt, err := strconv.ParseInt(ts, 10, 64) if err != nil { return errors.New("timestamp inválido") } if math.Abs(float64(time.Now().Unix()-tsInt)) > 300 { return errors.New("timestamp fora da janela") }
body, err := io.ReadAll(r.Body) if err != nil { return errors.New("corpo ilegível") }
mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(ts)) mac.Write([]byte(".")) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sig), []byte(expected)) { return errors.New("assinatura inválida") } return nil}import hmac, hashlib, time
def verify_sentinex_webhook(headers, raw_body: bytes, secret: str): sig = headers.get("X-Sentinex-Signature") ts_raw = headers.get("X-Sentinex-Timestamp") if not sig or not ts_raw: raise ValueError("Headers de assinatura ausentes")
ts = int(ts_raw) if abs(time.time() - ts) > 300: raise ValueError("Timestamp fora da janela")
expected = hmac.new( secret.encode(), f"{ts_raw}.".encode() + raw_body, hashlib.sha256, ).hexdigest()
if not hmac.compare_digest(sig, expected): raise ValueError("Assinatura inválida")Política de retry
Seção intitulada “Política de retry”São 3 tentativas no total, e não quatro:
| Tentativa | Quando |
|---|---|
| 1ª | assim que o worker pega a delivery |
| 2ª | 10s após a falha da 1ª |
| 3ª | 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.
DLQ (Dead Letter Queue)
Seção intitulada “DLQ (Dead Letter Queue)”A DLQ é a listagem de deliveries filtrada por status. Não existe rota /subscriptions/{id}/dlq.
GET /api/v1/admin/outbound-webhooks/deliveries?status=deadGET /api/v1/admin/outbound-webhooks/deliveries?status=dead&subscription_id={id}&limit=100Status possíveis: pending, in_progress, delivered, failed, dead. O limit tem default 50 e teto 500.
Replay manual (JWT, TENANT_ADMIN, MFA recente):
POST /api/v1/admin/outbound-webhooks/deliveries/{delivery_id}/replayApenas deliveries em failed ou dead são replayáveis. Uma delivery já entregue devolve 409, para não duplicar efeito no seu lado.
Rotação de secret
Seção intitulada “Rotação de secret”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.
Boas práticas no consumer
Seção intitulada “Boas práticas no consumer”- Responda 2xx rápido: o timeout do lado do Sentinex é de 10 segundos por tentativa, e processamento pesado deve ir para fila assíncrona
- Idempotência por
X-Sentinex-Delivery-IdmaisX-Sentinex-Event-Id: a entrega é at-least-once e o mesmo evento pode chegar duas vezes - Endpoint HTTPS obrigatório, sem query string e sem fragmento (validado na criação)
- Sem autenticação extra: a assinatura HMAC já é a auth
- 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:
ngrok http 4000# https://abc123.ngrok.io → seu localhost:4000