Webhooks
Events on your address: the list, the headers, the HMAC signature check, retries and loop protection.
On this page
Events #
| Event | When | In data |
|---|---|---|
| conversation.started | the visitor started a conversation | conversation |
| message.created | a new message in either direction | conversation, message |
| conversation.closed | the conversation was closed | conversation, reason |
| visitor.identified | the site passed visitor details | conversation |
| webhook.test | a test from the dashboard, cannot be subscribed to | — |
The names are final. A new event is added next to the old ones, and an old one is never renamed: your handlers are written against these names.
What the request looks like #
The conversation and message objects are the same ones the API serves: one parser works for both.
POST /your-handler HTTP/1.1
Content-Type: application/json
X-WidgetChat-Event: message.created
X-WidgetChat-Delivery: message.created:8f1c…
X-WidgetChat-Attempt: 1
X-WidgetChat-Timestamp: 1755878400
X-WidgetChat-Signature: sha256=6b2f…
{"id": "message.created:8f1c…",
"event": "message.created",
"createdAt": "2026-08-22T12:00:00Z",
"workspaceKey": "example-ru",
"data": {"conversation": { … }, "message": { … }}}Checking the signature #
What is signed is the string timestamp + "." + body, not the body alone: a signature over a bare body could be replayed a day later and the handler would not tell the replay from a fresh event.
Check three things: the signature matches in a constant-time comparison; the timestamp is no more than five minutes away from your clock; the id from X-WidgetChat-Delivery has not been seen before.
// Go
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(r.Header.Get("X-WidgetChat-Timestamp") + "." + string(body)))
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
ok := hmac.Equal([]byte(expected), []byte(r.Header.Get("X-WidgetChat-Signature")))# PHP
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
$ok = hash_equals($expected, $signature);Answers and retries #
We wait 20 seconds for the answer. Answer right away and do the work afterwards — a slow handler counts as a failure. Every attempt is visible in the delivery log in the dashboard: the event, the attempt number, the response code, the error text.
| Your answer | What we do |
|---|---|
| 2xx | counted as accepted, we send no more |
| 4xx | no retry: the recipient will not change its mind |
| 5xx, timeout, dropped connection | 3 attempts, a 5 second pause doubling after that |
Loop protection #
A reply sent by your own program also produces message.created. Look at the origin field inside message: api is you, telegram is an operator from the group, bitrix24, amocrm and retailcrm are a manager from a CRM, widget is the visitor.
Requirements for the address #
HTTPS only and an external address only: the event carries a customer's conversation. An address inside a private network is not accepted.