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
<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>?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.
| Attribute | What it does |
|---|---|
| ?ws= (query) | The project’s UUID. Required for auto-start. |
| data-email | The visitor’s email. Sent to identify on load; it overwrites the contact’s email. |
| data-name | The visitor’s name. Saved if the contact has no name yet (or only a placeholder such as “Guest”). |
| data-phone | A phone number, as is. Saved to the contact’s extra_data.phone if it’s empty. |
| data-telegram-id | The visitor’s Telegram ID (a number). Saved to the contact if it has no Telegram ID yet. |
| data-visitor-token | A 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-locale | The widget language: ru or en (en-GB → en). How the language is picked is below. |
| data-cookie-domain | The 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-base | The 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.
<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.
// 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:
| 1 | init({ locale }) | the language passed to SupportHub.init() |
| 2 | window.__SH_LOCALE | a global your page set before the bundle loaded |
| 3 | data-locale | the script tag attribute |
| 4 | <html lang> | the page language (ru, en, en-GB), if the project’s texts exist in it |
| 5 | navigator.language | the visitor’s browser language, if the project’s texts exist in it |
| 6 | computed.locales.default | the 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.
<script
src="https://support.forestsnet.com/widget-bundle?ws=WORKSPACE_UUID"
data-locale="en"
async
></script><script>window.__SH_LOCALE = "en";</script>
<script src="https://support.forestsnet.com/widget-bundle?ws=WORKSPACE_UUID" async></script><!-- 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>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.
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:
| # | Storage | Notes |
|---|---|---|
| 1 | cookie 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. |
| 2 | localStorage sh-visitor-<ws> | Per origin, until site data is cleared. |
| 3 | sessionStorage sh-visitor-<ws> | Until the tab is closed. Helps when cookies and localStorage are unavailable. |
| 4 | IndexedDB sh-widget | Written in the background; read on start only if storages 1–3 are empty. |
| 5 | localStorage sh-visitor-session | The 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.
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.
.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.
| Method | Purpose |
|---|---|
| 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
- The bundle works out the API address (
window.__SH_API_BASE): the/widget-bundleresponse sets it; without that, it’sdata-api-base, the script’s origin or the SupportHub cloud. It reads thedata-*attributes and, if the script address has?ws=, starts the widget. - It takes the settings from
window.__SH_WIDGET_CONFIGor requestsGET /api/webhooks/widget/{ws}/config. - It finds the visitor ID in storage or creates a new one.
- It sends the visitor’s data, if any:
POST /api/webhooks/widget/{ws}/identify. - 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 abovewindow.__SH_API_BASE: the API addresswindow.__SH_WIDGET_CONFIG: the widget settings, if the server put them into the bundlewindow.__SH_LOCALE: the language fromdata-localeor set by your pagewindow.__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 buildermessage.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.updatedand 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.

