# 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

| 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:

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": "lan@example.com", "phone": null, "fields": {}, "returning": true },
  "conversation": { "id": "conv_…", "status": "open", "unread": 1 },
  "csat": null,
  "config": { "…": "same as GET /config" }
}
```

- With `identityToken`: the customer with that `sub`. If `token` belongs 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`).
- `serverTime` is 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 newest `createdAt` you have shown (or `serverTime` if 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 `fields` claim had keys Heilo skipped, the answer also has `rejectedFields` (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), or `null` (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:

```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.

- The ask arrives as `csat` on the `conversation:status` event (`{ status: "closed", conversationId, csat }`), on `POST /websites/:id/sessions` and on `GET /me`:

```json
{ "conversationId": "conv_…", "rating": null, "editableUntil": 1767744000000 }
```

- Show the thumbs while `Date.now() < editableUntil` and the customer hasn't started a new chat. `csat` is `null` when 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)" }`. Without `conversationId` it rates the customer's most recently closed chat. Leaving `comment` out keeps the current comment; `""` or `null` removes it. A longer comment is cut at 1000 characters.
- Response `200 { csat }` with the saved rating:

```json
{ "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 (`editableUntil` moves to `ratedAt` + 24 h). `ratedAt` stays the first rating's time, `updatedAt` is 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 than `good` / `bad`).
- Each change reaches your team at once (Inbox, Analytics) and sends the `conversation.rated` webhook. Your other open sessions of the same customer get `conversation: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, `null` when 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 }`. The `message:send` ack 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 after `unauthorized`), waiting longer each time, except for `origin_not_allowed`, `upgrade_required` and `website_paused`. After every reconnect fetch `GET /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:

```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).

- The widget sends it as `identityToken` with `POST /api/widget/:websiteId/session`. The same rules as `POST /sessions` apply: a bad, expired or too long-lived token is `401 identity_invalid` / `identity_expired`, and the widget then writes the reason in the browser console and starts as an anonymous visitor.
- The `email` from the token is locked (`409 identity_locked` on `Heilo('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:

```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: 'lan@example.com', 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`):

| 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. A `visitor` value **never overwrites or clears** a trusted one; the key comes back as `locked`. 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 `visitor` values as **Not verified**. Webhooks and the REST API include `source` with 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:

```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 }`.
