Build an app
An app is a service you host. Installed from a manifest, it receives hooks over HTTP, can veto or rewrite messages, renders panels beside conversations, and calls back with a scoped token. Complete hook servers in Node and Python.
Last updated
Publish a manifest
A JSON document at a URL you control.
nameis 2 to 40 lower-case characters, unique per workspace;api_versionmust be 1; scopes are permission keys; every URL must be absolute and publicly reachable over https.{ "name": "orders", "title": "Orders", "version": "1.0.0", "api_version": 1, "description": "Orders beside the conversation; blocks replies that leak an order secret.", "scopes": ["conversations:read", "contacts:read"], "hooks": { "message.before_send": { "url": "https://orders.example/hooks/send", "timeout_ms": 3000, "required": false }, "conversation.created": { "url": "https://orders.example/hooks/created" } }, "panels": [ { "key": "orders", "title": "Orders", "url": "https://orders.example/panel", "location": "conversation" } ], "settings_schema": { "type": "object", "properties": { "store_url": { "type": "string", "title": "Store URL" } } } }Install it
Settings › Marketplace › Build › New extension (permission
apps:manage): paste the manifest URL, review the requested scopes, approve all or a subset. Kommio fetches the manifest through its SSRF-safe client; the response carries the installation token and signing secret, once. The app is then listed in this workspace's Marketplace as Private: offered to this workspace alone, installed here only, with its own settings here.curl -X POST "$KOMMIO_URL/apps" -H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \ -d '{"manifest_url": "https://orders.example/kommio.json", "scopes": ["conversations:read"]}'const { data: app } = await admin.POST("/apps", { body: { manifest_url: "https://orders.example/kommio.json", scopes: ["conversations:read"] } }); // app.token (it starts with kap_) and app.secret are shown once: hand them to your serviceResponse
201 Created{ "id": 5, "name": "orders", "title": "Orders", "version": "1.0.0", "status": "active", "scopes": ["conversations:read"], "requested_scopes": ["conversations:read", "contacts:read"], "hooks": ["conversation.created", "message.before_send"], "panels": [{ "key": "orders", "title": "Orders", "url": "https://orders.example/panel", "location": "conversation" }], "token_prefix": "kap_Q1w9z2Kx", "token": "kap_Q1w9z2KxT7mA4bV8nH1cL3pE6rD0sF", "secret": "whsec_5mN8qR2tV7yB1cX4zA9kL6pJ3hG0dF" }Handle hooks
Hook Delivered Answer conversation.createdAfter the conversation exists, off the request path, retried on failure. Website chats carry the visitor context under visitor.Any 2xx. message.before_sendBefore an agent reply is recorded; the agent waits. {"action":"allow"},{"action":"veto",or"reason":"<what the agent sees>"} {"action":"rewrite",."content":"<the replacement>"} inbound.eventBefore an inbound message is stored. allow,drop(with a reason) orrewritewithtextandsubject.Every delivery is a JSON POST with
Kommio-App-HookandKommio-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "t.body">using the signing secret. An empty 2xx means allow.
A complete hook server
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.KOMMIO_APP_SECRET!, TOKEN = process.env.KOMMIO_APP_TOKEN!;
const FORBIDDEN = /order secret|internal only/i;
function verify(header = "", body: string) {
const p = Object.fromEntries(header.split(",").map((kv) => kv.split("=") as [string, string]));
const exp = createHmac("sha256", SECRET).update(p.t + "." + body).digest("hex");
return Math.abs(Date.now() / 1000 - Number(p.t)) < 300 && exp.length === (p.v1 || "").length && timingSafeEqual(Buffer.from(exp), Buffer.from(p.v1));
}
const json = (res: import("node:http").ServerResponse, code: number, body: unknown) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); };
createServer((req, res) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", async () => {
const url = new URL(req.url!, "http://x");
if (req.method === "GET" && url.pathname === "/kommio.json") return json(res, 200, manifest);
if (!verify(req.headers["kommio-signature"] as string, raw)) return json(res, 401, { error: "bad signature" });
const ev = JSON.parse(raw);
switch (ev.hook) {
case "message.before_send": {
const content: string = ev.message.content;
if (FORBIDDEN.test(content)) return json(res, 200, { action: "veto", reason: "Replies must not mention internal order secrets." });
if (content.includes("{{eta}}")) {
const conv = await fetch(ev.callback_api + "/conversations/" + ev.message.conversation_id, { headers: { Authorization: "Bearer " + TOKEN } }).then((r) => r.json());
return json(res, 200, { action: "rewrite", content: content.replace("{{eta}}", await etaFor(conv.custom_attributes?.order_id)) });
}
return json(res, 200, { action: "allow" });
}
case "conversation.created":
await recordInCrm(ev.conversation);
return json(res, 204, {});
}
res.writeHead(404); res.end();
});
}).listen(4567);import hmac, hashlib, os, re, time, requests
from flask import Flask, request, jsonify, abort
SECRET, TOKEN = os.environ["KOMMIO_APP_SECRET"].encode(), os.environ["KOMMIO_APP_TOKEN"]
FORBIDDEN = re.compile(r"order secret|internal only", re.I)
app = Flask(__name__)
def verify(header, body):
p = dict(kv.split("=", 1) for kv in header.split(","))
exp = hmac.new(SECRET, f"{p['t']}.".encode() + body, hashlib.sha256).hexdigest()
return abs(time.time() - int(p["t"])) < 300 and hmac.compare_digest(exp, p.get("v1", ""))
@app.get("/kommio.json")
def manifest():
return jsonify(MANIFEST)
@app.post("/hooks/send")
def before_send():
body = request.get_data()
if not verify(request.headers.get("Kommio-Signature", ""), body):
abort(401)
ev = request.get_json(force=True)
content = ev["message"]["content"]
if FORBIDDEN.search(content):
return jsonify(action="veto", reason="Replies must not mention internal order secrets.")
if "{{eta}}" in content:
conv = requests.get(f"{ev['callback_api']}/conversations/{ev['message']['conversation_id']}", headers={"Authorization": f"Bearer {TOKEN}"}).json()
return jsonify(action="rewrite", content=content.replace("{{eta}}", eta_for(conv["custom_attributes"].get("order_id"))))
return jsonify(action="allow")Failure policy
Synchronous hooks fail open: a timeout (3 seconds by default, at most 10) or a 5xx is logged as skipped and the action proceeds. Set "required": true on a hook that must approve every message; then a missing answer blocks the action and the agent sees "Orders must approve outgoing messages and did not answer; try again". Every delivery, with outcome and latency, is under Settings › Marketplace › Installed › Apps › Deliveries and GET /apps/{id}/log; the stored request body is redacted for the reader like any response (a reader whose role masks a field sees it masked there too).
Calling back
The installation token carries the approved scopes and account-wide visibility. Calls outside the scopes answer 403. Pausing or deleting the app stops the token at once; rotate it any time. Refresh manifest re-fetches the document; scopes added by a new version stay unapproved until an administrator grants them.
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].