App webhooks and events: setup, topics, signature verification

Last updated: 11 November 2026 · 8 min read

A webhook is an HTTP request sent to your server when something happens in Clomni: message.created arrives as soon as a customer writes, so you do not need to check the list every minute. This article is about the webhook of an app on developer.clomni.ai. The workspace webhook in the dashboard (Settings › Workspace settings › Integrations › Webhook) is separate: for that one, see the “Set up a webhook” article.

Steps

  1. 1

    Set up the webhook

    In the app's “Webhooks” section:

    1. Enter the URL. It must start with https:// and be reachable from the internet: private addresses such as localhost, 10.x and 192.168.x are not accepted, and redirects are not followed.
    2. Choose the topics. In an internal app only topics that match an approved scope can be chosen. In a public app a requested scope is enough.
    3. Save the secret: it starts with whsec_ and is shown only once.

    An app has one webhook address.

  2. 2

    Verify the address

    When you save the URL, Clomni sends { "type": "webhook.verify", "challenge": "c2b1f0a8e7d94c3b" } to it. Your server must answer within 5 seconds with 200 and the same value in the body: { "challenge": "c2b1f0a8e7d94c3b" }. If no answer comes, the webhook stays “unverified” and no events are sent: fix the server and save again in the portal. This request is signed too.

  3. 3

    Topics and the scope they need

    • conversation.created, conversation.updated (label, priority, attribute), conversation.status_changed, conversation.assigned: conversations:read.
    • message.created (incoming, outgoing, private note), message.updated (the status became delivered, read or failed): messages:read.
    • contact.created, contact.updated: contacts:read.

    If a scope is removed from the app, its matching topic stops too. In a public app, when an administrator removes the app, app.uninstalled arrives whatever topics you chose.

  4. 4

    The shape of the request

    Headers: Content-Type: application/json, User-Agent: Clomni-Webhooks/1.0, webhook-id, webhook-timestamp, webhook-signature. Body:

    {
      "id": "evt_2Wq8mX4vT9kLpR3n",
      "type": "message.created",
      "api_version": "v1",
      "workspace_id": 42,
      "occurred_at": "2026-11-08T16:00:00.412Z",
      "data": { "id": 51234, "conversation_number": 1042, "direction": "incoming", "content": "Hi, when will my order arrive?" }
    }

    id is the same as the webhook-id header: use it to spot duplicates. data has the same shape as in the sync endpoints. conversation.updated and conversation.status_changed also carry changed_attributes. In a public app the envelope also carries installation_id.

  5. 5

    Verify the signature

    Anyone can fake a webhook that is not checked.

    1. Read the body raw. Do not parse the JSON and write it again.
    2. The signed text: {webhook-id}.{webhook-timestamp}.{raw body}.
    3. The key: the part of the secret after whsec_, base64 decoded.
    4. Compute HMAC-SHA256 and encode it with base64.
    5. webhook-signature can hold several space-separated v1,<signature> values: if one matches in a constant-time comparison, the signature is valid.
    6. If webhook-timestamp is older than 5 minutes, reject the request.
    import crypto from "node:crypto";
    
    function verifyWebhook(rawBody, headers, secret) {
      const id = headers["webhook-id"], ts = headers["webhook-timestamp"], sigs = headers["webhook-signature"];
      if (!id || !ts || !sigs || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
      const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
      const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest();
      return sigs.split(" ").some((part) => {
        const [v, sig] = part.split(",");
        const given = Buffer.from(sig || "", "base64");
        return v === "v1" && given.length === expected.length && crypto.timingSafeEqual(given, expected);
      });
    }
    import base64, hashlib, hmac, time
    
    def verify_webhook(raw_body: bytes, headers, secret: str) -> bool:
        msg_id, ts, sigs = headers.get("webhook-id"), headers.get("webhook-timestamp"), headers.get("webhook-signature")
        if not msg_id or not ts or not sigs or abs(time.time() - int(ts)) > 300:
            return False
        key = base64.b64decode(secret.removeprefix("whsec_"))
        expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()).decode()
        return any(p.partition(",")[0] == "v1" and hmac.compare_digest(p.partition(",")[2], expected) for p in sigs.split(" "))

    Test values: secret whsec_aSGonRLU/DDLGZkKrR6fe7DCdyC22NiWOb8feJg/O6k=, webhook-id evt_2Wq8mX4vT9kLpR3n, webhook-timestamp 1794153600, body {"id":"evt_2Wq8mX4vT9kLpR3n","type":"message.created","api_version":"v1","workspace_id":42,"occurred_at":"2026-11-08T16:00:00Z","data":{"id":51234}}. The result must be v1,hJufoXNHJ1dUd1zcZdtiPLkp9Px9eS+ZIUi9odi4Cvk=. This secret exists only for the docs and the timestamp is old: turn off the 5-minute check while testing.

  6. 6

    Delivery and retries

    • Your server must return 2xx within 5 seconds: put the event in a queue and answer right away.
    • If no 2xx comes, Clomni sends it again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours.
    • If no delivery succeeds for 3 days in a row, the webhook is turned off and the app owner gets an email. Fix the server and turn the webhook on again in the portal.
    • The same event can arrive twice (drop duplicates by webhook-id), and order is not guaranteed (sort by occurred_at).
    • message.updated does not give the reason for failed: read the message and look at content_attributes.external_error.
  7. 7

    Check in the portal

    The “Webhooks” section has the delivery log for the last 30 days (topic, status, response code, duration, error). “Send test” sends a sample event of type webhook.test right away, and “Resend” sends a failed delivery again. For 24 hours after you change the secret, the header carries two signatures (old and new). If your server was down for a long time, take the missed events from GET /v1/events with the same ids. To test locally, get an HTTPS address with a tunnel such as ngrok or Cloudflare Tunnel.

  8. 8

    If webhooks do not arrive

    1. Is the webhook in the “verified” status in the portal? If not, your server does not return the challenge within 5 seconds.
    2. Is the topic's scope approved?
    3. Does your server answer 2xx within 5 seconds? Check the response code and the error in the log.
    4. Is the URL reachable from the internet, is it https, and is there no redirect?
    5. If the webhook was turned off, turn it on again.

Document