Installing the widget
One script tag on a portal. The tracker that feeds the live visitor list and the chat surface the visitor talks through.
The widget is the script an Institution pastes into its website. It does two jobs at once: it is the tracker that feeds the live visitor list, and it is the chat surface the visitor talks through.
One file, no runtime dependencies, no framework, roughly 9 KB gzipped.
Install
That is the whole installation. Put it before </body> on every page.
The exact snippet for a site, with its own key already filled in, is on the
Sites screen and is returned by GET /v1/sites/{site_id}/snippet.
Attributes
| Attribute | Required | What it does |
|---|---|---|
data-site | yes | The site key from Settings, Websites. Public by design: it only authorises anonymous visitor traffic for one site. |
data-api | no | API root including the version segment, for example https://api.acme.go.ke/v1. Defaults to the origin the script was served from plus /v1. |
data-consent | no | required holds everything until grantConsent() is called. granted declares that the site handles consent itself and skips the bootstrap round trip. Omitted means the site's own tracking_consent_required setting decides. |
data-debug | no | Logs to console.debug. Without it the widget is completely silent, including when the backend is down. |
Loading it asynchronously
If a tag manager loads widget.js with async, queue calls so nothing is lost
before the bundle arrives:
The queue is drained in order as soon as the bundle boots.
It cannot break the host page
This is a design constraint rather than a claim, because the widget runs on someone else's page, often a government portal that nobody wants to be responsible for taking down.
- Every network call resolves to
nullrather than rejecting. There is no unhandled rejection to surface in the portal's own error tracking. - Every storage call is wrapped. Safari private mode throws on
localStorageaccess, not only on write. - Every listener runs inside a guard. A listener that throws is caught and logged, never rethrown into the page.
- The whole boot is inside one try/catch.
- A circuit breaker opens after three consecutive failures and the widget stops touching the network for up to five minutes, so an outage cannot become a retry storm from every visitor at once.
- With
data-debugabsent, nothing is ever written to the console.
Shadow DOM isolation
The whole UI lives in a shadow root with all: initial on the host, so the
portal's CSS cannot reach in and the widget's cannot leak out. If a browser has
no attachShadow, the UI does not mount and the tracker keeps working: a
visitor on an ancient browser still appears in the live list, they just cannot
chat.
Single-page applications produce pageviews
history.pushState and replaceState are patched, preserving their return
value and receiver, and popstate and hashchange are handled.
A burst of router calls collapses into one pageview for the final URL, so a
transition that writes history three times does not report three pages. That
matters because page_view_count is a column an agent reads on the live row,
and inflating it makes the row lie.
Accessibility
- The launcher is a real button with
aria-haspopup,aria-expandedand an accessible name that includes the unread count. - The panel is
role="dialog"witharia-modal="false"and no focus trap. The visitor is still on your page, and trapping them in a support widget would be hostile. - Escape closes the panel and returns focus to the launcher.
- The transcript is
role="log"witharia-live="polite". The typing indicator announces through a visually hidden status region. prefers-reduced-motionis respected.
On a narrow viewport, opening the panel focuses the panel rather than the composer, so the on-screen keyboard does not cover the transcript before anything has been read.
Endpoints it calls
| Call | When |
|---|---|
GET /v1/track/widget/{site_key} | Boot, unless data-consent="granted". |
POST /v1/track/session | Once per page load. Returns the visitor token, session id and widget settings. |
POST /v1/track/pageview | The landing page, then every SPA route change. |
POST /v1/track/heartbeat | Every 25s while the tab is visible, and once on pagehide as a keepalive request, or sendBeacon where keepalive is unsupported. |
POST /v1/track/identify | On identify(), once per session per distinct set of traits. |
POST /v1/track/event | On track(), chat opened, invitation accepted or dismissed. |
WS /v1/ws/widget?site_key=&session_id= | Agent replies, typing, proactive invitations, and outbound visitor messages. |
POST /v1/track/message | Fallback only, when the socket is not open. |
Full request and response shapes are in the track reference.