Heilo · Chat Client API

Heilo Chat Client API and Chat UI SDK

Build the chat into your own web app, iOS or Android app with your own design. Conversations land in the Heilo Inbox as usual, and your team answers there (or from the agents app). This is the visitor's side of the chat. For your own back office (reading conversations, replying as an agent, tickets) use the REST API (/api/v1, API keys) and webhooks instead.

  • Plans: Pro and Business (and the free trial), like the REST API. On other plans every call answers 402 upgrade_required.
  • Base URL: https://<your Heilo server>/api/client/v1 (e.g. https://app.heilo.chat/api/client/v1).
  • Errors: JSON { "error": "<message in the X-Locale language>", "code": "<machine code>" }. Branch on code.
  • Times are Unix epoch milliseconds.

1. Security model

PieceWhere it livesWhat it can do
Website id (ws_…), the public keyIn your app or pageStart a new anonymous chat, read the website's public settings
Identity secret (hid_…)Only on your serverSign identity tokens: "this is my signed-in customer 42"
Identity token (JWT, HS256)Made by your server, passed to the app for a momentStart a session as that customer, on any device
Session token (Bearer)Kept by the app (localStorage, AsyncStorage, Keychain)Everything for that one visitor of that one website

Rules the server enforces:

  1. Identity tokens must be HS256 JWTs signed with the website's identity secret, with a sub (your user id, up to 128 characters) and an exp no more than 24 hours away (10 minutes is plenty). alg: none, other algorithms, a missing exp, a wrong signature or an expired token are refused (identity_invalid, identity_expired). The email, name and phone claims are trusted and kept up to date on the contact, and so are the custom field values in fields (section 8).
  2. A customer's history (sub) is only reachable with a valid identity token for that sub, or with a session token that was issued to that sub. Session tokens are bound to the identity they were issued for: when an anonymous visitor signs in, their old anonymous token stops working and the app gets a new one.
  3. Verified sessions last 7 days and are not extended without a fresh identity token. Anonymous sessions last 90 days and renew on each session start.
  4. Only signed-in customers (a per-website switch): anonymous Client API sessions and old anonymous session tokens are refused (identity_required). The website's chat widget is not affected.
  5. A verified customer's email comes from your identity token and can't be changed by the client (409 identity_locked). Name, phone and custom fields can, but custom field values sent by the client never overwrite what your team, the REST API or an identity token set (section 8).
  6. Allowed domains (website settings) apply to every request that carries an Origin header, which browsers always send. Native apps and servers send none, so they rely on identity tokens. The Origin check stops other websites from embedding your chat; identity tokens stop impersonation.
  7. Rotation: create a new identity secret at any time (Websites & widget → Install). The previous secret keeps working for 24 hours so you can deploy the new one.
  8. Rate limits: requests, new sessions, messages and uploads are limited per IP address and per visitor to stop abuse (429 rate_limited: wait a little and try again). A normal chat never gets near them. File types and sizes follow the plan, like the widget.
  9. Heilo keeps the identity secret encrypted and only shows it to owners and admins who ask for it.

2. Signing identity tokens on your server

Node.js:

js
import jwt from 'jsonwebtoken';

app.get('/api/heilo-token', requireLogin, (req, res) => {
  const token = jwt.sign(
    { sub: String(req.user.id), email: req.user.email, name: req.user.name },
    process.env.HEILO_IDENTITY_SECRET,
    { algorithm: 'HS256', expiresIn: '10m' },
  );
  res.type('text/plain').send(token);
});

PHP (composer require firebase/php-jwt):

php
use Firebase\JWT\JWT;

echo JWT::encode([
  'sub'   => (string) $user->id,
  'email' => $user->email,
  'name'  => $user->name,
  'exp'   => time() + 600,
], getenv('HEILO_IDENTITY_SECRET'), 'HS256');

