Zellwo
Dashboard

JavaScript reference

widget.js is the browser SDK: the chat bubble, the search bar and zellwo.event() analytics, in one script loaded from your own workspace:

https://zellwo.zellwo.com/widget.js

There is no package to install and no client_id to pass: the script knows this workspace's embed key. The developer hub walks through the whole setup in order; the server side is the REST API. For an AI coding assistant, this page is also plain markdown.

Install

Paste before </body>, or in <head>. The inline config and the async script can come in either order; the widget handles both.

<script>
  window.zellwo = window.zellwo || { _q: [], event: function (a, e, i) { this._q.push([a, e, i]); } };
  window.ZellwoWidgetInit = { mode: 'bubble', position: 'bottom-right' };
  var zellwoConsent = 'approved'; // wire this to your consent banner; the script loads only when it is 'approved'
  if (zellwoConsent === 'approved') {
    var zellwoScript = document.createElement('script');
    zellwoScript.async = true;
    zellwoScript.src = 'https://zellwo.zellwo.com/widget.js';
    document.head.appendChild(zellwoScript);
  }
</script>

The first line is a queue: zellwo.event() calls made before the script has loaded are kept and sent when it arrives, so you never check whether it is ready. Without it, an event fired too early throws zellwo is not defined.

Until the script loads, nothing runs and nothing is stored. Once it loads, it:

  • fetches a visitor session from your workspace and stores it in a first-party cookie and in localStorage on your site (zellwo_session_id:<client_id>, one year);
  • records the first page of a visit;
  • reads the ad-platform cookies present on your site (_ga, _fbp, _gcl_aw and similar) for attribution;
  • sends search-bar text as it is typed, when typed search is on.

You decide when zellwoConsent becomes 'approved', under the law that applies to you. Zellwo does not show a consent banner.

Analytics only

Leave ZellwoWidgetInit out and the script runs as a tracker with no UI, the way Google Analytics or the Meta pixel do. No init() call is needed:

<script>
  window.zellwo = window.zellwo || { _q: [], event: function (a, e, i) { this._q.push([a, e, i]); } };
  var zellwoConsent = 'approved'; // wire this to your consent banner; the script loads only when it is 'approved'
  if (zellwoConsent === 'approved') {
    var zellwoScript = document.createElement('script');
    zellwoScript.async = true;
    zellwoScript.src = 'https://zellwo.zellwo.com/widget.js';
    document.head.appendChild(zellwoScript);
  }
</script>

It posts to the same ingest queue as the server-side analytics API, so events from the browser and from your backend land in one place. Setting ZellwoWidgetInit later still upgrades the page to bubble or search.

Synchronous, with a control instance

Load the script first and call ZellwoWidget.init() when you want the instance it returns (see Instance methods). Behind the same gate, the call goes in the script's onload:

<script>
  var zellwoConsent = 'approved'; // wire this to your consent banner; the script loads only when it is 'approved'
  if (zellwoConsent === 'approved') {
    var zellwoScript = document.createElement('script');
    zellwoScript.src = 'https://zellwo.zellwo.com/widget.js';
    zellwoScript.onload = function () {
      const widget = ZellwoWidget.init({ mode: 'bubble' });
      document.getElementById('help-button').onclick = () => widget.open();
    };
    document.head.appendChild(zellwoScript);
  }
</script>

Allowed origins

