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

HeaderValue
X-Chat-EventEvent type, such as message.created
X-Chat-DeliveryId of this delivery attempt
X-Chat-TimestampWhen it was signed, Unix seconds
X-Chat-Signaturev1= 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.mjs

And 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.mjs

Retries and replays

Signing secrets

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

TypeSent when
conversation.createdA conversation starts
conversation.assignedAn 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.transferredIt moves to another agent or group
conversation.status_changedIt is resolved, reopened or changes status
conversation.ratedThe customer rates it
message.createdA message is sent
contact.createdA contact is created
contact.updatedA contact changes
agent.status_changedAn agent goes online, away or offline
handoff.requested, tool.requestedThe AI assistant hands off or calls an action. Sent once the AI assistant is released.
ticket.created, ticket.updatedA ticket is created, linked to another conversation, edited, closed or reopened