Heilo · Webhooks

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 webhooks feature). 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 2xx within 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-Timestamp header is in seconds.

1. Add an endpoint

  1. In the dashboard, open Integrations → Webhooks and go to Custom webhook.
  2. Enter the Endpoint URL, e.g. https://your-app.com/webhooks/heilo.
  3. Pick the Events: All events (the default; it also gets events Heilo adds later) or only the ones you need.
  4. Press Add webhook. The endpoint gets its own signing secret (whsec_…): press Copy secret and store it on your server.
  5. Press Send test to send a ping event 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:

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:

HeaderValue
Content-Typeapplication/json (the body is UTF-8)
User-AgentHeilo-Webhooks/1.0
X-Heilo-Eventthe event name, e.g. message.created (same as event in the body)
X-Heilo-Deliverythe event id, e.g. evt_mw1u94awk3p9x2q7d1 (same as id in the body, on every retry and every endpoint)
X-Heilo-TimestampUnix time in seconds when this attempt was signed
X-Heilo-Signaturesha256= followed by the lowercase hex HMAC-SHA256 of the request (section 3)

2.1 Body

json
{
  "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.

FieldTypeMeaning
idstringEvent id (evt_…). The same on every retry and on every endpoint: use it to drop duplicates
eventstringEvent name (section 4), or ping for a test
createdAtnumberWhen the event happened, in ms. The same on every retry
accountIdstringYour workspace id (acc_…)
dataobjectWhat 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

AttemptWhen
1right away
25 seconds after attempt 1 failed
330 seconds after attempt 2 failed
42 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.

2.3 Order and duplicates

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.

json
{
  "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

  1. Read the raw body and verify the signature and the timestamp (section 3). Refuse with 401 otherwise.
  2. Skip event ids you already processed, answering 200.
  3. Answer 2xx within 10 seconds, also for ping and events you don't use.
  4. Branch on event (or the X-Heilo-Event header) and ignore unknown events and fields.
  5. 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:

  1. 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.
  2. Build the signed string: the X-Heilo-Timestamp value, a dot, then the raw body, e.g. 1793779925.{"id":"evt_mw1u94awk3p9x2q7d1",…}.
  3. 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.
  4. Compare sha256= + that hex with the X-Heilo-Signature header, in constant time.
  5. 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)

js
// 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
<?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:

bash
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

4. Events

EventFires whendata
conversation.createdA 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.assignedA conversation gets a new assignee: by hand, by auto-assign, or because a teammate answered an unassigned conversation{ conversation }
conversation.closedYour 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.reopenedYour team or the REST API reopens a conversation, or a teammate or the REST API replies in a closed one{ conversation }
conversation.ratedThe 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.createdAny 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.identifiedA 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.updatedA 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.createdA ticket is created in the dashboard or through the REST API{ ticket }
ticket.updatedA ticket's subject, description, status, priority, assignee, due date, contact or labels changed, or the contact was emailed that it is solved{ ticket, changes }
pingSend test only (section 2.4){ message }

These send no event:

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.

json
{
  "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 }
  ]
}
FieldTypeMeaning
idstringContact id (vis_…)
websiteIdstringThe website the visitor came from (ws_…)
name, email, phonestring or nullContact details. Values typed in the chat are not verified unless verifiedUserId is set
notesstring or nullYour team's private notes on the contact (up to 5000 characters)
metaobjectDevice 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
createdAtnumberFirst seen
lastSeenAtnumber or nullLast seen: a widget or app session, or a Messenger or Instagram message
onlinebooleanConnected to the chat when the event was built
channelstringweb (website widget or your app), messenger or instagram
verifiedUserIdstring or nullYour own user id (sub) when your server verified the customer with an identity token. Use it to match your users
lastInboundAtnumber or nullMessenger and Instagram: the customer's last message, which opens the platform's reply window
labelsarrayContact labels { id, name, color }, sorted by name
fieldsobject{ key: value } of the custom fields that have a value
customFieldsarrayEvery custom field of the workspace, in the workspace's order: { key, label, type, value, source }
conversationCountnumberOnly on data.contact in contact.updated: the contact's number of conversations

Custom fields:

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.

json
{
  "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.

FieldTypeMeaning
idstringConversation id (conv_…)
websiteIdstringws_…
visitorIdstringvis_…
channelstringweb (website widget or your app), messenger or instagram
statusstringopen or closed
assigneeIdstring or nullThe assigned teammate's Heilo user id (usr_…)
lastMessageAtnumber or nullTime of the last message
lastMessagePreviewstring or nullThe last message's first 190 characters, or the file name or card title when it has no text
lastMessageSenderstring or nullvisitor, agent or bot
unreadnumberVisitor messages the team hasn't read
firstResponseAtnumber or nullThe first teammate reply
aiHandoffAtnumber or nullWhen the AI assistant handed the conversation to the team
invitestring or nullProactive chats only: pending (no reply yet), accepted or ignored. null when the visitor started the conversation
createdAtnumberStart of the conversation
closedAtnumber or nullWhen it was closed
ratingobject or nullThe 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
visitorobjectThe visitor (section 4.1)
labelsarrayThe conversation's own labels { id, name, color }, not the contact's

Good to know:

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

json
{
  "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 }
  }
}

4.3 New messages: message.created

data is { conversation, message }: the conversation after the message, and the message.

json
{
  "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
    }
  }
}
FieldTypeMeaning
idstringMessage id (msg_…)
conversationIdstringconv_…
senderTypestringvisitor; 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)
senderIdstring or nullThe 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
senderobject or nullThe teammate: { name, avatarUrl }
typestringtext, product or order
bodystringUp to 5000 characters. "" when the message is only files or a card
attachmentsarrayFiles: { 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)
productobjectOnly with type: "product" (WooCommerce card): id, title, price, regularPrice, currency, image, url, sku, inStock
orderobjectOnly with type: "order" (WooCommerce card): id, number, status, statusLabel, total, currency, items (title, quantity), paymentUrl
aibooleantrue when the AI assistant wrote it
createdAtnumberWhen 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.

json
{
  "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 }
      ]
    }
  }
}

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

json
{
  "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"
  }
}
FieldTypeMeaning
contactobjectThe contact after the change (section 4.1), with conversationCount
changesarrayWhat changed: name, email, phone, notes, labels, and fields.<key> for each custom field set, changed or cleared
sourcestringWho changed it, see below
sourceComes fromCan change
teamYour team in the dashboard (Contacts, the Inbox side panel)details, notes, labels, custom fields
apiREST API PATCH /api/v1/contacts/:iddetails, notes, labels, custom fields
identityThe fields claim of an identity token your server signed, or the values the Heilo WordPress plugin signscustom fields
visitorHeilo('setFields'), Heilo('identify', { fields }), the pre-chat form, Chat Client API PATCH /mecustom 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.

json
{
  "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:

json
{
  "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"]
  }
}
FieldTypeMeaning
idstringTicket id (tkt_…)
numbernumberThe workspace's ticket number (#128 in the dashboard)
subjectstringUp to 200 characters
descriptionstring or nullUp to 10000 characters
statusstringopen, pending (waiting on someone else), solved or closed
prioritystringurgent, high, normal or low
assigneeIdstring or nullTeammate's Heilo user id
contactIdstring or nullvis_…
conversationIdstring or nullThe conversation it was created from
websiteIdstring or nullws_…
createdBystring or nullThe teammate who created it; null when created through the REST API
dueAtnumber or nullDue time. A due date given as a day is the end of that day, UTC
solvedAt, closedAtnumber or nullWhen it was solved, closed
customerNotifiedAtnumber or nullWhen the contact was last emailed that the ticket is solved
createdAt, updatedAtnumberCreated, last changed
labelsarray{ id, name, color }
contactobject or nullThe contact summary with labels and custom fields (section 4.1)
commentCountnumberInternal 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):

Every attempt updates the card, retries and Send test included, so a success replaces an earlier failure.

The card showsMeaningWhat to do
HTTP 401, HTTP 500, or another statusYour server answered, but not with 2xxCheck your server's logs. A 401 from your own check usually means a signature problem (below)
HTTP 404 or HTTP 405No POST route at that exact pathCheck the path and the method
HTTP 301, HTTP 302, HTTP 307, HTTP 308A redirect, which Heilo doesn't followUse the final URL (https, www or not, trailing slash)
The operation was aborted due to timeoutNo answer within 10 secondsAnswer first, do the work afterwards
fetch failedNo 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 HTTPSThe URL starts with http://Use https://
Couldn't resolve the domainDNS has no address for the hostCheck the DNS record
Internal addresses are not allowedThe host resolves to a private or local addressUse a public address or a tunnel

The signature never matches? Check that:

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