Heilo webhooks
Webhooks send Heilo events to your own server the moment they happen: a new conversation, a new message, a visitor leaving their email, a contact or a ticket changing. Use them to sync contacts to your CRM, open an issue in your own tool, alert your team or start an automation in Zapier, Make or n8n. Each event is one signed JSON POST to the URL you choose.
- Plans: Pro and Business, and the free trial (the
webhooksfeature). On Free and Starter nothing is sent. Endpoints you added stay saved and receive events again once the workspace is back on Pro or Business, but events from the time in between are never sent. - Where: dashboard → Integrations → Webhooks → Custom webhook, for workspace owners and admins. Endpoints are managed only there: there is no API for them.
- Scope: one workspace, every website in it. Tell websites apart with
websiteId. - Format:
POST,Content-Type: application/json, body{ id, event, createdAt, accountId, data }, signed with HMAC-SHA256 (section 3). - Delivery: answer any
2xxwithin 10 seconds. Otherwise Heilo tries again after 5 seconds, 30 seconds and 2 minutes, then gives up. - Times in bodies are Unix epoch milliseconds. Only the
X-Heilo-Timestampheader is in seconds.
1. Add an endpoint
- In the dashboard, open Integrations → Webhooks and go to Custom webhook.
- Enter the Endpoint URL, e.g.
https://your-app.com/webhooks/heilo. - Pick the Events: All events (the default; it also gets events Heilo adds later) or only the ones you need.
- Press Add webhook. The endpoint gets its own signing secret (
whsec_…): press Copy secret and store it on your server. - Press Send test to send a
pingevent and see the answer right away (section 2.4).
Each endpoint card has a switch to pause it, Send test, New secret (section 3.4), an edit button and a delete button. Editing changes the URL and the events; the endpoint keeps its id and its secret.
Rules for the URL, checked when you add it and again before every delivery:
https://only, up to 2048 characters. Any port is fine. The TLS certificate must be valid for the host: a self-signed one fails.- The host must resolve in DNS, and none of its addresses may be outside the public internet. Refused: IPv4
0.0.0.0/8,10.0.0.0/8,100.64.0.0/10,127.0.0.0/8,169.254.0.0/16,172.16.0.0/12,192.0.0.0/24,192.0.2.0/24,192.88.99.0/24,192.168.0.0/16,198.18.0.0/15,198.51.100.0/24,203.0.113.0/24, multicast224.0.0.0/4and240.0.0.0/4; IPv6 anything outside global unicast2000::/3(so::,::1,fc00::/7,fe80::/10, multicastff00::/8and NAT6464:ff9b::/96), plus2001::/23,2001:db8::/32,2002::/16and3fff::/20. An IPv4-mapped IPv6 address (::ffff:10.0.0.1) counts as its IPv4 address. - Redirects are not followed: a
301,302,307or308answer counts as a failed delivery. Use the final URL. - Heilo can't reach your laptop. To try a receiver locally, expose it with a tunnel that gives a public HTTPS URL (ngrok, Cloudflare Tunnel), or send yourself a signed request (section 3.3).
Zapier, Make, n8n. Paste the URL of a Zapier Catch Hook, a Make custom webhook or an n8n Webhook node as the endpoint and pick the events. The JSON arrives as described below; check the signature where the tool lets you run code.
Ready-made alerts. The same page connects Telegram, Discord, Microsoft Teams and Slack in a few clicks, with no code and no receiver: Heilo posts a formatted message in the app itself. They run on the same events and plans (new conversation, new visitor messages grouped to at most one alert a minute per chat, assigned, closed, after-chat rating, new ticket). A rating waits up to 90 seconds for the visitor's comment and goes out as one alert; later, only a changed score alerts again, comment edits don't. The rest of this guide is about custom webhooks.
2. The request
Every event is one POST per endpoint, with these headers:
| Header | Value |
|---|---|
Content-Type | application/json (the body is UTF-8) |
User-Agent | Heilo-Webhooks/1.0 |
X-Heilo-Event | the event name, e.g. message.created (same as event in the body) |
X-Heilo-Delivery | the event id, e.g. evt_mw1u94awk3p9x2q7d1 (same as id in the body, on every retry and every endpoint) |
X-Heilo-Timestamp | Unix time in seconds when this attempt was signed |
X-Heilo-Signature | sha256= followed by the lowercase hex HMAC-SHA256 of the request (section 3) |
2.1 Body
{
"id": "evt_mw1u94awk3p9x2q7d1",
"event": "message.created",
"createdAt": 1793779925000,
"accountId": "acc_mv2k1q8w3e5r7t9y0u",
"data": {
"conversation": { "id": "conv_mw1u94aw8f2k6m0p3s", "status": "open", "visitor": { "id": "vis_mvxmcwlc4n8q2r7t1y", "name": "Lan Đoàn" } },
"message": { "id": "msg_mw1u94awq5x1c7v3b9", "senderType": "visitor", "type": "text", "body": "Hi! Can I export my notes to PDF?" }
}
}The objects in data are shortened here: section 4 has every field.
| Field | Type | Meaning |
|---|---|---|
id | string | Event id (evt_…). The same on every retry and on every endpoint: use it to drop duplicates |
event | string | Event name (section 4), or ping for a test |
createdAt | number | When the event happened, in ms. The same on every retry |
accountId | string | Your workspace id (acc_…) |
data | object | What the event carries (section 4) |
Heilo may add events and add fields to objects over time. Ignore what you don't know instead of failing on it.
2.2 Success, timeout and retries
- Any
2xxstatus within 10 seconds is a success. The response body is ignored. - Anything else is a failure: any other status (including
3xx, since redirects aren't followed, and4xx), no answer within 10 seconds, or a connection, DNS or TLS error. - A failed delivery is tried again up to 3 more times, each attempt with a fresh
X-Heilo-Timestampand signature and the same body, sent to the endpoint's current URL and signed with its current secret (an edit or New secret in the meantime applies to the retries too):
| Attempt | When |
|---|---|
| 1 | right away |
| 2 | 5 seconds after attempt 1 failed |
| 3 | 30 seconds after attempt 2 failed |
| 4 | 2 minutes after attempt 3 failed |
After the 4th failure the event is dropped for that endpoint. There is no later redelivery and no replay button. Retries wait in the Heilo server's memory, so a restart of Heilo during those few minutes also drops them. If missing an event matters (syncing contacts, for example), reconcile with the REST API from time to time.
- Answer
2xxto every event you receive, includingpingand events you don't handle. Any other answer is retried. - Keep the answer fast: if the work takes longer than a few seconds, store the event (a queue, a jobs table), answer
200, then process it. - A retry that is already scheduled still goes out if you pause or delete the endpoint in the meantime.
2.3 Order and duplicates
- Duplicates happen. When your server did the work but answered late or with an error, the retry brings the same event again. Store every
idyou processed (a table with a unique key) and answer200without doing the work twice. - No ordering guarantee. Each event goes to each endpoint on its own, as soon as it happens, and a retry can land after newer events. A visitor's first message even sends
message.createdjust beforeconversation.created. Don't rely on arrival order: keep the newest state bycreatedAt(or a ticket'supdatedAt), or fetch the current state from the REST API when it matters. - One request per event and endpoint, no batching. Two endpoints that both subscribe to an event each get it, with the same
id. - Your own changes come back. A reply you send through the REST API arrives as
message.createdwithsenderType: "agent", and a contact you update through the REST API ascontact.updatedwithsource: "api". Filter on these so your receiver doesn't loop.
2.4 Test events
Send test posts a ping event to that one endpoint at once and shows the result: Test event delivered (HTTP 200) or Test failed: …. It is signed like any event, goes out even when the endpoint is paused and whatever events it subscribes to, and is never retried. ping can't be chosen in the event list.
{
"id": "evt_mw1uc86ob2n4m6v8c0",
"event": "ping",
"createdAt": 1793780070000,
"accountId": "acc_mv2k1q8w3e5r7t9y0u",
"data": { "message": "Your Heilo webhook works!" }
}The message text follows the dashboard's language. Answer 2xx and do nothing else with it.
2.5 Checklist for a receiver
- Read the raw body and verify the signature and the timestamp (section 3). Refuse with
401otherwise. - Skip event ids you already processed, answering
200. - Answer
2xxwithin 10 seconds, also forpingand events you don't use. - Branch on
event(or theX-Heilo-Eventheader) and ignore unknown events and fields. - Don't rely on arrival order, and ignore your own REST API changes (
senderType: "agent"replies,source: "api").
3. Verifying the signature
Anyone who learns your URL can send it requests, so check every request before trusting it:
- Take the raw request body, the exact bytes Heilo sent, before any JSON parsing. Parsing and serializing again changes the bytes (spacing, key order,
Đwritten asĐ) and the signature no longer matches. - Build the signed string: the
X-Heilo-Timestampvalue, a dot, then the raw body, e.g.1793779925.{"id":"evt_mw1u94awk3p9x2q7d1",…}. - Compute HMAC-SHA256 of that string. The key is the endpoint's signing secret as plain text and in full,
whsec_prefix included (don't base64-decode it). Hex-encode the result in lowercase. - Compare
sha256=+ that hex with theX-Heilo-Signatureheader, in constant time. - Refuse a timestamp more than 5 minutes away from your clock, so a captured request can't be replayed later. Heilo signs every attempt again with a fresh timestamp, so retries pass this check.
3.1 Node.js (Express)
// Node.js 20+, npm install express
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.HEILO_WEBHOOK_SECRET; // whsec_…, from Copy secret
const TOLERANCE_SECONDS = 5 * 60;
// Ids of events already handled. In production: a database table with a unique key on the id.
const handled = new Set();
function isFromHeilo(req) {
const timestamp = req.get('X-Heilo-Timestamp') ?? '';
const signature = req.get('X-Heilo-Signature') ?? '';
if (!Buffer.isBuffer(req.body) || !/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected =
'sha256=' + crypto.createHmac('sha256', SECRET).update(`${timestamp}.`).update(req.body).digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
async function handleEvent({ event, data }) {
switch (event) {
case 'conversation.created':
console.log('New conversation with', data.conversation.visitor?.name ?? 'a visitor');
break;
case 'message.created':
// replies you send through the REST API come back as senderType "agent": react to visitors only
if (data.message.senderType === 'visitor') console.log('Visitor wrote:', data.message.body);
break;
case 'contact.updated':
// skip what your own server changed through the REST API
if (data.source !== 'api') console.log('Contact', data.contact.id, 'changed:', data.changes.join(', '));
break;
case 'ticket.updated':
if (data.changes.includes('status')) console.log(`Ticket #${data.ticket.number} is now ${data.ticket.status}`);
break;
default:
// ping, and events you don't use (new ones may be added): nothing to do
break;
}
}
const app = express();
// Register this route before app.use(express.json()): the signature covers the raw bytes.
app.post('/webhooks/heilo', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
if (!isFromHeilo(req)) return res.sendStatus(401);
const payload = JSON.parse(req.body.toString('utf8'));
if (handled.has(payload.id)) return res.sendStatus(200); // a retry of an event already handled
try {
await handleEvent(payload); // must answer within 10 seconds: queue slow work and answer first
handled.add(payload.id);
res.sendStatus(200);
} catch (err) {
console.error('Heilo webhook failed', payload.id, err);
res.sendStatus(500); // Heilo tries again after 5 s, 30 s and 2 min
}
});
app.use(express.json()); // the rest of your app
app.listen(3000);If a global express.json() runs before this route, req.body is already an object and the check fails: keep the webhook route first, as above. In other runtimes, sign the body as text before parsing it, e.g. await request.text() in a Next.js route handler or a Cloudflare Worker.
3.2 PHP
<?php
$secret = getenv('HEILO_WEBHOOK_SECRET'); // whsec_…
$body = file_get_contents('php://input'); // the raw body, before json_decode
$timestamp = $_SERVER['HTTP_X_HEILO_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_HEILO_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300 || !hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
// skip $event['id'] if you already handled it: retries send the same id
if ($event['event'] === 'message.created' && $event['data']['message']['senderType'] === 'visitor') {
error_log('Heilo: ' . $event['data']['message']['body']);
}
http_response_code(200);3.3 Sending yourself a signed request
To try your receiver without Heilo, sign a request the same way and send it with curl:
SECRET='whsec_…' # the endpoint's secret
BODY='{"id":"evt_local_test_1","event":"ping","createdAt":1793780070000,"accountId":"acc_test","data":{"message":"Your Heilo webhook works!"}}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
curl -i -X POST http://localhost:3000/webhooks/heilo \
-H 'Content-Type: application/json' \
-H 'X-Heilo-Event: ping' \
-H 'X-Heilo-Delivery: evt_local_test_1' \
-H "X-Heilo-Timestamp: $TS" \
-H "X-Heilo-Signature: sha256=$SIG" \
--data-binary "$BODY"3.4 The signing secret
- Each endpoint has its own secret:
whsec_followed by 32 characters, created with the endpoint. - The endpoint card shows only its first characters. Copy secret copies all of it, and owners and admins can copy it again at any time.
- Keep it on your server (an environment variable), never in a page, an app or a repository.
- Rotating: New secret on the endpoint card (owners and admins) creates a new secret. From then on every attempt, retries included, is signed with the new one, and the old one stops at once: copy the new secret to your server right away. Deliveries that fail in the meantime are retried for about 2.5 minutes (section 2.2), signed with the new secret.
- Rotating without a single failed delivery: add a second endpoint with the same URL (it gets its own secret), make your receiver accept a signature made with either secret, then delete the old endpoint and drop the old secret. While both endpoints exist, every event arrives twice with the same
id, which your duplicate check already handles. - One receiver for several workspaces: give each workspace its own URL (e.g.
/webhooks/heilo/acme) so you know which secret to use, or readaccountIdfrom the body to pick the secret, then verify before you use anything else from it.
4. Events
| Event | Fires when | data |
|---|---|---|
conversation.created | A visitor writes and has no open conversation (website widget, your app through the Chat Client API, Messenger, Instagram), or a teammate writes first to a visitor (proactive chat) | { conversation } |
conversation.assigned | A conversation gets a new assignee: by hand, by auto-assign, or because a teammate answered an unassigned conversation | { conversation } |
conversation.closed | Your team or the REST API closes a conversation; a proactive chat the visitor never answered is closed after their visit; or reopening a conversation closes the visitor's other open one | { conversation } |
conversation.reopened | Your team or the REST API reopens a conversation, or a teammate or the REST API replies in a closed one | { conversation } |
conversation.rated | The visitor rates a chat your team closed (thumbs up or down in the widget or your Chat Client API app), changes the score, or adds, edits or removes the comment | { conversation, previous } |
message.created | Any message in a conversation: from the visitor, a teammate, the REST API, a chatbot rule, the AI assistant or the away message. Never internal notes | { conversation, message } |
visitor.identified | A name, email or phone from the visitor's side changes: the widget's identify, the pre-chat form, the Chat Client API, an identity token or the Heilo WordPress plugin | { visitor } |
contact.updated | A contact's details, notes, labels or custom fields changed: by your team, the REST API, an identity token, or the page or app (custom fields) | { contact, changes, source } |
ticket.created | A ticket is created in the dashboard or through the REST API | { ticket } |
ticket.updated | A ticket's subject, description, status, priority, assignee, due date, contact or labels changed, or the contact was emailed that it is solved | { ticket, changes } |
ping | Send test only (section 2.4) | { message } |
These send no event:
- internal notes in a conversation, and comments on a ticket
- unassigning a conversation, assigning it to the teammate it already has, reading it, typing
- a visitor sending the same rating again
- a visitor sending the same name, email and phone again
- changing only a conversation's labels (they ride along with its next conversation or message event)
- deleting a ticket, and tickets unassigned because a teammate left the workspace
- renaming or deleting a label or a custom field (a deleted field's values disappear without an event)
- anything at all while the workspace is not on a plan with webhooks
4.1 The visitor and contact object
Every person in an event comes complete, with labels and custom fields: data.conversation.visitor (conversation and message events), data.visitor (visitor.identified), data.contact (contact.updated) and data.ticket.contact (ticket events, a shorter form). Visitor and contact are the same thing in Heilo: the vis_… id works with both GET /api/v1/visitors/:id and GET /api/v1/contacts/:id in the REST API.
{
"id": "vis_mvxmcwlc4n8q2r7t1y",
"websiteId": "ws_mv2k1qa1h6d9s3f5g7",
"name": "Lan Đoàn",
"email": "[email protected]",
"phone": null,
"notes": "Asked about team plans in October.",
"meta": {
"url": "https://app.example.com/notes/export",
"title": "Export notes",
"referrer": "https://www.google.com/",
"language": "vi-VN",
"timezone": "Asia/Ho_Chi_Minh",
"screen": "1440x900",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
"country": "VN",
"region": "Ho Chi Minh",
"city": "Thu Duc",
"visits": 3,
"firstUrl": "https://www.example.com/pricing"
},
"createdAt": 1793524800000,
"lastSeenAt": 1793779800000,
"online": true,
"channel": "web",
"verifiedUserId": "42",
"lastInboundAt": null,
"labels": [{ "id": "lbl_mv9r2c1a7d3f5h8k0m", "name": "VIP", "color": "#22c55e" }],
"fields": { "plan": "pro", "order_count": 3 },
"customFields": [
{ "key": "plan", "label": "Plan", "type": "select", "value": "pro", "source": "identity" },
{ "key": "order_count", "label": "Orders", "type": "number", "value": 3, "source": "visitor" },
{ "key": "signup_date", "label": "Signed up", "type": "date", "value": null, "source": null }
]
}| Field | Type | Meaning |
|---|---|---|
id | string | Contact id (vis_…) |
websiteId | string | The website the visitor came from (ws_…) |
name, email, phone | string or null | Contact details. Values typed in the chat are not verified unless verifiedUserId is set |
notes | string or null | Your team's private notes on the contact (up to 5000 characters) |
meta | object | Device and visit details. Keys vary by channel: the website widget sends url, title, referrer, language, timezone, screen, plus userAgent, the approximate location from the IP address when known (country as an ISO code, region, city; timezone too when the browser or app sent none), visits, firstUrl; apps on the Chat Client API also via: "api", platform, appVersion; Messenger and Instagram channelAccount, username, avatarUrl. Informational only |
createdAt | number | First seen |
lastSeenAt | number or null | Last seen: a widget or app session, or a Messenger or Instagram message |
online | boolean | Connected to the chat when the event was built |
channel | string | web (website widget or your app), messenger or instagram |
verifiedUserId | string or null | Your own user id (sub) when your server verified the customer with an identity token. Use it to match your users |
lastInboundAt | number or null | Messenger and Instagram: the customer's last message, which opens the platform's reply window |
labels | array | Contact labels { id, name, color }, sorted by name |
fields | object | { key: value } of the custom fields that have a value |
customFields | array | Every custom field of the workspace, in the workspace's order: { key, label, type, value, source } |
conversationCount | number | Only on data.contact in contact.updated: the contact's number of conversations |
Custom fields:
- Every plan with webhooks includes custom fields, so
fieldsandcustomFieldsare always there ({}and[]when the workspace has none). typeistext,number,dateorselect.valueis a number fornumber, aYYYY-MM-DDstring fordate, one of the options forselect, text otherwise, andnullwhen not set.sourcesays who set the value:team(your team in the dashboard),api(REST API),identity(an identity token or the Heilo WordPress plugin),visitor(sent from the page or the app, not verified), ornullwhen there is no value. The rules about who can overwrite what are in Chat Client API, section 8.- Match fields by
key(lowercase letters, digits and_, up to 40 characters). Your team can rename a field'slabel; its key stays. - Don't trust
visitorvalues for anything that matters (a plan, a credit limit): the customer can change them.
data.ticket.contact is a summary: id, name, email, phone, channel, websiteId, plus labels, fields and customFields. Fetch the full contact from the REST API if you need more.
4.2 Conversation events
conversation.created, conversation.assigned, conversation.closed and conversation.reopened carry { conversation }: the conversation right after the change, with its visitor and its labels.
{
"id": "evt_mw1u952od4f6h8j0k2",
"event": "conversation.assigned",
"createdAt": 1793779926000,
"accountId": "acc_mv2k1q8w3e5r7t9y0u",
"data": {
"conversation": {
"id": "conv_mw1u94aw8f2k6m0p3s",
"websiteId": "ws_mv2k1qa1h6d9s3f5g7",
"visitorId": "vis_mvxmcwlc4n8q2r7t1y",
"channel": "web",
"status": "open",
"assigneeId": "usr_mv2k1q8w3e5r7t9y1a",
"lastMessageAt": 1793779925000,
"lastMessagePreview": "Hi! Can I export my notes to PDF?",
"lastMessageSender": "visitor",
"unread": 1,
"firstResponseAt": null,
"aiHandoffAt": null,
"invite": null,
"createdAt": 1793779925000,
"closedAt": null,
"rating": null,
"visitor": {
"id": "vis_mvxmcwlc4n8q2r7t1y",
"name": "Lan Đoàn",
"email": "[email protected]",
"verifiedUserId": "42",
"labels": [{ "id": "lbl_mv9r2c1a7d3f5h8k0m", "name": "VIP", "color": "#22c55e" }],
"fields": { "plan": "pro", "order_count": 3 }
},
"labels": [{ "id": "lbl_mv9r2e7c2f4h6k8m0p", "name": "Export", "color": "#3b82f6" }]
}
}
}The visitor is shortened here: it carries every field of section 4.1.
| Field | Type | Meaning |
|---|---|---|
id | string | Conversation id (conv_…) |
websiteId | string | ws_… |
visitorId | string | vis_… |
channel | string | web (website widget or your app), messenger or instagram |
status | string | open or closed |
assigneeId | string or null | The assigned teammate's Heilo user id (usr_…) |
lastMessageAt | number or null | Time of the last message |
lastMessagePreview | string or null | The last message's first 190 characters, or the file name or card title when it has no text |
lastMessageSender | string or null | visitor, agent or bot |
unread | number | Visitor messages the team hasn't read |
firstResponseAt | number or null | The first teammate reply |
aiHandoffAt | number or null | When the AI assistant handed the conversation to the team |
invite | string or null | Proactive chats only: pending (no reply yet), accepted or ignored. null when the visitor started the conversation |
createdAt | number | Start of the conversation |
closedAt | number or null | When it was closed |
rating | object or null | The visitor's after-chat rating { score, comment, ratedAt, updatedAt } (score: good or bad; comment: string or null; ratedAt: the first rating; updatedAt: the last change), null until they rate. See conversation.rated below |
visitor | object | The visitor (section 4.1) |
labels | array | The conversation's own labels { id, name, color }, not the contact's |
Good to know:
- A visitor has at most one open conversation. When they write again after it was closed, Heilo starts a new conversation (new id,
conversation.created); it does not reopen the old one. - In
conversation.createdfor a visitor's chat,assigneeIdis stillnull: auto-assign, when on and a teammate is online, follows withconversation.assigned. A proactive chat starts assigned to the teammate who wrote, withinvite: "pending", and sends noconversation.assigned. conversation.assignedfires only whenassigneeIdchanges to another teammate. Unassigning sends nothing; the next event of the conversation showsassigneeId: null.- A visitor has one open conversation at a time: when your team or the REST API reopens an older one, the open one is closed first and sends
conversation.closed, then the older one sendsconversation.reopened. assigneeIdis a Heilo user id. The teammate's name is on their messages (message.sender.name).- A typical new chat sends:
message.created(the visitor) →conversation.created→conversation.assigned(auto-assign) →message.created(a chatbot rule, the AI assistant or the away message, if any) →message.created(teammate replies) →conversation.closed→conversation.rated(if the visitor rates it). Arrival order can differ (section 2.3).
After-chat ratings: conversation.rated
When your team closes a website chat, the widget (and Chat Client API apps) ask the visitor "How was this chat?" with a thumbs up or down and an optional comment. conversation.rated carries { conversation, previous }: the conversation with its new rating, and the rating before this change (null for the first rating).
{
"id": "evt_mw1v0c3pq8s2u4w6y8",
"event": "conversation.rated",
"createdAt": 1793781200000,
"accountId": "acc_mv2k1q8w3e5r7t9y0u",
"data": {
"conversation": {
"id": "conv_mw1u94aw8f2k6m0p3s",
"status": "closed",
"closedAt": 1793780400000,
"rating": { "score": "bad", "comment": "Mình chờ hơi lâu", "ratedAt": 1793780700000, "updatedAt": 1793781200000 },
"visitor": { "id": "vis_mvxmcwlc4n8q2r7t1y", "name": "Lan Đoàn", "email": "[email protected]", "…": "section 4.1" },
"…": "the other conversation fields above"
},
"previous": { "score": "good", "comment": null, "ratedAt": 1793780700000, "updatedAt": 1793780700000 }
}
}- One rating per conversation. The visitor can rate within 7 days of the close and change it for 24 hours after the first rating; every change that really changes the score or the comment sends a new event (sending the same rating again sends nothing).
- Only website chats (the widget and your own app on the Chat Client API) are asked, never Messenger or Instagram, and not proactive chats the visitor never answered. Each website can turn the question off (Websites & widget → Automation → Ask for a rating after the chat).
- The conversation is usually
closed, but your team may have reopened it by the time you process the event: readratingfor the score, notstatus. - The rating stays on the conversation in the REST API (
rating) and counts in the dashboard's Analytics (CSAT = share ofgoodratings).
4.3 New messages: message.created
data is { conversation, message }: the conversation after the message, and the message.
{
"id": "evt_mw1u94awk3p9x2q7d1",
"event": "message.created",
"createdAt": 1793779925000,
"accountId": "acc_mv2k1q8w3e5r7t9y0u",
"data": {
"conversation": {
"id": "conv_mw1u94aw8f2k6m0p3s",
"websiteId": "ws_mv2k1qa1h6d9s3f5g7",
"visitorId": "vis_mvxmcwlc4n8q2r7t1y",
"channel": "web",
"status": "open",
"assigneeId": null,
"lastMessageAt": 1793779925000,
"lastMessagePreview": "Hi! Can I export my notes to PDF?",
"lastMessageSender": "visitor",
"unread": 1,
"firstResponseAt": null,
"aiHandoffAt": null,
"invite": null,
"createdAt": 1793779925000,
"closedAt": null,
"visitor": {
"id": "vis_mvxmcwlc4n8q2r7t1y",
"websiteId": "ws_mv2k1qa1h6d9s3f5g7",
"name": "Lan Đoàn",
"email": "[email protected]",
"phone": null,
"notes": null,
"meta": {
"url": "https://app.example.com/notes/export",
"title": "Export notes",
"referrer": "",
"language": "vi-VN",
"timezone": "Asia/Ho_Chi_Minh",
"screen": "1440x900",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
"visits": 3,
"firstUrl": "https://www.example.com/pricing"
},
"createdAt": 1793524800000,
"lastSeenAt": 1793779800000,
"online": true,
"channel": "web",
"verifiedUserId": "42",
"lastInboundAt": null,
"labels": [{ "id": "lbl_mv9r2c1a7d3f5h8k0m", "name": "VIP", "color": "#22c55e" }],
"fields": { "plan": "pro", "order_count": 3 },
"customFields": [
{ "key": "plan", "label": "Plan", "type": "select", "value": "pro", "source": "identity" },
{ "key": "order_count", "label": "Orders", "type": "number", "value": 3, "source": "visitor" },
{ "key": "signup_date", "label": "Signed up", "type": "date", "value": null, "source": null }
]
},
"labels": []
},
"message": {
"id": "msg_mw1u94awq5x1c7v3b9",
"conversationId": "conv_mw1u94aw8f2k6m0p3s",
"senderType": "visitor",
"senderId": "vis_mvxmcwlc4n8q2r7t1y",
"sender": null,
"type": "text",
"body": "Hi! Can I export my notes to PDF?",
"attachments": [
{ "id": "k3j9x2m8q1w7e5r4t6y0u2i8", "url": "/uploads/k3j9x2m8q1w7e5r4t6y0u2i8/notes.png", "name": "notes.png", "mime": "image/png", "size": 48213, "width": 1280, "height": 720 }
],
"ai": false,
"createdAt": 1793779925000
}
}
}| Field | Type | Meaning |
|---|---|---|
id | string | Message id (msg_…) |
conversationId | string | conv_… |
senderType | string | visitor; agent (a teammate, a REST API reply, a reply typed in the Messenger or Instagram app); or bot (a chatbot rule, the AI assistant, the away message) |
senderId | string or null | The visitor's id for visitor, the teammate's user id for agent. null for bot, for REST API replies without agentEmail and for replies typed in the Messenger or Instagram app |
sender | object or null | The teammate: { name, avatarUrl } |
type | string | text, product or order |
body | string | Up to 5000 characters. "" when the message is only files or a card |
attachments | array | Files: { id, url, name, mime, size }, plus width and height for images. url is a path on Heilo: prepend https://app.heilo.chat (the REST API gives the same files with the full link). Anyone with the link can download the file, no API key needed. removed: true when the workspace later deleted the file to free up storage (the link then answers 410) |
product | object | Only with type: "product" (WooCommerce card): id, title, price, regularPrice, currency, image, url, sku, inStock |
order | object | Only with type: "order" (WooCommerce card): id, number, status, statusLabel, total, currency, items (title, quantity), paymentUrl |
ai | boolean | true when the AI assistant wrote it |
createdAt | number | When it was sent |
React to senderType: "visitor" for incoming messages. Your own REST API replies come back as agent messages.
4.4 Visitor details: visitor.identified
Fires when a name, email or phone from the visitor's side changes: Heilo('identify', …) in the website widget, the pre-chat form, PATCH /me in the Chat Client API, or the details in an identity token or from the Heilo WordPress plugin. data is { visitor } with every field of section 4.1.
{
"id": "evt_mw1u7k2pm3n5b7v9c1",
"event": "visitor.identified",
"createdAt": 1793779860000,
"accountId": "acc_mv2k1q8w3e5r7t9y0u",
"data": {
"visitor": {
"id": "vis_mw1u6fuoz8x6c4v2b0",
"websiteId": "ws_mv2k1qa1h6d9s3f5g7",
"name": "Lan Đoàn",
"email": "[email protected]",
"phone": "+84 912 000 111",
"notes": null,
"meta": { "url": "https://www.example.com/pricing", "title": "Pricing", "language": "vi-VN", "timezone": "Asia/Ho_Chi_Minh", "screen": "390x844", "visits": 1, "firstUrl": "https://www.example.com/pricing" },
"createdAt": 1793779800000,
"lastSeenAt": 1793779800000,
"online": true,
"channel": "web",
"verifiedUserId": null,
"lastInboundAt": null,
"labels": [],
"fields": {},
"customFields": [
{ "key": "plan", "label": "Plan", "type": "select", "value": null, "source": null },
{ "key": "order_count", "label": "Orders", "type": "number", "value": null, "source": null },
{ "key": "signup_date", "label": "Signed up", "type": "date", "value": null, "source": null }
]
}
}
}- It fires only when at least one of the three really changed: the same details sent again (a page that calls
identifyon every load) send nothing. - Details typed in the chat are not verified. Only
verifiedUserId(set by your server's identity token) proves who the customer is. - Changes made by your team or the REST API send
contact.updatedinstead. Custom fields sent in the same call follow ascontact.updatedwithsource: "visitor".
4.5 Contact changes: contact.updated
Fires when a contact's details, notes, labels or custom fields actually changed (saving the same values sends nothing). data is { contact, changes, source }.
{
"id": "evt_mw1x0q3hj6k8l0z2x4",
"event": "contact.updated",
"createdAt": 1793784000000,
"accountId": "acc_mv2k1q8w3e5r7t9y0u",
"data": {
"contact": {
"id": "vis_mvxmcwlc4n8q2r7t1y",
"websiteId": "ws_mv2k1qa1h6d9s3f5g7",
"name": "Lan Đoàn",
"email": "[email protected]",
"phone": null,
"notes": "Asked about team plans in October.",
"meta": { "url": "https://app.example.com/notes/export", "title": "Export notes", "language": "vi-VN", "timezone": "Asia/Ho_Chi_Minh", "screen": "1440x900", "visits": 3, "firstUrl": "https://www.example.com/pricing" },
"createdAt": 1793524800000,
"lastSeenAt": 1793779800000,
"online": false,
"channel": "web",
"verifiedUserId": "42",
"lastInboundAt": null,
"labels": [{ "id": "lbl_mv9r2c1a7d3f5h8k0m", "name": "VIP", "color": "#22c55e" }],
"conversationCount": 2,
"fields": { "plan": "business", "order_count": 3 },
"customFields": [
{ "key": "plan", "label": "Plan", "type": "select", "value": "business", "source": "team" },
{ "key": "order_count", "label": "Orders", "type": "number", "value": 3, "source": "visitor" },
{ "key": "signup_date", "label": "Signed up", "type": "date", "value": null, "source": null }
]
},
"changes": ["labels", "fields.plan"],
"source": "team"
}
}| Field | Type | Meaning |
|---|---|---|
contact | object | The contact after the change (section 4.1), with conversationCount |
changes | array | What changed: name, email, phone, notes, labels, and fields.<key> for each custom field set, changed or cleared |
source | string | Who changed it, see below |
source | Comes from | Can change |
|---|---|---|
team | Your team in the dashboard (Contacts, the Inbox side panel) | details, notes, labels, custom fields |
api | REST API PATCH /api/v1/contacts/:id | details, notes, labels, custom fields |
identity | The fields claim of an identity token your server signed, or the values the Heilo WordPress plugin signs | custom fields |
visitor | Heilo('setFields'), Heilo('identify', { fields }), the pre-chat form, Chat Client API PATCH /me | custom fields |
A visitor's own name, email or phone sends visitor.identified, not contact.updated. Skip source: "api" if your server made the change itself.
4.6 Ticket events
ticket.created carries { ticket }; ticket.updated carries { ticket, changes } and is sent only when something changed. Tickets are created in the dashboard (from a conversation or from scratch) or through the REST API.
{
"id": "evt_mw1x1bk0r5t7y9u1i3",
"event": "ticket.created",
"createdAt": 1793784600000,
"accountId": "acc_mv2k1q8w3e5r7t9y0u",
"data": {
"ticket": {
"id": "tkt_mw1x1bk0a2s4d6f8g0",
"number": 128,
"subject": "PDF export cuts off long notes",
"description": "Notes longer than 3 pages lose their last page in the PDF.",
"status": "open",
"priority": "high",
"assigneeId": "usr_mv2k1q8w3e5r7t9y1a",
"contactId": "vis_mvxmcwlc4n8q2r7t1y",
"conversationId": "conv_mw1u94aw8f2k6m0p3s",
"websiteId": "ws_mv2k1qa1h6d9s3f5g7",
"createdBy": "usr_mv2k1q8w3e5r7t9y1a",
"dueAt": 1794095999999,
"solvedAt": null,
"closedAt": null,
"customerNotifiedAt": null,
"createdAt": 1793784600000,
"updatedAt": 1793784600000,
"labels": [{ "id": "lbl_mv9r2d4b8e1g3j5l7n", "name": "Bug", "color": "#ef4444" }],
"contact": {
"id": "vis_mvxmcwlc4n8q2r7t1y",
"name": "Lan Đoàn",
"email": "[email protected]",
"phone": null,
"channel": "web",
"websiteId": "ws_mv2k1qa1h6d9s3f5g7",
"labels": [{ "id": "lbl_mv9r2c1a7d3f5h8k0m", "name": "VIP", "color": "#22c55e" }],
"fields": { "plan": "pro", "order_count": 3 },
"customFields": [
{ "key": "plan", "label": "Plan", "type": "select", "value": "pro", "source": "identity" },
{ "key": "order_count", "label": "Orders", "type": "number", "value": 3, "source": "visitor" },
{ "key": "signup_date", "label": "Signed up", "type": "date", "value": null, "source": null }
]
},
"commentCount": 0
}
}
}A ticket.updated for the same ticket, solved and the customer emailed:
{
"id": "evt_mw1yb7k2c9v1b3n5m7",
"event": "ticket.updated",
"createdAt": 1793872801000,
"accountId": "acc_mv2k1q8w3e5r7t9y0u",
"data": {
"ticket": {
"id": "tkt_mw1x1bk0a2s4d6f8g0",
"number": 128,
"subject": "PDF export cuts off long notes",
"description": "Notes longer than 3 pages lose their last page in the PDF.",
"status": "solved",
"priority": "high",
"assigneeId": "usr_mv2k1q8w3e5r7t9y1a",
"contactId": "vis_mvxmcwlc4n8q2r7t1y",
"conversationId": "conv_mw1u94aw8f2k6m0p3s",
"websiteId": "ws_mv2k1qa1h6d9s3f5g7",
"createdBy": "usr_mv2k1q8w3e5r7t9y1a",
"dueAt": 1794095999999,
"solvedAt": 1793872800000,
"closedAt": null,
"customerNotifiedAt": 1793872801000,
"createdAt": 1793784600000,
"updatedAt": 1793872801000,
"labels": [{ "id": "lbl_mv9r2d4b8e1g3j5l7n", "name": "Bug", "color": "#ef4444" }],
"contact": {
"id": "vis_mvxmcwlc4n8q2r7t1y",
"name": "Lan Đoàn",
"email": "[email protected]",
"phone": null,
"channel": "web",
"websiteId": "ws_mv2k1qa1h6d9s3f5g7",
"labels": [{ "id": "lbl_mv9r2c1a7d3f5h8k0m", "name": "VIP", "color": "#22c55e" }],
"fields": { "plan": "pro", "order_count": 3 },
"customFields": [
{ "key": "plan", "label": "Plan", "type": "select", "value": "pro", "source": "identity" },
{ "key": "order_count", "label": "Orders", "type": "number", "value": 3, "source": "visitor" },
{ "key": "signup_date", "label": "Signed up", "type": "date", "value": null, "source": null }
]
},
"commentCount": 1
},
"changes": ["status", "customerNotified"]
}
}| Field | Type | Meaning |
|---|---|---|
id | string | Ticket id (tkt_…) |
number | number | The workspace's ticket number (#128 in the dashboard) |
subject | string | Up to 200 characters |
description | string or null | Up to 10000 characters |
status | string | open, pending (waiting on someone else), solved or closed |
priority | string | urgent, high, normal or low |
assigneeId | string or null | Teammate's Heilo user id |
contactId | string or null | vis_… |
conversationId | string or null | The conversation it was created from |
websiteId | string or null | ws_… |
createdBy | string or null | The teammate who created it; null when created through the REST API |
dueAt | number or null | Due time. A due date given as a day is the end of that day, UTC |
solvedAt, closedAt | number or null | When it was solved, closed |
customerNotifiedAt | number or null | When the contact was last emailed that the ticket is solved |
createdAt, updatedAt | number | Created, last changed |
labels | array | { id, name, color } |
contact | object or null | The contact summary with labels and custom fields (section 4.1) |
commentCount | number | Internal comments on the ticket |
changes lists what changed, in this order: subject, description, status, priority, assignee, dueAt, contact (linked to another contact or unlinked; conversationId is then cleared too), labels, customerNotified (the contact was emailed that the ticket is solved). Comments on a ticket and deleting it send no event.
5. Delivery status and troubleshooting
Each endpoint card in Integrations → Webhooks shows its latest attempt only (there is no full delivery history):
- No events sent yet: nothing went to it so far.
- Last delivery with the time and Delivered (HTTP 200): the latest attempt succeeded.
- Last delivery with the time, Failed and the reason: the latest attempt failed.
Every attempt updates the card, retries and Send test included, so a success replaces an earlier failure.
| The card shows | Meaning | What to do |
|---|---|---|
HTTP 401, HTTP 500, or another status | Your server answered, but not with 2xx | Check your server's logs. A 401 from your own check usually means a signature problem (below) |
HTTP 404 or HTTP 405 | No POST route at that exact path | Check the path and the method |
HTTP 301, HTTP 302, HTTP 307, HTTP 308 | A redirect, which Heilo doesn't follow | Use the final URL (https, www or not, trailing slash) |
The operation was aborted due to timeout | No answer within 10 seconds | Answer first, do the work afterwards |
fetch failed | No connection: server down, firewall, closed port, or a TLS problem (expired, self-signed or wrong-host certificate) | Test from outside your network: curl -v https://your-app.com/webhooks/heilo |
Webhooks must use HTTPS | The URL starts with http:// | Use https:// |
Couldn't resolve the domain | DNS has no address for the host | Check the DNS record |
Internal addresses are not allowed | The host resolves to a private or local address | Use a public address or a tunnel |
The signature never matches? Check that:
- you sign the raw body, not
JSON.stringify(req.body)or a re-encoded array - the secret is complete (use Copy secret: the card only shows its start) and belongs to this endpoint
- the HMAC key is the whole secret text,
whsec_included - the signed string is
X-Heilo-Timestamp(seconds, notcreatedAt) +.+ body - you compare with the whole header,
sha256=included - your server's clock is right, if you check the 5-minute window
An endpoint that keeps failing stays on: Heilo never switches it off by itself. Pause it with its switch while you fix your server; events sent during the pause are not delivered later.
6. Limits
- Endpoints: no fixed number per workspace. Each endpoint that is on gets every event it subscribed to.
- Rate: no cap and no batching, one request per event and endpoint as events happen. Expect bursts: a visitor's message, a chatbot or AI answer and an assignment can arrive within the same second.
- Size: the body has no size cap of its own. The largest texts are a message
body(up to 5000 characters), contactnotes(5000), a ticketdescription(10000) and custom field text values (1000 each, up to 50 fields). Accept at least 1 MB. - Timeout: 10 seconds per attempt, 4 attempts at most (section 2.2).
7. Related
- REST API: fetch more than an event carries, e.g. a conversation with all its messages (
GET https://app.heilo.chat/api/v1/conversations/:id), a contact (GET /api/v1/contacts/:id), a ticket with its timeline (GET /api/v1/tickets/:id), or the contact of one of your signed-in users (GET /api/v1/contacts?userId=42, yourverifiedUserId). It also replies, updates contacts and tickets. Authenticate withAuthorization: Bearer <API key>from Integrations → REST API. - MCP server: let Claude, Cursor or VS Code read the same conversations, contacts and tickets and answer questions about them.
- Chat Client API, section 8: how custom fields are set from your page, your app, an identity token or the REST API, and which
sourcecan overwrite which.