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 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/jsonwith every body (up to 200 KB). Answers carry the result indata. - Times are Unix epoch milliseconds. Custom field dates are
YYYY-MM-DDstrings. - 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
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.
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:
RateLimit-Policy: "120-in-1min"; q=120; w=60; pk=:…:
RateLimit: "120-in-1min"; r=117; t=42r 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):
{ "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:
{ "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, ornullwhen 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).
{
"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
{
"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": "[email protected]", "…": "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 |
{ "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.
{
"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 |
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": "[email protected]"}'Answer 201 { "data": <message> } (section 6.3), with senderType: "agent".
- With
agentEmailthe reply carries that teammate's name and photo, and a conversation nobody is assigned to is assigned to them (webhookconversation.assigned). Not a member:404 not_found, nothing is sent. - Without it,
senderIdandsenderarenull: 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 (webhookconversation.closed). - The reply reaches the Inbox and the visitor's chat in real time and sends the
message.createdwebhook. 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
deliveryErrorand shown in the Inbox; get the conversation again to see it.
6.5 Close or reopen: PATCH /conversations/:id
{ "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
{
"id": "vis_mhb2jz8e5n1c0w7r4t",
"websiteId": "ws_mh7k2c9q4f8x1a0b2c",
"name": "Lan Đoàn",
"email": "[email protected]",
"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.
{ "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).
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 |
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"}}'{ "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"] }changeslists 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.updatedwebhook with{ contact, changes, source: "api" }and updates open dashboards. - Every check comes before anything is saved: with
400 unknown_field,400 validation_erroror400 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 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/falseare stored as text), a number (3,"12,5"), a date asYYYY-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 is400 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 aslocked). Trusted sources overwrite each other: your team, the REST API, and thefieldsclaim 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
apiand 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
{
"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": "[email protected]", "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.
{
"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.
{
"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,bodyis its text) orevent(a change, described inmeta).author:{ id, name, avatarUrl }of the teammate, ornullfor what the API did withoutagentEmail.- Event
meta.type:status,priority(from,to),assignee(from,to:{ id, name }ornull),due(from,to: ms ornull),contact(to:{ id, name }ornull),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 |
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": "[email protected]", "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.
{ "status": "solved", "notifyCustomer": true, "customerMessage": "The refund is on its way, it takes 3 to 5 working days." }{ "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 (authornull).- When something changed: the
ticket.updatedwebhook with{ ticket, changes }. solvedsetssolvedAt;closedsetsclosedAt(andsolvedAtif it wasn't set);openorpendingclears both.- Checked before anything is saved:
400 notify_needs_solved(notifyCustomeron 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
notifyErrorsays why; otherwise it isnull.
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
agentEmailon 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 asassigneeId,senderIdandauthor.id. Label ids come from thelabelsin answers. Field keys are in Contacts → Custom fields and in everycustomFieldslist. - Messages beyond a conversation's latest 200.
- Reports: the MCP server 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:
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": "[email protected]"}'Node.js 20 (no dependencies; save as reply.mjs, run HEILO_API_KEY=cm_live_… node reply.mjs):
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: '[email protected]', // 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:
// 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: 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: 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: 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.