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
Set up the webhook
In the app's “Webhooks” section:
- Enter the URL. It must start with
https://and be reachable from the internet: private addresses such aslocalhost,10.xand192.168.xare not accepted, and redirects are not followed. - 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.
- Save the secret: it starts with
whsec_and is shown only once.
An app has one webhook address.
- Enter the URL. It must start with
-
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 with200and 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
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 becamedelivered,readorfailed):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.uninstalledarrives whatever topics you chose. -
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?" } }idis the same as thewebhook-idheader: use it to spot duplicates.datahas the same shape as in the sync endpoints.conversation.updatedandconversation.status_changedalso carrychanged_attributes. In a public app the envelope also carriesinstallation_id. -
5
Verify the signature
Anyone can fake a webhook that is not checked.
- Read the body raw. Do not parse the JSON and write it again.
- The signed text:
{webhook-id}.{webhook-timestamp}.{raw body}. - The key: the part of the secret after
whsec_, base64 decoded. - Compute HMAC-SHA256 and encode it with base64.
webhook-signaturecan hold several space-separatedv1,<signature>values: if one matches in a constant-time comparison, the signature is valid.- If
webhook-timestampis 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-idevt_2Wq8mX4vT9kLpR3n,webhook-timestamp1794153600, 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 bev1,hJufoXNHJ1dUd1zcZdtiPLkp9Px9eS+ZIUi9odi4Cvk=. This secret exists only for the docs and the timestamp is old: turn off the 5-minute check while testing. -
6
Delivery and retries
- Your server must return
2xxwithin 5 seconds: put the event in a queue and answer right away. - If no
2xxcomes, 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 byoccurred_at). message.updateddoes not give the reason forfailed: read the message and look atcontent_attributes.external_error.
- Your server must return
-
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.testright 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 fromGET /v1/eventswith the sameids. To test locally, get an HTTPS address with a tunnel such as ngrok or Cloudflare Tunnel. -
8
If webhooks do not arrive
- Is the webhook in the “verified” status in the portal? If not, your server does not return the
challengewithin 5 seconds. - Is the topic's scope approved?
- Does your server answer
2xxwithin 5 seconds? Check the response code and the error in the log. - Is the URL reachable from the internet, is it
https, and is there no redirect? - If the webhook was turned off, turn it on again.
- Is the webhook in the “verified” status in the portal? If not, your server does not return the