Widget and identity
Live chat widget JavaScript API
Embed the chat on your site with one snippet, sign your users in with identity verification, and open, close and listen to the chat from your own code with window.Zono.
Script: https://zono.support/widget.js
Embed
Paste this before the closing </body> tag. Your widget token is on the live chat channel's settings page.
<script>
window.ZonoChat = { token: 'wgt_your_widget_token' };
</script>
<script src="https://zono.support/widget.js" async></script>window.ZonoChat options
| Option | Type | Description |
|---|---|---|
| token* | string | The live chat channel's public widget token (wgt_...), from Settings > Channels > Live chat. It is public: it only identifies which chat to open. Rotating it keeps the previous token working for 24 hours. |
| user | object | The logged-in visitor: { id, email, name, hash }. Leave it out for anonymous visitors, or pass it later with Zono.identify(). |
| user.id | string | number | Your own user id, sent as a string. externalId is accepted as an alias. When present, it is what the hash signs. |
| user.email | string | The user's email. Signed only when there is no id; with an id it is kept as an unverified claim. |
| user.name | string | Display name shown to your team. Never covered by the hash. |
| user.hash | string | Hex HMAC-SHA256 of the identifier, computed on your server with the channel's identity secret. Without it the identity is only a claim. |
| apiBase | string | Override the API origin. Defaults to the origin widget.js was loaded from. |
| locale | string | The widget's language as a BCP 47 code ('de', 'pt-BR'). Used when your team enabled that language under Settings > Brand > Text & languages; otherwise the widget follows the visitor's browser languages, then your default language. The copy you wrote yourself (greeting, offline message, pre-chat labels) is shown as written. For a verified visitor (user.hash) an offered language is also kept as the customer's language for the emails we send them. Change it later with Zono.setLocale(). |
| theme | 'auto' | 'light' | 'dark' | The widget's color theme. Overrides the Theme set on the live chat channel (Automatic by default). Automatic follows your page: a dark class (Tailwind) or data-theme="dark" on <html> or <body>, or color-scheme: dark, and otherwise the visitor's system setting, switching live when either changes. A data-theme attribute on the widget.js script tag works too. The widget renders in a Shadow DOM, so your page's CSS never changes it. |
Light and dark themes
The widget comes in light and dark, tinted with your channel's primary color. By default it matches your page: a dark class or data-theme="dark" on <html>, or color-scheme: dark, and otherwise the visitor's system setting, switching live when either changes. Pick a fixed theme for the channel in Settings > Channels > Live chat, or per page with theme or a data-theme attribute on the script tag. It renders in a Shadow DOM, so your site's CSS never restyles it.
<script>
// 'auto' (default) follows your page's dark mode, then the visitor's system setting.
window.ZonoChat = { token: 'wgt_your_widget_token', theme: 'dark' };
</script>
<!-- or: <script src="https://zono.support/widget.js" data-theme="dark" async></script> -->
<script src="https://zono.support/widget.js" async></script>Identity verification
Generate your identity secret under Settings > Customer sign-in > Identity verification (admins and owners). One workspace secret covers every live chat channel and, if you use it, customers signing in to the portal. It is shown once: keep it on your server and never ship it to the browser. Your server signs each logged-in user and passes the signature as user.hash. A channel's own identity secret (Settings > Channels > Live chat) still works as a fallback.
- hash = hex(HMAC-SHA256(secret, identifier)). The identifier is user.id as a string (trimmed) when you pass an id; otherwise the email, trimmed and lowercased. The same semantics as Intercom's user_hash.
- Sign exactly what you send: if the widget gets id 42, sign "42". Name and, when you sign an id, email are not covered by the hash.
- A hash is your server's assertion about who the visitor is, and Zono trusts it as far as it trusts the secret: whoever holds the secret can sign any identity. See the Customer identity guide for the trust model.
- A valid hash links the chat to that customer on every device, folds the visitor's anonymous chat on this browser into it, and always issues a fresh visitor token.
- A hash the server cannot verify is refused (403 identity-verification-failed): the widget logs a console warning and continues as an anonymous visitor.
- Without a hash, or before a secret exists, the id and email are unverified claims: they never link the visitor to an existing customer.
- Require verified identities (enforcement mode) ignores unsigned identity fields altogether.
- The workspace secret is checked first, then the live chat channel's own secret, so embeds signed with a channel secret keep working.
- Recommended: pass user.token instead of user.hash. It is an HS256 JWT with claims typ "identity", sub (the user id, or the lowercased email), optional email and name, iat, and exp at most 24 hours after iat. Add aud (this widget's token) to make it valid for this widget only. Unlike a hash, a token expires, so one copied out of a page stops working. Zono tolerates 60 seconds of clock difference; only alg HS256 is accepted.
- Sign the token with a key derived from your secret, never the secret itself: key = hex(SHA-256("zono-widget-identity-v1:" + secret)), the 64 lowercase hex characters used as the HMAC key. Metadata tokens use "zono-widget-metadata-v1:" instead, so neither kind of token can stand in for the other or for a user.hash.
- A token that is expired, lives longer than 24 hours, has another typ, names another aud, uses another algorithm or is signed with another key is refused (403 identity-verification-failed), like a bad hash. If you also send user.id, it must match sub. user.hash keeps working unchanged.
- Once a session's identity token expires (60 seconds of grace), the session is trusted with nothing until the page passes a fresh token: its widget calls answer 401 identity-expired. Call Zono('identify', { id, token }) with a new token before the old one runs out.
- Rotating the workspace secret keeps the previous one verifying for 24 hours, so you can update your servers without breaking chats. Revoke deletes every secret at once.
<script>
window.ZonoChat = {
token: 'wgt_your_widget_token',
user: {
id: 'usr_12345', // your user id (externalId is accepted as an alias)
email: '[email protected]',
name: 'Jane Doe',
hash: '<?= $userHash ?>' // hex HMAC-SHA256 computed on your server
}
};
</script>
<script src="https://zono.support/widget.js" async></script>Computing the hash on your server
function zonoWidgetHash(string $secret, ?string $userId, ?string $email = null): string
{
// The user id as a string, or the lowercased, trimmed email when there is no id.
$id = trim((string) $userId);
$identifier = $id !== '' ? $id : mb_strtolower(trim((string) $email));
return hash_hmac('sha256', $identifier, $secret);
}
// $userHash = zonoWidgetHash(getenv('ZONO_IDENTITY_SECRET'), (string) $user->id);Signing an expiring identity token (recommended)
<?php
// Identity token: an HS256 JWT that expires (at most 24 hours).
function zono_b64url(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
// Signed with a key derived from the secret, never the secret itself.
$key = hash('sha256', 'zono-widget-identity-v1:'.getenv('ZONO_IDENTITY_SECRET'));
$header = zono_b64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = zono_b64url(json_encode([
'typ' => 'identity',
'sub' => (string) $user->id, // or the lowercased email
'email' => $user->email,
'name' => $user->name,
'aud' => 'wgt_your_widget_token', // optional: this widget only
'iat' => time(),
'exp' => time() + 3600, // at most 24 hours after iat
]));
$signature = zono_b64url(hash_hmac('sha256', "$header.$payload", $key, true));
$userToken = "$header.$payload.$signature"; // pass as user.tokenThe same secret can sign customers in to your portal. The customer identity guide covers the trust model, portal tokens and the redirect contract.
JavaScript API
Once widget.js has loaded, window.Zono exposes these methods. It can also be called as Zono('open'), the same way as the pre-load queue.
Zono.open(): void
Opens the chat panel and starts the visitor's session. Calling it again while open does nothing. Called before the widget has loaded, it opens the panel as soon as it appears. With the launcher hidden (Settings > Channels > Live chat > Launcher), this is how your own button opens the chat.
document.querySelector('#help').addEventListener('click', () => Zono.open());Zono.close(): void
Closes the chat panel. The conversation carries on and new replies still arrive. Idempotent, and queued like open() before load.
Zono.close();Zono.identify(user: { id?, email?, name?, hash?, token? }): void
Signs the visitor in after the page loaded, for example after a login in a single-page app. Same fields and verification as window.ZonoChat.user: user.token (an expiring identity token, recommended) or user.hash is sent to the server and checked there. Call it again with a fresh token before the old one expires. Switching to another user drops the chat stored for the previous one and starts a fresh session; the same user keeps theirs; an anonymous visitor keeps their conversation, which moves into the verified account. An unverifiable hash logs a console warning and continues anonymously. A call without an object carrying an id or email (or with a non-string name or hash) logs a warning and is ignored.
Zono.identify({ id: 'usr_12345', email: '[email protected]', name: 'Jane Doe', hash: userHashFromYourServer });Zono.setAttributes(attributes: Record<string, string | number | boolean>): void
Attaches details about the visitor (their plan, account id, the page they came from) that your team sees with the chat. Merged with earlier calls and sent with the session, before or after it starts. Keys are 1 to 40 lowercase letters, digits and underscores; values are strings of at most 255 characters, numbers or booleans; at most 50 keys. Attributes are unverified: anyone can set them from the browser console, so they never identify the visitor or link them to a customer (use identify() with a hash for that). Invalid keys or values log a warning and are skipped. Your team sees the values on the ticket under "Sent by your website", marked as not verified, so never put secrets in them.
Zono.setAttributes({ plan: 'pro', seats: 12, trial: false });Zono.setMetadata(data: Record<string, string | number | boolean | null>, options?: { token?: string }): void
Attaches details about this chat (the order being discussed, the cart total) that your team sees under "From your app" on the conversation and, once it becomes a ticket, on the ticket. Applied to the visitor's open conversation, or held and applied to the next one they start in this session. Keys are 1 to 40 lowercase letters, digits and underscores; zono_ is reserved and keys naming passwords, secrets, tokens, cards, CVV, SSN or IBAN are refused. Values are strings of at most 255 characters, numbers or booleans; null removes a key. At most 30 keys and 4 KB per call; the latest write wins. Strings that look like a card number or an IBAN are stored as [removed]. Values set from the browser are unsigned. For signed values pass { token }: an HS256 JWT your server mints with the metadata key, hex(SHA-256("zono-widget-metadata-v1:" + identity secret)), claims typ "metadata", sub (the visitor's user id), data (the object to store), iat, exp (at most 10 minutes later), jti (unique, works once) and aud (this widget's token, required). It is honoured only for a visitor verified with an identity token (user.token) that has not expired and whose sub matches; anything else is refused (403) and nothing is stored. A value set from the browser never replaces or removes a signed one. The values are never sent to the AI agent. From your server you can also use the REST API (PATCH /api/v1/conversations/{id}/metadata).
Zono.setMetadata({ order_id: 'A-1042', cart_total: 59.9 });
Zono.setMetadata({}, { token: metadataTokenFromYourServer });Zono.startConversation(text: string): void
Opens the panel and sends text (trimmed, 1 to 5,000 characters) as the visitor's message, starting a conversation when none is open. Useful for "Ask about this plan" buttons. Queued like open() when called before the widget has loaded. A message started this way skips the pre-chat form.
document.querySelector('#ask-pricing').addEventListener('click', () => Zono.startConversation('I have a question about the Pro plan.'));Zono.showArticle(idOrSlug: string): boolean
Opens the panel on a help article, read inside the widget. Takes the article id or its slug. Needs the help centre in the widget (Settings > Channels > Live chat > Help); without it the call logs a warning, returns false and does nothing. Queued like open() when called before the widget has loaded.
document.querySelector('#refund-help').addEventListener('click', () => Zono.showArticle('how-refunds-work'));Zono.search(query: string): boolean
Opens the panel on the results of a help search for query (1 to 200 characters). Same requirements as showArticle().
Zono.search('refund');Zono.showSpace(name: 'home' | 'messages' | 'help'): boolean
Opens the panel on one of the widget's tabs. Returns false (and warns) for another name, or for a tab your team turned off.
Zono.showSpace('help');Zono.showConversation(conversationId?: string): boolean
Opens the panel on the visitor's current conversation. With the id of an earlier conversation it opens that transcript instead.
document.querySelector('#open-chat').addEventListener('click', () => Zono.showConversation());Zono.newMessage(prefill?: string): void
Opens the conversation with prefill (up to 5,000 characters) in the message box. Nothing is sent: the visitor reads it, edits it and presses send. While the team is away and the offline form shows, the text goes into the form's message box. Unlike startConversation(), the pre-chat form still applies.
Zono.newMessage('I have a question about order #1042: ');Zono.suggest(articles: Array<{ title: string, id?: string, slug?: string, url?: string }>): void
Shows up to five articles you pick, as "These might help" at the end of the Home tab. An entry with an id or slug opens in the widget's reader; an entry with only a url opens that page in a new tab. Entries with nothing to open are skipped. An empty list clears them. They last until the page reloads.
Zono.suggest([{ id: '01J8Z3X9', title: 'How refunds work' }, { title: 'Pricing', url: 'https://example.com/pricing' }]);Zono.setHelpContext(context: { articles?: string[], collection?: string, title?: string }): void
Picks the articles the Help tab shows under "Suggested for this page", for what the visitor is doing right now. articles is up to six article ids or slugs, collection a collection id, title a heading of up to 60 characters. It comes before the page rules set in the dashboard and lasts until clearHelpContext() or a page reload. Search still covers every article. Invalid input logs a warning and is ignored.
Zono.setHelpContext({ articles: ['how-refunds-work', 'change-your-plan'], title: 'Billing help' });Zono.clearHelpContext(): void
Removes what setHelpContext() set. The Help tab goes back to the page rules, or its usual articles.
Zono.clearHelpContext();Zono.toggle(): boolean
Opens the panel when it is closed and closes it when it is open. Returns whether the panel is now open (the state it is heading to when the widget has not loaded yet).
document.querySelector('#chat-button').addEventListener('click', () => Zono.toggle());Zono.unreadCount(): number
How many messages from your team or the AI agent arrived while the panel was closed. It resets to 0 when the panel opens. Listen to the unread:changed event to follow it.
badge.textContent = String(Zono.unreadCount());Zono.setLocale(code: string): void
Switches the widget's language, for example when your site's language picker changes. Same rules as the locale option: the language must be one your team enabled, otherwise the widget keeps the best match. Queued like open() when called before the widget has loaded. A call without a language code logs a warning and is ignored.
document.querySelector('#language').addEventListener('change', (event) => Zono.setLocale(event.target.value));Zono.on(event: 'open' | 'close' | 'message' | WidgetEvent, callback): () => void
Listens to the widget. 'open' and 'close' fire whenever the panel opens or closes (the launcher, Zono.open() / close(), auto-open), with no argument. 'message' is the same as onMessage(): the callback receives the message object below. The other events are listed under Events below and pass a small payload. Returns a function that unsubscribes. A callback that throws is logged and never breaks the widget.
const off = Zono.on('open', () => analytics.track('Chat opened'));
Zono.on('message', (message) => console.log(message.body));
Zono.on('unread:changed', ({ count }) => updateBadge(count));
// Later
off();Zono.onMessage(callback: (message) => void): () => void
Calls back for every new message from your team or the AI agent, never for the visitor's own messages or for history restored when the page loads. Returns a function that unsubscribes. A callback that throws is logged to the console and never breaks the widget. The same as Zono.on('message', callback).
const unsubscribe = Zono.onMessage((message) => {
if (message.author === 'agent') showUnreadBadge();
});
// Later
unsubscribe();The message your callback receives
| id | string | The message id. |
| body | string | The message as plain text (HTML removed). |
| author | 'agent' | 'ai' | 'agent' for a teammate, 'ai' for the AI agent. |
| created_at | string | null | ISO-8601 timestamp. |
| conversation_id | string | null | The conversation the message belongs to. |
Zono.logout(): void
Forgets this browser's chat session: call it when your user signs out so the next person on a shared device starts fresh. The widget stays on the page as an anonymous chat. window.ZonoChatWidget.logout() is kept as an alias.
Zono.logout();Events
Besides open, close and message, Zono.on(name, callback) delivers these events. The callback receives the payload shown.
| unread:changed | { count: number } | The unread count moved: a new message arrived while the panel was closed, or opening the panel cleared it. |
| conversation:started | { conversation_id: string } | The visitor's first message of a new conversation was accepted. |
| rating | { rating: number } | The visitor rated a finished conversation. |
| identity:expired | {} | The identity token the session was verified with has expired. Mint a fresh one on your server and call Zono('identify', { id, token }). |
| handoff | {} | The visitor asked the AI agent for a teammate, and the AI agent stepped out. Never fires in AI agent only mode, where the AI agent answers alone and offers the visitor an email form or help articles instead. |
| article:viewed | { id: string, title: string, source?: 'page_rule' | 'sdk_context' } | The visitor opened a help article in the widget. source says when it came from "Suggested for this page": a page rule or setHelpContext(). |
| search | { query: string, results: number } | The visitor searched the help centre in the widget. results is how many articles matched. |
| suggestion:clicked | { article_id: string, position: number, set_id: string } | The visitor opened an article from the "These might help" suggestions shown while they type. |
| space:changed | { space: string } | The visitor switched tab: 'home', 'messages' or 'help'. |
Open an article from a link
Add data-zono-article with an article id or slug to any link or button on your page. A click opens that article in the widget. Links added later work too, and a new-tab click (Ctrl or Cmd) behaves as usual.
<!-- Opens the article in the widget. Without the widget, the link works as a normal link. -->
<a href="https://help.example.com/articles/how-refunds-work" data-zono-article="how-refunds-work">How refunds work</a>Calling Zono before the script loads
widget.js loads asynchronously. Add the one-line stub first and call Zono('method', ...args) right away: the calls are queued and replayed in order once the widget has loaded.
<script>
window.Zono=window.Zono||function(){(window.Zono.q=window.Zono.q||[]).push(arguments)};
window.ZonoChat = { token: 'wgt_your_widget_token' };
// Safe before widget.js has loaded: replayed in order once it has.
Zono('identify', { id: 'usr_12345', email: '[email protected]', hash: 'USER_HASH' });
Zono('open');
</script>
<script src="https://zono.support/widget.js" async></script>Logout on sign-out
The browser keeps a visitor token so a returning visitor sees their chat. Call Zono.logout() when your user signs out, so the next person on a shared device starts a fresh, anonymous chat.
async function signOut() {
await fetch('/logout', { method: 'POST' });
// Forget this browser's chat so the next person starts fresh.
window.Zono?.logout();
}Custom CSS and behaviour rules
Stable class names
Custom CSS (Premium, Settings > Channels > Live chat) is added inside the widget's Shadow DOM, after its own stylesheet. Target these classes: they are kept across releases. Anything else is internal and may change. The home greeting and the conversation header no longer sit on a brand-colour gradient: they use the panel background, so CSS that set light text on .zc-hero or .zc-head should set its own background too. The full-height mobile sheet now applies at 639px wide and below (it was 479px); target it with .zc-root[data-sheet="true"] rather than a media query.
| .zc-root | The widget's root: carries the theme (data-theme="light|dark"), data-sheet="true" while the panel is the full-height sheet (screens 639px wide and below), and the custom properties. |
| .zc-launcher | The launcher button (data-has-label when it shows a text label). |
| .zc-panel | The chat panel. |
| .zc-hero | The greeting block at the top of the home screen (panel background, no brand gradient). |
| .zc-head | The conversation header (panel background, no brand gradient). |
| .zc-bubble | A message bubble. |
| .zc-mine | The row of a message the visitor sent. |
| .zc-composer | The message box at the bottom of the conversation. |
| .zc-send | The send button. |
| .zc-btn-primary | Primary buttons (forms, feedback). |
| .zc-field | Text fields (contact prompt, pre-chat form, feedback). |
| .zc-block | Every home screen block, with zc-block-{type} (greeting, conversation, kb_search, popular, articles, hours, links, ticket). |
| .zc-prechat | The pre-chat form. |
| .zc-privacy | The privacy notice before the first message (and its consent box). |
CSS variables
The brand variables are set inline on .zc-root from your brand kit, so overriding them needs !important. Prefer the brand kit (colours, radius, font): it styles the widget and the portal alike.
| --zc-brand | The brand colour (fills, the visitor's bubbles, buttons). |
| --zc-brand-fg | Text on the brand colour. |
| --zc-bg | The panel background. |
| --zc-text | The main text colour. |
| --zc-border | Hairline borders. |
| --zc-radius | The panel's corner radius. |
| --zc-font | The font stack. |
Behaviour rules and their limits
Behaviour rules are part of Premium and fall back to the defaults on other plans. URL rules show or hide the launcher by page path (/pricing, /docs/*; * matches anything) and are checked again on single-page navigation; Zono.open() still opens the chat on a hidden page. Auto-open waits the delay once per browser tab, never on phones and never after the visitor opened or closed the chat. Allowed domains are checked by the widget and by the server, which refuses the bootstrap, the session and the widget's other endpoints when the browser's Origin (or else Referer) header names another host; *.example.com covers subdomains, not example.com itself. It keeps other sites from embedding your widget in a browser, but it is not authentication: a script outside a browser can send any header, so keep using identity verification for signed-in users.
CSP, allowed origins and storage
Content-Security-Policy
Allow https://zono.support in script-src for both files the widget uses: widget.js, and widget-spaces.js, which widget.js loads from the same address the first time the Help or Messages tab, an article or a search is needed (and in the background once the chat has been opened). A strict script-src that lists only the widget.js URL breaks those tabs: the visitor sees a retry message. If you host the files yourself, keep widget.js and widget-spaces.js together in one folder. Also allow https://zono.support in connect-src (the chat API). When your team uses real-time delivery, also allow https://js.pusher.com in script-src and the WebSocket host in connect-src; without it the widget falls back to polling. The widget builds its interface with DOM APIs inside a Shadow DOM and styles it with a constructable stylesheet, so it needs no unsafe-inline or unsafe-eval. Browsers without constructable stylesheets get a <style> element carrying the nonce of the widget.js script tag, if it has one; widget-spaces.js is loaded with that same nonce.
Allowed origins
The widget API answers cross-origin requests from any site and sends no cookies, so it works on any domain, staging included, unless you list allowed domains under the channel's behaviour rules (Premium; see Customisation). The widget token is public by design: what protects your customers' conversations is the per-visitor token the server issues and, for logged-in users, identity verification. Turn on Require verified identities if unsigned names and emails should be ignored.
Storage
The visitor token is kept in localStorage, with a cookie fallback (SameSite=None; Secure) for browsers that block storage. It is tied to the identity it was issued for and dropped when the signed-in user changes.
Identity secret
The identity secret is the one value on this page that must never reach the browser. Sign user.token (preferred, it expires) or compute user.hash on your server, keep the secret in your server-side configuration, and rotate it under Settings > Customer sign-in > Identity verification if it leaks: the previous secret keeps verifying for 24 hours while you deploy the new one.
Portal sign-in
Customers can also be signed in to your portal with a short-lived token that your server signs with a key derived from the same secret. Your login endpoint must only redirect a token to one of your own portal origins. The Customer identity guide has the recipes, the claims and that origin check.