This is the contract surfaced to consumer sites. Where the current behavior has a known limitation, it is stated under Known limitations in the relevant section rather than hidden.
Embedding#
Drop these two tags into the host page. The launcher script injects the floating button and loads the conversation panel when it is needed (see What the widget does to your page).
<script>
window.bettercomConfig = { clientId: 'YOUR_ORG_SHORT_ID' }
</script>
<script async src="https://YOUR_HOST/v1/widget/YOUR_ORG_SHORT_ID"></script>YOUR_ORG_SHORT_ID is the organization's short id, shown in the install snippet under Settings, Installation in the agent dashboard. The loader at /v1/widget/:shortId is served per organization with that id already baked in, so the bettercomConfig tag is only needed to pass other fields (darkMode, user, sound, locale, tracking).
Load timing#
The launcher does not appear immediately. When the DOM is ready it fetches the organization's settings (up to 3 seconds), then waits for either showAfterSeconds after the window load event (default 1 second) or, when the organization enabled lcpGated, for the page's Largest Contentful Paint to settle (up to 8 seconds; browsers that report no LCP, some older versions of Safari among them, wait for the first key press, pointer press or scroll, or the 8 seconds). Actions called before then are queued and run once the launcher exists (see Actions are queued).
When the settings request is refused (HTTP 400 to 404: a site that is not allowed, or a clientId no workspace has), the launcher stays hidden instead of showing a button that would open an empty panel. It logs one console warning, [bettercom] hidden: unknown clientId or site not allowed, and the ready event does not fire. Queued and later actions do nothing. A slow or failing settings server (no answer within 3 seconds, a 5xx, a network error) still shows the launcher with the default look, so a visitor can reach you when the server recovers.
Browsers#
The widget's scripts are ES2020 and its styles target Chrome 87, Edge 88, Firefox 78 and Safari 14 or later. On an older browser the chat may not open (the launcher then says it could not load); the host page is never affected.
Content Security Policy#
A page with a CSP needs these directives for the widget host (https://YOUR_HOST): script-src (the loader, the helper, the panel and language scripts), connect-src for https://YOUR_HOST and wss://YOUR_HOST (API requests and the chat socket), img-src for avatars, logos and uploaded images, plus blob: for the preview of an image before it is sent, and media-src for voice messages and videos, plus blob: to play a voice message back before it is sent. No 'unsafe-inline' is needed: the widget's styles are constructed stylesheets inside its shadow root, which style-src does not govern (browsers without constructable stylesheets, before Safari 16.4, use a <style> element in the shadow root instead and need style-src 'unsafe-inline'). A voice call is set up over the chat socket; its audio then travels over WebRTC through the STUN and TURN servers the server names, which we have not yet tested under a strict policy. The Performance and CSP page has the directives ready to paste.
Allowed domains#
An organization can restrict the domains the widget may load on (Settings, Installation, Allowed domains). When a list is set, the loader and the widget's API requests from any other origin are refused with HTTP 403, and the widget does not appear.
Supporting the async stub#
Sites that load the widget asynchronously can stub window.bettercom and queue calls before the launcher arrives. The launcher drains the queue on load, in order. Every call works through the queue, event handlers included:
<script>
window.bettercom = window.bettercom || function () {
(window.bettercom.q = window.bettercom.q || []).push(arguments)
}
window.bettercom('on', 'ready', function () { /* widget is on the page */ })
window.bettercom('identify', { userId: 'u_123', userHash: '...', email: 'ada@example.com' })
window.bettercom('update', { darkMode: true })
</script> Without the stub, calling window.bettercom(...) before the script has loaded throws, because the function does not exist yet.
window.bettercomConfig (declarative)#
Read once when the launcher script runs.
| Field | Type | Notes |
|---|---|---|
clientId | string | The org's short id. Optional with the per-org loader URL (the id is baked in); when set it overrides the baked-in id. |
darkMode | boolean | If true, mounts the widget in dark theme. Default false. |
user | User | Identifies the visitor (name, email, user id and hash, attributes). Same as calling identify. See User. |
tracking | boolean | Page-view tracking consent. false: no page views, session-only storage. true: granted. Omitted: the workspace decides (on by default). See Consent. |
locale | string | The visitor's language as a BCP 47 tag ('de', 'pt-BR'). Wins over the workspace default and the browser. See Languages. |
sound | boolean | If false, mutes the soft chime that plays on incoming agent messages. Default true. The chime is generated with the Web Audio API (no asset) and only plays after the visitor has interacted with the page (browser autoplay policy). Only read from bettercomConfig: boot and update ignore it. |
preview is reserved for the agent dashboard's settings preview. Do not set it on a real site: it renders sample data and disables messaging.
<script>
window.bettercomConfig = {
clientId: 'YOUR_ORG_SHORT_ID',
darkMode: true,
sound: false,
locale: 'de',
user: { name: 'Ada Lovelace', email: 'ada@example.com' }
}
</script>window.bettercom(action, payload?) (imperative)#
A single dispatcher. Calls return immediately and return nothing.
| Action | Payload | What it does |
|---|---|---|
boot | { clientId?, darkMode?, user?, locale?, tracking? } | Start (or restart after shutdown) and apply the fields. |
update | { darkMode?, user?, locale?, tracking? } | Apply fields at runtime. |
open / show | none | Open the panel. |
close / hide | none | Close the panel. |
toggle | none | Flip the panel. |
identify | User | Identify the visitor; see User. |
setLocale | string | null | Set the visitor's language. |
consent | true | false | 'granted' | 'denied' | null | Grant or revoke page-view tracking. |
on / off | event name, handler | Listen to events. |
shutdown | none | Remove the widget and forget the visitor. |
Actions are queued#
boot, identify, setLocale, consent, on and off apply immediately (consent reaches the server as soon as the visitor exists, right after the launcher's settings request; identify once the visitor exists and your page has finished loading, see User). shutdown also applies at once, also before the launcher exists. Every other action called before the launcher has finished its first load (settings fetch plus the load timing described above) is queued and replayed in order once the launcher exists. After shutdown, those actions wait the same way until the next boot. open, close, show and hide are idempotent; toggle is not, by definition.
bettercom('boot', config)#
Starts the widget if it has not started yet (or again after shutdown), and applies darkMode, user and locale from the payload. Calling it twice is safe: the second call only applies its fields.
window.bettercom('boot', {
darkMode: true,
user: { name: 'Ada Lovelace', email: 'ada@example.com' }
}) Accepts clientId, darkMode, user, locale and tracking.
A clientId different from the running one removes the current widget (its open panel and connection included) and starts again for the new organization, with that organization's settings, visitor and storage keys. The first organization's visitor stays stored for when you switch back.
bettercom('update', patch)#
Apply a partial update at runtime. An open conversation re-renders without losing its open/close state, scroll position, or in-flight typing.
// Switch theme
window.bettercom('update', { darkMode: true })
// Pre-identify (hides the in-widget identify form)
window.bettercom('update', { user: { name: 'Ada Lovelace', email: 'ada@example.com' } })
// Combine
window.bettercom('update', { darkMode: false, user: { email: 'guest@example.com' } })| Field | Type | Behavior |
|---|---|---|
darkMode | boolean | Toggles the bc-dark class on the shadow host. |
user | User | Same as identify: merged into the visitor's identity. Provided non-empty fields overwrite stored ones; empty or missing fields leave stored values untouched. |
locale | string | null | Same as setLocale. |
tracking | boolean | null | Same as consent. |
bettercom('open') / bettercom('close') / bettercom('toggle')#
show and hide are the same as open and close (kept for existing sites).
Open / close / flip the panel. The first open lazy-loads the conversation bundle. Opening moves keyboard focus into the panel (a dialog named after your brandName); closing returns it to the launcher. The visitor can also close the panel with Escape. A closed panel is hidden and out of the tab order.
On phones (screens up to 480 px wide, and landscape phones up to 480 px tall) the open panel is a full-screen sheet: it fits the visible viewport (above the on-screen keyboard, inside the safe-area insets; on a page with viewport-fit=cover the colored header reaches under the status bar and its content stays below it), the page behind it does not scroll (the widget sets overflow: hidden on html and body while the sheet is open and restores your values when it closes), the sheet is opaque (your page never shows through it), and Tab stays inside the sheet.
bettercom('identify', user)#
Tells the widget who the visitor is: name, email, your own user id (with its hash, to prove it) and custom attributes. Fields are merged with what you sent before. A different userId never keeps the previous userHash; the same userId does, so a later call that only adds attributes or an email (identify({ userId, attributes })) stays verified.
window.bettercom('identify', {
userId: 'u_123',
userHash: '4f1c...e9', // hex HMAC-SHA256 of the userId, computed on your server
name: 'Ada Lovelace',
email: 'ada@example.com',
attributes: { plan: 'pro', seats: 12, trial: false }
})The widget sends it to the server once the visitor exists (right after the launcher's settings request, so the panel does not have to be open), and again whenever it changes. See User for every field, the limits and identity verification.
bettercom('setLocale', locale)#
Sets the visitor's language at runtime, for example when your site's own language switcher changes. locale is a BCP 47 tag ('fr', 'fr-CA'; 'pt_BR' is accepted too). It applies at once, also before the widget has finished loading, and the open panel switches its text in place (nothing reloads, the conversation and the draft stay). null or '' removes your choice, so the workspace default and the browser decide again. The choice also holds for a later boot in the same page view.
window.bettercom('setLocale', 'de')
window.bettercom('setLocale', null) // back to workspace default / browserbettercom('consent', value)#
Grants or revokes page-view tracking at runtime, for example from your cookie banner. true or 'granted' grants, false or 'denied' revokes, null leaves it to the workspace's setting. It applies at once: storage moves between persistent and session-only, page views start or stop, and the server is told (and, on a revoke, deletes the page history it stored for the visitor). The widget does not remember the choice itself: pass it on every page load (bettercomConfig.tracking or boot), as your consent tool knows it. See Consent and tracking.
window.bettercom('consent', true) // the visitor accepted
window.bettercom('consent', false) // the visitor declined or withdrewbettercom('on', event, handler) / bettercom('off', event, handler?)#
Adds or removes an event handler. off without a handler removes every handler of that event. Handlers survive shutdown and boot. A handler that throws does not affect the widget or other handlers (the error is rethrown asynchronously, so it still shows in the console). See Events.
bettercom('shutdown')#
Removes the widget and forgets the visitor on this browser. Call it on logout. It applies at once, also when the launcher has not appeared yet (a logout that navigates away right after the call leaves nothing stored), and a settings or identify answer still in flight is ignored. It:
- closes the chat connection (agents see the visitor go offline) and unmounts the panel, firing
closefirst when the panel was open; - ends a voice call in progress (the agent is told, the microphone and the call connection are released);
- removes the widget's host element, the helper and panel scripts and their internal handles (
window.__bettercomHelper,window.__bettercomConversation), and every listener and timer the widget added;history.pushStateandhistory.replaceStateare restored (unless another script wrapped them after the widget did); - clears the visitor id and token (both cookies and
localStorage), the remembered panel state and view, the dismissed-preview marker, and the unsent-message queue insessionStorage.
window.bettercom itself stays, and nothing starts the widget again until the next bettercom('boot'), which starts it in the same page view, as a new anonymous visitor. Event handlers stay registered; the user passed to identify is forgotten.
Events#
window.bettercom('on', 'messageReceived', function (e) {
console.log('Reply in', e.conversationId, ':', e.text)
})| Event | Payload | When |
|---|---|---|
ready | none | The launcher is on the page (after the load timing above). A handler added after that runs at once (asynchronously). Fires again after a shutdown and boot. |
open | none | The panel opened (click, open, toggle, a restored open panel). |
close | none | The panel closed. |
unreadCountChange | { count: number } | The visitor's unread count changed (it drops to 0 when the panel opens). Only on a change. |
messageReceived | { conversationId: string, messageId: string, text: string, automated: boolean, ts: number } | A message from the team (a person, or the AI agent when automated is true) arrived on the chat connection. text is plain text, empty for a voice message or files only. ts is milliseconds since 1970. |
messageSent | { conversationId: string, messageId: string, text: string, ts: number } | A message the visitor sent was stored by the server (not when it was only queued). |
messageReceived and messageSent come from the chat connection, which exists once the visitor has opened the panel in this page view; before that, unreadCountChange tells you about new replies.
User#
The visitor identity object, passed to identify, update({ user }), boot({ user }) or bettercomConfig.user:
type User = {
name?: string // alias for fullName (if both are given, name wins)
fullName?: string
email?: string
userId?: string | number // your own id for this user, at most 255 characters
userHash?: string // hex HMAC-SHA256(identity secret, userId), 64 characters
attributes?: { [key: string]: string | number | boolean | null }
} Values are trimmed; empty strings are treated as missing. A userHash without a userId is ignored, and a userId over 255 characters is dropped.
Attributes are a flat map, shown to your team next to the conversation. Limits (the server applies them and saves the rest):
- at most 50 keys per visitor;
- keys start with a letter and use letters, digits and
_, at most 40 characters (plan,seats,signed_up_at);email,name,fullName,userId,userHash,idand_idare reserved; - values are strings (trimmed, at most 255 characters), finite numbers or booleans;
nullremoves a key; nested objects and arrays are refused.
Refused keys are listed in one console warning (attributes not saved: ...). Attributes merge per key with what was stored before.
When a user is given:
- The in-widget identify form is hidden on the Home view.
- The widget sends it to
POST /v1/widget/identifyonce the visitor exists, so the dashboard shows the name, email and attributes even if the visitor never opens the panel. The code that sends it is the helper (about 2.3 KB): on a page that passes a user, the launcher fetches it (cached like any script) after yourloadevent, then sends. The panel is not fetched for this. Calls made before it arrives are kept in order and merged; none is dropped. If the helper cannot be fetched, the user waits and the launcher tries again 30 seconds later, when the visitor opens the chat, and when the tab comes back into view. - When the panel is open, name and email also go over the chat connection on every (re)connect.
Identity verification#
Without verification, identity is a claim: anyone can call identify with any name, email or user id, so do not treat it as authentication. With verification, the widget joins a returning user to their earlier conversations on any device.
- In the dashboard, create the workspace's identity secret (keep it on your server; never put it in the page).
- On your server, compute
userHash = hex(HMAC-SHA256(secret, userId))for the signed-in user and render it into the page. - Pass both:
bettercom('identify', { userId, userHash, ... }).
// Node.js, on your server
const userHash = require('crypto').createHmac('sha256', IDENTITY_SECRET).update(String(user.id)).digest('hex')When the hash matches, the visitor is that user: earlier conversations of the same user (on other browsers or devices) come along, and the widget switches to that visitor if needed (an open panel reconnects as it).
When the hash does not match, or is missing while the workspace requires verification, the visitor is not verified: no earlier conversations come along, and the widget logs one console warning (identify: userHash rejected...). With Require verification on, nothing from that call is saved. With it off, the name, email and user id are saved, marked unverified for your team, and never used to join conversations; a visitor who is already verified keeps its name and email.
Languages#
The widget speaks English, German (de), Spanish (es), French (fr), Italian (it), Portuguese (pt), Dutch (nl), Swedish (sv) and Bosnian (bs). Every visible string and every screen reader label is translated; dates, times and numbers are formatted for the visitor's locale. Product names (Bettercom, Aura) are not translated.
Which language. The first of these that the widget can speak:
- the locale your page passes:
bettercomConfig.locale,bootorupdatewithlocale, or setLocale; - the workspace's default language (
localein the workspace's widget settings); - the browser's preferred languages, in order (
navigator.languages); - English.
A regional tag uses its base language (pt-BR and pt-PT speak pt, de-AT speaks de) and keeps the full tag for dates and numbers. A locale the widget does not speak (say ja) is skipped and the next one is tried, unless the workspace has written its own strings for that language (below), in which case it is used with those strings and English for the rest. The widget sets lang on its host element (and on the panel) to the tag it chose.
Loading. English is built into the widget. Another language is a separate file of up to 2.9 KB (brotli), fetched from the same host as the widget script (/v1/widget/lang/<code>.js?v=<build>, a script tag, so your CSP's script-src for the widget host already covers it) only when the visitor's language resolves to it, after the launcher has appeared. The widget requests only the languages the server lists as served (langs in the settings response); a server that lists none gets no language requests, and the visitor sees English plus any wording the workspace wrote for their language, with lang set to the language actually shown. Until it arrives, and if it cannot load, the widget shows English; when it lands only the text changes (no layout jump, no reload).
Workspace wording. A workspace can replace any string, per language, in its widget settings (strings, below). Those win over the built-in translation. They are plain text: markup is shown as typed, never rendered. The five welcome settings (welcomeEyebrow, welcomeEyebrowPersonalized, welcomeTitle, welcomeMessage, faqSectionTitle) follow the visitor's language while they are left at their English defaults; once a workspace writes its own text there, that text is shown as written in every language (use strings to give it per language versions).
Theming#
Two independent dimensions:
- Org branding. Set by the organization in the dashboard and fetched from
GET /v1/widget/settings/:shortIdautomatically. Host pages cannot override these:
| Setting | Effect |
|---|---|
primaryColor | Brand color for the launcher, header, buttons, and visitor bubbles. |
welcomeEyebrow, welcomeEyebrowPersonalized | Small greeting above the title. The personalized one is used once the visitor is identified and supports {firstName}, {fullName}, {email} placeholders. |
welcomeTitle, welcomeMessage | Home view heading and text. |
faqSectionTitle | Heading over the FAQ list (empty hides it). |
brandName | Name shown in headers, on threads nobody has claimed yet, and on the message preview bubble when the server does not name the reply's author. |
responseTime | Expected reply time copy. |
position | right (default) or left. |
launcherSize | cozy (default) or compact. |
logoUrl, logoFillColor | Logo in the Home header; SVG logos are tinted with the fill color. A logo that cannot be loaded (or an SVG served without an image type) shows brandName in its place. |
showAfterSeconds, lcpGated | Launcher load timing (see Load timing). |
showPoweredBy | "Powered by Bettercom" footer (plan dependent). |
preChatEmail | { enabled, required }: ask for an email before the first message. |
locale | Default language for visitors ('de', 'pt-BR'). See Languages. |
strings | Own wording per language, keyed by base language code, then by string key. Plain text, up to 300 characters each; unknown keys are ignored. |
availability | Business hours and away mode: whether the team is there for visitors. See Away and business hours. |
- Light / dark mode. Controlled by the host site via
darkMode: true|false. Affects neutrals (panel background, message bubbles, input chrome) but never the org primary color or message body styles.
There is no "auto" mode that follows the OS. Host sites that want OS-driven theming should implement that themselves and call bettercom('update', { darkMode: matchMedia('(prefers-color-scheme: dark)').matches }).
The launcher button is exactly primaryColor; its icon is white or dark, whichever keeps 3:1 against it. Text and controls on primaryColor stay readable (WCAG AA, 4.5:1) for any color you pick: on light colors the text turns dark; on mid colors the fill behind text is deepened just enough for white text to pass. Links, focus rings and other accent-colored text on the panel use a matching readable shade. Keyboard focus shows a visible ring on every control. On Home the brand color is the hero only; the rest of the panel, the tab bar and the footer are neutral in every view, with the brand color marking the active tab. A teammate photo that cannot be loaded leaves the teammate's initial on a quiet disc, and an article or answer image that cannot be loaded leaves a neutral box: never a broken image icon.
Motion is short (120 to 220 ms), eases out and never bounces, and moves only with transform and opacity: the panel opens out of the launcher button, new messages rise a few pixels as they fade in, banners slide in. The typing dots run five passes (about 7 seconds) and stop; the only loop is a ringing call. Nothing in the panel animates while it is closed. While content loads, the panel shows a still skeleton in the shape of what is coming, not a spinner.
The widget follows the visitor's reduced-motion preference: nothing moves, slides or loops; only fades stay (the panel fades in place). On phones the open panel fills the screen (see open / close).
What the widget does to your page#
For developers checking the widget's footprint:
- One global.
window.bettercom(the function above).window.bettercomConfigis yours; the widget reads a copy of it and never writes it (nor creates it). While the helper is loaded,window.__bettercomHelperholds its internal handle, and while the panel is loaded,window.__bettercomConversationholds its (neither is an API; both are removed onshutdown). - Elements.
<div id="bettercom-root" lang="...">at the end of<body>, holding a closed shadow root, and, once loaded, the helper's script<script id="bettercom-helper-script">and the panel's<script id="bettercom-panel-script">in<head>(language files add a script tag that removes itself once loaded). All widget styles live inside the shadow root; nothing reaches your page's CSS. Your CSS does not reach inside it either (all: initialon the host), but rules that match the host element itself still apply to it, for examplebody > div { transform: ... }or a lowz-indexstacking context: keep such rules off#bettercom-root. The launcher and panel areposition: fixed: they reserve no space in your layout and cause no layout shift. - Weight. The launcher script is under 8 KB (brotli; about 7.8 KB today). The panel (about 59.5 KB, under a 60 KB budget) loads only when the visitor opens the chat, never before your
loadevent. The helper (about 2.3 KB, under a 3 KB budget) comes afterloadonly when there is a user to send or an unread reply to show. A language file (up to 2.9 KB, under 3 KB) is fetched only for a visitor whose language needs it. The budgets are enforced by the widget build, which also writes the exact byte counts to its build report. - Timing. When the DOM is ready (
DOMContentLoaded) the launcher fetches its settings and, with tracking on, sends one page-view ping and starts watching navigation; with a host consent choice it also posts it once the visitor exists (andidentifyafterload, above). It draws nothing until yourloadevent plusshowAfterSeconds(or, withlcpGated, until your Largest Contentful Paint settles). The launcher does no synchronous layout reads. - Requests. Only to the widget's own host (settings, unread count, page-view pings, the helper, panel and language scripts, the chat socket). No third-party requests, fonts or trackers. One exception, voice calls: a call connects through the STUN and TURN servers the server sends; when the server sends none, the widget uses Google's public STUN server (
stun.l.google.com), which sees the visitor's IP address. Nothing is contacted unless a call starts. Text uses the visitor's system font (system-ui). - Listeners. On
window:popstate,hashchangeandpagehide(page views, only with tracking on), until the launcher appears a one-timeloadlistener, and until your LCP settles, one-timekeydown,pointerdownandscrolllisteners (none of them can delay scrolling). Ondocument:visibilitychange, and a one-timeDOMContentLoadedwhen the script runs while the page is still loading.history.pushStateandreplaceStateare wrapped to see page changes (tracking on only) and restored onshutdownwhen nothing wrapped them after the widget. Once the panel has been opened it also listens toonline,offlineandpageshowonwindow, to amatchMediachange for the phone-sheet breakpoint, and, while a phone sheet is open, tovisualViewportresizeandscroll(passive).shutdownremoves all of them. While the panel is open on a phone,htmlandbodygetoverflow: hidden. - Timers. One unread-count poll every 30 seconds while the panel is closed and the tab visible.
Data the widget stores#
For your cookie and privacy notices. <id> is the organization's short id. "Persistent" is the lifetime with tracking allowed; with tracking off (see Consent and tracking) every row is session-only: cookies have no expiry (they end with the browser session) and localStorage rows live in sessionStorage instead.
| Where | Key | Purpose | Persistent lifetime | Tracking off |
|---|---|---|---|---|
| Cookie (host page domain) | bettercom-<id>-id | Visitor id, so the visitor keeps their conversations | 400 days, renewed whenever the widget reads it | Session cookie |
| Cookie (host page domain) | bettercom-<id>-vtk | Visitor token: the secret that proves this browser owns the visitor id. Without it the id opens nothing | 400 days, renewed whenever the widget reads it | Session cookie |
| localStorage | bettercom-<id>-vid | Copy of the visitor id (fallback when cookies are blocked) | Until cleared | sessionStorage, until the tab closes |
| localStorage | bettercom-<id>-vtk | Copy of the visitor token | Until cleared | sessionStorage |
| localStorage | bettercom-<id>-launcher-open | Re-opens the panel on the next page load if it was left open (not on phones) | Until the panel is closed | sessionStorage |
| localStorage | bettercom-<id>-view | Last panel view (home, list, messages) | Until cleared | sessionStorage |
| localStorage | bettercom-<id>-bubble-dismissed-ts | Hides a dismissed message preview bubble | Until cleared | sessionStorage |
| sessionStorage | bettercom-<id>-outbox | Messages the visitor sent that the server has not confirmed yet, so a reload can deliver them | Until they are delivered or the tab closes | Same |
| sessionStorage | bettercom-<id>-away | Conversations in which the visitor dismissed the away email line | Until the tab closes | Same |
The visitor id, token and outbox are what a conversation needs and are always kept for the session. shutdown clears every key in this table for the organization, from both storages.
Consent and tracking#
Page-view tracking is on by default. It is off when your page says so (bettercomConfig.tracking: false, boot/update with tracking: false, or bettercom('consent', false)), or when the workspace turned page views off in its settings and your page has not granted consent.
With tracking off the widget:
- sends no page-view pings and installs no navigation listeners (if it already installed them, they stay but send nothing), and does not report panel opens (
/v1/widget/open); - keeps only session storage, as in the table above (granting or revoking at runtime moves what is stored, so nothing persistent is left after a revoke);
- tells the server, which then skips device and location enrichment for the visitor. A returning visitor in a new browser session starts as a new visitor, unless you identify them with a verified user id.
Chat itself works the same either way.
What the widget sends:
- Page views (tracking on only). On load and on every in-page navigation, a ping with the page's hostname, path, and hash. To observe single-page-app navigation the launcher wraps
history.pushStateandhistory.replaceStateand listens forpopstate,hashchange, andpagehide. - Device and location (tracking on only). The server derives device information from the user agent and an approximate location from the IP address (GeoIP), plus the browser's time zone (the settings request leaves the time zone out when your page has declined tracking).
- Identity, when your page calls
identify(see User). - Consent, when your page sets or changes it.
- Language file. When the visitor's language is not English and the server serves it, one request for that language's strings to the widget host (no visitor data in it).
- Unread checks. While the panel is closed and the tab is visible, the launcher asks for the visitor's unread count every 30 seconds and when the tab comes back into view. Hidden tabs do not ask. When the answer carries a message preview the visitor has not dismissed, the helper is fetched (after
load, about 2.3 KB; not the panel) to draw the preview bubble.
Several tabs#
Tabs of your site in the same browser share one visitor. Open panels keep each other current over a BroadcastChannel named bettercom-<id> (no storage, nothing leaves the browser): a message sent in one tab appears in the others' open conversation. The unread count and preview bubble are fetched by each tab when it comes into view, so a tab you switch to shows the current count at once (a panel opened in one tab has cleared it on the server). Each tab keeps its own unsent-message queue, so nothing is sent twice. Browsers without BroadcastChannel catch up from the server instead.
Visitor credential#
The visitor id is a public identifier. The server issues a random token with it and keeps only a hash. The token is handed to the widget exactly once: in the response to the launcher's settings request that created the visitor (or that gave a visitor stored before tokens existed its first token). The widget stores both (see the table above).
On every later page load the launcher's settings request presents the stored pair, and the server answers with the same visitor and no token. A request that presents an id without its matching token gets a brand-new, empty visitor (and that visitor's token) instead; the old id opens nothing.
The widget presents the pair the same way everywhere: on its chat connection, and as the X-Bettercom-Visitor-Id and X-Bettercom-Visitor-Token request headers on every visitor request (settings, unread count, page-view ping, panel open, identify, consent, CSAT, uploads, help-article feedback). A visitor request with an id but no matching token is refused. The token never appears in a URL.
The token cookie is readable by scripts on your page, like the id cookie. Anything that can run script on your page can read it and act as that visitor in the chat; keep third-party scripts you do not trust off pages that embed the widget.
Links and images in messages#
Messages, FAQ answers, and help articles are shown with links and images kept only when they are safe: links may use http, https, mailto, or tel; images http or https. A path that starts with a single / (for example an uploaded file at /v1/uploads/...) points at the Bettercom server, not at your site, and opens there. Other relative links (docs/x, ../x, #top) are removed and their text is shown without a link. Every link opens in a new tab.
Sending#
Messages the visitor sends while offline are queued and delivered in order when the connection comes back. The queue also survives a reload of the page (for that browser tab) and is delivered the next time the panel opens. A message the server does not confirm within 10 seconds is sent once more automatically; if that also goes unconfirmed it is marked "Not delivered" with a Retry button. Retrying never creates a duplicate, and the first messages of a new conversation never start a second one. A message that was not delivered stays in its own conversation (or, for a conversation not yet created, comes back when the visitor starts a new one). Reactions are available once a message is delivered. The visitor's latest delivered message is marked "Sent".
When the server refuses a message it says why, in plain words, under the message, and offers what helps:
| Refusal | The visitor sees | Actions |
|---|---|---|
Too long (BODY_TOO_LONG, MESSAGE_TOO_LONG) | Too long to send. Shorten it and send again. | Edit (puts the text back in the composer), Remove |
Too fast (RATE_LIMITED) | Sent too fast. Trying again in a moment. The queue waits the server's retryAfterMs (5 seconds without it, at most 60), then sends once more; a second refusal asks the visitor to retry. | Retry, Remove |
Blocked (VISITOR_BLOCKED) | This chat is not accepting your messages. The composer is disabled with the same sentence. | Remove |
Empty (EMPTY) | This message was empty. | Remove |
Bad attachment (INVALID_ATTACHMENT) | This attachment cannot be sent. | Remove |
Conversation gone (NOT_FOUND) | This conversation is no longer available. | Edit, Remove |
| Anything else, or no answer | Not delivered. | Retry, Remove |
A message longer than the server accepts is stopped before it is sent, with its text kept. A file over 5 MB is not attached and the visitor is told which one. A failed upload says so (too large, type not accepted, too many at once, not allowed, or a connection problem) and keeps the attachments in the composer for another try.
Connection#
The conversation shows the connection state in words under its header: "Connecting…" (only if it takes more than a moment), "Reconnecting" and "You are offline" (both say that messages sent now will go out later), "Back online." for a few seconds after a reconnect, and "Chat is not reachable right now." with a Retry button when the server closed the connection or the first connection did not succeed within 12 seconds. Screen readers hear these changes and every new message (with who wrote it) through a polite live region. When the visitor comes back to a tab that was in the background, the widget reconnects at once if needed and fetches anything it missed. The connection falls back to HTTP long polling on networks that block WebSockets.
Away and business hours#
When the workspace's business hours are over, its away mode is on, or no teammate is available, the server says so (availability in the widget settings, updated live over the chat connection). The widget then:
- shows an amber status dot and "Away" in the conversation header, with when the team is back in the visitor's own time zone and language ("Back at 9:00", "Back tomorrow at 9:00", "Back Monday at 9:00", "Back on Dec 24 at 9:00");
- shows the same line on Home in place of the reply-time line;
- shows one line above the composer, before the visitor writes: "Leave your email and we'll get back to you." Opening it shows the workspace's own away message (when set), when the team is back, and the email field; the visitor can also dismiss it for that conversation (for the rest of the tab's session). It is not shown when the visitor's email is known (identify, an earlier form, or a required pre-chat email), or when a teammate is assigned to the conversation.
Without that field (older servers) the widget counts the team as online, as before, and offers the email capture only when nobody is online and the visitor has written.
Talk to a person#
While the AI agent (Aura) is the one answering a conversation, the conversation ends with one action: "Talk to a person". Using it sends one request; the action is replaced by "The team has been notified. A teammate will reply here." (also read out to screen readers), and the AI stops answering in that conversation. If the request does not go through, the action comes back with "Could not reach the team. Try again." The row disappears once a teammate takes the conversation. A visitor who types a request for a person ("can I talk to a human?") gets the same result: the server recognizes it. The action appears only when the server supports it.
Quick reference#
// Boot (implicit via bettercomConfig + script tag, or explicit)
window.bettercom('boot', { clientId?, darkMode?, user?, locale?, tracking? })
// Live updates
window.bettercom('update', { darkMode?, user?, locale?, tracking? })
// Who the visitor is (userHash: hex HMAC-SHA256 of userId, from your server)
window.bettercom('identify', { userId?, userHash?, name?, email?, attributes? })
// Language (null: back to workspace default, then browser)
window.bettercom('setLocale', 'de')
// Tracking consent (null: the workspace decides)
window.bettercom('consent', true)
// Visibility (show and hide are aliases of open and close)
window.bettercom('open')
window.bettercom('close')
window.bettercom('toggle')
// Events: ready, open, close, unreadCountChange, messageReceived, messageSent
window.bettercom('on', 'unreadCountChange', function (e) { console.log(e.count) })
window.bettercom('off', 'unreadCountChange')
// Remove from the page and forget the visitor (logout); boot starts again
window.bettercom('shutdown')