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.
- Acknowledge fast with a
2xxand process asynchronously. A slow consumer looks like a failing one. - Anything that is not
2xxis retried on a schedule (at least 8 attempts spanning at least 24 hours), then parked as dead-lettered and replayable. - Verify the signature over the raw body bytes. Parsing and re-serialising the JSON changes the bytes and breaks the MAC.
#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.
| Channel | Receives |
|---|---|
compliance | Restricted AML content: alerts, episodes, case status, risk-state changes. This endpoint belongs to your compliance systems and nowhere else. |
operational | Sanitized 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 type | Classification | Deliverable on |
|---|---|---|
alert.opened | restricted_aml | compliance |
episode.updated | restricted_aml | compliance |
episode.escalated | restricted_aml | compliance |
episode.resolved | restricted_aml | compliance |
episode.reopened | restricted_aml | compliance |
case.status_changed | restricted_aml | compliance |
case.disposed | restricted_aml | compliance |
risk_state.changed | restricted_aml | compliance |
recommendation.issued | advisory | operational |
coverage.degraded | operational | compliance, operational |
coverage.recovered | operational | compliance, operational |
import.progress | operational | operational |
import.completed | operational | operational |
import.failed | operational | operational |
ruleset.candidate_ready | operational | compliance |
ruleset.activated | operational | compliance |
ruleset.rolled_back | operational | compliance |
conformance.drift_detected | operational | operational |
webhook.test | operational | compliance, 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.
{
"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"
}
}| Header | Meaning |
|---|---|
Kytrix-Webhook-Signature | t=<unix seconds>,v1=<hex>[,v1=<hex>] — one v1= per active secret. |
Kytrix-Webhook-Timestamp | The same t value, as its own header. |
Kytrix-Webhook-Id | The 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).
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:
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;
}- Reject on: a missing or malformed signature header, a timestamp outside the tolerance, no secret matching any
v1=entry, a body that is not aWebhookEnvelope, and an event not deliverable on this endpoint’s channel. - A redelivery of an envelope id you have already handled is not a rejection — ack it with
2xxand do nothing.
#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
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=be015ff1fce00873dbfacb1e3989986f19ffc001fe96acc9f4cf2bb4ed76183dsecret (hex) 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
body (empty)
timestamp (t) 1767225601
signed payload 1767225601.<body bytes>
expected header t=1767225601,v1=30d8bf684ed1eed003324119d2221b9b564e45c5494b64d13a97630e5a06c42dsecret (hex) 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
body {"memo":"café ☕"}
timestamp (t) 1767225602
signed payload 1767225602.<body bytes>
expected header t=1767225602,v1=34963ae5d1115d23e26e09675ef44a598e4a133d5cf984245d4cd14355916a58secret (hex) 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
body {
"a": 1,
"b": [1, 2]
}
timestamp (t) 1767225603
signed payload 1767225603.<body bytes>
expected header t=1767225603,v1=55f8427d13a6d8cbebd27d73acfd79b8f54c1a8eb81efb5a77e972c8629192f2secret (hex) 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
body {"id":"whe_2"}
timestamp (t) 4102444800
signed payload 4102444800.<body bytes>
expected header t=4102444800,v1=790c018c38c370b9177b55f33e2774bfea67c8b6e297d3d1458440b8778c8745secret 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=bd7c1e90bbeefabd6769e39691cc35add32954a4af520257ab8153c1a6c680a4Verification vectors
| Case | Accepts? | Header |
|---|---|---|
| valid-within-tolerance | yes | t=1767225600,v1=be015ff1fce00873dbfacb1e3989986f19ffc001fe96acc9f4cf2bb4ed76183d |
| timestamp-beyond-tolerance | no | t=1767225600,v1=be015ff1fce00873dbfacb1e3989986f19ffc001fe96acc9f4cf2bb4ed76183d |
| wrong-secret | no | t=1767225600,v1=be015ff1fce00873dbfacb1e3989986f19ffc001fe96acc9f4cf2bb4ed76183d |
| tampered-body | no | t=1767225600,v1=be015ff1fce00873dbfacb1e3989986f19ffc001fe96acc9f4cf2bb4ed76183d |
| rotation-consumer-has-previous-only | yes | t=1767225604,v1=b5fb9f34cb958e45eaf9daf0413c08a2f5f0efa677c0c0542f2471b1e54d9cb1,v1=bd7c1e90bbeefabd6769e39691cc35add32954a4af520257ab8153c1a6c680a4 |
| rotation-consumer-has-current-only | yes | t=1767225604,v1=b5fb9f34cb958e45eaf9daf0413c08a2f5f0efa677c0c0542f2471b1e54d9cb1,v1=bd7c1e90bbeefabd6769e39691cc35add32954a4af520257ab8153c1a6c680a4 |
| malformed-header | no | v1=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.
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]);- Deploy the two-secret configuration before you rotate, not after.
- Keep verifying with both until
previous_secret_valid_until; drop the old one after. - The secret is returned exactly once, at registration and at each rotation. There is no endpoint that reads it back.
#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.