Extend

Webhook receiver: a sample service

A small service that receives Kommio webhooks, in Node, Python and Go with no dependencies: it verifies the signature exactly as Kommio signs, answers 200 at once, handles each delivery once across retries and replays, and reacts to three events.

Last updated

What it is

A webhook is Kommio telling your URL that something happened, as signed JSON. This sample is the receiver you would put behind that URL, in three versions with the same behaviour and nothing beyond each language's standard library. Each one:

  1. verifies the signature exactly as Kommio signs it: the header Kommio-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 with the endpoint secret (it starts with whsec_ and is used as it is) over <t>.<raw body>; it refuses a timestamp more than five minutes away and compares in constant time;
  2. answers 200 at once and does the work afterwards. Kommio waits up to 15 seconds and retries anything that is not a 2xx: after 1 min, 5 min, 15 min, 1 h, 4 h, 8 h and 12 h;
  3. handles each delivery once: a retry or a replay from Settings → Webhooks carries the same delivery id (the Kommio-Delivery header and id in the body), so the receiver remembers the ids it accepted and answers a repeat with {"ok":true,"duplicate":true} without processing it again;
  4. handles three events: conversation.created, message.created (inbound messages; the event carries no message text, read it with an API key from GET /api/conversations/{id}/messages) and csat.submitted, where low ratings are called out. Replace describe with your own work.

When to use it

Copy this when another system needs to stay in step with Kommio: a ticket in your tracker when a conversation opens, a Slack line on a low rating, a row in your warehouse per message. If you need to act inside the agent screen or block a reply, build an app; if you need to answer the customer, a bot.

Set up

  1. Run a version where Kommio can reach it

    A production Kommio posts only to public https URLs (an operator can allow private networks with KOMMIO_OUTBOUND_ALLOW_PRIVATE); a non-production server also posts to http://127.0.0.1:4600/webhooks. For a local receiver and a hosted Kommio, use a tunnel, for example cloudflared tunnel --url http://localhost:4600.

  2. Subscribe

    Settings → Webhooks → New webhook, URL https://<your host>/webhooks, events conversation.created, message.created and csat.submitted. Copy the secret shown once.

  3. Restart with the secret

    In KOMMIO_WEBHOOK_SECRET. PORT defaults to 4600; the path is /webhooks.

    cd node   && KOMMIO_WEBHOOK_SECRET=<endpoint secret> node --experimental-strip-types receiver.ts
    cd python && KOMMIO_WEBHOOK_SECRET=<endpoint secret> python3 receiver.py
    cd go     && KOMMIO_WEBHOOK_SECRET=<endpoint secret> go run main.go

The code

examples/webhook-receiver/node/receiver.ts
// A Kommio webhook receiver for Node 22, no dependencies:
//
//   KOMMIO_WEBHOOK_SECRET=<endpoint secret> PORT=4600 node --experimental-strip-types receiver.ts
//
// It verifies the Kommio-Signature header exactly as Kommio signs it,
// answers 200 at once and handles the event afterwards (Kommio waits at
// most 15 seconds and retries anything but a 2xx), and handles each
// delivery once: a retry or a replay carries the same delivery id.
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { pathToFileURL } from 'node:url';

/** The envelope every webhook delivery carries (docs: kommio.io/docs/extend/webhook-receiver/). */
export type Delivery = {
  id: number; // the delivery: the same on a retry or a replay
  event_id: number;
  type: string;
  account_id: number;
  object_type?: string;
  object_id?: number;
  occurred_at: string;
  data: Record<string, unknown>;
  visitor?: Record<string, unknown>;
};

/**
 * Kommio-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
 * with the endpoint's secret (it starts with whsec_ and is used as it is). Reject a timestamp
 * more than five minutes from your clock and compare in constant time.
 */
export function verifySignature(secret: string, header: string | undefined, rawBody: string, nowMs = Date.now()): boolean {
  if (!secret || !header) return false;
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.trim().split('=') as [string, string]));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(nowMs / 1000 - t) > 300) return false;
  const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'));
  const given = Buffer.from(parts.v1 ?? '');
  return given.length === expected.length && timingSafeEqual(given, expected);
}

/** What the example does with each event; replace with your own work. */
export function describe(d: Delivery): string | null {
  switch (d.type) {
    case 'conversation.created':
      return `New conversation ${d.object_id} in inbox ${d.data.inbox_id} from contact ${d.data.contact_id}`;
    case 'message.created':
      // The event carries no message text; read it with an API key:
      // GET /api/conversations/{conversation_id}/messages
      return d.data.direction === 'inbound' ? `Customer wrote in conversation ${d.data.conversation_id} (message ${d.object_id})` : null;
    case 'csat.submitted':
      return d.data.low
        ? `LOW RATING ${d.data.rating} on conversation ${d.data.conversation_id}: ${d.data.comment || '(no comment)'}`
        : `Rating ${d.data.rating} on conversation ${d.data.conversation_id}`;
    default:
      return null; // subscribed to more than this example handles
  }
}

