Connect your systems

Receive events with webhooks

Subscribe an HTTPS endpoint, verify every delivery, handle retries without duplicates, and replay from the ledger. Complete receivers in Node, Python and Go.

Last updated

  1. Subscribe

    Settings, Webhooks, or the API. events is a list of types or ["*"]. The signing secret is returned once.

    curl
    curl -X POST "$KOMMIO_URL/webhooks" -H "Authorization: Bearer $KOMMIO_KEY" -H "Content-Type: application/json" \
      -d '{"name": "orders", "url": "https://orders.example/hooks/kommio", "events": ["conversation.created", "message.created", "conversation.updated"]}'
  2. Verify the signature

    Each delivery carries Kommio-Signature: t=<unix seconds>,v1=<hex> where v1 is HMAC-SHA256 over t + "." + rawBody with the secret. Compare in constant time and reject timestamps older than five minutes. Read the raw body before any JSON parsing.

  3. Deduplicate

    Every delivery carries the event_id (also in the Kommio-Event-Id header); retries and replays reuse it. Store the ids you have processed (a unique index is enough) and skip repeats. Delivery is at least once and not ordered.

  4. Answer fast

    Return 2xx within a few seconds and do the work afterwards. A non-2xx or a timeout is retried after 1 minute, 5 minutes, 15 minutes, 1 hour, 4 hours, 8 hours and 12 hours; then the delivery is marked failed and the endpoint "failing" until it is edited. Every attempt is in the ledger with its status.

Complete receivers

receiver.ts
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.KOMMIO_WEBHOOK_SECRET!;
const seen = new Set<number>(); // use your database in production

function verify(header: string | undefined, body: string): boolean {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=") as [string, string]));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac("sha256", SECRET).update(t + "." + body).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(parts.v1 || "");
  return a.length === b.length && timingSafeEqual(a, b);
}

createServer((req, res) => {
  let body = "";
  req.on("data", (c) => (body += c));
  req.on("end", async () => {
    if (!verify(req.headers["kommio-signature"] as string, body)) { res.writeHead(401); return res.end(); }
    const event = JSON.parse(body);
    if (seen.has(event.event_id)) { res.writeHead(200); return res.end(); }
    seen.add(event.event_id);
    res.writeHead(200); res.end();            // acknowledge first
    switch (event.type) {                      // then work
      case "message.created":
        if (event.data.direction === "inbound") await notifyOrdersTeam(event.data.conversation_id);
        break;
      case "conversation.updated":
        if (event.data.reason === "resolved") await closeTicket(event.object_id);
        break;
    }
  });
}).listen(8787);

What a delivery looks like

POST https://orders.example/hooks/kommio
Kommio-Signature: t=1790000000,v1=5f1c7e2a9b4d6f8e0c1a3b5d7f9e2c4a6b8d0f1e3c5a7b9d2f4e6a8c0b1d3f5e
Kommio-Event: message.created
Kommio-Event-Id: 91822
Kommio-Delivery: 1204
User-Agent: Kommio-Webhooks/1
Content-Type: application/json

{
  "id": 1204,
  "event_id": 91822,
  "type": "message.created",
  "account_id": 12,
  "object_type": "message",
  "object_id": 55010,
  "occurred_at": "2026-09-22T09:12:00Z",
  "data": { "conversation_id": 4821, "direction": "inbound", "kind": "reply", "sender_type": "contact" }
}

id identifies the delivery (the same on a retry or a replay), event_id the event. data carries the ids you need to decide whether to fetch more; read the full object with the API when you do. Types and payload fields are in the event catalogue.

Visitor context in payloads

For conversation.created and message.created of a website chat, the delivery also carries the visitor's context under visitor when the conversation has one (the widget sends it with the visitor's first message). It is read when the request is built, so a retry or a replay sends the context as it is then. Other events and channels have no visitor key. Bots get the same object on every event about a web chat they hold, and apps with the conversation.created hook.

visitor
"visitor": {
  "conversation_id": 4821,
  "collected_at": "2026-09-22T09:10:10Z", "updated_at": "2026-09-22T09:12:40Z",
  "ip": "203.0.113.0", "country_code": "PT", "country": "Portugal", "region": "Lisbon", "city": "Lisbon",
  "asn": 3243, "org": "MEO", "tor": false, "likely_vpn": false,
  "geo_attribution": "IP geolocation by DB-IP",
  "browser": "Chrome 128", "os": "macOS", "device": "desktop", "languages": ["pt-PT", "en"], "timezone": "Europe/Lisbon",
  "referrer": "https://www.google.com/", "landing_page": "https://shop.example/?utm_source=news",
  "utm": { "source": "news", "medium": "email", "campaign": "autumn" },
  "current_page": { "url": "https://shop.example/pricing", "title": "Pricing", "at": "2026-09-22T09:11:00Z" },
  "trail": [ { "url": "https://shop.example/", "title": "Home", "at": "2026-09-22T09:02:00Z" } ],
  "page_context": { "cart_total": 129.5 },
  "js_errors": []
}
  • ip follows the workspace's visitor IP setting: the full address (default), shortened, or absent. Role field rules do not apply, since a webhook is set up for the whole workspace.
  • When the location came from DB-IP's databases, geo_attribution is present; if you show the location, show that credit with a link to db-ip.com.
  • page_context is what the page passed to Kommio.setContext and is unverified. Fields without a value are omitted. What is collected, and how to limit it, is on visitor context.

Daily delivery budget and failing endpoints

Every workspace has a daily delivery budget: the deliveries it may record per UTC day, counted across everything that delivers through the ledger (endpoints, agent bots, and the "Send a webhook" action of rules and macros). Set it under Settings › Webhooks (webhooks:manage); the default is 10,000, and 0 is unlimited.

  • Past the budget, a delivery is kept in the ledger with the status over_budget and the reason, and is not sent, not on a later day either: the budget is there to stop a runaway (a rule loop, an import, a storm) from flooding your endpoint, and carrying the overflow into the next day would spend that day's budget on stale events. The next UTC day starts with a fresh budget.
  • Over-budget deliveries are listed with ?status=over_budget. Replaying one sends it on purpose and does not count against the budget; "Replay all failed" leaves them alone. A receiver that needs every event reads what it missed from the API.
  • GET /webhooks returns the budget and today's use under budget (limit, used_today, over_budget_today, reached, resets_at, failing_notice_after), and the Webhooks page shows the same card. On Kommio Cloud a fair-use cap on deliveries per day sits above the budget.
  • When an endpoint fails 20 deliveries in a row (a failure is a delivery that used up its retries; the number is the setting failing_notice_after, and 0 turns the notice off), everyone who manages webhooks gets a notification in the app and by email, once per outage, and again only after the endpoint has delivered something. They are also told once a day when the budget is reached.

Ledger and replay

# recent deliveries with status and response code (?status=failed for the failed ones)
curl "$KOMMIO_URL/webhooks/9/deliveries" -H "Authorization: Bearer $KOMMIO_KEY"

# replay one
curl -X POST "$KOMMIO_URL/webhooks/9/deliveries/1204/replay" -H "Authorization: Bearer $KOMMIO_KEY"

# after an outage: replay every failed delivery (the oldest 100, spaced out; at most once a minute)
curl -X POST "$KOMMIO_URL/webhooks/9/deliveries/replay-failed" -H "Authorization: Bearer $KOMMIO_KEY"

# rotate the secret (returned once; the old one stops working at once)
curl -X POST "$KOMMIO_URL/webhooks/9/rotate-secret" -H "Authorization: Bearer $KOMMIO_KEY"

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].