Webhooks
KauZap sends an HTTPS POST to your endpoint when something happens, such as a new
conversation or message. Verify each request before you trust it.
Request
| Header | Value |
|---|---|
X-Chat-Event | Event type, such as message.created |
X-Chat-Delivery | Id of this delivery attempt |
X-Chat-Timestamp | When it was signed, Unix seconds |
X-Chat-Signature | v1= followed by the hex HMAC-SHA256 of timestamp + "." + raw body, keyed with your endpoint's signing secret. For 24 hours after a secret rotation there are two, new first, separated by a comma: v1=<new>,v1=<old>. Accept the request if any one matches. |
{
"id": "evt_8241",
"type": "conversation.created",
"created_at": "2026-11-14T10:00:00Z",
"tenant_id": "…",
"data": { "conversation_id": "…" }
}
Payloads carry ids and state only, never message text or personal data. Fetch the details you need through the API.
Verify the signature in Node
Use the raw body exactly as received. Re-serialised JSON will not match. This is the sample app's verifier:
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* Verify a KauZap webhook before you parse it.
* Pass the raw request body exactly as received, not re-serialised JSON.
*
* @param {object} opts
* @param {string} opts.secret The endpoint's signing secret.
* @param {string | undefined} opts.timestamp X-Chat-Timestamp header (Unix seconds).
* @param {string | Buffer} opts.rawBody
* @param {string | undefined} opts.signatureHeader X-Chat-Signature header. During secret
* rotation it holds several values: `v1=<hex>,v1=<hex>`.
* @param {number} [opts.toleranceSeconds] Maximum age of the timestamp. Default 300.
* @param {number} [opts.now] Current time in milliseconds. Default Date.now().
* @returns {{ ok: true } | { ok: false, reason: 'malformed' | 'stale_timestamp' | 'bad_signature' }}
*/
export function verifyWebhookSignature({
secret,
timestamp,
rawBody,
signatureHeader,
toleranceSeconds = 300,
now = Date.now(),
}) {
if (!secret || !timestamp || !/^\d{1,12}$/.test(timestamp) || !signatureHeader) {
return { ok: false, reason: 'malformed' };
}
// Rejecting old timestamps stops an attacker from re-sending a captured delivery later.
if (Math.abs(Math.floor(now / 1000) - Number(timestamp)) > toleranceSeconds) {
return { ok: false, reason: 'stale_timestamp' };
}
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
const candidates = signatureHeader
.split(',')
.map((part) => part.trim())
.filter((part) => /^v1=[0-9a-f]{64}$/.test(part))
.map((part) => Buffer.from(part.slice(3), 'hex'));
if (candidates.length === 0) return { ok: false, reason: 'malformed' };
// Check every candidate so timing does not reveal which one matched.
let match = false;
for (const candidate of candidates) match = timingSafeEqual(candidate, expected) || match;
return match ? { ok: true } : { ok: false, reason: 'bad_signature' };
}
examples/node-integration/webhook-signature.mjsAnd a complete receiver with node:http, which verifies, parses, then processes each event id once:
import { createServer } from 'node:http';
import { pathToFileURL } from 'node:url';
import { mintIdentityToken } from './identity-token.mjs';
import { verifyWebhookSignature } from './webhook-signature.mjs';
const MAX_BODY = 1024 * 1024;
/**
* Sample integration server.
* GET /token identity token for a demo user (your real server checks its own session first)
* POST /webhooks verifies the signature, then logs the event id and type (never the payload)
*
* @param {object} config
* @param {string} config.identitySecret
* @param {string} config.identityKid
* @param {string} config.propertyKey
* @param {string} config.webhookSecret
* @param {(line: string) => void} [config.log]
* @param {() => number} [config.now]
*/
export function createIntegrationServer({
identitySecret,
identityKid,
propertyKey,
webhookSecret,
log = console.log,
now = Date.now,
}) {
// shortcut: in-memory and unbounded, so duplicates are caught only while this process runs;
// use a table with a unique key on the event id in production.
const seenEvents = new Set();
return createServer(async (req, res) => {
const send = (status, body) => {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify(body));
};
if (req.method === 'GET' && req.url === '/token') {
// Demo user. In your app, read the signed-in user from your own session here.
const token = mintIdentityToken({
secret: identitySecret,
kid: identityKid,
propertyKey,
sub: 'demo_user_1',
name: 'Demo User',
ttlSeconds: 300,
now: now(),
});
return send(200, { token });
}
if (req.method === 'POST' && req.url === '/webhooks') {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY) return send(413, { error: 'payload_too_large' });
chunks.push(chunk);
}
const rawBody = Buffer.concat(chunks);
const result = verifyWebhookSignature({
secret: webhookSecret,
timestamp: req.headers['x-chat-timestamp'],
rawBody,
signatureHeader: req.headers['x-chat-signature'],
now: now(),
});
if (!result.ok) {
log(`webhook rejected: ${result.reason}`);
return send(401, { error: result.reason });
}
// Parse only after the signature checks out.
let event;
try {
event = JSON.parse(rawBody.toString('utf8'));
} catch {
event = null;
}
if (typeof event?.id !== 'string') return send(400, { error: 'invalid_event' });
// The event id is stable across retries and replays: process each one once.
if (seenEvents.has(event.id)) {
log(`webhook duplicate: ${event.id}`);
return send(200, { received: true, duplicate: true });
}
seenEvents.add(event.id);
log(`webhook received: ${event.id} ${event.type}`);
return send(200, { received: true });
}
send(404, { error: 'not_found' });
});
}
function requireEnv(name) {
const value = process.env[name];
if (!value) {
console.error(`Set ${name}. See README.md.`);
process.exit(1);
}
return value;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
const port = Number(process.env.PORT ?? 5178);
createIntegrationServer({
identitySecret: requireEnv('KAUZAP_IDENTITY_SECRET'),
identityKid: process.env.KAUZAP_IDENTITY_KID ?? 'k1',
propertyKey: requireEnv('KAUZAP_PROPERTY_KEY'),
webhookSecret: requireEnv('KAUZAP_WEBHOOK_SECRET'),
}).listen(port, '127.0.0.1', () => {
console.log(`Sample integration on http://127.0.0.1:${port} (GET /token, POST /webhooks)`);
});
}
examples/node-integration/server.mjsRetries and replays
- Answer with any 2xx within 10 seconds. Do slow work after you respond.
- Anything else is retried with exponential backoff and jitter: 8 attempts over about 24 hours.
- An endpoint that has had nothing but failed deliveries for 3 hours is disabled, its queued retries stop, and tenant admins are notified. Re-enable it in Settings, Developers; replay what it missed from the delivery log.
- Admins can replay any delivery from the delivery log.
- Delivery is at least once: the same event can arrive more than once, so dedupe by event id (below).
Signing secrets
- The secret is shown once, when the endpoint is created or its secret is rotated.
- After
rotate-secret, requests are signed with both the new and the old secret for 24 hours. Switch your receiver to the new secret within that time. - If the create response is lost and you retry with the same
Idempotency-Key, the replayed response shows a masked secret. CallPOST /v1/webhooks/{id}/rotate-secretto get a new one.
Process each event once
The event id stays the same across retries and replays, so you may receive an event more than once. Store the ids you have handled, for example in a table with a unique key, and skip any you have seen. Reject requests whose timestamp is more than 5 minutes old so a captured request cannot be replayed later.
Events
| Type | Sent when |
|---|---|
conversation.created | A conversation starts |
conversation.assigned | An agent is assigned, or a supervisor takes over. An assignment also changes the status, so if you subscribe to conversation.status_changed too you get both events. |
conversation.transferred | It moves to another agent or group |
conversation.status_changed | It is resolved, reopened or changes status |
conversation.rated | The customer rates it |
message.created | A message is sent |
contact.created | A contact is created |
contact.updated | A contact changes |
agent.status_changed | An agent goes online, away or offline |
handoff.requested, tool.requested | The AI assistant hands off or calls an action. Sent once the AI assistant is released. |
ticket.created, ticket.updated | A ticket is created, linked to another conversation, edited, closed or reopened |