Extend

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

  1. Publish a manifest

    A JSON document at a URL you control. name is 2 to 40 lower-case characters, unique per workspace; api_version must be 1; scopes are permission keys; every URL must be absolute and publicly reachable over https.

    https://orders.example/kommio.json
    {
      "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" } } }
    }
  2. 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"]}'
    Response 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" }
  3. Handle hooks

    HookDeliveredAnswer
    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","reason":"<what the agent sees>"} or {"action":"rewrite","content":"<the replacement>"}.
    inbound.eventBefore an inbound message is stored.allow, drop (with a reason) or rewrite with text and subject.

    Every delivery is a JSON POST with Kommio-App-Hook and Kommio-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "t.body"> using the signing secret. An empty 2xx means allow.

A complete hook server

server.ts
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);

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