Widget
For Developers

Widget

The chat widget is one script tag on your page. It shows a button in the corner and opens the support chat, the message feed and the visitor’s past conversations. Visitors don’t install anything or sign up.

Minimal install

index.html
<script>
(function(w, d){
  function loadWidget(token){
    var s = d.createElement('script');
    s.async = 1;
    s.src = 'https://support.forestsnet.com/widget-bundle?ws=WORKSPACE_UUID';
    if (token) s.setAttribute('data-visitor-token', token);
    d.head.appendChild(s);
  }
  // Optional: a signed visitor token from your server
  // (one visitor across devices, see /docs/widget/hmac).
  // Without it, just call loadWidget().
  fetch('/api/supporthub/token', { credentials: 'include' })
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(data){ loadWidget(data && data.token); })
    .catch(function(){ loadWidget(); });
})(window, document);
</script>
Auto-start needs only the ?ws= parameter, the project’s UUID. A ready snippet with it is in Settings → Channels → Web Widget and in Widget Builder → Embed snippet. The /widget-bundle response already carries the API address and, if the server got the widget settings in time, the settings too, so no separate request for them is needed. Otherwise the widget fetches them itself.

Script tag attributes

The bundle reads data-* from its own script tag. Write them into the tag directly or, in the snippet above, set them on the created element with s.setAttribute(…), the way data-visitor-token is passed.

AttributeWhat it does
?ws= (query)The project’s UUID. Required for auto-start.
data-emailThe visitor’s email. Sent to identify on load; it overwrites the contact’s email.
data-nameThe visitor’s name. Saved if the contact has no name yet (or only a placeholder such as “Guest”).
data-phoneA phone number, as is. Saved to the contact’s extra_data.phone if it’s empty.
data-telegram-idThe visitor’s Telegram ID (a number). Saved to the contact if it has no Telegram ID yet.
data-visitor-tokenA signed visitor token from your server: it ties the visitor on different devices to one contact. The format is in HMAC visitor token; the signing secret comes from GET /api/v1/widget/signing-secret.
data-localeThe widget language: ru or en (en-GB → en). How the language is picked is below.
data-cookie-domainThe domain of the visitor cookie, for example .example.com, to keep one conversation across subdomains. Takes priority over the builder value.
data-hide-launcher="true"Hide the floating button. Open the widget from your own button with SupportHub.open().
data-api-baseThe API address. The /widget-bundle response sets the address from the server configuration itself, and that value wins, so with the standard snippet this attribute has no effect.

Passing user data

If your site already knows the visitor, pass the data in the tag. On load the widget sends it to POST /api/webhooks/widget/{ws}/identify and creates or updates the contact, even if the visitor hasn’t opened the chat yet. The pre-chat form no longer asks for fields that are already known.

index.html (SSR / Blade / EJS / Twig)
<script
  async
  src="https://support.forestsnet.com/widget-bundle?ws=WORKSPACE_UUID"
  data-email="{{ user.email }}"
  data-name="{{ user.full_name }}"
  data-phone="{{ user.phone }}"
  data-telegram-id="{{ user.telegram_id }}"
></script>

Identifying from JavaScript

If the user signs in after the page has loaded (an SPA), call window.SupportHub.identify(). It’s available as soon as the bundle has run; calls made before the widget finished starting are sent once it’s up. Before the bundle has loaded, window.SupportHub doesn’t exist.

app.js
// After the user signs in:
await window.SupportHub.identify({
  email: user.email,
  name: user.fullName,
  phone: user.phone,
  telegram_id: user.telegramId,
  // visitor_token: tokenFromYourServer,
});

The fields are the same as the data-* ones, plus visitor_token; full_name works instead of name. Send only what you know: empty fields erase nothing. The email is overwritten on every call; the name, phone and Telegram ID are saved only if the contact doesn’t have them yet.

Widget language

The widget speaks two languages, ru and en. If the builder’s Brand → Widget language is set to Always Russian or Always English (brand.language is "ru" or "en"), the widget is always in that language and the chain below doesn’t apply. With Auto ("auto", the default), the language is picked down this chain; the first usable value wins:

1init({ locale })the language passed to SupportHub.init()
2window.__SH_LOCALEa global your page set before the bundle loaded
3data-localethe script tag attribute
4<html lang>the page language (ru, en, en-GB), if the project’s texts exist in it
5navigator.languagethe visitor’s browser language, if the project’s texts exist in it
6computed.locales.defaultthe project’s main language, when nothing above fits

