# 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](/docs/) walks through the whole setup in order; the server side is the [REST API](/docs/api/). For an AI coding assistant, this page is also [plain markdown](/docs/js.md).

## Install

### Async (recommended)

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

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

### Consent

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:

```html
<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](/docs/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`:

```html
<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:

```html
<a id="zellwo-fallback" hidden href="mailto:support@example.com">Chat is unavailable right now — email us at support@example.com</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.

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `mode` | string | `'bubble'` | `'bubble'`, `'search'` or `'analytics'`. With no UI option at all (`mode`, `position`, `primaryColor`, …) the mode is `'analytics'`. |
| `client_id` | string | this workspace's key | Optional; the script fills it in. |
| `baseUrl` | string | the script's own origin | Your workspace URL. |
| `primaryColor` | string | `'#000'` | Primary brand colour. |
| `secondaryColor` | string | `'#ffffff'` | Background of the chat and the search dropdown. The search bar itself is always white. |
| `position` | string | `'bottom-right'` | `'bottom-right'`, `'bottom-left'`, `'top-right'`, `'top-left'`; a floating search bar also takes `'top-center'` and `'bottom-center'`. |
| `horizontal` | number | `20` | Horizontal offset in pixels. |
| `vertical` | number | `20` | Vertical offset in pixels. |
| `z_index` | number | `50` | CSS z-index. |
| `border_radius` | string | `'24px'` | Border radius. |
| `width` | string | `'400px'` | Chat iframe width. |
| `height` | string | `'600px'` | Chat iframe height. |
| `max_width` | string | `'600px'` | Maximum width. |
| `max_height` | string | `'600px'` | Maximum height. |
| `placeholder` | string | `'Explain what you need help with…'` | Input placeholder. |
| `hidden` | boolean | `false` | Start hidden; show it with `show()`. |
| `open` | boolean or object | `false` | Open 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 }`. |
| `fallbackEmail` | string | `''` | 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 &lt;address&gt;* 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

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `widget_size` | string | `'medium'` | `'small'`, `'medium'`, `'large'`. |
| `show_preview` | boolean | `false` | Show a preview message popup. |
| `preview_message` | string | `'Hi! How can we help you today?'` | Preview message text. |
| `preview_delay` | number | `3000` | Delay before the preview shows, in ms. |

### Search bar options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `container` | string or Element | `null` | CSS selector or element the search bar replaces. Avoid it in single-page apps (below). |
| `inline` | boolean | `true` | Inline mode for an embedded search bar. |
| `search_products` | boolean | `true` | Product 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_prices` | boolean | `true` | Show 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_events` | boolean | `true` | Send `search` (`{ query }`) when the bar is submitted and `click` (`{ query, product, url, price }`) when a suggestion is opened. |
| `typing_events` | boolean | `true` | Send `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_debounce` | number | `1000` | Pause in ms before a typed query is sent. |
| `typing_min_length` | number | `3` | Shortest typed query that is sent. |
| `show_preview` | boolean | `false` | Preview message popup below the bar; clicking it submits the text as a search. |
| `preview_message` | string | `'Hi! How can we help you today?'` | Preview text, also the query submitted on click. |
| `preview_delay` | number | `3000` | Delay before the preview shows, in ms. |

## Widget modes

### Bubble (default)

A floating chat button that opens the chat on click.

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

### Search bar

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

```javascript
// 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()`.

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

```javascript
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:

```jsx
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:

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

```javascript
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

| Method | What 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()`

```javascript
zellwo.event(action, extraData, identification)
```

| Argument | Type | Meaning |
|---|---|---|
| `action` | string, required | The event name: `pageview`, `login`, `purchase`, `custom:video_played`, … |
| `extraData` | object | Your properties, stored on the event exactly as sent: `{ plan: 'pro', amount: 49.99 }`. |
| `identification` | object | Who 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.

| Action | When | Suggested `extraData` |
|---|---|---|
| `pageview` | a page visit; the title is added for you | `{ section, source }` |
| `register` | a signup | `{ plan, source }` |
| `login` | a sign-in | `{ method }` |
| `logout` | a sign-out; also detaches the widget session (see *Signing out*) | `{}` |
| `view_item` | a product or item viewed | `{ product_id, product_name, price }` |
| `add_to_cart` | an item added to the cart | `{ product_id, quantity, price }` |
| `checkout` | checkout started | `{ cart_id, amount, currency }` |
| `purchase` | a completed transaction | `{ order_id, amount, currency }` |
| `search` | a search, submitted or (with `typed: true`) paused on while typing | `{ query, typed? }` |
| `click` | a button or link | `{ button_id, element }` |
| `form_submit` | a form sent | `{ form_id, form_name }` |
| `custom:<name>` | anything else | whatever describes it |

```javascript
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

| Data | On the event | On the contact |
|---|---|---|
| `extraData` | yes, exactly as sent | `<key>` = latest, `$initial_<key>` = first; `event_id`, `engagement_time_msec` and empty values excluded |
| Standard browser properties | yes | `referring_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 IP | as `$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](/docs/#events-server) has the server code; the [REST API reference](/docs/api/) the ingest rules.

| | `widget.js` (browser) | Analytics API (server) |
|---|---|---|
| Auth | none, the embed key is public | `Authorization: Bearer <token>` (an MCP & API token, never the signing secret) |
| Identity | `userToken` (signed JWT), or unverified `email` / `phone` | `email`, `phone`, `external_id` |
| Batching | one request per event | up 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](/docs/#events-server)):

```python
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:

```javascript
// 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:

```javascript
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

| Claim | Type | Required | Description |
|-------|------|----------|-------------|
| `email` | string | one of `email`, `phone` | Stored as the verified email. |
| `phone` | string | one of `email`, `phone` | Stored 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_id` | string | no | The 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. |
| `exp` | number | yes | Expiry 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

| Field | Type | Description |
|-------|------|-------------|
| `firstName`, `lastName` | string | Stored on the contact. |
| `email`, `phone` | string | Accepted but **not verified**; prefer the token. |

### Signing the token

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

## Platform examples

### Django

```python
# 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)
```

```html
<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)

```tsx
// 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

```php
// 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

```php
// 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');
}
```

```smarty
{* 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.

```liquid
<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

```tsx
// 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.

```python
# 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'),
```

```jsx
// 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:

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

```javascript
zellwo.event('login', {}, { userToken: serverGeneratedJWT, firstName: 'John', lastName: 'Doe' });  // recommended
zellwo.event('login', {}, { userToken: serverGeneratedJWT });                                        // verified, no name
zellwo.event('login', {}, { email: 'user@example.com' });                                           // 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](/docs/api/#section/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.