The widget runs only on the origins listed for the embed key: Channels → Inbound → Widget Chat → Manage → Allowed Origins in the dashboard. Add your live domain and your local one while developing (Vite runs on http://localhost:5173). On any other origin init() and every event are refused and the console prints a [Zellwo] warning naming the blocked origin, so check the console first when nothing arrives.

When Zellwo is unreachable

Two things can fail: the chat page inside the bubble, or widget.js itself. Pass fallbackEmail and the bubble shows Chat is unavailable right now — email us at … when the chat page has not come up within 8 seconds. If widget.js never loads, none of our code runs, so the page shows the fallback itself:

<a id="zellwo-fallback" hidden href="mailto:[email protected]">Chat is unavailable right now — email us at [email protected]</a>
<script async src="https://zellwo.zellwo.com/widget.js" onerror="document.getElementById('zellwo-fallback').hidden = false"></script>

Configuration options

Pass these in ZellwoWidgetInit or ZellwoWidget.init(). The behaviour options (search_products, display_prices, search_events, typing_events, typing_debounce, typing_min_length, show_preview, preview_message, preview_delay) also have site-wide defaults an owner sets without code under Channels → Inbound → Widget Chat → Manage → Settings; a value passed here overrides the stored one.

OptionTypeDefaultDescription
modestring'bubble''bubble', 'search' or 'analytics'. With no UI option at all (mode, position, primaryColor, …) the mode is 'analytics'.
client_idstringthis workspace's keyOptional; the script fills it in.
baseUrlstringthe script's own originYour workspace URL.
primaryColorstring'#000'Primary brand colour.
secondaryColorstring'#ffffff'Background of the chat and the search dropdown. The search bar itself is always white.
positionstring'bottom-right''bottom-right', 'bottom-left', 'top-right', 'top-left'; a floating search bar also takes 'top-center' and 'bottom-center'.
horizontalnumber20Horizontal offset in pixels.
verticalnumber20Vertical offset in pixels.
z_indexnumber50CSS z-index.
border_radiusstring'24px'Border radius.
widthstring'400px'Chat iframe width.
heightstring'600px'Chat iframe height.
max_widthstring'600px'Maximum width.
max_heightstring'600px'Maximum height.
placeholderstring'Explain what you need help with…'Input placeholder.
hiddenbooleanfalseStart hidden; show it with show().
openboolean or objectfalseOpen the panel as soon as the launcher is mounted, the way a #zellwo-chat link does: true opens Home (or Messages once the visitor has a conversation), or pass the target open() takes, e.g. { tab: 'help' }. Bubble mode only; a "Contact us" landing page sets window.ZellwoWidgetInit = { mode: 'bubble', open: true }.
fallbackEmailstring''Where to write when the chat cannot load. If the chat page has not answered within 8 seconds of opening (network error, 5xx, blocked origin), the panel shows Chat is unavailable right now — email us at <address> with a mailto: link. Without it the panel only says the chat is unavailable. Checked once per open, never retried in a loop.

Bubble options

OptionTypeDefaultDescription
widget_sizestring'medium''small', 'medium', 'large'.
show_previewbooleanfalseShow a preview message popup.
preview_messagestring'Hi! How can we help you today?'Preview message text.
preview_delaynumber3000Delay before the preview shows, in ms.

Search bar options

OptionTypeDefaultDescription
containerstring or ElementnullCSS selector or element the search bar replaces. Avoid it in single-page apps (below).
inlinebooleantrueInline mode for an embedded search bar.
search_productsbooleantrueProduct suggestions under the bar while typing (two characters or more, 200 ms debounce): thumbnail, title with the match highlighted, the first line of the description and the price. Arrow keys move, Enter opens the highlighted product, Esc closes, and the last row asks the assistant about the typed text. Matching is on word starts, so lab finds Labrador but not "available".
display_pricesbooleantrueShow each suggestion's price. The stored dashboard value also decides whether the assistant's product cards carry a price and whether it may quote prices; a value passed here only affects the suggestions.
search_eventsbooleantrueSend search ({ query }) when the bar is submitted and click ({ query, product, url, price }) when a suggestion is opened.
typing_eventsbooleantrueSend search with typed: true once the visitor pauses typing, so queries never submitted still reach the timeline. The same text is sent once; a submit cancels the pending typed event and sends the plain search instead.
typing_debouncenumber1000Pause in ms before a typed query is sent.
typing_min_lengthnumber3Shortest typed query that is sent.
show_previewbooleanfalsePreview message popup below the bar; clicking it submits the text as a search.
preview_messagestring'Hi! How can we help you today?'Preview text, also the query submitted on click.
preview_delaynumber3000Delay before the preview shows, in ms.

Widget modes

Bubble (default)

A floating chat button that opens the chat on click.

ZellwoWidget.init({ mode: 'bubble', position: 'bottom-right', widget_size: 'medium', show_preview: true, preview_message: 'Need help? Chat with us!' });

A search bar that opens the chat with the answer under it.

// Floating
ZellwoWidget.init({ mode: 'search', position: 'top-center', placeholder: 'Search our help center...' });

// Inline, replacing an element
ZellwoWidget.init({ mode: 'search', container: '#my-search-container', inline: true, search_products: false, show_preview: true, preview_delay: 2000 });

The chat opens under the bar and collapses on a click anywhere else. Once the visitor has a conversation, the magnifier becomes a chat icon: with the box empty it opens and closes the chat, with text it sends the text into it. The suggestions dropdown offers an "Ask about …" row that does the same.

ZellwoWidget.ask(message)

Sends message into the chat from your own page, for an "Ask about this product" button: the chat opens with the message sent and the assistant answering. Search mode only; returns false when there is no search bar on the page, and scrolls to the bar first when it is out of view. Works with ZellwoWidgetInit as well as ZellwoWidget.init().

document.getElementById('ask-about-product').onclick = () => window.ZellwoWidget && ZellwoWidget.ask('Firestone WinterHawk 4 XL 235/40 R19');

ZellwoWidget.open(target)

Opens the widget already on the page, with the same targets as widget.open() (see Instance methods), so an async install needs no instance of its own. On a page where widget.js only tracks (no ZellwoWidgetInit), the first call adds the messenger and opens it; the page shows no chat button before that. Returns false only when the widget is not running on the page (script missing, consent not given, origin not allowed). Calls made before the script has loaded are not queued, so guard with window.ZellwoWidget &&; to open a conversation on arrival, put the target in open of ZellwoWidgetInit instead.

document.getElementById('open-ticket').onclick = () => window.ZellwoWidget && ZellwoWidget.open({ ticket: '0192f5c4-7b1e-7c3a-9d2e-4f6a8b0c1d2e' });

// On arrival
window.ZellwoWidgetInit = { mode: 'bubble', open: { ticket: '0192f5c4-7b1e-7c3a-9d2e-4f6a8b0c1d2e' } };

Analytics mode

mode: 'analytics', or no UI option at all: no bubble, no search bar, just the tracker. One exception: a visitor who arrives with #zellwo-chat in the URL, where the "Continue this conversation in the chat" link in reply emails lands them, gets the bubble even on an analytics-only page, opened on arrival. The link carries a short-lived, single-use code in that fragment, and the chat continues as the contact the email went to: when the chat opens, the server binds the browser's own session to that contact. The widget removes the code from the URL. Set mode: 'analytics' explicitly to keep the chat off such a page too.

Single-page apps (React, Vue, Vite)

Analytics touches no DOM, so React reconciliation, StrictMode double-mounting and Vite HMR do not affect it. Put the snippet in index.html and call zellwo.event() from anywhere:

zellwo.event('purchase', { order_id: 'ORD-9821', amount: 49, currency: 'EUR' });

// With "Send a pageview automatically" off (the default), report route changes yourself
const location = useLocation();
useEffect(() => { window.zellwo?.event('pageview'); }, [location.pathname]);

For the chat, use bubble, or search without container: both append to document.body, outside your framework's root, where nothing reconciles them away. The container option inserts a node next to your element and hides the original; inside a React tree that node is foreign, and the next re-render can remove it or fail with NotFoundError: Failed to execute 'removeChild' on 'Node'. The element is also usually not mounted yet when the async script runs, so the widget falls back to document.body with a console warning. If you need inline, mount into a div React never re-renders and init after it exists:

function SearchSlot() {
  const ref = useRef(null);
  const widget = useRef(null);
  useEffect(() => {
    if (widget.current) return;  // StrictMode runs effects twice in development
    widget.current = window.ZellwoWidget.init({ mode: 'search', container: ref.current });
    return () => { widget.current?.destroy(); widget.current = null; };
  }, []);
  return <div ref={ref} />;
}

Instance methods

ZellwoWidget.init() returns a control object.

widget.open([target])

Opens the messenger. target is a mode name, 'help', or an object naming the page to open.

widget.open();                                // Home, or Messages once the visitor has a conversation
widget.open('search');
widget.open('help');                          // the Help tab
widget.open({ tab: 'messages' });             // home | messages | help
widget.open({ article: 'returns' });          // one help article by its slug
widget.open({ reference: 'KRZZ4Z' });        // the conversation about something in your own system
widget.open({ ticket: '…uuid…' });            // one of the visitor's conversations by its uuid

open({ reference }) opens the visitor's own open conversation whose external_reference (set by your backend through POST /api/tickets/upsert) equals reference, when the browser session is proven for that contact (below). In every other case (an anonymous session, no such conversation of their own, a reference that belongs to somebody else) it starts a new conversation and notes the reference for your staff as requested_reference. The answer looks the same in all of them, so open() never tells a page whether a reference exists, and a customer can never set external_reference themselves.

open({ ticket }) opens that conversation when the browser session may see it: an anonymous session only the conversation it is in, a signed-in or otherwise proven session any of the contact's own. Otherwise it opens the normal chat. An unknown uuid and somebody else's get the same answer, so a page can't probe for tickets. Typical use is a "Your tickets" list your backend fetched with GET /api/tickets, each row opening its conversation in the chat.

The messenger

The bubble opens a three-tab messenger: Home (your logo, up to three team members as initials, a greeting, Ask a question, and the team's status: online with the reply time inside your working hours, away with the next opening time outside them, or the plain reply-time line without working hours), Messages (the visitor's conversations, newest first, closed and solved ones read-only, Ask a question starting a new one) and Help (your public knowledge-base articles by category, with a keyword search; Still need help? at the end of an article starts a conversation). The greeting, the people, and the two status lines are set under Channels › Inbound › Widget Chat › Settings › Messenger, or through PATCH /api/workspace/widget-style. Help lists articles that are both public and visible; GET /api/articles/public?key=<embed key> returns the same list without a staff token.

Conversations

Messages lists the ten newest conversations with a preview, a relative time and an unread dot; the launcher shows a red dot while any reply is unread. Other conversations are shown only in a browser session proven for that contact: one bound by a signed userToken (see User identification) or by the claim link sent by email. An anonymous session sees exactly the conversation it is in, and files open only from a conversation the session may see. A signed-in customer's list also includes the tickets your backend opened for them through POST /api/tickets/upsert.

Other methods

MethodWhat it does
widget.close([target])Closes the chat.
widget.toggle([target])Opens or closes the chat.
widget.show([target])Shows a widget started with hidden: true.
widget.hide([target])Hides the widget, animated.
widget.focus()Focuses the search input (search mode).
widget.destroy()Removes every widget element and listener.

Analytics: zellwo.event()

zellwo.event(action, extraData, identification)
ArgumentTypeMeaning
actionstring, requiredThe event name: pageview, login, purchase, custom:video_played, …
extraDataobjectYour properties, stored on the event exactly as sent: { plan: 'pro', amount: 49.99 }.
identificationobjectWho the visitor is: { userToken, firstName, lastName } (see User identification).

Events go out with sendBeacon, falling back to fetch with keepalive, so they survive a page unload.

Event names

Standard names are accepted as they are. Any other name needs the custom: prefix; a name without it is rejected.

ActionWhenSuggested extraData
pageviewa page visit; the title is added for you{ section, source }
registera signup{ plan, source }
logina sign-in{ method }
logouta sign-out; also detaches the widget session (see Signing out){}
view_itema product or item viewed{ product_id, product_name, price }
add_to_cartan item added to the cart{ product_id, quantity, price }
checkoutcheckout started{ cart_id, amount, currency }
purchasea completed transaction{ order_id, amount, currency }
searcha search, submitted or (with typed: true) paused on while typing{ query, typed? }
clicka button or link{ button_id, element }
form_submita form sent{ form_id, form_name }
custom:<name>anything elsewhatever describes it
zellwo.event('pageview', { section: 'pricing', source: 'google' });
zellwo.event('view_item', { product_id: 'SKU-12345', product_name: 'Wireless Headphones', price: 79.99 });
zellwo.event('custom:video_played', { video_id: 'demo-video-123', duration: 45, completed: true });

Pageviews

Two switches under Channels → Inbound → Widget Chat → Manage → Events (first_pageview and auto_pageview on PATCH /api/workspace/widget-style):

  • Record the first page of a visit, on by default: one pageview for the page the script loads on, only when the browser had no Zellwo session cookie yet. That is the landing page, the one carrying the ad click, and the one page a server-side integration cannot report, because the cookie it forwards does not exist until the script has run. Nothing is sent when the cookie was already there, so a page your server reports is never counted twice. It yields to the automatic pageview and to a page script that queued its own events.
  • Send a pageview automatically, off by default: the script records a pageview once the page has loaded and again on every client-side route change (pushState, replaceState, back and forward), skipping a repeat of the URL it just recorded, so React Router, Vue Router and hash routers need no code. It stays quiet on a page whose own script already queued events or sent a pageview.

With the automatic pageview off, call zellwo.event('pageview') from your router's navigation hook.

Engagement time

Every event carries engagement_time_msec: the milliseconds the page was visible and focused since the previous event this page sent (GA4's model). The clock pauses while the tab is hidden or the window blurred and resets after each send, so the values of a session add up to its foreground time, and the value on a single-page app's pageview is the time spent on the previous page. A value you pass in extraData wins, which is how a server-side integration forwards GA4's own figure. It stays on the event, never on the contact. Time after the last event of a visit is not reported.

Standard properties

Every event carries the browser facts a server cannot see: event_id (unique per event), pathname, referring_domain ($direct without a referrer), language (the page's <html lang>, else the browser's), browser_language, screen_width, screen_height, viewport_width, viewport_height, device_pixel_ratio and timezone (the browser's IANA zone; kept on the contact as $latest_timezone when it is a real zone, and shown to staff as Local time). Empty facts are left out and a key you pass in extraData wins. The server adds os, browser, device, device_type, country and the campaign keys of the page's URL to the same event. referring_domain, language and browser_language also reach the contact with a $initial_ copy; event_id, pathname and the screen and viewport keys stay on the event.

Your own properties (extraData)

extraData is stored on the event exactly as sent: any key names, with string, number, boolean, array or object values. It also updates the contact: each key holds the latest value sent, and the first scalar value ever seen is kept once under $initial_<key> (lists, objects, null and "" get no first copy). A key named like a server metadata field (country, url, referrer, os, utm_source, …) is stored under its plain name but gets no $initial_ copy.

For purchase, amount and currency are the money keys: the dashboard's Purchases and Revenue tiles read amount first and fall back to total, value and order_total. Send order_id on every purchase: it is shown in Recent Purchases, and a second purchase with the same order_id for the same contact is stored once, so a retry or replay never counts twice. currency is an ISO 4217 code (any case); a purchase without one is in the workspace currency, and the contact's $purchase_total is kept in the workspace currency, converted at the daily ECB rate.

What is stored where

DataOn the eventOn the contact
extraDatayes, exactly as sent<key> = latest, $initial_<key> = first; event_id, engagement_time_msec and empty values excluded
Standard browser propertiesyesreferring_domain, language, browser_language as latest and $initial_; the rest never
Server metadata (IP, country, OS, browser, UTM, ad click ids, ad cookies)OS, browser, device, device type, country and campaign keys; never the IPas $initial_* (first touch, never overwritten) and $latest_* (every event)

The server metadata is collected for you on every event: ip, country (GeoIP), os, browser, device, device_type (User-Agent), url, referrer, every utm_* and ad click id (gclid, fbclid, msclkid, ttclid, …) parsed from the page URL, and the ad cookies the workspace lists (_ga, _fbp, _fbc, _gcl_aw, …, stored without the leading underscore: $initial_ga).

Browser or server, per event type

Pick one source per event type. Browser for what the page sees (pageviews, product views, searches, clicks), server for what only the backend knows (a webhook-confirmed payment, a refund, a plan change) and for anything that must carry external_id. Both land on one profile when your server passes the visitor's session_id along: widget.js stores it in a first-party cookie on your own domain, zellwo_session_id:<client_id>, so every request to your backend carries it. The developer hub has the server code; the REST API reference the ingest rules.

widget.js (browser)Analytics API (server)
Authnone, the embed key is publicAuthorization: Bearer <token> (an MCP & API token, never the signing secret)
IdentityuserToken (signed JWT), or unverified email / phoneemail, phone, external_id
Batchingone request per eventup to 100 per call on /ingest/batch

external_id is not accepted from the browser outside a signed token: any page could claim to be any customer.

User identification

A page cannot be trusted to say who is signed in, so identity comes as a userToken: a JWT your backend signs with HS256 and the widget signing secret (Channels → Inbound → Widget Chat → Manage → User Identity, or Channels → Manage tokens → For widget signing secret; owners only). Never the API token, and never in front-end code. A verified token writes email and phone onto the contact as verified fields and folds the visitor's anonymous profile into their customer profile, so everything they did before signing in is kept.

On your server, with exp (Python here; Node, PHP and the shell in the developer hub):

claims = {'email': user.email, 'exp': int(time.time()) + 3600}  # exp: required, at most 24 hours ahead
token = jwt.encode(claims, ZELLWO_SIGNING_SECRET, algorithm='HS256')

In the page, for the signed-in visitor:

// userToken: the token signed above
zellwo.event('login', {}, { userToken: 'eyJhbGciOiJIUzI1NiIs...', firstName: 'John', lastName: 'Doe' });

Send it once per session from any page, not only from the login page: a returning visitor arrives already signed in.

Signing out

When the visitor signs out of your site, sign them out of the widget too, before you redirect or reload:

await zellwo.event('logout');

The browser's widget session is detached from the contact and replaced by a fresh anonymous one, so the next person on the same browser never sees the previous person's conversations. One logout event is recorded on the contact the session belonged to. Their contact, tickets and history stay as they are; signing in again (a userToken on login) reconnects them. A logout sent from your server through the analytics API only records the event; it does not sign the browser out.

Claims inside the token

ClaimTypeRequiredDescription
emailstringone of email, phoneStored as the verified email.
phonestringone of email, phoneStored as the verified phone. Send E.164 (+4797009747); any other spelling is read as international first, then as a national number of the visitor's browser country and your workspace country, and stored as typed when none of those gives a valid number.
external_idstringnoThe user's id in your system, at most 100 characters. Matched before the email, so a customer whose address changed stays one contact; the same id your backend sends to POST /api/tickets/upsert. Only the signed claim counts.
expnumberyesExpiry in Unix seconds, at most 24 hours ahead; a token expiring later is refused. A token without exp is accepted and logged until 1 December 2026, and refused after.

A token carrying only external_id identifies too, but that contact cannot be emailed.

Fields outside the token

FieldTypeDescription
firstName, lastNamestringStored on the contact.
email, phonestringAccepted but not verified; prefer the token.

Signing the token

Sign on your server with any JWT library. The developer hub has copy-paste code for Python, Node, PHP and the shell; the platform examples below show it in place.

Platform examples

Django

# views.py: pip install pyjwt
import time
import jwt

def page(request):
    context = {}
    if request.user.is_authenticated:
        claims = {'email': request.user.email, 'external_id': str(request.user.pk), 'exp': int(time.time()) + 3600}
        context['zellwo_token'] = jwt.encode(claims, settings.ZELLWO_SIGNING_SECRET, algorithm='HS256')
    return render(request, 'page.html', context)
<script>
  window.zellwo = window.zellwo || { _q: [], event: function (a, e, i) { this._q.push([a, e, i]); } };
  window.ZellwoWidgetInit = { mode: 'bubble' };
  {% if zellwo_token %}zellwo.event('login', {}, { userToken: '{{ zellwo_token }}', firstName: '{{ request.user.first_name|escapejs }}' });{% endif %}
</script>
<script async src="https://zellwo.zellwo.com/widget.js"></script>

Next.js (App Router)

// app/zellwo.tsx: npm install jsonwebtoken
import jwt from 'jsonwebtoken';
import Script from 'next/script';

export function Zellwo({ email }: { email?: string }) {
  const userToken = email ? jwt.sign({ email: email.toLowerCase(), exp: Math.floor(Date.now() / 1000) + 3600 }, process.env.ZELLWO_SIGNING_SECRET!, { algorithm: 'HS256' }) : null;
  const queue = `window.zellwo = window.zellwo || { _q: [], event: function (a, e, i) { this._q.push([a, e, i]); } };
window.ZellwoWidgetInit = { mode: 'bubble' };${userToken ? `\nzellwo.event('login', {}, { userToken: '${userToken}' });` : ''}`;
  return (
    <>
      <Script id="zellwo-queue" strategy="beforeInteractive" dangerouslySetInnerHTML={{ __html: queue }} />
      <Script src="https://zellwo.zellwo.com/widget.js" strategy="afterInteractive" />
    </>
  );
}

Render <Zellwo email={session?.user?.email} /> from a server component, so the secret never reaches the browser.

WordPress / WooCommerce

// functions.php: composer require firebase/php-jwt
use Firebase\JWT\JWT;

add_action('wp_footer', function () {
    $user_token = '';
    if (is_user_logged_in()) {
        $user = wp_get_current_user();
        $user_token = JWT::encode(['email' => strtolower($user->user_email), 'exp' => time() + 3600], ZELLWO_SIGNING_SECRET, 'HS256');
    }
    ?>
    <script>
      window.zellwo = window.zellwo || { _q: [], event: function (a, e, i) { this._q.push([a, e, i]); } };
      window.ZellwoWidgetInit = { mode: 'bubble' };
      <?php if ($user_token): ?>zellwo.event('login', {}, { userToken: '<?php echo esc_js($user_token); ?>', firstName: '<?php echo esc_js($user->first_name); ?>' });<?php endif; ?>
    </script>
    <script async src="https://zellwo.zellwo.com/widget.js"></script>
    <?php
});

PrestaShop

// modules/zellwo/zellwo.php
use Firebase\JWT\JWT;

public function hookDisplayFooter($params) {
    $user_token = '';
    if ($this->context->customer->isLogged()) {
        $user_token = JWT::encode(['email' => strtolower($this->context->customer->email), 'exp' => time() + 3600], Configuration::get('ZELLWO_SIGNING_SECRET'), 'HS256');
    }
    $this->context->smarty->assign(['zellwo_user_token' => $user_token]);
    return $this->display(__FILE__, 'views/templates/hook/footer.tpl');
}
{* views/templates/hook/footer.tpl *}
<script>
  window.zellwo = window.zellwo || { _q: [], event: function (a, e, i) { this._q.push([a, e, i]); } };
  window.ZellwoWidgetInit = { mode: 'bubble' };
  {if $zellwo_user_token}zellwo.event('login', {}, { userToken: '{$zellwo_user_token|escape:'javascript'}' });{/if}
</script>
<script async src="https://zellwo.zellwo.com/widget.js"></script>

Shopify (Liquid)

Liquid cannot sign a JWT, so a Shopify app signs it server-side, as in the examples above, and stores it in a customer metafield. The token must carry an exp at most 24 hours ahead, so the app refreshes the metafield at least that often.

<script>
  window.zellwo = window.zellwo || { _q: [], event: function (a, e, i) { this._q.push([a, e, i]); } };
  window.ZellwoWidgetInit = { mode: 'bubble' };
  {% if customer and customer.metafields.custom.zellwo_token %}
  zellwo.event('login', {}, { userToken: '{{ customer.metafields.custom.zellwo_token }}' });
  {% endif %}
</script>
<script async src="https://zellwo.zellwo.com/widget.js"></script>

Vite / React with server rendering

// server.ts: sign on the server, hand only the token to the app
const userToken = user?.email ? jwt.sign({ email: user.email.toLowerCase(), exp: Math.floor(Date.now() / 1000) + 3600 }, process.env.ZELLWO_SIGNING_SECRET!, { algorithm: 'HS256' }) : null;
res.send(renderToString(<App userToken={userToken} />));

// App.tsx: the snippet sits in index.html; the queue makes the timing irrelevant
function App({ userToken }: { userToken: string | null }) {
  useEffect(() => { if (userToken) window.zellwo.event('login', {}, { userToken }); }, [userToken]);
  return <div>...</div>;
}

React app with its own API (Vite, Lovable, Bolt, Base44)

The common shape of an app built with an AI tool: a React bundle served as static files, and a backend under /api/ that answers "who is signed in" (/api/auth/me/, /api/user, …). The token is signed by that backend and travels in the same response; the React side only passes it on.

# Backend, in the "current user" response: pip install pyjwt
'zellwo_token': jwt.encode({'email': user.email.lower(), 'external_id': str(user.pk), 'exp': int(time.time()) + 3600}, settings.ZELLWO_SIGNING_SECRET, algorithm='HS256'),
// Frontend, where the current user is loaded; the snippet sits in index.html
useEffect(() => {
  if (me?.zellwo_token) window.zellwo.event('login', {}, { userToken: me.zellwo_token, firstName: me.first_name, lastName: me.last_name });
}, [me?.id]);

// Sign-out handler
async function signOut() {
  await window.zellwo.event('logout');
  // then clear your own token and redirect, as before
}

Sign a fresh token on every "current user" call rather than storing one, so it never outlives exp. The effect is keyed on the user's id, not the token: a refetch (on window focus, say) signs a new token, and keying on it would record another login each time. A backend in Node signs the same claims with jwt.sign(claims, process.env.ZELLWO_SIGNING_SECRET, { algorithm: 'HS256' }) from jsonwebtoken.

If an AI assistant writes this for you, give it the backend and the frontend together (a frontend-only workspace has no safe place to sign) and these rules:

Add the Zellwo widget with verified user identity. Reference: https://zellwo.zellwo.com/docs/js.md
- Paste the Async install snippet from the reference into index.html. There is no npm package.
- The signing secret lives only in the backend environment as ZELLWO_SIGNING_SECRET. Never in frontend
  code, never in a VITE_ / NEXT_PUBLIC_ / REACT_APP_ variable, never in localStorage.
- The backend signs an HS256 JWT with claims email (lowercased), external_id (the user's id as a string)
  and exp (now + 3600 seconds), and returns it as zellwo_token in the current-user response, freshly
  signed on every call.
- The frontend calls window.zellwo.event('login', {}, { userToken, firstName, lastName }) once the
  current user has loaded, in a useEffect keyed on the user's id (not on the token).
- In TypeScript, declare it once: declare global { interface Window { zellwo: { event: (action: string, extraData?: object, identification?: object) => void } } }
- On sign-out, call await window.zellwo.event('logout') before clearing the site's own session and redirecting.
- Do not use the widget's "container" option.
- Finally, build the frontend and confirm the secret's value does not appear anywhere in the output.

A secret that reaches the browser lets anyone sign a token for any customer and read that customer's conversations in the widget. If it ever leaks, rotate it under User Identity.

Front end only

serverGeneratedJWT is a token your server signed with exp, as under User identification above.

zellwo.event('login', {}, { userToken: serverGeneratedJWT, firstName: 'John', lastName: 'Doe' });  // recommended
zellwo.event('login', {}, { userToken: serverGeneratedJWT });                                        // verified, no name
zellwo.event('login', {}, { email: '[email protected]' });                                           // NOT verified, avoid

Browser support

Chrome 60+, Firefox 55+, Safari 11+, Edge 79+. Events are sent with sendBeacon, falling back to fetch with keepalive. Chat message times inside the widget are shown in the visitor's own clock, whatever the workspace timezone.

Changelog

Versions follow the REST API's; changes to the server side are in the REST API reference's changelog.

1.46.0

  • The chat's colours (widgetStyle in the bootstrap) come from the brand kit's primary colour (Settings › Brand, PUT /brand-kit), with a near-white tint of it as the chat background; the messenger Home can show a custom full-width button (home_button_label, home_button_url on PATCH /workspace/widget-style).
  • open in the configuration (ZellwoWidgetInit or ZellwoWidget.init({...})): true or an open() target opens the panel on arrival in bubble mode, so an async install no longer needs the #zellwo-chat fragment.
  • open({ ticket }) opens one of the visitor's conversations by its uuid, and ZellwoWidget.open(target) opens the widget already on the page without an instance of its own, adding the messenger on demand to a tracking-only page.
  • A link to a conversation the browser session may no longer open now shows the normal chat instead of an error.

1.29.0

  • A React app with its own API example with rules to hand an AI coding assistant, and this reference as plain markdown at /docs/js.md.
  • zellwo.event('logout') detaches the browser from the signed-in contact and starts a fresh anonymous session (see Signing out).

1.25.1

  • This reference moved out of the REST API docs to /docs/js/, with the workspace's own address in every snippet.

1.12.0

  • Click tracking: external links in outbound mail record a click event and carry ?zw=<token> so the widget joins the browsing to the contact.