Heilo · Chat widget SDK

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.

  • 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 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

Attributes and window.HeiloSettings

Script attributeHeiloSettings keyWhat it does
data-website-idwebsiteIdYour website id. Required.
data-identity-tokenidentityTokenA signed-in customer's identity token (section 3).
data-languagelanguageYour page's language, used while the widget language is Auto (see setLanguage).
noneapiBaseThe 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>

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

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

Content-Security-Policy

If your site sends a Content-Security-Policy header, add:

DirectiveSourceFor
script-srchttps://app.heilo.chatwidget.js
connect-srchttps://app.heilo.chat wss://heilo.chatthe chat session, file uploads and the live connection (WebSocket, with HTTP long polling as a fallback)
img-srchttps://app.heilo.chatphotos and files in the chat, your logo and button image
img-srchttps://*.googleusercontent.comyour teammates' profile photos (from their Google accounts)
img-srcblob: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.

CommandArgumentsReturnsWhat it does
opennonenothingOpens the chat window, and shows a hidden widget.
closenonenothingMinimizes the window to the button.
togglenonenothingOpens a closed window, closes an open one.
shownonenothingBrings the widget back after hide.
hidenonenothingTakes the whole widget off the screen; the chat stays connected.
isOpennonetrue or falseWhether the window is open.
identify{ name, email, phone, fields, token }, all optionala promise of the visitorAttaches 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.
sendtextnothingSends a message as the visitor.
onevent, callbacknothingListens to ready, open, close, message or rated.
offevent, callbacknothingRemoves that listener.
setLanguagecode or nullnothingYour page's language, for the widget's own texts.
setCart{ items, total, currency }nothingA WooCommerce cart for your team (Heilo WordPress plugin).
resetnonenothingForgets this browser's chat and reloads the page.

Any other name logs [Heilo] Unknown command "name" and does nothing.

When commands run

open, close, toggle

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:

identify

js
Heilo('identify', { name: 'Lan Đoàn', email: '[email protected]', 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.

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.codeWhen
validation_erroran 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_lockeda verified customer's email, set or changed from the page
identity_invalid, identity_expiredthe token was refused (section 3)
widget_unavailablethe widget couldn't start on this page (see When commands run)
undefineda 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.

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
EventPayloadFires
ready{ visitorId, verified }once per page load, when the widget is on the page and the queued commands have run
opennoneon every open: the button, the new message preview, Heilo('open'), Heilo('toggle')
closenoneon every close: the button, the minimize button, Escape, Heilo('close'), Heilo('toggle')
messagea 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)

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://…" }
}

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.

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.

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:

Included in every plan, Free too. The Chat UI SDK uses the same secret and tokens; the full rules are in Security model and Signing identity tokens.

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
subRequired. Your user id (a string or a number), up to 128 characters. The same sub is the same customer on every device.
expRequired, at most 24 hours ahead (expiresIn sets it).
emailOptional. The customer's email in Heilo, locked in the chat.
nameOptional, up to 120 characters.
phoneOptional, up to 40 characters.
fieldsOptional. Contact custom fields { key: value } the customer can't change (section 4).

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 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-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.

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: '[email protected]', 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 typeSend
Texta string, up to 1000 characters (true and false are stored as text)
Numbera 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
Dropdownexactly one of the field's options

Result and rejected keys

setFields resolves to { saved, rejected }:

codeWhy
unknown_fieldno custom field has that key (so a field you delete never breaks your page)
lockedyour team, the REST API or an identity token set this value, and the page can't overwrite or clear it
upgrade_requiredthe 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' },
);

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]:

ConsoleMeaning
[Heilo] Missing data-website-id on the <script> tagNo website id: add data-website-id (or HeiloSettings.websiteId).
[Heilo] Could not start the widget: This domain isn't allowed to use this widgetThe page's domain isn't in the website's allowed domains (section 1).
[Heilo] Could not start the widget: Website not foundThe 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 errorYour own on listener threw; the widget keeps working.

No button and no [Heilo] message: