KYTRIX Developer documentation Financial Crime Control Plane

Signed, at-least-once, classified

Webhooks

What KYTRIX sends you, how to verify it, how to rotate the secret without dropping a message, and why the channel you register on is a compliance decision.

Event catalog generated from @kytrix/contracts · vectors from packages/signing/vectors/webhook-vectors.json (signature v1)

#The delivery model

KYTRIX pushes signed JSON to endpoints you register. Delivery is at-least-once: a redelivery is normal operation, not an incident. Dedupe on the envelope id (and, if you want per-attempt bookkeeping, delivery.delivery_id) and never on arrival order.

#Channels are a compliance control

Every endpoint is registered on exactly one channel, and the channel decides what may reach it. This is not a routing preference — it is the tipping-off control.

ChannelReceives
complianceRestricted AML content: alerts, episodes, case status, risk-state changes. This endpoint belongs to your compliance systems and nowhere else.
operationalSanitized advisory and operational events only. recommendation.issued carries a subject, a band, a severity, a recommendation code and an episode reference — no typology names, no narrative, no detector ids, enforced by the schema itself.

#Event catalog

All 19 event types, generated from WEBHOOK_EVENT_CLASSIFICATION in packages/contracts/src/webhooks.ts. "Deliverable on" is exhaustive: an event never reaches a channel it does not list.

Event typeClassificationDeliverable on
alert.openedrestricted_amlcompliance
episode.updatedrestricted_amlcompliance
episode.escalatedrestricted_amlcompliance
episode.resolvedrestricted_amlcompliance
episode.reopenedrestricted_amlcompliance
case.status_changedrestricted_amlcompliance
case.disposedrestricted_amlcompliance
risk_state.changedrestricted_amlcompliance
recommendation.issuedadvisoryoperational
coverage.degradedoperationalcompliance, operational
coverage.recoveredoperationalcompliance, operational
import.progressoperationaloperational
import.completedoperationaloperational
import.failedoperationaloperational
ruleset.candidate_readyoperationalcompliance
ruleset.activatedoperationalcompliance
ruleset.rolled_backoperationalcompliance
conformance.drift_detectedoperationaloperational
webhook.testoperationalcompliance, operational

#The envelope

Every delivery body is a WebhookEnvelope — see the generated schema. classification must agree with the catalog above; a body where it does not is not a KYTRIX delivery.

json — a delivery body
{
  "id": "whe_01k4f2b9m7q0z8t3d5r6y7w8xc",
  "type": "webhook.test",
  "classification": "operational",
  "created_at": "2026-09-05T14:31:02.117Z",
  "tenant_id": "01920000-0000-7000-8000-000000000001",
  "data": {
    "endpoint_id": "whk_01k4f2b9m7q0z8t3d5r6y7w8xd",
    "channel": "compliance",
    "message": "KYTRIX endpoint verification",
    "sent_at": "2026-09-05T14:31:02.117Z"
  },
  "delivery": {
    "attempt": 1,
    "delivery_id": "whd_01k4f2b9m7q0z8t3d5r6y7w8xe"
  }
}
HeaderMeaning
Kytrix-Webhook-Signaturet=<unix seconds>,v1=<hex>[,v1=<hex>] — one v1= per active secret.
Kytrix-Webhook-TimestampThe same t value, as its own header.
Kytrix-Webhook-IdThe envelope id — the value to dedupe on, available without parsing the body.

#Verifying a delivery

The signed payload is "<t>." + body_bytes, and each v1= entry is hex(HMAC-SHA256(secret, signed_payload)). A delivery verifies when any of your secrets matches any v1= entry and |now − t| is within the tolerance (default 300 seconds).

typescript
import express from 'express';
import { WebhookReceiver } from '@kytrix/sdk';

// One receiver per endpoint, kept for the lifetime of the process (it remembers deliveries).
const receiver = new WebhookReceiver({
  secrets: [process.env.KYTRIX_WEBHOOK_SECRET!],  // during rotation: [new, previous]
  channel: 'compliance',                          // refuses anything not deliverable here
});

// express.raw — the RAW bytes, not a parsed body.
app.post('/kytrix/webhooks', express.raw({ type: '*/*' }), (req, res) => {
  const result = receiver.verify({ body: req.body, headers: req.headers });
  if (!result.ok) {
    if (result.reason === 'replayed') return res.status(200).end();  // already handled — ack it
    console.warn('rejected webhook', result.reason, result.detail);
    return res.status(400).end();
  }
  res.status(204).end();          // ack fast…
  void queue.add(result.event);   // …then process asynchronously
});

Without the SDK, the whole verification is ten lines of node:crypto. Compare in constant time, and read the raw body before any JSON middleware touches it:

