Heilo · REST API

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/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_…

Every request is checked, before anything else (a wrong path with a bad key answers 401, not 404):

StatuscodeWhen
401unauthorizedNo Authorization header (Missing API key), or an unknown or revoked key (Invalid API key)
402upgrade_requiredThe workspace's plan has no REST API
403account_suspendedHeilo 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.

StatuscodeWhen
400validation_errorA 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
400invalid_jsonThe body isn't valid JSON
400unknown_fieldA custom field key the workspace doesn't have (section 7.6)
400label_not_foundA label id that isn't in the workspace
400assignee_not_member, contact_not_found, conversation_not_found, contact_mismatch, notify_needs_solved, no_customer_emailTickets (section 8)
401unauthorizedSection 1
402upgrade_requiredSection 1
403account_suspendedSection 1
404not_foundAn 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
413payload_too_largeThe body is over 200 KB
429rate_limitedSection 2
500internal_errorSomething 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

ListNext pageOrder
GET /conversationscursor=<nextCursor>Latest activity first
GET /contactscursor=<nextCursor>Most recently seen first
GET /ticketscursor=<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": "[email protected]", "…": "the contact object, section 7.1" }
}
FieldMeaning
channelweb (the chat widget or your own app), messenger or instagram
statusopen or closed. A visitor has at most one open conversation
assigneeIdThe teammate in charge (usr_…), or null
lastMessagePreviewUp to 190 characters of the last message, or the file's name when it was only a file. Internal notes don't change it
lastMessageSendervisitor, agent (your team or the API) or bot (keyword rules, the away message, the AI assistant)
unreadVisitor messages your team hasn't read in the Inbox yet
firstResponseAtThe team's first reply (an API reply counts)
aiHandoffAtWhen the AI assistant handed the chat over to the team
invitenull when the visitor started the chat. When a teammate wrote first: pending (no answer yet), accepted or ignored
ratingThe 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
labelsThe conversation's labels
visitorThe visitor, complete with labels and custom fields (section 7.1, without conversationCount)

6.2 List conversations: GET /conversations

QueryType
statusopen or closedBoth when left out
websiteIdwebsite idOnly that website
limit1 to 100Default 25
cursorstringThe previous answer's nextCursor
beforemsOnly 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
      }
    ]
  }
}
FieldMeaning
senderTypevisitor, agent or bot
senderIdThe 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
typetext; 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)
bodyThe 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
aitrue for answers written by the AI assistant
deliveryErrorOnly 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

BodyType
bodystring, 1 to 5000 characters after trimmingRequired. Text only: the API can't send files or cards
agentEmailemail, any caseOptional. 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": "[email protected]"}'

Answer 201 { "data": <message> } (section 6.3), with senderType: "agent".

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": "[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
}
FieldMeaning
name, email, phoneWhat the person left in the chat, what your identity token says, or what your team or the API set. null when unknown
notesYour team's notes about the contact
metaWhat 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
lastSeenAtThe last visit, or null
onlineThe chat is open in a browser or app right now
channelweb, messenger or instagram
verifiedUserIdYour own user id (the identity token's sub) when the person signed in with an identity token, otherwise null
lastInboundAtMessenger and Instagram: the person's last message, which opens the platform's reply window. null on the web
labels{ id, name, color }
fieldsCustom field values that are set, { key: value } (numbers as numbers, dates as YYYY-MM-DD)
customFieldsEvery 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)
conversationCountOnly 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.

QueryType
qstring, up to 100 charactersName or email contains it (any case), phone contains it, or the exact contact id
userIdstring, up to 128 charactersYour own user id of a customer who signed in with an identity token (the token's sub), exact match
labelslabel ids, comma-separated, up to 20Contacts with any of these labels
labelModeany or allall: contacts with every label in labels. Default any
channelweb, messenger or instagram
websiteIdwebsite id
scopeidentified or allall: every visitor. Default identified
seenWithindays, 1 to 3650Seen in the last N days
notSeenFordays, 1 to 3650Not seen for N days
fieldsJSON array, up to 10 conditionsCustom field filters, see below
segmentIda saved segment's idThat segment's filters, with the other parameters on top. Unknown: 404 not_found
limit1 to 100Default 25
cursorstringThe 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.

opMatches
eqEqual (any case for text; number fields compare as numbers)
containsThe value contains the text, any case
gt, ltGreater or less: numbers as numbers, dates as YYYY-MM-DD, text alphabetically
set, unsetHas 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).

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.

BodyType
namestring up to 120 characters, or null"" or null clears it
emailemail, "" or nullStored in lowercase. "" or null clears it
phonestring up to 40 characters, or null"" or null clears it
notesstring up to 5000 characters, or null"" or null clears it
labelsarray of label ids, up to 20Replaces all the contact's labels. [] removes them all
fieldsobject { key: value }, up to 50 keysCustom 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"] }

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:

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": "[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
}
FieldMeaning
numberThe workspace's ticket number: #1, #2…
statusopen (to do), pending (waiting on someone else, e.g. the customer), solved (done, can be reopened), closed (archived)
priorityurgent, high, normal or low
assigneeIdThe teammate in charge (usr_…), or null
contactId, conversationId, websiteIdWhat the ticket is linked to, or null
createdByThe teammate who created it (dashboard or agents app); null from the API
dueAtThe due time, or null. Past it while open or pending, the ticket is overdue
solvedAt, closedAtWhen it was solved, closed. Reopening clears both
customerNotifiedAtWhen 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
commentCountInternal comments (changes not counted)

8.2 List tickets: GET /tickets

QueryType
statuscomma-separated: open, pending, solved, closed, or active (= open,pending)Every status when left out
prioritycomma-separated: urgent, high, normal, low
assigneea teammate's user id, or nonenone: nobody is assigned. me doesn't exist here (400)
contactIdcontact id
conversationIdconversation id
labelslabel ids, comma-separatedTickets with any of them (the first 20 count)
qstring, up to 100 charactersSubject and description (accents optional), the contact's name, email or phone, or the ticket number (12 or #12)
overdue1 or trueOnly overdue tickets (open or pending, past dueAt)
sortupdated, created, due, priorityDefault updated (last updated first). due: soonest first, no due date last. priority: urgent first, then last updated
limit1 to 100Default 25
cursorstringThe 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 }
    ]
  }
}

8.4 Create a ticket: POST /tickets

BodyType
subjectstring, 1 to 200 charactersRequired
descriptionstring up to 10000 characters, or null
statusopen, pending, solved, closedDefault open
priorityurgent, high, normal, lowDefault normal
contactIdcontact id, or null
conversationIdconversation id, or nullLinks the conversation; its visitor becomes the contact when contactId is left out
assigneeEmailemail, any caseThe teammate in charge. Not a member: 404 not_found
assigneeIda teammate's user id, or nullThe same by id. Not a member: 400 assignee_not_member. assigneeEmail wins when both are sent
dueAtms, an ISO date-time, YYYY-MM-DD, or nullYYYY-MM-DD means the end of that day (23:59:59.999 UTC)
labelsarray 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": "[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:

BodyType
notifyCustomerbooleantrue: email the contact that their request is sorted. The ticket must be solved after this call
customerMessagestring up to 2000 charactersA 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 }

8.6 Add an internal comment: POST /tickets/:id/comments

BodyType
bodystring, 1 to 5000 characters after trimmingRequired
agentEmailemail, any caseOptional. 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:

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": "[email protected]"}'

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: '[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:

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 } });
}