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 withasync. - 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):
<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
Heiloat once, so your code can callHeilo('…')beforewidget.jshas downloaded: the calls are queued and run in order once the chat is ready. Without the stub,Heilodoesn't exist untilwidget.jshas run. If you never callHeiloyourself, the second tag alone is enough. asynckeeps your page from waiting for the script. The widget waits for the DOM by itself, so the tags also work in<head>.- Load
widget.jsfrom 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.json 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: fixedwithz-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:
localStoragekeysheilo:YOUR_WEBSITE_ID(the visitor's chat session) andheilo:YOUR_WEBSITE_ID:lead(pre-chat form answered or skipped),sessionStoragekeysheilo:YOUR_WEBSITE_ID:openandheilo: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.ratedwebhook. 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. |
<script>
window.HeiloSettings = { websiteId: 'YOUR_WEBSITE_ID', language: 'vi' };
</script>
<script async src="https://app.heilo.chat/widget.js"></script>window.HeiloSettingsmust be set beforewidget.jsruns, 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.jsloads,Heilo('init', { websiteId: 'YOUR_WEBSITE_ID' })also sets the website id. It only works from the queue: oncewidget.jshas loaded,initis 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:
// 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);
}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.Heiloat call time (as the helper does), don't keep a reference to it: oncewidget.jshas loaded,window.Heilois 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 andsetAttribute('data-website-id', …)as above. The Install tab has this version ready for Tag Manager.
TypeScript declarations:
// 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 })andHeilo('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. SoHeilo('identify', …).then(…)throws aTypeErrorat that point. Useawaitinsidetry(awaitingundefinedis fine), or make the call from areadylistener 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,identifyandsetFieldswait 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 andreadynever fires. The calls that were waiting reject, and so does every lateropen,close,toggle,send,identifyorsetFields, witherr.codewidget_unavailableand the reason aserr.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 onready.
open, close, toggle
openopens the chat window, shows the widget if it was hidden withhide, 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.closeminimizes the window to the button. The button, the minimize button in the chat header andEscapeinside the chat do the same.toggleopens 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
openevent. Read it withHeilo('isOpen')oncewidget.jshas loaded (the stub returnsundefined).
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
messageevents still fire, but the visitor sees no button, badge or preview. Show it again when a reply comes in (section 5). openalso shows a hidden widget.hideisn'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,openandsendcan'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
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.
- 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:
identifynever 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
emailrejects withidentity_locked. Name, phone and fields can still change. tokensigns 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
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
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 withidentify(or an identity token), then the form doesn't ask for them. - A pre-chat form without required fields doesn't hold messages:
sendskips 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
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:verifiedistruefor 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.openandclosefire on every call, even when the window was already open or closed.messagedoesn'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
messagewhen the widget reconnects, once each, oldest first. ratedfires in the tab where the visitor tapped, after Heilo saved it. Use it for your own analytics, for exampleHeilo('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:
{
"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) orbot(keyword replies, the away message, the AI assistant).type:text, orproduct/orderfor WooCommerce cards, with aproduct/orderobject.bodyis empty when the message is only files or a card.attachments[].urlis 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 havewidthandheightwhen known.agentis{ name, avatarUrl }for a teammate (avatarUrlcan benull), andnullfor bot messages.createdAtis in Unix epoch milliseconds.
setLanguage
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-BRispt). 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 orHeiloSettings.languagedoes the same.
reset
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
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.
itemsmust be an array, or the call is ignored. Up to 100 items, each withtitle(up to 300 characters) andquantity(a whole number), and optionallyproductId,price,imageandurl(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 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):
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 anexpmore than 24 hours away or withoutsubis refused, and so is every token while the website has no identity secret yet. - An
email,nameorphonethat 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:
<!-- 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>// 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, oridentifyqueued beforewidget.jsloads). A token passed later still works, but the widget has then started an anonymous chat first and switches. HeiloSettings.identityTokenwins overdata-identity-token, and a queuedHeilo('identify', { token })wins over both.
A single-page app, with the heilo.js helper from section 1:
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 withidentifyandsetFields, and fields from the page never overwrite the token's (section 4). - Refused token at page load (attribute,
HeiloSettingsor a queuedidentify): 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 witherr.codeidentity_invalidoridentity_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 withHeilo('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 needsreset, because nothing reloads by itself. - Sites that only use plain
identify(no tokens) should callreseton 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:
// 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 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,''orundefinedclears a value this visitor set.identifyleaves empty values out and never clears. - A JavaScript
Dateis 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/falseornull; anything else (an object, an array) makessetFieldsreject withvalidation_error, andidentifyleaves 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:
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, orinvalid_valuewith the reason, in the console) and never fail the sign-in. Afieldsclaim 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. To set fields from your back office, use the REST API.
5. Recipes
Open the chat from your own button
<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:
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):
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:
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
<script async src="https://app.heilo.chat/widget.js" data-website-id="YOUR_WEBSITE_ID" data-language="vi"></script>// 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: your own chat interface in a web app, iOS or Android app, with the same identity tokens.
- REST API: read conversations, reply as your team and update contacts and their custom fields from your own systems.
- Webhooks: events such as
conversation.created,message.created,visitor.identifiedandcontact.updated, sent to your server.