# 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:

- `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`, multicast `224.0.0.0/4` and `240.0.0.0/4`; IPv6 anything outside global unicast `2000::/3` (so `::`, `::1`, `fc00::/7`, `fe80::/10`, multicast `ff00::/8` and NAT64 `64:ff9b::/96`), plus `2001::/23`, `2001:db8::/32`, `2002::/16` and `3fff::/20`. An IPv4-mapped IPv6 address (`::ffff:10.0.0.1`) counts as its IPv4 address.
- Redirects are not followed: a `301`, `302`, `307` or `308` answer 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

```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.

| 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 `2xx` status 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, and `4xx`), 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-Timestamp` and 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 `2xx` to every event you receive, including `ping` and 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 `id` you processed (a table with a unique key) and answer `200` without 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.created` just before `conversation.created`. Don't rely on arrival order: keep the newest state by `createdAt` (or a ticket's `updatedAt`), 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.created` with `senderType: "agent"`, and a contact you update through the REST API as `contact.updated` with `source: "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.

```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

- 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 read `accountId` from 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.

```json
{
  "id": "vis_mvxmcwlc4n8q2r7t1y",
  "websiteId": "ws_mv2k1qa1h6d9s3f5g7",
  "name": "Lan Đoàn",
  "email": "lan@example.com",
  "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 `fields` and `customFields` are always there (`{}` and `[]` when the workspace has none).
- `type` is `text`, `number`, `date` or `select`. `value` is a number for `number`, a `YYYY-MM-DD` string for `date`, one of the options for `select`, text otherwise, and `null` when not set.
- `source` says 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), or `null` when there is no value. The rules about who can overwrite what are in [Chat Client API, section 8](https://app.heilo.chat/docs/client-api#8-contact-custom-fields).
- Match fields by `key` (lowercase letters, digits and `_`, up to 40 characters). Your team can rename a field's `label`; its key stays.
- Don't trust `visitor` values 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.

```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": "lan@example.com",
        "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.created` for a visitor's chat, `assigneeId` is still `null`: auto-assign, when on and a teammate is online, follows with `conversation.assigned`. A proactive chat starts assigned to the teammate who wrote, with `invite: "pending"`, and sends no `conversation.assigned`.
- `conversation.assigned` fires only when `assigneeId` changes to another teammate. Unassigning sends nothing; the next event of the conversation shows `assigneeId: 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 sends `conversation.reopened`.
- `assigneeId` is 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).

```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": "lan@example.com", "…": "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: read `rating` for the score, not `status`.
- The rating stays on the conversation in the REST API (`rating`) and counts in the dashboard's Analytics (CSAT = share of `good` ratings).

### 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": "lan@example.com",
        "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.

```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": "lan@example.com",
      "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 `identify` on 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.updated` instead. Custom fields sent in the same call follow as `contact.updated` with `source: "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 }`.

```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": "lan@example.com",
      "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.

```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": "lan@example.com",
        "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": "lan@example.com",
        "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, not `createdAt`) + `.` + 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), contact `notes` (5000), a ticket `description` (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](https://app.heilo.chat/docs/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`, your `verifiedUserId`). It also replies, updates contacts and tickets. Authenticate with `Authorization: Bearer <API key>` from Integrations → REST API.
- [MCP server](https://app.heilo.chat/docs/mcp): let Claude, Cursor or VS Code read the same conversations, contacts and tickets and answer questions about them.
- [Chat Client API, section 8](https://app.heilo.chat/docs/client-api#8-contact-custom-fields): how custom fields are set from your page, your app, an identity token or the REST API, and which `source` can overwrite which.