Steps 1–3 always apply. Steps 4 and 5 count only for the languages in computed.locales.supported of the /config response: a language is there when every text the project changed in the builder exists in it (the rule is on the Brand and header page). Other languages (fr, xx) are skipped. The first two letters count: en-GB → en, ru-RU → ru. Most of the time you don’t need to do anything: with <html lang="en"> the widget is in English on its own if the project’s texts exist in English.

With the script tag attribute
<script
  src="https://support.forestsnet.com/widget-bundle?ws=WORKSPACE_UUID"
  data-locale="en"
  async
></script>
With a global set before loading
<script>window.__SH_LOCALE = "en";</script>
<script src="https://support.forestsnet.com/widget-bundle?ws=WORKSPACE_UUID" async></script>
Manual start with init() (SPA)
<!-- The bundle without ?ws= doesn't start by itself -->
<script>window.__SH_LOCALE = "en";</script>
<script src="https://support.forestsnet.com/widget-bundle" async
        onload="SupportHub.init({ workspace_id: 'WORKSPACE_UUID' })"></script>

<script>
  // Switch the language on the fly: remove the widget and start it again
  function setWidgetLocale(lang) {
    window.__SH_LOCALE = lang; // the widget language; builder texts switch along with it
    window.SupportHub.destroy();
    window.SupportHub.init({ workspace_id: "WORKSPACE_UUID", locale: lang });
  }
</script>
Don’t call init() if the tag is already on the page with ?ws=: the widget is running and you’d get a second one. applyConfig() is not the way to switch the language on a live site: it replaces the widget settings with the defaults plus the fields you pass (the method is for the builder preview).

Texts in two languages

The widget’s buttons and service labels always follow the widget language. The texts you write in the widget builder can be set per language: the language switch (RU / EN) sits above the section. These fields are: Brand (title, subtitle, project name), Welcome (title, subtitle, message, button, response time), Forms (the pre-chat message), Conversation (the history label, the rating prompt), Hours (offline, outside hours, the close button), System Messages and Email Bridge (label and hint). If a text is empty in the needed language, the other language’s version is shown.

The widget picks the text variant itself, in the same language as its buttons (by the chain above, init({ locale }) included). The settings come with every language at once (/config?locale=all): both when they’re built into the bundle with the standard snippet (?ws=) and when the widget requests them after a start with init(). Your edits in Interface Texts work per language the same way: the widget takes the edits for its own language, and a language with no edits keeps the standard texts.

Where the visitor session is stored

The visitor ID (vs_<random>) ties an anonymous session to a contact. To survive reloads, closed tabs and a cleared storage here and there, the widget keeps it in five places:

#StorageNotes
1cookie sh_visitor_<ws>1 year, Path=/, SameSite=Lax, Secure on https. The only storage that works across subdomains (with the cookie domain). Safari (ITP) keeps cookies set by scripts for 7 days at most.
2localStorage sh-visitor-<ws>Per origin, until site data is cleared.
3sessionStorage sh-visitor-<ws>Until the tab is closed. Helps when cookies and localStorage are unavailable.
4IndexedDB sh-widgetWritten in the background; read on start only if storages 1–3 are empty.
5localStorage sh-visitor-sessionThe old key with no project in it: read to carry over earlier visitors, written if missing.

Read order: 1 → 2 → 3 → 5. The first non-empty value wins and is copied into the other storages: if the cookie is gone, the next load picks the ID up from localStorage and writes it back to the cookie.

Tabs in one browser sync through BroadcastChannel('sh-widget-sync'), or the storage event where it isn’t available, so two tabs opened at once don’t create two IDs.

There is nothing to configure here. The only setting is the cookie domain for subdomains (below).

One visitor across subdomains

By default the cookie is set for the current host: a visitor on example.com and on app.example.com is two contacts with two conversations. Storages 2–5 are always tied to the origin, so a shared session is possible only through a cookie with a domain:

  • In the builder: Widget Builder → Advanced → Cookie domain → .example.com. The snippet stays the same on every subdomain.
  • With the attribute: data-cookie-domain=".example.com". The attribute takes priority over the builder value, handy when staging and production live on different domains.
