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 oncode. - Times are Unix epoch milliseconds.
1. Security model
| Piece | Where it lives | What it can do |
|---|---|---|
Website id (ws_…), the public key | In your app or page | Start a new anonymous chat, read the website's public settings |
Identity secret (hid_…) | Only on your server | Sign identity tokens: "this is my signed-in customer 42" |
| Identity token (JWT, HS256) | Made by your server, passed to the app for a moment | Start 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:
- Identity tokens must be HS256 JWTs signed with the website's identity secret, with a
sub(your user id, up to 128 characters) and anexpno more than 24 hours away (10 minutes is plenty).alg: none, other algorithms, a missingexp, a wrong signature or an expired token are refused (identity_invalid,identity_expired). Theemail,nameandphoneclaims are trusted and kept up to date on the contact, and so are the custom field values infields(section 8). - A customer's history (
sub) is only reachable with a valid identity token for thatsub, or with a session token that was issued to thatsub. 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. - 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.
- 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. - 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). - Allowed domains (website settings) apply to every request that carries an
Originheader, 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. - 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.
- 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. - 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:
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):
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):
<script src="https://app.heilo.chat/sdk/heilo-chat.js"></script> <!-- window.HeiloChat -->import { HeiloChat } from 'https://app.heilo.chat/sdk/heilo-chat.mjs'; // or copy the file into your projectTypes: 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).
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 reconnectingLosing 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
{ "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:
{
"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" }
}- With
identityToken: the customer with thatsub. Iftokenbelongs to an anonymous visitor on this device who hasn't been linked to anyone, their chat becomes the customer's. - With only
token: resumes that visitor (a verified one keeps the token's original expiry). - With neither: a new anonymous visitor, unless the website requires identity (
401 identity_required). serverTimeis the server's clock (ms). Your own client can keep it as the point to catch up from: after a lost connection,GET /messages?after=the newestcreatedAtyou have shown (orserverTimeif none) returns what you missed. The SDK does this for you.- A token from the website widget can be traded in here. The widget takes the same identity tokens (section 7) and never resumes a verified customer without a fresh one.
- When the identity token's
fieldsclaim had keys Heilo skipped, the answer also hasrejectedFields(section 8). The SDK writes them to the console. csat: the after-chat rating to show for the chat your team last closed (section 4.1), ornull(no closed chat, an open one, or nothing left to rate).
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:
{ "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.
- The ask arrives as
csaton theconversation:statusevent ({ status: "closed", conversationId, csat }), onPOST /websites/:id/sessionsand onGET /me:
{ "conversationId": "conv_…", "rating": null, "editableUntil": 1767744000000 }- Show the thumbs while
Date.now() < editableUntiland the customer hasn't started a new chat.csatisnullwhen there is nothing to ask: the chat is open, the website turned ratings off (dashboard: Websites & widget → Automation → Ask for a rating after the chat, on by default), the team never got an answer to a proactive invite, or the time is up. - Send
{ "score": "good" | "bad", "comment": "optional, up to 1000 characters", "conversationId": "conv_… (optional)" }. WithoutconversationIdit rates the customer's most recently closed chat. Leavingcommentout keeps the current comment;""ornullremoves it. A longer comment is cut at 1000 characters. - Response
200 { csat }with the saved rating:
{ "csat": { "conversationId": "conv_…", "rating": { "score": "good", "comment": "Nhanh lắm", "ratedAt": 1767139200000, "updatedAt": 1767139200000 }, "editableUntil": 1767225600000 } }- Windows: a first rating within 7 days of the close; the customer can change the score or the comment for 24 hours after that first rating (
editableUntilmoves toratedAt+ 24 h).ratedAtstays the first rating's time,updatedAtis the last change. - Errors:
404 not_found(no such chat of this customer),409 conversation_open(reopened by your team),403 rating_disabled(turned off for the website),409 rating_expired(time is up),400 validation_error(a score other thangood/bad). - Each change reaches your team at once (Inbox, Analytics) and sends the
conversation.ratedwebhook. Your other open sessions of the same customer getconversation:rating{ csat }.
5. Realtime
Socket.IO namespace /visitor, auth: { token, locale }, transport websocket.
- Server → app:
message:new{ message },agent:typing{ typing, name },conversation:status{ status, conversationId, csat }(csat: section 4.1,nullwhen not asking),conversation:rating{ csat }(the customer rated on another device),agents:online{ online, hours }. - App → server:
message:send({ body, attachments }, ack),typing{ typing },page{ url, title },activity{ hidden }. Themessage:sendack is{ ok: true, messageId, createdAt }(createdAt: the server's time, in ms) or{ ok: false, error, code }. - A refused handshake carries the reason in
err.message:unauthorized(start a new session),identity_required,origin_not_allowed,upgrade_required,website_paused. - Writing your own client instead of the SDK: Socket.IO reconnects by itself after a network drop, but not after a refused handshake or a disconnect sent by the server (
io server disconnect). Reconnect yourself then (with a new session afterunauthorized), waiting longer each time, except fororigin_not_allowed,upgrade_requiredandwebsite_paused. After every reconnect fetchGET /messages?after=to get what arrived meanwhile: realtime events sent while you were offline are not replayed.
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:
<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).
- The widget sends it as
identityTokenwithPOST /api/widget/:websiteId/session. The same rules asPOST /sessionsapply: a bad, expired or too long-lived token is401 identity_invalid/identity_expired, and the widget then writes the reason in the browser console and starts as an anonymous visitor. - The
emailfrom the token is locked (409 identity_lockedonHeilo('identify', { email })); name, phone and custom fields can still be sent (section 8). - Without a fresh token, a verified customer's chat isn't reopened in that browser: after signing out (call
Heilo('reset')), whoever uses the computer next starts a new chat. - Plain
Heilo('identify', { name, email })keeps working but is never verified: the Inbox shows those details as Not verified, and Verified only for customers with an identity token.
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:
// 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 });// 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:
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):
| source | Set by | Trusted |
|---|---|---|
team | Your team in the dashboard (Inbox, Contacts) | yes |
api | REST API PATCH /api/v1/contacts/:id with an API key | yes |
identity | The fields claim of an identity token your server signed, or the values the Heilo WordPress plugin signs for a logged-in customer | yes |
visitor | Heilo('setFields'), Heilo('identify', { fields }), the pre-chat form, PATCH /me | no |
- Anything the browser or the app sends is
visitor, even in a verified session: the customer can open the developer tools and change those calls. Avisitorvalue never overwrites or clears a trusted one; the key comes back aslocked. The same value as the stored one is fine. - A trusted value always wins, and a trusted source sending the value a visitor already set confirms it (it becomes that source and is locked from then on).
- The Inbox and the contact profile mark
visitorvalues as Not verified. Webhooks and the REST API includesourcewith every value, so your systems can tell too. - 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.
What happens to a bad key or value
From the page / app (visitor) | From an identity token or the WordPress plugin | From the REST API | |
|---|---|---|---|
| Unknown key | skipped, in rejectedFields as unknown_field | skipped, unknown_field | 400 unknown_field |
| Wrong type, not an option | 400 validation_error, nothing from the call is saved | skipped, invalid_value with the reason | 400 validation_error |
| Value set by a trusted source | skipped, locked | written | written |
| Plan without custom fields | skipped, upgrade_required (name, email, phone are still saved) | skipped, upgrade_required | 402 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:
{
"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 }.