# Heilo chat widget and JavaScript SDK

The Heilo chat widget is the chat button and chat window on your website or web app: one script, `widget.js`, and your team answers in the Heilo Inbox. The same script gives your page a small JavaScript API, `Heilo('command', …)`, to open the chat from your own button, tell Heilo who the visitor is, fill in contact fields and react to new messages.

Building the chat into your own interface instead of using the widget? That is the Chat UI SDK (`HeiloChat`): [Chat Client API and Chat UI SDK](https://app.heilo.chat/docs/client-api).

- **Plans**: the widget, this API and identity tokens work on every plan, Free included. Contact custom fields (section 4) need Pro, Business or the free trial.
- **Script**: `https://app.heilo.chat/widget.js`, one file with no dependencies, loaded with `async`.
- **Website id** (`ws_…`): Websites & widget → your website → Install. The install code there already has it filled in. It is public, so it is fine in your page.
- **Global**: `window.Heilo`, a function: `Heilo('open')`, `Heilo('identify', { … })` and so on.

## 1. Install

### The snippet

Paste this on every page, right before `</body>` (once in a shared layout or footer file is enough):

```html
<script>
  window.Heilo = window.Heilo || function () { (Heilo.q = Heilo.q || []).push(arguments) };
</script>
<script async src="https://app.heilo.chat/widget.js" data-website-id="YOUR_WEBSITE_ID"></script>
```

- The first script is the queue stub. It defines `Heilo` at once, so your code can call `Heilo('…')` before `widget.js` has downloaded: the calls are queued and run in order once the chat is ready. Without the stub, `Heilo` doesn't exist until `widget.js` has run. If you never call `Heilo` yourself, the second tag alone is enough.
- `async` keeps your page from waiting for the script. The widget waits for the DOM by itself, so the tags also work in `<head>`.
- Load `widget.js` from your Heilo server, don't copy the file: the widget talks to the server it was loaded from, and it gets updates there.
- One widget per page: a second copy of `widget.js` on the same page is ignored.

The Install tab also has step-by-step guides for common platforms (WordPress, Wix, Google Tag Manager and more), a test page (**Open widget test page**) and an **Is it working?** check that turns green as soon as the widget loads on your site.

### What it adds to your page

- One element, `<div id="heilo-widget">`, at the end of `<body>`. Everything else lives in its shadow root, so your CSS doesn't reach the widget and the widget's CSS doesn't touch your page. No iframe.
- The button sits in the bottom right or bottom left corner (website settings), `position: fixed` with `z-index: 2147483000`. On phones (up to 480 px wide, or up to 500 px tall) the open chat fills the screen and the page behind it doesn't scroll.
- Browser storage, no cookies: `localStorage` keys `heilo:YOUR_WEBSITE_ID` (the visitor's chat session) and `heilo:YOUR_WEBSITE_ID:lead` (pre-chat form answered or skipped), `sessionStorage` keys `heilo:YOUR_WEBSITE_ID:open` and `heilo:YOUR_WEBSITE_ID:peek` (window open, message preview dismissed, for this tab). With storage blocked, the chat still works for that page view.
- What it sends to Heilo: when the chat starts, the page URL, title and referrer, the browser language, time zone and screen size; then every new URL or title, whether the tab is in the background and a small heartbeat while the visitor uses the page (for the live visitors list); and the visitor's messages, typing and files.
- The live connection comes back by itself after a network drop, a computer that slept or a server restart. The messages your team sent meanwhile then show up in the chat without reloading the page, with the sound and the new message preview while the chat is closed. A chat your team ended meanwhile shows as ended, as it would have live.
- After-chat rating: when your team closes a chat, the widget asks "How was this chat?" with a thumbs up and a thumbs down, then an optional comment. The visitor can rate within 7 days of the close and change their mind for 24 hours after the first rating (the card shows again on later page loads during that time, until they start a new chat). Turn it off per website under Websites & widget → Automation → **Ask for a rating after the chat** (on by default). Ratings show in the Inbox, in Analytics (CSAT) and in the `conversation.rated` webhook. Messenger and Instagram chats are never asked.

### Attributes and `window.HeiloSettings`

| Script attribute | `HeiloSettings` key | What it does |
| --- | --- | --- |
| `data-website-id` | `websiteId` | Your website id. Required. |
| `data-identity-token` | `identityToken` | A signed-in customer's identity token (section 3). |
| `data-language` | `language` | Your page's language, used while the widget language is Auto (see `setLanguage`). |
| none | `apiBase` | The Heilo server address. Default: the origin of the script's `src`. |

```html
<script>
  window.HeiloSettings = { websiteId: 'YOUR_WEBSITE_ID', language: 'vi' };
</script>
<script async src="https://app.heilo.chat/widget.js"></script>
```

- `window.HeiloSettings` must be set before `widget.js` runs, and it wins over the attributes.
- The Heilo WordPress plugin also sets `HeiloSettings.identity` (a WordPress customer signed by the plugin). Don't set it yourself.
- Queued before `widget.js` loads, `Heilo('init', { websiteId: 'YOUR_WEBSITE_ID' })` also sets the website id. It only works from the queue: once `widget.js` has loaded, `init` is an unknown command.
- Embed codes from before the rename to Heilo (`window.ChatMe`, `window.ChatMeSettings`) keep working, and their visitors keep their chat.

### Adding it from your JavaScript code

Single-page apps (React, Vue, Svelte, Angular…) can add the script from code. A small helper module:

```js
// heilo.js
const HEILO_SRC = 'https://app.heilo.chat/widget.js';

/** Heilo('command', …) that also works before widget.js has loaded (calls are queued). */
export function heilo(...args) {
  window.Heilo =
    window.Heilo ||
    function () {
      (window.Heilo.q = window.Heilo.q || []).push(arguments);
    };
  return window.Heilo(...args);
}

/** Adds widget.js to the page once. Call it when your app starts. */
export function loadHeilo({ websiteId, language } = {}) {
  if (document.querySelector(`script[src="${HEILO_SRC}"]`)) return;
  const script = document.createElement('script');
  script.async = true;
  script.src = HEILO_SRC;
  script.setAttribute('data-website-id', websiteId);
  if (language) script.setAttribute('data-language', language);
  document.head.appendChild(script);
}
```

```js
import { heilo, loadHeilo } from './heilo.js';

loadHeilo({ websiteId: 'YOUR_WEBSITE_ID', language: document.documentElement.lang });
heilo('on', 'message', (message) => console.log('New reply:', message.body));
```

- Read `window.Heilo` at call time (as the helper does), don't keep a reference to it: once `widget.js` has loaded, `window.Heilo` is a new function, and calls on an old reference to the stub are never run.
- Only call it in the browser. With server rendering (Next.js, Nuxt, SvelteKit…), call it from an effect, a mount hook or an event handler.
- Google Tag Manager: its Custom HTML tags drop `data-*` attributes from script tags, so create the script element with JavaScript and `setAttribute('data-website-id', …)` as above. The Install tab has this version ready for Tag Manager.

TypeScript declarations:

```ts
// heilo.d.ts
type HeiloCommand =
  | 'open' | 'close' | 'toggle' | 'show' | 'hide' | 'isOpen'
  | 'identify' | 'setFields' | 'send' | 'on' | 'off' | 'setLanguage' | 'setCart' | 'reset';

declare global {
  interface Window {
    Heilo?: ((command: HeiloCommand, ...args: any[]) => any) & { q?: IArguments[] };
    HeiloSettings?: { websiteId?: string; identityToken?: string; language?: string; apiBase?: string };
  }
}

export {};
```

### Single-page apps

- Load the widget once, when the app starts. It stays on the page through client-side navigation and notices route changes by itself (it checks the URL and the title every 1.5 seconds), so your team sees which pages the visitor is on. Nothing to call.
- There is no command to remove the widget. Hide it with `Heilo('hide')` where you don't want it (section 5). If your framework replaces the whole `<body>` on navigation, the widget goes with it until the next full page load.
- Sign-in and sign-out without a page load: `Heilo('identify', { token })` and `Heilo('reset')` (section 3). Language switch: `Heilo('setLanguage', code)`.

### Content-Security-Policy

If your site sends a `Content-Security-Policy` header, add:

| Directive | Source | For |
| --- | --- | --- |
| `script-src` | `https://app.heilo.chat` | `widget.js` |
| `connect-src` | `https://app.heilo.chat wss://heilo.chat` | the chat session, file uploads and the live connection (WebSocket, with HTTP long polling as a fallback) |
| `img-src` | `https://app.heilo.chat` | photos and files in the chat, your logo and button image |
| `img-src` | `https://*.googleusercontent.com` | your teammates' profile photos (from their Google accounts) |
| `img-src` | `blob:` | optional: previews of files the visitor attaches (without it, the preview appears once the upload is done) |
| `style-src` | `'unsafe-inline'` | the widget's own `<style>` element in its shadow root; without it the widget has no styles |

### Allowed domains

Websites & widget → your website → General → **Only allow the widget on these domains**. Empty means any domain. Otherwise the page's host must match a rule: `example.com` (that host only, not `www.example.com`), `*.example.com` (the domain and all its subdomains), or a host with a port for local testing, such as `localhost:5173`. On other domains the widget doesn't start, and the console shows `[Heilo] Could not start the widget: This domain isn't allowed to use this widget`.

## 2. Commands

Call every command as `Heilo('command', …arguments)`. That form works before and after `widget.js` has loaded. Once it has loaded, the commands are also methods (`Heilo.open()`, `Heilo.identify({ … })`), but the stub doesn't have them.

| Command | Arguments | Returns | What it does |
| --- | --- | --- | --- |
| `open` | none | nothing | Opens the chat window, and shows a hidden widget. |
| `close` | none | nothing | Minimizes the window to the button. |
| `toggle` | none | nothing | Opens a closed window, closes an open one. |
| `show` | none | nothing | Brings the widget back after `hide`. |
| `hide` | none | nothing | Takes the whole widget off the screen; the chat stays connected. |
| `isOpen` | none | `true` or `false` | Whether the window is open. |
| `identify` | `{ name, email, phone, fields, token }`, all optional | a promise of the visitor | Attaches the visitor's details, or signs in a customer with `token`. |
| `setFields` | `{ key: value, … }` | a promise of `{ saved, rejected }` | Fills the contact's custom fields. |
| `send` | `text` | nothing | Sends a message as the visitor. |
| `on` | `event`, `callback` | nothing | Listens to `ready`, `open`, `close`, `message` or `rated`. |
| `off` | `event`, `callback` | nothing | Removes that listener. |
| `setLanguage` | `code` or `null` | nothing | Your page's language, for the widget's own texts. |
| `setCart` | `{ items, total, currency }` | nothing | A WooCommerce cart for your team (Heilo WordPress plugin). |
| `reset` | none | nothing | Forgets this browser's chat and reloads the page. |

Any other name logs `[Heilo] Unknown command "name"` and does nothing.

### When commands run

- **Before the script has loaded** (the stub): every call is queued and returns `undefined`. Once the chat is ready, the queue runs in order; results are dropped and errors only show in the console. So `Heilo('identify', …).then(…)` throws a `TypeError` at that point. Use `await` inside `try` (awaiting `undefined` is fine), or make the call from a `ready` listener when you need its result.
- **After the script has loaded, before the chat is ready** (its first request is still running): `open`, `close`, `toggle`, `send`, `identify` and `setFields` wait and run in order once it is ready, and return a promise that settles then. The other commands act at once.
- **If the widget can't start** (no website id, domain not allowed, website paused, network error): the console shows one `[Heilo]` warning with the reason (section 6), the widget stays off the page and `ready` never fires. The calls that were waiting reject, and so does every later `open`, `close`, `toggle`, `send`, `identify` or `setFields`, with `err.code` `widget_unavailable` and the reason as `err.message`. Ignoring those promises is fine: they don't add unhandled-rejection errors to the console. Calls queued by the stub never run. Don't make your own interface wait on `ready`.

### `open`, `close`, `toggle`

- `open` opens the chat window, shows the widget if it was hidden with `hide`, clears the unread badge and marks your team's messages as read. It also moves the keyboard focus into the chat (on touch screens without popping up the keyboard), so open the chat from a visitor's action, such as a click, rather than on page load.
- `close` minimizes the window to the button. The button, the minimize button in the chat header and `Escape` inside the chat do the same.
- `toggle` opens a closed window and closes an open one.
- The open or closed state is kept for the browser tab: a chat left open is open again on the next page, without a new `open` event. Read it with `Heilo('isOpen')` once `widget.js` has loaded (the stub returns `undefined`).

### `show` and `hide`

`hide` takes the whole widget, button and window, off the screen; `show` brings it back as it was. That is not the same as `close` and `open`:

- A hidden widget stays connected: your team's messages still arrive and `message` events still fire, but the visitor sees no button, badge or preview. Show it again when a reply comes in (section 5).
- `open` also shows a hidden widget.
- `hide` isn't remembered: call it on every page load where the widget should stay hidden.
- When the server turns the chat off while the page is open (the website got paused, or its allowed domains no longer include this page), the widget hides itself and the console says `[Heilo] The chat is off: … (website_paused)` or `(origin_not_allowed)`. `show`, `open` and `send` can't bring it back: they are ignored, with `[Heilo] Heilo('show') was ignored. The chat is off: …` in the console once. The next page load starts it again if the chat is on by then.
- To hide only the button on phones, use the setting instead: Websites & widget → your website → Appearance → **Hide the chat button on phones**. The chat still opens with `Heilo('open')` from your own button.

### `identify`

```js
Heilo('identify', { name: 'Lan Đoàn', email: 'lan@example.com', phone: '+84 901 234 567' });
```

Attaches what you know about the visitor to their contact in Heilo: `name` (up to 120 characters), `email` (a valid address), `phone` (up to 40 characters) and `fields` (contact custom fields, section 4). Your team sees them in the Inbox right away, and sending a name, email or phone fires the `visitor.identified` webhook. With an email, Heilo can email your team's replies to a visitor who has left the page.

- These details come from the visitor's browser, so anyone can put anything there: the Inbox marks them **Not verified**. For customers signed in to your app, use an identity token (section 3).
- Empty values are left out: `identify` never clears a detail. Call it again with a new value to change one.
- What you send shows up in the chat right away: the pre-chat form stops asking for those details (what the visitor already typed in the other boxes stays), and the email card disappears once there is an email.
- For a customer verified with an identity token, the email can't be set or changed from the page: a different `email` rejects with `identity_locked`. Name, phone and fields can still change.
- `token` signs in a customer (section 3). A token and details can go in the same call: the token is used first, then the details are saved.

It returns a promise of the visitor: `{ id, name, email, phone }`, plus `fields` (the answers to your pre-chat form's own fields) when you sent `fields`. With only a token: `{ id, name, email, phone, verified, fields, returning }`. It rejects with an `Error` whose `code` is:

| `err.code` | When |
| --- | --- |
| `validation_error` | an invalid email, a name or phone that is too long, or a custom field value of the wrong type; nothing from the call is saved |
| `identity_locked` | a verified customer's email, set or changed from the page |
| `identity_invalid`, `identity_expired` | the token was refused (section 3) |
| `widget_unavailable` | the widget couldn't start on this page (see When commands run) |
| `undefined` | a network error, or too many requests from this address (wait a little and try again) |

`err.message` is written for people: in Vietnamese when the widget shows Vietnamese, otherwise in English (Heilo's server messages exist in those two languages). Check `err.code` in your code, not the message.

### `setFields`

```js
Heilo('setFields', { plan: 'pro', order_count: 3, signup_date: '2026-11-04' });
```

Fills the contact's custom fields by key; `null` or `''` clears a value this visitor set. It returns a promise of `{ saved, rejected }`. Values, limits and codes are in section 4.

### `send`

```js
Heilo('send', 'Hi, I have a question about invoice #1042');
```

Sends `text` as the visitor, exactly as if they had typed it: it starts or continues their conversation and notifies your team. The text is trimmed and cut at 5000 characters; empty text is ignored. It doesn't open the window. A message that fails shows **Not sent** with a retry link in the chat; the command itself returns nothing. Up to 8 messages per 10 seconds per visitor.

- Send from a visitor's action, never on page load: every message is a real conversation in your Inbox.
- **Pre-chat lead form with required fields** (Websites & widget → your website → Hours & lead form): while the visitor hasn't given a required detail, the message waits. The chat shows the form, and the message goes out as soon as the visitor sends it. Open the window too, so they see the form: `Heilo('open'); Heilo('send', '…')`. A waiting message is dropped if the visitor leaves the page first. Details you already have can go in first with `identify` (or an identity token), then the form doesn't ask for them.
- A pre-chat form without required fields doesn't hold messages: `send` skips it, and since the form is only asked before the visitor's first message, it isn't shown after that.
- Heilo checks the required fields on its server too: a first message from the widget without them is refused, so a modified page can't skip the form. This only applies to the widget. Your own chat UI (Chat Client API) and Messenger / Instagram have no pre-chat form.

### `on` and `off`

```js
const onMessage = (message) => console.log(message.agent?.name, message.body);
Heilo('on', 'message', onMessage);
Heilo('off', 'message', onMessage); // the same function
```

| Event | Payload | Fires |
| --- | --- | --- |
| `ready` | `{ visitorId, verified }` | once per page load, when the widget is on the page and the queued commands have run |
| `open` | none | on every open: the button, the new message preview, `Heilo('open')`, `Heilo('toggle')` |
| `close` | none | on every close: the button, the minimize button, `Escape`, `Heilo('close')`, `Heilo('toggle')` |
| `message` | a message (below) | a new message from your team or a bot while the page is open |
| `rated` | `{ conversationId, score, comment }` | the visitor rated a closed chat in the widget, or changed their rating (`score`: `good` or `bad`, `comment` can be `null`) |

- `ready`: `verified` is `true` for a customer signed in with an identity token. Add the listener before the chat is ready (through the stub is fine): a listener added after that never gets it.
- `open` and `close` fire on every call, even when the window was already open or closed.
- `message` doesn't fire for the visitor's own messages, or for earlier messages loaded with the chat.
- Messages that arrived while the connection was down fire `message` when the widget reconnects, once each, oldest first.
- `rated` fires in the tab where the visitor tapped, after Heilo saved it. Use it for your own analytics, for example `Heilo('on', 'rated', ({ score }) => gtag('event', 'chat_rated', { score }))`.
- An error thrown in your listener is caught and logged with `[Heilo]`; the widget keeps working.

The `message` payload:

```json
{
  "id": "msg_…",
  "from": "agent",
  "type": "text",
  "body": "Hi Lan, the PDF export is under Settings → Data.",
  "attachments": [{ "id": "…", "url": "https://app.heilo.chat/uploads/…/export-guide.pdf", "name": "export-guide.pdf", "mime": "application/pdf", "size": 48213 }],
  "createdAt": 1762160000000,
  "agent": { "name": "Minh", "avatarUrl": "https://…" }
}
```

- `from`: `agent` (a teammate) or `bot` (keyword replies, the away message, the AI assistant).
- `type`: `text`, or `product` / `order` for WooCommerce cards, with a `product` / `order` object.
- `body` is empty when the message is only files or a card.
- `attachments[].url` is a full link to the file on the Heilo server the widget was loaded from, ready for an `<a href>` or `<img src>`, like the Chat Client API's. Images also have `width` and `height` when known.
- `agent` is `{ name, avatarUrl }` for a teammate (`avatarUrl` can be `null`), and `null` for bot messages.
- `createdAt` is in Unix epoch milliseconds.

### `setLanguage`

```js
Heilo('setLanguage', 'vi');
```

Your page's language, for the widget's built-in texts (message box, status line, buttons, pre-chat form). It applies while the website's widget language is **Auto** (Websites & widget → your website → Appearance → Widget language); a fixed language there always wins.

- With Auto, the widget uses your page's language first, then the browser's languages, then English.
- Supported: `en`, `vi`, `es`, `fr`, `de`, `pt`, `it`, `nl`, `id`, `th`, `ja`, `ko`, `zh`. A region is ignored (`pt-BR` is `pt`). An unsupported code falls through to the browser's languages.
- `Heilo('setLanguage', null)` goes back to the browser's languages.
- Your title, welcome message and quick replies are shown as you wrote them.
- At load, `data-language="vi"` on the script tag or `HeiloSettings.language` does the same.

### `reset`

```js
Heilo('reset');
```

Forgets this browser's chat session for your website and reloads the page, so the widget starts a new anonymous chat. It also forgets that the pre-chat form was answered and whether the window was open in this tab, so the next person gets the form again and a closed window. The conversation itself stays in your Inbox. Use it when a customer signs out (section 3).

### `setCart`

```js
Heilo('setCart', {
  items: [{ productId: '42', title: 'Notebook A5', quantity: 2, price: 120000, image: 'https://…', url: 'https://…' }],
  total: 240000,
  currency: 'VND',
});
```

The Heilo WordPress plugin calls this on WooCommerce sites, so your team sees the visitor's live cart in the Inbox's WooCommerce panel. That panel needs a WooCommerce site connected under Websites & widget → your website → WordPress; other web apps don't need `setCart`.

- `items` must be an array, or the call is ignored. Up to 100 items, each with `title` (up to 300 characters) and `quantity` (a whole number), and optionally `productId`, `price`, `image` and `url` (http or https links).
- A cart that doesn't fit these rules is dropped without an error. It is display only and never trusted.
- The last cart is sent again each time the widget reconnects.

## 3. Signed-in customers

`Heilo('identify', { name, email })` runs in the visitor's browser, so anyone can type any name or email there. For customers signed in to your web app, let your server vouch for them: it signs a short identity token with your website's identity secret, and the widget hands it to Heilo. Then:

- the Inbox shows the customer as **Verified**, with your user id;
- they find the same conversation on every device where they are signed in, and in your own chat UI if you also use the Chat UI SDK;
- their email can't be changed from the page;
- after they sign out, the next person on that computer doesn't see their chat.

Included in every plan, Free too. The Chat UI SDK uses the same secret and tokens; the full rules are in [Security model](https://app.heilo.chat/docs/client-api#1-security-model) and [Signing identity tokens](https://app.heilo.chat/docs/client-api#2-signing-identity-tokens-on-your-server).

### Step 1: create the identity secret

Websites & widget → your website → Install → **Verify signed-in customers** → **Create secret**. Keep it on your server only (an environment variable such as `HEILO_IDENTITY_SECRET`), never in the page or your app bundle. **Create a new secret** replaces it; the old one keeps working for 24 hours while you deploy the new one.

### Step 2: sign a token on your server

Node.js (`npm install jsonwebtoken`):

```js
import jwt from 'jsonwebtoken';

// Only for a signed-in customer. The secret never reaches the browser.
export function heiloIdentityToken(user) {
  return jwt.sign(
    {
      sub: String(user.id), // required: your user id
      email: user.email, // optional
      name: user.name, // optional
      phone: user.phone, // optional
      fields: { plan: user.plan }, // optional: custom fields the customer can't change (section 4)
    },
    process.env.HEILO_IDENTITY_SECRET,
    { algorithm: 'HS256', expiresIn: '1h' }, // at most 24 hours
  );
}

// single-page apps fetch it after sign-in (requireLogin = your own auth middleware)
app.get('/api/heilo-token', requireLogin, (req, res) => {
  res.json({ token: heiloIdentityToken(req.user) });
});
```

| Claim | |
| --- | --- |
| `sub` | Required. Your user id (a string or a number), up to 128 characters. The same `sub` is the same customer on every device. |
| `exp` | Required, at most 24 hours ahead (`expiresIn` sets it). |
| `email` | Optional. The customer's email in Heilo, locked in the chat. |
| `name` | Optional, up to 120 characters. |
| `phone` | Optional, up to 40 characters. |
| `fields` | Optional. Contact custom fields `{ key: value }` the customer can't change (section 4). |

- HS256 only. A token signed with another algorithm or a wrong secret, without `exp`, with an `exp` more than 24 hours away or without `sub` is refused, and so is every token while the website has no identity secret yet.
- An `email`, `name` or `phone` that doesn't fit (an invalid email, a name over 120 characters) is ignored; the rest of the token still counts. Other claims are ignored.
- The token is checked when the chat starts on a page (or when you pass it to `identify`), not afterwards, so one hour suits a page. Sign a new one for each page you render.
- Never let a shared cache (a CDN, a page cache) serve a page that carries a token: whoever gets that page can open that customer's chat until the token expires.

### Step 3: give the token to the widget

On every page load while the customer is signed in, in one of three ways:

```html
<!-- server-rendered pages: only when the customer is signed in -->
<script>
  window.Heilo = window.Heilo || function () { (Heilo.q = Heilo.q || []).push(arguments) };
</script>
<script async src="https://app.heilo.chat/widget.js" data-website-id="YOUR_WEBSITE_ID"
  data-identity-token="TOKEN_FROM_YOUR_SERVER"></script>
```

```js
// or before widget.js runs
window.HeiloSettings = { identityToken: tokenFromYourServer };

// or the command: queued before widget.js has loaded, it is used when the chat starts;
// later (e.g. right after sign-in in a single-page app) it switches the chat to that customer
Heilo('identify', { token: tokenFromYourServer });
```

- A page load without a token never reopens a verified customer's chat: the widget starts an anonymous chat instead. So pass the token on every page, not only right after sign-in.
- Hand over the token before the widget starts when you can (the attribute, `HeiloSettings`, or `identify` queued before `widget.js` loads). A token passed later still works, but the widget has then started an anonymous chat first and switches.
- `HeiloSettings.identityToken` wins over `data-identity-token`, and a queued `Heilo('identify', { token })` wins over both.

A single-page app, with the `heilo.js` helper from section 1:

```js
import { heilo, loadHeilo } from './heilo.js';

async function heiloToken() {
  const res = await fetch('/api/heilo-token');
  return res.ok ? (await res.json()).token : null; // null when signed out
}

// app start: the token first (queued for the chat's start), then the widget
export async function startChat() {
  const token = await heiloToken();
  if (token) heilo('identify', { token });
  loadHeilo({ websiteId: 'YOUR_WEBSITE_ID' });
}

// right after the customer signs in, without a page load
export async function onSignedIn() {
  try {
    await heilo('identify', { token: await heiloToken() });
  } catch (err) {
    console.warn('Heilo refused the identity token:', err.code); // identity_invalid or identity_expired
  }
}

// sign-out: your own sign-out first, then forget the chat (this reloads the page)
export async function onSignOut() {
  await fetch('/api/logout', { method: 'POST' });
  heilo('reset');
}
```

### What happens

- **Sign-in**: Heilo finds the customer by `sub`. If they have chatted on this website before, on any device, the widget loads that conversation. If not, the anonymous chat in this browser becomes theirs.
- **Email**: put the email in the token. From the page, a verified customer's email can't be set or changed (`409 identity_locked`). Name, phone and fields can still be sent with `identify` and `setFields`, and fields from the page never overwrite the token's (section 4).
- **Refused token at page load** (attribute, `HeiloSettings` or a queued `identify`): the console shows `[Heilo] The identity token was refused (identity_expired): … The chat continues as an anonymous visitor.` and the chat works as an anonymous visitor.
- **Refused token later**: `Heilo('identify', { token })` rejects with `err.code` `identity_invalid` or `identity_expired`, and the current chat stays as it is.
- **Tabs that stay open for days**: a signed-in customer's chat session lasts 7 days. After that the widget gets a new session by itself (on the next request or reconnect): with the identity token it was given while that token is still valid, otherwise the chat continues as a new anonymous visitor and the console shows `[Heilo] The chat session ended and the identity token was refused (identity_expired): … The chat continues as an anonymous visitor.` A single-page app that keeps customers signed in for longer should pass a fresh token again with `Heilo('identify', { token })` from time to time, for example when it renews its own session.

### Sign-out

Call `Heilo('reset')` once your own sign-out is done. It forgets this browser's chat and reloads the page, and the widget starts a new anonymous chat. The customer finds their conversation again the next time they sign in.

- Make it the last step: the page reloads right away.
- Stop passing the token first: if the reloaded page still carries one, the customer is signed in again.
- On a site with full page loads, a verified chat is already safe without `reset` (the next page has no token, so the chat isn't reopened). A single-page app needs `reset`, because nothing reloads by itself.
- Sites that only use plain `identify` (no tokens) should call `reset` on sign-out too. Otherwise the next person on that browser continues the same anonymous chat, with the details you attached.

## 4. Contact custom fields

Push what you know about the visitor (plan, number of orders, sign-up date) into their contact in Heilo. Your team sees the values in the Inbox and on the Contacts page, and webhooks and the REST API carry them. Custom fields are on Pro, Business and the free trial; on other plans every key is skipped with `upgrade_required` (a name, email or phone sent with `identify` is still saved).

Create the fields first under Contacts → Custom fields. Each has a **key** (for example `order_count`) and a type. Then send values by key:

```js
// anywhere, also before widget.js has loaded
Heilo('setFields', { plan: 'pro', order_count: 3, signup_date: '2026-11-04' });

// together with the visitor's details
Heilo('identify', { name: 'Lan Đoàn', email: 'lan@example.com', fields: { plan: 'pro' } });

// when you need the result: after widget.js has loaded, e.g. from a ready listener
Heilo('on', 'ready', async () => {
  const { saved, rejected } = await Heilo('setFields', { plan: 'pro' });
  if (rejected.length) console.warn('Not saved:', rejected);
});
```

### Values

| Field type | Send |
| --- | --- |
| Text | a string, up to 1000 characters (`true` and `false` are stored as text) |
| Number | a number or a numeric string, such as `3` or `"12,5"` |
| Date | `"YYYY-MM-DD"`; an ISO date-time or a JavaScript `Date` keeps its date part |
| Dropdown | exactly one of the field's options |

- With `setFields`, `null`, `''` or `undefined` clears a value this visitor set. `identify` leaves empty values out and never clears.
- A JavaScript `Date` is sent as its UTC date-time, so the stored date is the UTC date. For a calendar date such as a birthday, send the `"YYYY-MM-DD"` string.
- Up to 50 keys per call, keys up to 40 characters. Values are strings, numbers, `true` / `false` or `null`; anything else (an object, an array) makes `setFields` reject with `validation_error`, and `identify` leaves it out.

### Result and rejected keys

`setFields` resolves to `{ saved, rejected }`:

- `saved`: the keys that now hold the value you sent (also when it was already stored).
- `rejected`: `[{ key, code }]`, the keys Heilo skipped. The widget also writes them to the console: `[Heilo] Some custom fields were not saved: plan (locked)`.

| `code` | Why |
| --- | --- |
| `unknown_field` | no custom field has that key (so a field you delete never breaks your page) |
| `locked` | your team, the REST API or an identity token set this value, and the page can't overwrite or clear it |
| `upgrade_required` | the plan has no custom fields |

A value of the wrong type (text in a number field, a date in another format, a value that isn't one of the dropdown's options) rejects the whole call with `err.code` `validation_error`, and nothing from it is saved, with `identify` not even the name or email. `identify` with `fields` resolves to the visitor, not to `{ saved, rejected }`: use `setFields` when you need to know what was saved.

### Trusted values

Heilo remembers who set each value. Values from the page (`setFields`, `identify`, the pre-chat form) are the visitor's: the Inbox and the contact profile mark them **Not verified**, because anyone can change those calls in the browser's developer tools, even in a verified session. They never overwrite or clear a value your team, the REST API or an identity token set. When a trusted source later sends the same value a visitor set, the value becomes trusted and locked.

For values you rely on (the customer's plan, a credit limit, a VIP tier), use the identity token's `fields` claim, signed on your server:

```js
jwt.sign(
  { sub: String(user.id), email: user.email, fields: { plan: user.plan, order_count: user.orders } },
  process.env.HEILO_IDENTITY_SECRET,
  { algorithm: 'HS256', expiresIn: '1h' },
);
```

- In a token, unknown keys and bad values are skipped (`unknown_field`, or `invalid_value` with the reason, in the console) and never fail the sign-in. A `fields` claim that isn't a flat `{ key: value }` object within the limits above is ignored as a whole.
- Only the answers to your pre-chat form's own fields come back to the page (`visitor.fields`). Other fields stay private to your team.
- The full rules, the sources and the webhook format: [Contact custom fields](https://app.heilo.chat/docs/client-api#8-contact-custom-fields). To set fields from your back office, use the [REST API](https://app.heilo.chat/docs/rest-api).

## 5. Recipes

### Open the chat from your own button

```html
<button type="button" id="chat-button">Chat with us</button>
<script>
  document.getElementById('chat-button').addEventListener('click', () => Heilo('open'));
</script>
```

With the stub in place this works before `widget.js` has loaded: the click is queued and the chat opens as soon as it is ready.

### Use only your own button

Keep Heilo's button out of sight, show the widget only while the chat is open, and bring the button back (with its badge and preview) when a reply comes in:

```js
Heilo('on', 'ready', () => {
  if (!Heilo('isOpen')) Heilo('hide'); // a chat left open on the previous page stays open
});
Heilo('on', 'close', () => Heilo('hide'));
Heilo('on', 'message', () => Heilo('show'));
document.getElementById('chat-button').addEventListener('click', () => Heilo('open'));
```

### Hide the chat on some pages

On a site with full page loads, call `Heilo('hide')` on those pages. In a single-page app, check the route on start and after every navigation (your router's route-change hook):

```js
const NO_CHAT = ['/editor', '/onboarding'];

function syncHeilo(pathname) {
  Heilo(NO_CHAT.some((path) => pathname.startsWith(path)) ? 'hide' : 'show');
}

syncHeilo(location.pathname);
```

### React to new messages

An unread count on your own button, for example:

```js
let unread = 0;
const badge = document.getElementById('chat-unread');

Heilo('on', 'message', (message) => {
  if (Heilo('isOpen') && !document.hidden) return; // the visitor is looking at the chat
  unread += 1;
  badge.textContent = String(unread);
  badge.hidden = false;
});

Heilo('on', 'open', () => {
  unread = 0;
  badge.hidden = true;
});
```

`message.body`, `message.from` and `message.agent?.name` are there if you want to show more (the format is under `on` and `off` in section 2).

### Set the language from your app

```html
<script async src="https://app.heilo.chat/widget.js" data-website-id="YOUR_WEBSITE_ID" data-language="vi"></script>
```

```js
// when the visitor switches language in your app
Heilo('setLanguage', 'en');
```

This only changes the widget's texts while its language is Auto (Websites & widget → your website → Appearance).

## 6. Troubleshooting

The widget writes to the browser console with the prefix `[Heilo]`:

| Console | Meaning |
| --- | --- |
| `[Heilo] Missing data-website-id on the <script> tag` | No website id: add `data-website-id` (or `HeiloSettings.websiteId`). |
| `[Heilo] Could not start the widget: This domain isn't allowed to use this widget` | The page's domain isn't in the website's allowed domains (section 1). |
| `[Heilo] Could not start the widget: Website not found` | The website id is wrong, or the website was deleted. |
| `[Heilo] The chat is off: this website is paused because the workspace is above its plan’s website limit (website_paused).` | The website is paused (the Install tab says so). The widget stays hidden until you upgrade or remove another website. |
| `[Heilo] The chat is off: this domain isn't allowed to use this widget (origin_not_allowed).` | The allowed domains changed while the page was open (section 1). |
| `[Heilo] Heilo('open') was ignored. The chat is off: …` | `open`, `show` or `send` while the chat is off (section 2, `show` and `hide`). |
| `[Heilo] The identity token was refused (identity_expired): …` | An expired or invalid token (section 3); the chat continues as an anonymous visitor. |
| `[Heilo] The chat session ended and the identity token was refused (identity_expired): …` | A signed-in customer's 7-day chat session ran out in a tab that stayed open, and the page's token had expired too (section 3). |
| `[Heilo] Some custom fields were not saved: plan (unknown_field)` | Keys Heilo skipped, with the reason (section 4). |
| `[Heilo] Unknown command "opne"` | A typo in a command name. |
| `[Heilo]` and an error | Your own `on` listener threw; the widget keeps working. |

No button and no `[Heilo]` message:

- `Heilo('hide')` ran, or **Hide the chat button on phones** is on and you are on a phone.
- Your Content-Security-Policy blocks the widget: the console shows CSP errors instead (section 1).
- An old copy of the page is cached: clear your site's cache (caching plugins, a CDN) and reload.

## 7. Related

- [Chat Client API and Chat UI SDK](https://app.heilo.chat/docs/client-api): your own chat interface in a web app, iOS or Android app, with the same identity tokens.
- [REST API](https://app.heilo.chat/docs/rest-api): read conversations, reply as your team and update contacts and their custom fields from your own systems.
- [Webhooks](https://app.heilo.chat/docs/webhooks): events such as `conversation.created`, `message.created`, `visitor.identified` and `contact.updated`, sent to your server.