Claims: sub (required), exp (required, at most 24 h ahead), email, name, phone, fields (optional; fields = contact custom fields { key: value } the customer can't change, section 8). Other claims are ignored.

3. The SDK

One file with no dependencies to install (Socket.IO is bundled in):

html
<script src="https://app.heilo.chat/sdk/heilo-chat.js"></script>   <!-- window.HeiloChat -->
js
import { HeiloChat } from 'https://app.heilo.chat/sdk/heilo-chat.mjs';   // or copy the file into your project

Types: https://app.heilo.chat/sdk/heilo-chat.d.ts. For AI coding assistants, this whole guide plus the types is one Markdown file at https://app.heilo.chat/sdk/heilo-chat.md (the dashboard's "Copy Markdown for AI" adds your server address and website id on top).

js
const chat = new HeiloChat({
  websiteId: 'ws_…',
  baseUrl: 'https://app.heilo.chat',
  identityToken: () => fetch('/api/heilo-token').then((r) => r.text()), // optional
  // storage: AsyncStorage,            // React Native: keep the session across restarts
  // locale: 'vi',                     // language of error messages
  // meta: { platform: 'ios', appVersion: '2.1.0' },
  // realtime: false,                  // poll every pollInterval ms instead of a socket
});

chat.on('message', (message) => render(message));        // your team, the bot and this customer
chat.on('typing', ({ typing, name }) => showTyping(typing, name));
chat.on('status', ({ status, csat }) => showStatus(status, csat)); // 'open' | 'closed'; csat: ask for a rating (section 4.1)
chat.on('rating', (csat) => showRating(csat));            // the rating changed (here or on another device)
chat.on('availability', ({ online, hours }) => showOnline(online));
chat.on('connection', (state) => showState(state));       // 'connecting' | 'online' | 'offline'
chat.on('error', (err) => console.warn(err.code, err.message));

await chat.connect();
const { messages, hasMore } = await chat.history();          // oldest first
const older = await chat.history({ before: messages[0].createdAt });
await chat.send('Hi!');
await chat.send('', { files: [file] });                      // File, Blob or ArrayBuffer
await chat.identify({ name: 'Lan Đoàn', phone: '0901…' });
const { saved, rejected } = await chat.setFields({ order_count: 3, last_order: 'A123' }); // section 8
await chat.rate('good', { comment: 'Nhanh lắm' });         // after-chat rating, 'good' | 'bad' (section 4.1)
chat.setTyping(true);
await chat.markRead();
await chat.logout();                                         // when your customer signs out
chat.reconnect();                                            // React Native: on AppState 'active' (see below)
chat.disconnect();                                           // close it and stop reconnecting

Losing the connection. After connect() the SDK keeps the chat connected by itself: after a network drop, a phone going to sleep, a Heilo server restart or an expired session it reconnects (1 s, 2 s, 4 s… apart, up to a minute) and delivers the messages that arrived meanwhile as message events, oldest first, each once. connection tells your UI where it is (offline while it waits, online when it is back). If connect() itself fails because there is no network or the server is busy, it rejects with network_error (or the HTTP status) and keeps trying in the background, so you can show the error and wait for connection to become online. Browsers reconnect at once when the network or the tab comes back; in React Native call chat.reconnect() when the app returns to the foreground (AppState active) or NetInfo reports the network back. It only stops for good when the server refuses the website (origin_not_allowed, upgrade_required, website_paused, or identity_required without a valid identity token): you get an error event with that code, and chat.reconnect() tries again once you fixed it. chat.disconnect() closes the connection and stops reconnecting.

send adds an Idempotency-Key, so a retry after a network error never stores the message twice. When a session expires or the customer's identity changes, the SDK asks identityToken() again, starts a new session once and repeats the call.

React Native: pass storage: AsyncStorage (or a Keychain-backed object with getItem, setItem, removeItem), and send photos as a Blob: const blob = await (await fetch(uri)).blob(); await chat.send('', { files: [blob] }) (give it a name with chat.upload(blob, 'photo.jpg')).

4. REST reference

All endpoints except the first two need Authorization: Bearer <session token>.

GET /websites/:websiteId/config

The website's public settings: name, enabled, online, hours, branding, settings (colors, greeting, position, pre-chat form with its custom fields) and identity.required.

online is what the website's widget shows, set in Website & widget → Hours & lead form → Online status (settings.onlineStatus): agents (the default) while a teammate has Heilo open on the web or in the mobile app and the website is within its business hours, hours throughout the business hours, always all the time (then hours is null). The availability event (agents:online) sends the same { online, hours } whenever it changes.

The pre-chat form is the website widget's: Heilo enforces its required fields only for the widget, not for your app. Show it in your own UI if you want those details first (and save the answers with PATCH /me).

POST /websites/:websiteId/sessions

json
{ "token": "previous session token (optional)", "identityToken": "eyJ… (optional)", "meta": { "platform": "ios", "appVersion": "2.1.0", "url": "…", "title": "…", "language": "vi", "timezone": "Asia/Ho_Chi_Minh" } }

Response 200:

json
{
  "token": "eyJ…",
  "expiresAt": 1767225600000,
  "serverTime": 1767139200000,
  "visitor": { "id": "vis_…", "userId": "42", "verified": true, "name": "Lan Đoàn", "email": "[email protected]", "phone": null, "fields": {}, "returning": true },
  "conversation": { "id": "conv_…", "status": "open", "unread": 1 },
  "csat": null,
  "config": { "…": "same as GET /config" }
}

GET /me, PATCH /me

GET returns { visitor, conversation, csat, online } (visitor.fields = the answers to your pre-chat form's custom fields). PATCH takes { name?, email?, phone?, fields? } and returns { visitor }; with fields (contact custom fields, { key: value }, section 8) also savedFields (keys that now hold the value you sent) and rejectedFields ([{ key, code }]).

GET /messages?before=&after=&limit=

The visitor's messages across their conversations, oldest first (internal notes are never included). limit 1 to 100 (default 50). before (ms) pages back; after (ms) returns what's newer, oldest first, for clients that poll or reconnect (hasMore: ask again with the last one's createdAt). Response { messages, hasMore }.

Message:

json
{ "id": "msg_…", "from": "agent", "type": "text", "body": "Chào bạn", "attachments": [{ "id": "k3j9x0q2m8w1e4r7t5y6u1i0", "url": "https://app.heilo.chat/uploads/k3j9x0q2m8w1e4r7t5y6u1i0/a.jpg", "name": "a.jpg", "mime": "image/jpeg", "size": 12345 }], "createdAt": 1762160000000, "agent": { "name": "Lan", "avatarUrl": "https://…" } }

An attachment with removed: true was deleted by the workspace to free up storage: show a short note instead of the file (its url answers 410).

from: visitor, agent, bot or system. type can also be product or order (cards sent from the WooCommerce tools, with product / order objects).

POST /messages

{ "body": "text", "attachments": ["k3j9x0q2m8w1e4r7t5y6u1i0"] } (text and/or up to 10 upload ids: 24 lowercase letters and digits, no prefix). Header Idempotency-Key: <random> makes retries safe for 10 minutes. Response 201 { message, conversation }.

PUT /uploads?name=photo.jpg

Raw body (Content-Type: application/octet-stream). Response 201 { upload }. Then send upload.id in attachments.

POST /read, POST /typing

/read marks the team's messages as seen. /typing takes { "typing": true } and answers 204.

4.1 After-chat rating: POST /rating

When your team closes a chat, ask the customer how it went: a thumbs up or down and an optional comment, like the website widget does. One rating per conversation.

json
{ "conversationId": "conv_…", "rating": null, "editableUntil": 1767744000000 }
json
{ "csat": { "conversationId": "conv_…", "rating": { "score": "good", "comment": "Nhanh lắm", "ratedAt": 1767139200000, "updatedAt": 1767139200000 }, "editableUntil": 1767225600000 } }

5. Realtime

Socket.IO namespace /visitor, auth: { token, locale }, transport websocket.

6. Dashboard

Websites & widget → a website → Install → Verify signed-in customers: the public key and the identity secret (show, copy, create a new one), on every plan, then Your own chat UI (Chat Client API) with the Only signed-in customers switch (Pro and above). The guide with code samples is under Integrations → Chat UI SDK. In the Inbox, these chats show "Writes via: Your app" with the platform and app version, and the signed-in customer's id. Webhook and REST API visitor objects carry verifiedUserId, labels, fields and customFields (section 8).

7. The website chat widget

The chat widget (widget.js) takes the same identity tokens, so a customer signed in on your website is verified there too and finds the same chat as in your app. This works on every plan, the Free plan included: it protects your customers, so it isn't sold as an extra. Only the Chat Client API above needs Pro.

Give the widget a token signed on your server (section 2; one hour suits a page), only while the customer is signed in:

html
<script async src="https://app.heilo.chat/widget.js" data-website-id="ws_…" data-identity-token="TOKEN_FROM_YOUR_SERVER"></script>

Or window.HeiloSettings = { websiteId, identityToken }, or Heilo('identify', { token }) (before the script loads, or later, e.g. right after sign-in in a single-page app; the call returns a promise that rejects with err.code when the token is refused).

8. Contact custom fields

Push what you know about the customer (plan, number of orders, sign-up date) into their contact profile in Heilo. Your team sees the values in the Inbox and on the Contacts page, and every webhook and REST API answer carries them. Custom fields are a CRM feature: Pro, Business and the free trial.

The fields themselves are created in the dashboard (Contacts → Custom fields), each with a key (e.g. order_count) and a type. Send values by key:

js
// the website chat widget (every page with widget.js)
Heilo('setFields', { plan: 'pro', order_count: 3, signup_date: '2026-11-04' });
// once widget.js has loaded the call returns a promise: const { saved, rejected } = await Heilo('setFields', { … })
Heilo('identify', { name: 'Lan Đoàn', email: '[email protected]', fields: { plan: 'pro' } });

// your own chat UI (HeiloChat SDK) or PATCH /me { fields }
const { saved, rejected } = await chat.setFields({ plan: 'pro', order_count: 3 });
js
// your server: values the customer can't change, in the identity token
jwt.sign({ sub: String(user.id), email: user.email, fields: { plan: user.plan, order_count: user.orders } }, secret, { algorithm: 'HS256', expiresIn: '10m' });

WordPress / WooCommerce. The Heilo Live Chat plugin (1.1 and up) signs custom field values together with a logged-in customer's identity, no code needed: with WooCommerce, order_count, total_spent and customer_since (the date they registered). Create fields with those keys (Websites & widget → your website → WordPress → Create the missing fields). Add, change or remove keys in WordPress with the heilo_identity_fields filter:

php
add_filter( 'heilo_identity_fields', function ( $fields, $user ) {
	$fields['membership'] = get_user_meta( $user->ID, 'membership_level', true );
	return $fields;
}, 10, 2 );

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 or a JavaScript Date keeps its date), or one of a dropdown's options. null (or "") clears a value. Up to 50 keys a call.

Who can change what. Heilo remembers who set each value (source):

sourceSet byTrusted
teamYour team in the dashboard (Inbox, Contacts)yes
apiREST API PATCH /api/v1/contacts/:id with an API keyyes
identityThe fields claim of an identity token your server signed, or the values the Heilo WordPress plugin signs for a logged-in customeryes
visitorHeilo('setFields'), Heilo('identify', { fields }), the pre-chat form, PATCH /meno

What happens to a bad key or value

From the page / app (visitor)From an identity token or the WordPress pluginFrom the REST API
Unknown keyskipped, in rejectedFields as unknown_fieldskipped, unknown_field400 unknown_field
Wrong type, not an option400 validation_error, nothing from the call is savedskipped, invalid_value with the reason400 validation_error
Value set by a trusted sourceskipped, lockedwrittenwritten
Plan without custom fieldsskipped, upgrade_required (name, email, phone are still saved)skipped, upgrade_required402 upgrade_required

Skipped keys never fail a sign-in or a page: the widget and the SDK write them to the browser console ([Heilo] Some custom fields were not saved: …) and return them as rejected / rejectedFields. Only the answers to the pre-chat form's own fields are ever sent back to the page, so private fields your team keeps stay private.

Webhooks and the REST API. Every visitor or contact in an event or an answer (conversation.visitor, visitor, contact, ticket.contact) comes complete:

json
{
  "id": "vis_…",
  "name": "Lan Đoàn",
  "verifiedUserId": "42",
  "labels": [{ "id": "lbl_…", "name": "VIP", "color": "#22c55e" }],
  "fields": { "plan": "pro", "order_count": 3 },
  "customFields": [
    { "key": "plan", "label": "Plan", "type": "select", "value": "pro", "source": "identity" },
    { "key": "order_count", "label": "Orders", "type": "number", "value": 3, "source": "visitor" },
    { "key": "signup_date", "label": "Signed up", "type": "date", "value": null, "source": null }
  ]
}

fields has only the values that are set; customFields lists every field of the workspace in its order. A change from any source sends contact.updated with { contact, changes: ["fields.order_count"], source: "visitor" }. To find a signed-in customer's contact from your server, list GET /api/v1/contacts?userId=42 (your sub), then PATCH /api/v1/contacts/:id { fields }.