export type Answer = { status: number; body: Record<string, unknown> };

/**
 * The receiver without the HTTP server, so it can be tested: handle() checks
 * the signature, drops a delivery it has already accepted, and queues the
 * rest for process(), which runs after the answer has gone out.
 */
export function createReceiver(opts: { secret: string; process: (d: Delivery) => void | Promise<void>; now?: () => number; rememberMs?: number }) {
  // Delivery ids already accepted. In production keep them where every
  // instance sees them (a table with a unique key, Redis SET NX) for at
  // least a day: Kommio retries for about 24 hours.
  const seen = new Map<string, number>();
  const rememberMs = opts.rememberMs ?? 48 * 3600_000;
  const now = opts.now ?? Date.now;
  return {
    seen,
    handle(headers: Record<string, string | undefined>, rawBody: string): Answer {
      if (!verifySignature(opts.secret, headers['kommio-signature'], rawBody, now())) return { status: 401, body: { error: 'bad signature' } };
      let d: Delivery;
      try { d = JSON.parse(rawBody) as Delivery; } catch { return { status: 400, body: { error: 'not JSON' } }; }
      const id = headers['kommio-delivery'] ?? String(d.id);
      for (const [k, at] of seen) if (now() - at > rememberMs) seen.delete(k);
      if (seen.has(id)) return { status: 200, body: { ok: true, duplicate: true } };
      seen.set(id, now());
      // Answer first, work after: a slow handler must not make Kommio retry.
      setImmediate(() => { void Promise.resolve(opts.process(d)).catch((e) => console.error('webhook handler failed', id, e)); });
      return { status: 200, body: { ok: true } };
    },
  };
}

export function start(port = Number(process.env.PORT || 4600), secret = process.env.KOMMIO_WEBHOOK_SECRET || '') {
  if (!secret) throw new Error('set KOMMIO_WEBHOOK_SECRET to the endpoint secret Kommio showed (it starts with whsec_)');
  const receiver = createReceiver({ secret, process: (d) => { const line = describe(d); if (line) console.log(`[${d.type}] ${line}`); } });
  return createServer((req, res) => {
    if (req.method !== 'POST' || req.url !== '/webhooks') { res.writeHead(404); res.end(); return; }
    const chunks: Buffer[] = [];
    let size = 0;
    req.on('data', (c: Buffer) => { size += c.length; if (size <= 1 << 20) chunks.push(c); });
    req.on('end', () => {
      const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v[0] : v]));
      const a = size > 1 << 20 ? { status: 413, body: { error: 'too large' } } : receiver.handle(headers, Buffer.concat(chunks).toString('utf8'));
      res.writeHead(a.status, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(a.body));
    });
  }).listen(port, () => console.log(`webhook receiver on http://127.0.0.1:${port}/webhooks`));
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) start();

Verify

  • Start a chat on your website, or create a conversation: the receiver logs [conversation.created] New conversation 4822 in inbox 3 from contact 77 and [message.created] Customer wrote in conversation 4822 (message 55020).
  • Settings → Webhooks → your endpoint → Deliveries shows each delivery with status 200. Replay one: the receiver answers {"ok":true,"duplicate":true} without logging it again.
  • Tests, without a running Kommio. They run against testdata/deliveries.json, four requests captured from Kommio's own webhook deliverer with a fixed secret (the three events and a retry of one); a test in the Kommio repository regenerates that file from the server's code, so the examples cannot drift from what Kommio sends.
cd node   && npm install && npm run typecheck && npm test   # npm install only fetches typescript and @types/node
cd python && python3 -m unittest test_receiver.py
cd go     && go test ./...     # outside the Kommio repository run `go mod init example.com/webhook-receiver` first

Which should I use?

An extension is one thing you add to Kommio from Settings › Marketplace; what it can do is one or more of these.

  • Sidebar panel Your own page beside the conversation, such as recent orders or a CRM record. Orders panel · reference
  • Message hook Check, rewrite or block a reply before it is sent, or an incoming message before it is stored. Orders panel · reference
  • Event subscription Signed events at your URL when conversations, messages or contacts change. This page · reference
  • Agent bot Your bot answers new conversations first and hands off to a person. Echo bot · reference
  • Remote data lookup Fetch fields such as balance or VIP tier from your API into the contact and a sidebar panel; set up from the Marketplace, no code. Marketplace · reference
  • Automation action A rule or macro step that calls your system, such as sending a webhook. Send a webhook · reference
  • Rule or flow template Start an automation rule or a chatbot flow from a ready-made template in the Marketplace. Marketplace · reference

Your scripts and back office call Kommio with an API key you scope: see the API sync example. API sync example

Every claim on these pages is covered by a test in the repository, and the API reference is generated from the same contract every server serves at /api/openapi. Corrections to [email protected].