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
Subscribe
Settings, Webhooks, or the API.
eventsis a list of types or["*"]. The signing secret is returned once.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"]}'Verify the signature
Each delivery carries
Kommio-Signature: t=<unix seconds>,v1=<hex>wherev1is HMAC-SHA256 overt + "." + rawBodywith the secret. Compare in constant time and reject timestamps older than five minutes. Read the raw body before any JSON parsing.Deduplicate
Every delivery carries the
event_id(also in theKommio-Event-Idheader); 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.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
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);import hmac, hashlib, json, os, time
from flask import Flask, request, abort
SECRET = os.environ["KOMMIO_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
seen = set() # use your database in production
def verify(header: str, body: bytes) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
t = int(parts.get("t", 0))
if abs(time.time() - t) > 300:
return False
expected = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
@app.post("/hooks/kommio")
def hook():
body = request.get_data()
if not verify(request.headers.get("Kommio-Signature", ""), body):
abort(401)
event = json.loads(body)
if event["event_id"] in seen:
return "", 200
seen.add(event["event_id"])
queue.enqueue(handle, event) # acknowledge now, work later
return "", 200
def handle(event):
if event["type"] == "message.created" and event["data"]["direction"] == "inbound":
notify_orders_team(event["data"]["conversation_id"])package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var secret = []byte(os.Getenv("KOMMIO_WEBHOOK_SECRET"))
func verify(header string, body []byte) bool {
var t int64
var sig string
for _, kv := range strings.Split(header, ",") {
k, v, _ := strings.Cut(kv, "=")
switch k {
case "t":
t, _ = strconv.ParseInt(v, 10, 64)
case "v1":
sig = v
}
}
if t == 0 || time.Since(time.Unix(t, 0)) > 5*time.Minute {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
mac.Write(body)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(sig))
}
func main() {
http.HandleFunc("/hooks/kommio", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if !verify(r.Header.Get("Kommio-Signature"), body) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var ev struct {
EventID int64 `json:"event_id"`
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
_ = json.Unmarshal(body, &ev)
if !markSeen(ev.EventID) { // your store; false when already processed
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
go handle(ev.Type, ev.Data)
})
http.ListenAndServe(":8787", nil)
}What a delivery looks like
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": {
"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": []
}ipfollows 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_attributionis present; if you show the location, show that credit with a link to db-ip.com. page_contextis what the page passed toKommio.setContextand 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_budgetand 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 /webhooksreturns the budget and today's use underbudget(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].