Use your own domain, not a public suffix such as .co.uk: the browser rejects such a cookie. The leading dot is optional: a cookie with a Domain attribute covers all subdomains anyway.

Earlier visitors are carried over automatically: on the first visit the ID from the sh-visitor-session key is written into the cookie with your domain, and on the next subdomain the browser finds the same session.

JS API

init, identify, logout, on and off are on window.SupportHub as soon as the bundle has run; the other methods appear once the widget has started.

MethodPurpose
init(opts)Manual start. Options: workspace_id, locale, hideLauncher, apiBase, config, mode, onTokenExpired. With ?ws= in the tag the widget starts by itself.
open()Open the panel (for example, from your own button).
close()Collapse the panel.
isOpen()true if the panel is open.
setTab(tab)Switch the tab: "home" | "chat" | "help" | "news" | "miniapp".
identify(payload)Pass the visitor’s data: email, name, phone, telegram_id, visitor_token. Returns a Promise.
logout()The user signed out of your site: forget the token and the identify data, start a new anonymous session ID, close the WebSocket and take the widget back home. Returns a Promise. See Identifying visitors.
on(event, cb)Listen to a widget event (see “Events on the page”). Returns a function that removes the listener.
off(event, cb)Remove a listener added with on.
applyConfig(cfg)For the builder preview: replaces the settings with the defaults plus what you pass. Not for your site.
showState(s, sub?)Demo states for the preview; does nothing on a site.
destroy()Remove the widget: closes the WebSocket and removes its elements from the page. After that you can call init() again.

What happens on load

  1. The bundle works out the API address (window.__SH_API_BASE): the /widget-bundle response sets it; without that, it’s data-api-base, the script’s origin or the SupportHub cloud. It reads the data-* attributes and, if the script address has ?ws=, starts the widget.
  2. It takes the settings from window.__SH_WIDGET_CONFIG or requests GET /api/webhooks/widget/{ws}/config.
  3. It finds the visitor ID in storage or creates a new one.
  4. It sends the visitor’s data, if any: POST /api/webhooks/widget/{ws}/identify.
  5. It asks for an open conversation: GET /api/webhooks/widget/{ws}/recent. If there is one, it opens it and connects the WebSocket; if not, the WebSocket connects after the visitor’s first message.

Globals

  • window.SupportHub: the methods above
  • window.__SH_API_BASE: the API address
  • window.__SH_WIDGET_CONFIG: the widget settings, if the server put them into the bundle
  • window.__SH_LOCALE: the language from data-locale or set by your page
  • window.__SH_WIDGET_V2_MOUNTED: guards against a second auto-start
  • Variables prefixed with __sh_ are internal

WebSocket

Once the visitor has a conversation, the widget connects to wss://api.support.forestsnet.com/api/ws/widget/{visitor_id}?workspace_id={ws} (plus &signed_token=… with a visitor token). If there is no contact for the visitor, the server closes the connection with code 4401. Frames look like {"event": "…", "data": {…}}; the visitor gets the events of their own tickets:

  • message.created: a new message (internal notes don’t get here)
  • message.edited: a message was edited (content, edited_at)
  • message.deleted: a message was deleted (message_id, ticket_id)
  • typing: an operator is typing (ticket_id, sender), while typing indicators are on in the builder
  • message.seen_by_operator: an operator opened the conversation (message_id, ticket_id)
  • ticket.assigned: an operator took the ticket (operator_name, operator_avatar_url)
  • ticket.closed, ticket.reopened, ticket.transferred, ticket.updated and other ticket events

The server closes the connection if nothing has come from the client for 90 seconds and marks the visitor offline. To keep it open, send {"type": "ping"}; the server answers {"type": "pong"}. {"type": "typing", "ticket_id": "…"} shows the operators the visitor is typing. A refused signed_token is closed with code 4440 (expired) or 4401 (bad signature): connecting again with the same token is pointless. After a disconnect the widget reconnects by itself and loads the messages that came meanwhile; after such a code it waits for a new token.

Events on the page

SupportHub.on(event, cb) tells your page about two things. token_expired: the server refused the visitor_token; cb gets { reason: "expired" | "invalid" }, once per token. Fetch a fresh one and pass it to identify (see Identifying visitors). unread: the number of unread replies changed, { count }; useful when the launcher is hidden (hideLauncher) and you draw your own. To react to what happens in chats on your server, use webhooks.

Was this page helpful?