Webhooks

Los webhooks avisan a tu backend cuando pasa algo que requiere acción: un evento queda en revisión, hay una coincidencia en listas o un analista decide un caso.

Registrar un endpoint

Con una llave admin:

curl -s "$SAFEGATE_URL/v1/webhooks" \
  -H "Authorization: Bearer $SAFEGATE_ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://api.tucasino.mx/webhooks/safegate", "events": ["score.review", "score.decline", "case.decided"]}'

La respuesta trae el secret de firma (whsec_…) una sola vez. La URL debe ser https con un host público. Máximo 10 endpoints por entorno. Si omites events, recibes todos.

POST /v1/webhooks/{id}/test envía un evento webhook.test para que pruebes tu receptor.

Eventos

Tipo Cuándo data
score.review, score.decline Una evaluación terminó en review / decline event_id, event_type, score, decision, reasons (códigos), rules_triggered, screening_status, ruleset_version
screening.match Screening con match o potential_match event_id, status, lists_version, hits (list, kind, entity_id, score)
case.decided Un analista decidió un caso en el panel review_id, decision (approve / decline / escalate), case_status, label, event_ids
subject.erased Terminó un borrado de datos erasure_id, environment, erased_at, counts
webhook.test Prueba manual endpoint_id

Los webhooks no llevan datos personales, solo ids, códigos y scores. Cruza con tus registros usando event_id.

Entrega

Hacemos POST de {"id": "whe_…", "type", "created_at", "environment", "data"} con estos encabezados:

Encabezado Uso
Sentinel-Signature t=<unix>,v1=<hex>: verifica la firma
Sentinel-Delivery whd_…, igual en todos los reintentos: úsalo para descartar duplicados
Sentinel-Event El tipo de evento

Cualquier respuesta 2xx en menos de 10 s cuenta como entregada. Si no, reintentamos aprox. a 1 min, 5 min, 30 min, 2 h, 6 h y 12 h; después la entrega queda como dead y puedes reenviarla con POST /v1/webhooks/deliveries/{id}/retry. Las redirecciones no se siguen. La entrega es "al menos una vez": un mismo webhook puede llegar dos veces.

Verificar la firma

La firma es HMAC-SHA256(secret, "<t>.<cuerpo crudo>"). Calcúlala sobre el cuerpo crudo (antes de parsear el JSON) y rechaza t con más de 5 minutos de antigüedad.

Python:

import hashlib, hmac, time


def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts["t"])
    mac = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return abs(time.time() - t) <= tolerance and hmac.compare_digest(mac, parts.get("v1", ""))

Node.js:

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret, header, rawBody, tolerance = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const t = Number(parts.t);
  const mac = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
  const given = Buffer.from(parts.v1 ?? "", "utf8");
  const expected = Buffer.from(mac, "utf8");
  return Math.abs(Date.now() / 1000 - t) <= tolerance
    && given.length === expected.length && timingSafeEqual(given, expected);
}

Rotar el secreto

POST /v1/webhooks/{id}/rotate-secret regresa un secreto nuevo y aplica de inmediato, también a los reintentos pendientes.

Historial

GET /v1/webhooks/{id}/deliveries?status=dead muestra las entregas fallidas (las últimas 200 por endpoint, durante 14 días), con el último código HTTP y el error.