# Heilo REST API

Connect your own systems to Heilo from your server: read conversations and reply to visitors, keep contacts and their custom fields in step with your user database, and create and update tickets. Your team keeps working in the Heilo Inbox. What you do through the API shows up there at once and sends the same webhooks as the dashboard. For the visitor's side of the chat (your own chat UI in a web app or a mobile app) use the [Chat Client API](https://app.heilo.chat/docs/client-api) instead.

- **Plans**: Pro and Business (paid or from a promotion code) and the free trial. On Free and Starter, and once a trial ends without a paid plan, every call answers `402 upgrade_required`.
- **Base URL**: `https://app.heilo.chat/api/v1`
- **Keys**: Integrations → REST API in the dashboard, owners and admins only (section 1).
- **Format**: JSON. Send `Content-Type: application/json` with every body (up to 200 KB). Answers carry the result in `data`.
- **Times** are Unix epoch milliseconds. Custom field dates are `YYYY-MM-DD` strings.
- **Server side only**: a key reads every chat and contact of the workspace, and the API sends no CORS headers, so browsers can't call it. Keep the key in an environment variable such as `HEILO_API_KEY`, never in browser or app code.

## 1. Authentication

```text
Authorization: Bearer cm_live_…
```

- Create a key in Integrations → REST API: give it a name (e.g. "Internal CRM") and copy it right away. The full key (`cm_live_` and 32 more characters) is shown once; Heilo keeps only a fingerprint of it, so a lost key can't be shown again. The list shows each key's first 12 characters and when it was last used.
- A key belongs to the workspace, not to the person who created it, and works until it is revoked. Revoking it stops it at once.
- The same key works for the [MCP server](https://app.heilo.chat/docs/mcp).

Every request is checked, before anything else (a wrong path with a bad key answers 401, not 404):

| Status | `code` | When |
| --- | --- | --- |
| 401 | `unauthorized` | No `Authorization` header (`Missing API key`), or an unknown or revoked key (`Invalid API key`) |
| 402 | `upgrade_required` | The workspace's plan has no REST API |
| 403 | `account_suspended` | Heilo paused the workspace |

## 2. Rate limits

120 requests a minute per key. Failed requests count too; requests refused at authentication don't. Answers to authenticated requests carry the IETF draft headers:

```text
RateLimit-Policy: "120-in-1min"; q=120; w=60; pk=:…:
RateLimit: "120-in-1min"; r=117; t=42
```

`r` is what's left in the current minute, `t` the seconds until it starts again. Past the limit the answer is `429` with a `Retry-After: <seconds>` header and a JSON body like every other error (section 3):

```json
{ "error": "Too many tries. Wait a minute and try again.", "code": "rate_limited" }
```

Wait `Retry-After` seconds, then try again.

## 3. Errors

Every error is JSON with an HTTP status:

```json
{ "error": "Conversation not found", "code": "not_found" }
```

`error` is a readable message in English, or in Vietnamese with the header `X-Locale: vi`. Branch on the status and `code`, never on the message.

| Status | `code` | When |
| --- | --- | --- |
| 400 | `validation_error` | A parameter is missing, has the wrong type or is out of range. `error` starts with its name, e.g. `body: Too small: expected string to have >=1 characters`. Also a bad `cursor`, custom field value or `dueAt` |
| 400 | `invalid_json` | The body isn't valid JSON |
| 400 | `unknown_field` | A custom field key the workspace doesn't have (section 7.6) |
| 400 | `label_not_found` | A label id that isn't in the workspace |
| 400 | `assignee_not_member`, `contact_not_found`, `conversation_not_found`, `contact_mismatch`, `notify_needs_solved`, `no_customer_email` | Tickets (section 8) |
| 401 | `unauthorized` | Section 1 |
| 402 | `upgrade_required` | Section 1 |
| 403 | `account_suspended` | Section 1 |
| 404 | `not_found` | An id that isn't in the workspace, an `agentEmail` or `assigneeEmail` that isn't a teammate (`error` names the field, e.g. `assigneeEmail is not a member of this workspace`), an unknown `segmentId`, or an endpoint that doesn't exist |
| 413 | `payload_too_large` | The body is over 200 KB |
| 429 | `rate_limited` | Section 2 |
| 500 | `internal_error` | Something failed on Heilo's side. Try again later |

Keys in a body that an endpoint doesn't take are ignored.

## 4. Ids, times and pages

- Ids are strings. Treat them as opaque; the prefix says what they are: `ws_` website, `conv_` conversation, `vis_` visitor (a contact has its visitor's id), `msg_` message, `usr_` teammate, `lbl_` label, `tkt_` ticket, `tkc_` ticket comment or change, `seg_` saved segment.
- Times (`createdAt`, `lastMessageAt`, `dueAt`…) are Unix epoch milliseconds, or `null` when not set.
- Lists come in pages. Repeat the same query with the next-page parameter until it comes back `null`; the last page can be empty.

| List | Next page | Order |
| --- | --- | --- |
| `GET /conversations` | `cursor=<nextCursor>` | Latest activity first |
| `GET /contacts` | `cursor=<nextCursor>` | Most recently seen first |
| `GET /tickets` | `cursor=<nextCursor>` | `sort` (default: last updated first) |

Cursors are opaque strings: send them back as they are. Conversation and contact cursors hold their position exactly, so paging never skips or repeats one, but an item that changes while you page (a new message, a new visit) moves to the front and is only seen by a new first page. A ticket cursor is a position in the sorted list, so a ticket that changes while you page can move and show up twice or not at all: match tickets by id.

## 5. Websites

### 5.1 List websites: `GET /websites`

The workspace's websites, oldest first, in one answer (no pages).

```json
{
  "data": [
    {
      "id": "ws_mh7k2c9q4f8x1a0b2c",
      "name": "Example app",
      "domain": "app.example.com",
      "settings": {
        "color": "#d9391c",
        "headerTextColor": "auto",
        "position": "right",
        "language": "auto",
        "title": "Chat with us",
        "subtitle": "We usually reply in a few minutes",
        "allowedDomains": ["app.example.com"],
        "businessHours": { "enabled": false, "timezone": "UTC", "days": { "mon": [{ "from": "09:00", "to": "17:00" }], "…": "…" } },
        "onlineStatus": "agents",
        "…": "…"
      },
      "createdAt": 1790000000000
    }
  ]
}
```

`settings` is the chat widget as set in Websites & widget: `color`, `headerTextColor`, `position`, `language`, `title`, `subtitle`, `welcomeMessage`, `awayMessage`, `quickReplies`, `askEmail`, `autoAssign`, `allowedDomains`, `leadForm`, `businessHours`, `onlineStatus`, `launcher`, `logo`, `corners`, `panelSize`, `invite`, `attachments`. `onlineStatus` is when the widget shows the team as online: `agents` (while a teammate has Heilo open on the web or in the mobile app, within business hours), `hours` (throughout business hours) or `always`. `panelSize` is the chat window size on computers: `compact`, `standard` or `large`. `invite` is what shows above the closed chat button, built from `welcomeMessage` and `quickReplies`: `{ style, title, delay, mobile }` with `style` one of `none`, `notice`, `message`, `card`, `buttons`, `delay` in seconds (0 to 60) and `mobile` whether phones see it too. `headerTextColor` is the text color in the chat header and on the team avatars: `"auto"` (black or white, whichever reads better on `color`) or a hex code. Images (`logo`, `launcher.image`) are `{ id, url }` with a full link, or `null`. The API reads them; it can't change them.

## 6. Conversations and messages

### 6.1 The conversation object

```json
{
  "id": "conv_mhb2k1x0a7d3k9p2qz",
  "websiteId": "ws_mh7k2c9q4f8x1a0b2c",
  "visitorId": "vis_mhb2jz8e5n1c0w7r4t",
  "channel": "web",
  "status": "open",
  "assigneeId": null,
  "lastMessageAt": 1793870640000,
  "lastMessagePreview": "Hi, I can't sign in since this morning",
  "lastMessageSender": "visitor",
  "unread": 2,
  "firstResponseAt": null,
  "aiHandoffAt": null,
  "invite": null,
  "createdAt": 1793870580000,
  "closedAt": null,
  "rating": null,
  "labels": [{ "id": "lbl_mh9a1b2c3d4e5f6g7h", "name": "Bug", "color": "#ef4444" }],
  "visitor": { "id": "vis_mhb2jz8e5n1c0w7r4t", "name": "Lan Đoàn", "email": "lan@example.com", "…": "the contact object, section 7.1" }
}
```

| Field | Meaning |
| --- | --- |
| `channel` | `web` (the chat widget or your own app), `messenger` or `instagram` |
| `status` | `open` or `closed`. A visitor has at most one open conversation |
| `assigneeId` | The teammate in charge (`usr_…`), or `null` |
| `lastMessagePreview` | Up to 190 characters of the last message, or the file's name when it was only a file. Internal notes don't change it |
| `lastMessageSender` | `visitor`, `agent` (your team or the API) or `bot` (keyword rules, the away message, the AI assistant) |
| `unread` | Visitor messages your team hasn't read in the Inbox yet |
| `firstResponseAt` | The team's first reply (an API reply counts) |
| `aiHandoffAt` | When the AI assistant handed the chat over to the team |
| `invite` | `null` when the visitor started the chat. When a teammate wrote first: `pending` (no answer yet), `accepted` or `ignored` |
| `rating` | The visitor's after-chat rating, or `null`: `{ "score": "good" \| "bad", "comment": "…" \| null, "ratedAt": 1793871000000, "updatedAt": 1793871000000 }`. The widget (and Chat Client API apps) ask for it when your team closes a website chat; the visitor can change it for 24 hours after `ratedAt`, `updatedAt` is the last change. Webhook `conversation.rated` |
| `labels` | The conversation's labels |
| `visitor` | The visitor, complete with labels and custom fields (section 7.1, without `conversationCount`) |

### 6.2 List conversations: `GET /conversations`

| Query | Type | |
| --- | --- | --- |
| `status` | `open` or `closed` | Both when left out |
| `websiteId` | website id | Only that website |
| `limit` | 1 to 100 | Default 25 |
| `cursor` | string | The previous answer's `nextCursor` |
| `before` | ms | Only conversations whose last message is strictly older than this |

```json
{ "data": [{ "id": "conv_mhb2k1x0a7d3k9p2qz", "status": "open", "…": "…" }], "nextCursor": "WzE3OTM4NzA2NDAwMDAsImNvbnZfbWhiMmsxeDBhN2QzazlwMnF6Il0", "nextBefore": 1793870640000 }
```

Conversations come without their messages, latest activity first (two with the same `lastMessageAt` in id order). `nextCursor` is `null` on the last page; otherwise send it back as `cursor` for the next page, with the same other parameters.

`nextBefore` (the page's last `lastMessageAt`, `null` on the last page) is still there for integrations that page with `before=<nextBefore>`. That way of paging skips conversations whose last message has exactly the same millisecond as the page's last one: use `cursor`.

### 6.3 Get a conversation with its messages: `GET /conversations/:id`

The conversation (section 6.1) plus `messages`: its latest 200 messages, oldest first, internal notes included. Older messages can't be fetched.

```json
{
  "data": {
    "id": "conv_mhb2k1x0a7d3k9p2qz",
    "status": "open",
    "…": "…",
    "messages": [
      {
        "id": "msg_mhb2k1x2q8w3e5r7t9",
        "conversationId": "conv_mhb2k1x0a7d3k9p2qz",
        "senderType": "visitor",
        "senderId": "vis_mhb2jz8e5n1c0w7r4t",
        "sender": null,
        "type": "text",
        "body": "Hi, I can't sign in since this morning",
        "attachments": [
          { "id": "k3j9x0q2m8w1e4r7t5y6u1i0", "url": "https://app.heilo.chat/uploads/k3j9x0q2m8w1e4r7t5y6u1i0/screenshot.png", "name": "screenshot.png", "mime": "image/png", "size": 48213, "width": 1280, "height": 720 }
        ],
        "ai": false,
        "createdAt": 1793870640000
      }
    ]
  }
}
```

| Field | Meaning |
| --- | --- |
| `senderType` | `visitor`, `agent` or `bot` |
| `senderId` | The visitor's id for `visitor`, the teammate's user id for `agent`. `null` for `bot` and for API replies sent without `agentEmail` |
| `sender` | `{ name, avatarUrl }` of the teammate, or `null` |
| `type` | `text`; `note` (internal note, only your team sees it; may have `mentions`, the user ids it mentions); `product` or `order` (cards sent with the WooCommerce tools, with a `product` or `order` object) |
| `body` | The text, up to 5000 characters. Can be empty when the message is only files |
| `attachments` | `{ id, url, name, mime, size }`, plus `width` and `height` for images. `url` is a full link to the file; anyone with it can download the file, no API key needed. `removed: true` when the workspace deleted the file to free up storage (Storage page): the link then answers `410` |
| `ai` | `true` for answers written by the AI assistant |
| `deliveryError` | Only on Messenger and Instagram, when the platform refused a reply: `{ code, message }`, e.g. `window_closed` (the platform's reply window has passed) |

### 6.4 Reply to the visitor: `POST /conversations/:id/messages`

| Body | Type | |
| --- | --- | --- |
| `body` | string, 1 to 5000 characters after trimming | Required. Text only: the API can't send files or cards |
| `agentEmail` | email, any case | Optional. The teammate the reply is sent as. Not a member of the workspace: `404 not_found` |

```bash
curl -X POST "https://app.heilo.chat/api/v1/conversations/conv_mhb2k1x0a7d3k9p2qz/messages" \
  -H "Authorization: Bearer $HEILO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"body": "Thanks Lan, we reset your sign-in. Please try again.", "agentEmail": "you@example.com"}'
```

Answer `201 { "data": <message> }` (section 6.3), with `senderType: "agent"`.

- With `agentEmail` the reply carries that teammate's name and photo, and a conversation nobody is assigned to is assigned to them (webhook `conversation.assigned`). Not a member: `404 not_found`, nothing is sent.
- Without it, `senderId` and `sender` are `null`: the reply has no teammate's name in the Inbox, and the chat widget shows it under the website's name. The assignee doesn't change.
- A closed conversation is reopened first (webhook `conversation.reopened`), and any other open conversation of the same visitor is closed (webhook `conversation.closed`).
- The reply reaches the Inbox and the visitor's chat in real time and sends the `message.created` webhook. Once the team (or the API) has replied, the AI assistant stops answering in that conversation.
- Website and app chats: a visitor who left an email and doesn't have the chat open gets the reply by email, like replies from the Inbox.
- Messenger and Instagram: the reply goes out to the platform after the answer. A refusal (e.g. the 24-hour reply window has passed) is saved on the message as `deliveryError` and shown in the Inbox; get the conversation again to see it.

### 6.5 Close or reopen: `PATCH /conversations/:id`

```json
{ "status": "closed" }
```

`status`: `open` or `closed`. Answer `200 { "data": <conversation> }` (section 6.1). Closing sends the webhook `conversation.closed`. Reopening sends `conversation.reopened` and closes any other open conversation of the same visitor, each with its own `conversation.closed`. Sending the status it already has changes nothing and sends no webhook.

## 7. Visitors and contacts

Everyone who opens the chat (on a website, in your app, on Messenger or Instagram) is a visitor, and the Contacts page shows them as contacts: a contact has its visitor's id (`vis_…`). A visitor belongs to one website, so the same person on two websites is two contacts.

### 7.1 The contact object

```json
{
  "id": "vis_mhb2jz8e5n1c0w7r4t",
  "websiteId": "ws_mh7k2c9q4f8x1a0b2c",
  "name": "Lan Đoàn",
  "email": "lan@example.com",
  "phone": null,
  "notes": "Asked about yearly billing",
  "meta": { "url": "https://app.example.com/billing", "title": "Billing", "language": "vi", "timezone": "Asia/Ho_Chi_Minh", "visits": 4 },
  "createdAt": 1792000000000,
  "lastSeenAt": 1793871000000,
  "online": true,
  "channel": "web",
  "verifiedUserId": "42",
  "lastInboundAt": null,
  "labels": [{ "id": "lbl_mh9a7k6j5h4g3f2d1s", "name": "VIP", "color": "#22c55e" }],
  "fields": { "plan": "pro", "seats": 5 },
  "customFields": [
    { "key": "plan", "label": "Plan", "type": "select", "value": "pro", "source": "identity" },
    { "key": "seats", "label": "Seats", "type": "number", "value": 5, "source": "api" },
    { "key": "signup_date", "label": "Signed up", "type": "date", "value": null, "source": null }
  ],
  "conversationCount": 3
}
```

| Field | Meaning |
| --- | --- |
| `name`, `email`, `phone` | What the person left in the chat, what your identity token says, or what your team or the API set. `null` when unknown |
| `notes` | Your team's notes about the contact |
| `meta` | What the widget, your app or the channel reported, e.g. `url`, `title`, `referrer`, `firstUrl`, `language`, `timezone`, `screen`, `userAgent`, `country` (ISO code), `region`, `city`, `visits`, `platform`, `appVersion`. The location is approximate (from the IP address) and `timezone` falls back to it when the browser or app sent none. Every key is optional |
| `lastSeenAt` | The last visit, or `null` |
| `online` | The chat is open in a browser or app right now |
| `channel` | `web`, `messenger` or `instagram` |
| `verifiedUserId` | Your own user id (the identity token's `sub`) when the person signed in with an identity token, otherwise `null` |
| `lastInboundAt` | Messenger and Instagram: the person's last message, which opens the platform's reply window. `null` on the web |
| `labels` | `{ id, name, color }` |
| `fields` | Custom field values that are set, `{ key: value }` (numbers as numbers, dates as `YYYY-MM-DD`) |
| `customFields` | Every custom field of the workspace, in its order: `key`, `label`, `type` (`text`, `number`, `date`, `select`), `value` (`null` when not set) and `source` (who set it: `team`, `api`, `identity`, `visitor`, or `null`) |
| `conversationCount` | Only from `GET /contacts` and `GET /contacts/:id`: how many conversations the person had |

Visitors inside conversations, tickets and webhooks carry the same `labels`, `fields` and `customFields`. Custom fields are on every plan that has the API.

### 7.2 Get a visitor: `GET /visitors/:id`

`{ "data": <contact> }` without `conversationCount`. Unknown id: `404 not_found`.

### 7.3 List contacts: `GET /contacts`

The same filters as the Contacts page. By default only identified people: those who left a name, email or phone, signed in with an identity token, or have notes, a label or a custom field value.

| Query | Type | |
| --- | --- | --- |
| `q` | string, up to 100 characters | Name or email contains it (any case), phone contains it, or the exact contact id |
| `userId` | string, up to 128 characters | Your own user id of a customer who signed in with an identity token (the token's `sub`), exact match |
| `labels` | label ids, comma-separated, up to 20 | Contacts with any of these labels |
| `labelMode` | `any` or `all` | `all`: contacts with every label in `labels`. Default `any` |
| `channel` | `web`, `messenger` or `instagram` | |
| `websiteId` | website id | |
| `scope` | `identified` or `all` | `all`: every visitor. Default `identified` |
| `seenWithin` | days, 1 to 3650 | Seen in the last N days |
| `notSeenFor` | days, 1 to 3650 | Not seen for N days |
| `fields` | JSON array, up to 10 conditions | Custom field filters, see below |
| `segmentId` | a saved segment's id | That segment's filters, with the other parameters on top. Unknown: `404 not_found` |
| `limit` | 1 to 100 | Default 25 |
| `cursor` | string | The previous answer's `nextCursor` |

"Seen" is the last visit, or the first one for someone who never came back.

`fields` takes `[{ "key": "plan", "op": "eq", "value": "pro" }]`, URL-encoded: `fields=%5B%7B%22key%22%3A%22plan%22%2C%22op%22%3A%22eq%22%2C%22value%22%3A%22pro%22%7D%5D`. Every condition must hold.

| `op` | Matches |
| --- | --- |
| `eq` | Equal (any case for text; number fields compare as numbers) |
| `contains` | The value contains the text, any case |
| `gt`, `lt` | Greater or less: numbers as numbers, dates as `YYYY-MM-DD`, text alphabetically |
| `set`, `unset` | Has a value, has none (no `value` needed) |

`value` is a string up to 200 characters or a number; an empty `value` skips that condition. An unknown `key` is `400 unknown_field`. Broken JSON, an unknown `op`, a value that isn't a number (`eq`, `gt`, `lt` on a number field) or a date (`gt`, `lt` on a date field) is `400 validation_error`.

```json
{ "data": [{ "id": "vis_mhb2jz8e5n1c0w7r4t", "name": "Lan Đoàn", "…": "…" }], "nextCursor": "WzE3OTM4NzEwMDAwMDAsInZpc19taGIyano4ZTVuMWMwdzdyNHQiXQ", "total": 128 }
```

`total` counts every match and only comes with the first page (no `cursor`).

To find a signed-in customer's contact from your server, ask for `GET /contacts?userId=42` (your `sub`). Each website has its own contact for the same user, so add `websiteId` when you have more than one, or update every contact in `data`. A customer who never opened the chat has no contact yet: put values you want them to have in the `fields` claim of the identity token instead ([Chat Client API, section 8](https://app.heilo.chat/docs/client-api#8-contact-custom-fields)).

### 7.4 Get a contact: `GET /contacts/:id`

`{ "data": <contact> }`, identified or not. Unknown id: `404 not_found` (`Contact not found`).

### 7.5 Update a contact: `PATCH /contacts/:id`

Like an edit in the dashboard. Send only what you want to change.

| Body | Type | |
| --- | --- | --- |
| `name` | string up to 120 characters, or `null` | `""` or `null` clears it |
| `email` | email, `""` or `null` | Stored in lowercase. `""` or `null` clears it |
| `phone` | string up to 40 characters, or `null` | `""` or `null` clears it |
| `notes` | string up to 5000 characters, or `null` | `""` or `null` clears it |
| `labels` | array of label ids, up to 20 | Replaces all the contact's labels. `[]` removes them all |
| `fields` | object `{ key: value }`, up to 50 keys | Custom field values (section 7.6). `null` or `""` clears one |

```bash
curl -X PATCH "https://app.heilo.chat/api/v1/contacts/vis_mhb2jz8e5n1c0w7r4t" \
  -H "Authorization: Bearer $HEILO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"labels": ["lbl_mh9a7k6j5h4g3f2d1s"], "fields": {"plan": "business", "seats": 12, "signup_date": "2026-03-14"}}'
```

```json
{ "data": { "id": "vis_mhb2jz8e5n1c0w7r4t", "name": "Lan Đoàn", "fields": { "plan": "business", "seats": 12, "signup_date": "2026-03-14" }, "…": "…" }, "changes": ["fields.plan", "fields.seats", "fields.signup_date"] }
```

- `changes` lists what really changed (`name`, `email`, `phone`, `notes`, `labels`, `fields.<key>`); it is empty when everything already had those values.
- When something changed, Heilo sends the `contact.updated` webhook with `{ contact, changes, source: "api" }` and updates open dashboards.
- Every check comes before anything is saved: with `400 unknown_field`, `400 validation_error` or `400 label_not_found` (a label id that isn't in the workspace) nothing from the call is saved and no webhook is sent.
- The API can change any contact's name, email and phone, also ones that came from an identity token; the customer's next sign-in with an identity token sets the token's values again.

### 7.6 Custom field values

The rules are the same everywhere in Heilo; the [Chat Client API guide, section 8](https://app.heilo.chat/docs/client-api#8-contact-custom-fields) has them all. In short, for the REST API:

- The fields themselves are created in the dashboard (Contacts → Custom fields), each with a **key** (e.g. `plan`) and a type. Send values by key, up to 50 keys a call.
- **Values** follow the field's type: text (up to 1000 characters; `true` / `false` are stored as text), a number (`3`, `"12,5"`), a date as `YYYY-MM-DD` (an ISO date-time keeps its date), or one of a dropdown's options. `null` (or `""`) clears a value.
- An unknown key is `400 unknown_field`; a wrong type or a value that isn't one of the options is `400 validation_error`. Either way nothing from the call is saved.
- Values you set here have the source `api`: trusted, like your team's. What the page or the app sends (`Heilo('setFields')`, the pre-chat form, `PATCH /me`) can't overwrite or clear them (the key comes back there as `locked`). Trusted sources overwrite each other: your team, the REST API, and the `fields` claim of an identity token at the customer's next sign-in.
- The REST API can overwrite any value, whoever set it. Sending the value a visitor already set confirms it: it becomes `api` and is locked from then on.
- Values you rely on (the customer's plan, a credit limit, a VIP tier) belong in the identity token or the REST API, never only in the page.

## 8. Tickets

Tickets are follow-ups your team has to finish, created from a conversation or from scratch, for a contact or for nobody. Every plan with the API has them.

### 8.1 The ticket object

```json
{
  "id": "tkt_mhc0a1s2d3f4g5h6j7",
  "number": 12,
  "subject": "Refund the double charge",
  "description": "Charged twice for the November invoice.",
  "status": "open",
  "priority": "high",
  "assigneeId": "usr_mh1q2w3e4r5t6y7u8i",
  "contactId": "vis_mhb2jz8e5n1c0w7r4t",
  "conversationId": "conv_mhb2k1x0a7d3k9p2qz",
  "websiteId": "ws_mh7k2c9q4f8x1a0b2c",
  "createdBy": null,
  "dueAt": 1795219199999,
  "solvedAt": null,
  "closedAt": null,
  "customerNotifiedAt": null,
  "createdAt": 1793871300000,
  "updatedAt": 1793871300000,
  "labels": [{ "id": "lbl_mh9a5s4d3f2g1h0j9k", "name": "Billing", "color": "#3b82f6" }],
  "contact": { "id": "vis_mhb2jz8e5n1c0w7r4t", "name": "Lan Đoàn", "email": "lan@example.com", "phone": null, "channel": "web", "websiteId": "ws_mh7k2c9q4f8x1a0b2c", "labels": [{ "id": "lbl_mh9a7k6j5h4g3f2d1s", "name": "VIP", "color": "#22c55e" }], "fields": { "plan": "pro", "seats": 5 }, "customFields": ["… as in section 7.1"] },
  "commentCount": 1
}
```

| Field | Meaning |
| --- | --- |
| `number` | The workspace's ticket number: #1, #2… |
| `status` | `open` (to do), `pending` (waiting on someone else, e.g. the customer), `solved` (done, can be reopened), `closed` (archived) |
| `priority` | `urgent`, `high`, `normal` or `low` |
| `assigneeId` | The teammate in charge (`usr_…`), or `null` |
| `contactId`, `conversationId`, `websiteId` | What the ticket is linked to, or `null` |
| `createdBy` | The teammate who created it (dashboard or agents app); `null` from the API |
| `dueAt` | The due time, or `null`. Past it while `open` or `pending`, the ticket is overdue |
| `solvedAt`, `closedAt` | When it was solved, closed. Reopening clears both |
| `customerNotifiedAt` | When the contact was last emailed that the ticket is solved |
| `contact` | `{ id, name, email, phone, channel, websiteId }` with `labels`, `fields` and `customFields` (section 7.1), or `null` |
| `commentCount` | Internal comments (changes not counted) |

### 8.2 List tickets: `GET /tickets`

| Query | Type | |
| --- | --- | --- |
| `status` | comma-separated: `open`, `pending`, `solved`, `closed`, or `active` (= `open,pending`) | Every status when left out |
| `priority` | comma-separated: `urgent`, `high`, `normal`, `low` | |
| `assignee` | a teammate's user id, or `none` | `none`: nobody is assigned. `me` doesn't exist here (`400`) |
| `contactId` | contact id | |
| `conversationId` | conversation id | |
| `labels` | label ids, comma-separated | Tickets with any of them (the first 20 count) |
| `q` | string, up to 100 characters | Subject and description (accents optional), the contact's name, email or phone, or the ticket number (`12` or `#12`) |
| `overdue` | `1` or `true` | Only overdue tickets (`open` or `pending`, past `dueAt`) |
| `sort` | `updated`, `created`, `due`, `priority` | Default `updated` (last updated first). `due`: soonest first, no due date last. `priority`: urgent first, then last updated |
| `limit` | 1 to 100 | Default 25 |
| `cursor` | string | The previous answer's `nextCursor` |

An unknown status or priority is `400 validation_error`.

```json
{
  "data": [{ "id": "tkt_mhc0a1s2d3f4g5h6j7", "number": 12, "…": "…" }],
  "nextCursor": null,
  "total": 1,
  "counts": { "open": 1, "pending": 0, "solved": 4, "closed": 2, "overdue": 0 }
}
```

`total` (tickets matching every filter) and `counts` (per status, plus `overdue`, for the same filters without `status` and `overdue`) only come with the first page.

### 8.3 Get a ticket with its timeline: `GET /tickets/:id`

The ticket (section 8.1) plus `timeline`: its internal comments and changes, oldest first. Unknown id: `404 not_found`.

```json
{
  "data": {
    "id": "tkt_mhc0a1s2d3f4g5h6j7",
    "…": "…",
    "timeline": [
      { "id": "tkc_mhc0b9v8c7x6z5l4k3", "ticketId": "tkt_mhc0a1s2d3f4g5h6j7", "kind": "comment", "body": "Refund sent, see invoice 2026-031.", "meta": null, "author": null, "createdAt": 1793872000000 },
      { "id": "tkc_mhc0c1v2b3n4m5q6w7", "ticketId": "tkt_mhc0a1s2d3f4g5h6j7", "kind": "event", "body": null, "meta": { "type": "status", "from": "open", "to": "solved" }, "author": null, "createdAt": 1793872100000 }
    ]
  }
}
```

- `kind`: `comment` (an internal comment, `body` is its text) or `event` (a change, described in `meta`).
- `author`: `{ id, name, avatarUrl }` of the teammate, or `null` for what the API did without `agentEmail`.
- Event `meta.type`: `status`, `priority` (`from`, `to`), `assignee` (`from`, `to`: `{ id, name }` or `null`), `due` (`from`, `to`: ms or `null`), `contact` (`to`: `{ id, name }` or `null`), `customerNotified` (`email`, `message`).

### 8.4 Create a ticket: `POST /tickets`

| Body | Type | |
| --- | --- | --- |
| `subject` | string, 1 to 200 characters | Required |
| `description` | string up to 10000 characters, or `null` | |
| `status` | `open`, `pending`, `solved`, `closed` | Default `open` |
| `priority` | `urgent`, `high`, `normal`, `low` | Default `normal` |
| `contactId` | contact id, or `null` | |
| `conversationId` | conversation id, or `null` | Links the conversation; its visitor becomes the contact when `contactId` is left out |
| `assigneeEmail` | email, any case | The teammate in charge. Not a member: `404 not_found` |
| `assigneeId` | a teammate's user id, or `null` | The same by id. Not a member: `400 assignee_not_member`. `assigneeEmail` wins when both are sent |
| `dueAt` | ms, an ISO date-time, `YYYY-MM-DD`, or `null` | `YYYY-MM-DD` means the end of that day (23:59:59.999 UTC) |
| `labels` | array of label ids, up to 20 | |

```bash
curl -X POST "https://app.heilo.chat/api/v1/tickets" \
  -H "Authorization: Bearer $HEILO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"subject": "Refund the double charge", "conversationId": "conv_mhb2k1x0a7d3k9p2qz", "priority": "high", "assigneeEmail": "you@example.com", "dueAt": "2026-11-20"}'
```

Answer `201 { "data": <ticket> }` and the `ticket.created` webhook. Errors, all before anything is saved: `400 contact_not_found`, `400 conversation_not_found`, `400 contact_mismatch` (the conversation belongs to another contact), `400 label_not_found`, `400 assignee_not_member`, `400 validation_error` (e.g. a `dueAt` that isn't a date).

### 8.5 Update a ticket: `PATCH /tickets/:id`

Send only what you want to change: `subject`, `description`, `status`, `priority`, `assigneeEmail` (`null` unassigns), `assigneeId`, `contactId`, `dueAt` (`null` or `""` clears it) and `labels` (replaces them all), with the same types as in section 8.4. The linked conversation can't be changed here, and changing or removing the contact unlinks the conversation.

Two more fields email the contact:

| Body | Type | |
| --- | --- | --- |
| `notifyCustomer` | boolean | `true`: email the contact that their request is sorted. The ticket must be `solved` after this call |
| `customerMessage` | string up to 2000 characters | A note for that email |

The email only has the ticket number, the website and your note, never the subject, description or comments. It goes out in the contact's language, from the website's name.

```json
{ "status": "solved", "notifyCustomer": true, "customerMessage": "The refund is on its way, it takes 3 to 5 working days." }
```

```json
{ "data": { "id": "tkt_mhc0a1s2d3f4g5h6j7", "status": "solved", "…": "…" }, "changes": ["status", "customerNotified"], "notifyError": null }
```

- `changes`: `subject`, `description`, `status`, `priority`, `assignee`, `dueAt`, `contact`, `labels`, `customerNotified`. A change of status, priority, assignee, due date or contact is also written to the timeline (author `null`).
- When something changed: the `ticket.updated` webhook with `{ ticket, changes }`.
- `solved` sets `solvedAt`; `closed` sets `closedAt` (and `solvedAt` if it wasn't set); `open` or `pending` clears both.
- Checked before anything is saved: `400 notify_needs_solved` (`notifyCustomer` on a ticket that isn't solved), `400 no_customer_email` (no contact, or a contact without an email), and the errors of section 8.4.
- When the email can't be sent (the contact unsubscribed, a sending limit, a mail server error), the other changes are kept and `notifyError` says why; otherwise it is `null`.

### 8.6 Add an internal comment: `POST /tickets/:id/comments`

| Body | Type | |
| --- | --- | --- |
| `body` | string, 1 to 5000 characters after trimming | Required |
| `agentEmail` | email, any case | Optional. The teammate shown as the author. Not a member: `404 not_found` |

Answer `201 { "data": <comment> }` (the `comment` entry of section 8.3). Customers never see comments. Adding one moves the ticket's `updatedAt` but sends no webhook.

## 9. What the API doesn't do

So you don't look for it:

- Start a conversation, send files or product cards, write internal notes in a conversation, mark a conversation read, assign a conversation (except through `agentEmail` on a reply), or delete anything.
- Change websites, labels, custom field definitions, saved segments or the team.
- List teammates, labels, custom fields or segments. Teammates are named by email (`agentEmail`, `assigneeEmail`); their user ids show up as `assigneeId`, `senderId` and `author.id`. Label ids come from the `labels` in answers. Field keys are in Contacts → Custom fields and in every `customFields` list.
- Messages beyond a conversation's latest 200.
- Reports: the [MCP server](https://app.heilo.chat/docs/mcp) has them for your AI assistant.

## 10. Example: answer the chats that are waiting

List the open conversations and reply where the visitor wrote last.

curl:

```bash
export HEILO_API_KEY="cm_live_…"

curl -s "https://app.heilo.chat/api/v1/conversations?status=open&limit=100" \
  -H "Authorization: Bearer $HEILO_API_KEY"

curl -s -X POST "https://app.heilo.chat/api/v1/conversations/conv_mhb2k1x0a7d3k9p2qz/messages" \
  -H "Authorization: Bearer $HEILO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"body": "Thanks for waiting! We are on it.", "agentEmail": "you@example.com"}'
```

Node.js 20 (no dependencies; save as `reply.mjs`, run `HEILO_API_KEY=cm_live_… node reply.mjs`):

```js
const BASE = 'https://app.heilo.chat/api/v1';

async function heilo(method, path, body) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.HEILO_API_KEY}`,
      ...(body ? { 'Content-Type': 'application/json' } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (res.status === 429) {
    // rate_limited: wait and try again
    const wait = Number(res.headers.get('retry-after')) || 60;
    await new Promise((resolve) => setTimeout(resolve, wait * 1000));
    return heilo(method, path, body);
  }
  const json = await res.json();
  if (!res.ok) throw Object.assign(new Error(json.error), { status: res.status, code: json.code });
  return json;
}

// every open conversation, latest activity first
const open = [];
let cursor = null;
do {
  const page = await heilo('GET', `/conversations?status=open&limit=100${cursor ? `&cursor=${cursor}` : ''}`);
  open.push(...page.data);
  cursor = page.nextCursor;
} while (cursor);

// the visitor wrote last: waiting for an answer
const waiting = open.filter((c) => c.lastMessageSender === 'visitor');
for (const conversation of waiting) {
  const name = conversation.visitor?.name ?? 'there';
  await heilo('POST', `/conversations/${conversation.id}/messages`, {
    body: `Hi ${name}, thanks for waiting! We are on it.`,
    agentEmail: 'you@example.com', // a teammate's sign-in email
  });
}
console.log(`Answered ${waiting.length} of ${open.length} open chats`);
```

Keeping a contact in step with your app works the same way:

```js
// your user 42 just upgraded
const { data: contacts } = await heilo('GET', '/contacts?userId=42');
for (const contact of contacts) {
  await heilo('PATCH', `/contacts/${contact.id}`, { fields: { plan: 'business', seats: 12 } });
}
```

## 11. Related

- [Webhooks](https://app.heilo.chat/docs/webhooks): Heilo calls your server when a conversation starts, a message comes in, a contact or a ticket changes. Use the REST API to fetch more than an event carries.
- [MCP server](https://app.heilo.chat/docs/mcp): the same API keys let an AI assistant (Claude, Cursor, VS Code) read the workspace's chats, contacts, tickets and reports.
- [Chat Client API and Chat UI SDK](https://app.heilo.chat/docs/client-api): the visitor's side, for your own chat UI in a web app, iOS or Android app, plus identity tokens and the full custom field rules.