typescript
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyKytrixWebhook(opts: {
  header: string;          // Kytrix-Webhook-Signature
  rawBody: Buffer;         // the exact bytes received
  secrets: Buffer[];       // [current] — or [current, previous] during rotation
  nowSeconds: number;
  toleranceSeconds?: number;
}): boolean {
  const tolerance = opts.toleranceSeconds ?? 300;
  const parts = opts.header.split(',').map((s) => s.trim());
  const t = parts.find((s) => s.startsWith('t='))?.slice(2);
  const signatures = parts.filter((s) => s.startsWith('v1=')).map((s) => s.slice(3));
  if (t === undefined || !/^\d{1,12}$/.test(t) || signatures.length === 0) return false;
  if (Math.abs(opts.nowSeconds - Number(t)) > tolerance) return false;

  const signed = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), opts.rawBody]);
  for (const secret of opts.secrets) {
    const expected = createHmac('sha256', secret).update(signed).digest();
    for (const candidate of signatures) {
      if (candidate.length !== expected.length * 2) continue;
      const got = Buffer.from(candidate, 'hex');
      if (got.length === expected.length && timingSafeEqual(got, expected)) return true;
    }
  }
  return false;
}

#Webhook test vectors

Published, deterministic vectors from packages/signing/vectors/webhook-vectors.json. Assert against these before you point a real endpoint at KYTRIX — every KYTRIX consumer (the SDK, the conformance CLI, the delivery worker) asserts the same file.

Signing vectors

text — json-event
secret (hex)     000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
body             {"id":"whe_1","type":"alert.opened","classification":"restricted_aml"}
timestamp (t)    1767225600
signed payload   1767225600.<body bytes>
expected header  t=1767225600,v1=be015ff1fce00873dbfacb1e3989986f19ffc001fe96acc9f4cf2bb4ed76183d
text — empty-body
secret (hex)     000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
body             (empty)
timestamp (t)    1767225601
signed payload   1767225601.<body bytes>
expected header  t=1767225601,v1=30d8bf684ed1eed003324119d2221b9b564e45c5494b64d13a97630e5a06c42d
text — unicode-body
secret (hex)     000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
body             {"memo":"café ☕"}
timestamp (t)    1767225602
signed payload   1767225602.<body bytes>
expected header  t=1767225602,v1=34963ae5d1115d23e26e09675ef44a598e4a133d5cf984245d4cd14355916a58
text — body-with-newlines
secret (hex)     000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
body             {
  "a": 1,
  "b": [1, 2]
}

timestamp (t)    1767225603
signed payload   1767225603.<body bytes>
expected header  t=1767225603,v1=55f8427d13a6d8cbebd27d73acfd79b8f54c1a8eb81efb5a77e972c8629192f2
text — large-timestamp
secret (hex)     000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
body             {"id":"whe_2"}
timestamp (t)    4102444800
signed payload   4102444800.<body bytes>
expected header  t=4102444800,v1=790c018c38c370b9177b55f33e2774bfea67c8b6e297d3d1458440b8778c8745
text — dual-secret-rotation
secret 1 (hex) 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
secret 2 (hex) ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100
body             {"id":"whe_3","type":"recommendation.issued"}
timestamp (t)    1767225604
signed payload   1767225604.<body bytes>
expected header  t=1767225604,v1=b5fb9f34cb958e45eaf9daf0413c08a2f5f0efa677c0c0542f2471b1e54d9cb1,v1=bd7c1e90bbeefabd6769e39691cc35add32954a4af520257ab8153c1a6c680a4

Verification vectors

CaseAccepts?Header
valid-within-toleranceyest=1767225600,v1=be015ff1fce00873dbfacb1e3989986f19ffc001fe96acc9f4cf2bb4ed76183d
timestamp-beyond-tolerancenot=1767225600,v1=be015ff1fce00873dbfacb1e3989986f19ffc001fe96acc9f4cf2bb4ed76183d
wrong-secretnot=1767225600,v1=be015ff1fce00873dbfacb1e3989986f19ffc001fe96acc9f4cf2bb4ed76183d
tampered-bodynot=1767225600,v1=be015ff1fce00873dbfacb1e3989986f19ffc001fe96acc9f4cf2bb4ed76183d
rotation-consumer-has-previous-onlyyest=1767225604,v1=b5fb9f34cb958e45eaf9daf0413c08a2f5f0efa677c0c0542f2471b1e54d9cb1,v1=bd7c1e90bbeefabd6769e39691cc35add32954a4af520257ab8153c1a6c680a4
rotation-consumer-has-current-onlyyest=1767225604,v1=b5fb9f34cb958e45eaf9daf0413c08a2f5f0efa677c0c0542f2471b1e54d9cb1,v1=bd7c1e90bbeefabd6769e39691cc35add32954a4af520257ab8153c1a6c680a4
malformed-headernov1=deadbeef

#Secret rotation

Rotation is overlapping by design, so there is no window where a delivery cannot be verified. POST /v1/webhook-endpoints/{id}/rotate-secret returns the new secret once and a previous_secret_valid_until instant. Until then KYTRIX emits two v1= entries per delivery — one per secret.

typescript
const rotated = await kytrix.webhookEndpoints.rotateSecret(endpointId);
await storeSecret(endpointId, rotated.secret);        // this is your only chance to see it

receiver.setSecrets([rotated.secret, oldSecret]);      // overlap: verify with both
// after rotated.previous_secret_valid_until has passed:
receiver.setSecrets([rotated.secret]);

#Redelivery and replay

Use GET /v1/webhook-deliveries to see every attempt with its response status, latency and next scheduled retry, and POST /v1/webhook-deliveries/{id}/replay to send the same envelope again after you have fixed your consumer. A replay reuses the envelope id, so a consumer that dedupes correctly will ignore it — which is exactly the property you want to test before you need it.