Identifying visitors
Widget / Identify

Identifying visitors

Link a visitor to a contact in SupportHub. There are three ways to do it, depending on what you know about the user and when.

Why you need it

By default a visitor is anonymous: the backend recognizes them by a random vs_* ID that the widget keeps in a cookie, localStorage, sessionStorage and IndexedDB (see multi-tier storage). If your site already knows the user (they’re signed in to your customer account, CRM or app), identify writes their email, name and phone to the contact, and the operator sees them in the contact card right away. To get one user’s conversations from different devices into one contact, you need a visitor_token (method 3).

Three ways

MethodWhen to use
data-* attributesYour server renders the page and already knows the user (Blade / EJS / Twig). Just put the email and name into the template.
window.SupportHub.identify()An SPA where the user signs in after the page loads. Call it from your sign-in handler.
visitor_tokenOne contact across devices: the user writes from a phone and from a laptop. Your backend signs the token with HMAC.

Method 1: data-* attributes

If your backend renders the page when the user is already known, put the data straight into the <script> tag. The widget reads the attributes on load and sends them to POST /api/webhooks/widget/{workspace_id}/identify, provided at least one of the email, name, phone or Telegram ID is set.

Important: the attributes are read from the <script> element that loads the bundle. If you install the widget with the loader from the “Embed snippet”, set them on the element it creates, the same way the loader already sets data-visitor-token: s.setAttribute('data-email', user.email).

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

Available data-* fields

data-email
Type: stringDefault: —
The visitor’s email. Every identify overwrites the contact’s email. The backend finds an anonymous visitor by the vs_* ID, not by email: the same email from another browser gives a separate contact. For one contact across devices, use a visitor_token.
data-name
Type: stringDefault: —
The full name, saved as full_name. It’s written only if the contact has no name yet or has a placeholder one (“Visitor …”, “Guest”).
data-phone
Type: stringDefault: —
Phone number (E.164 is best: +79161234567). Saved to extra_data.phone if the contact has no phone yet.
data-telegram-id
Type: intDefault: —
Numeric Telegram ID. Written to the contact if it has no Telegram ID yet. The backend never uses it to find or merge contacts: a contact from your Telegram bot with the same ID stays separate; see “What happens on the backend” below.
data-visitor-token
Type: string (HMAC)Default: —
An HMAC token from your backend, for cross-device identification (see method 3).

Method 2: window.SupportHub.identify()

In an SPA, where the user signs in after the page loads, call the JS API. The method is available as soon as the bundle loads, so there’s no need to wait for init(): calls made before initialization finishes are queued and sent once the widget starts. It accepts email, name (or full_name), phone, telegram_id and visitor_token.

js
// onLoginSuccess callback
window.SupportHub.identify({
  email: user.email,
  name: user.fullName,
  phone: user.phone,
  telegram_id: user.telegramId,
});

How it behaves

  • If the call has at least one field (email, name, phone or Telegram ID), the widget sends it to the backend. The contact is created if it doesn’t exist yet, even if the visitor hasn’t sent a single message.
  • If the contact already exists, the email is overwritten on every call; the name is written only if there’s none or it’s a placeholder; the phone and Telegram ID only if they’re still empty. Empty fields don’t overwrite anything.
  • Every call with data is a request to the backend (at most 20 per minute per visitor) and a contact.identified event. If it includes an email and the visitor has an open ticket, it also adds a row to the ticket timeline noting that the client introduced themselves. So call identify when the user signs in or their details change, not on every route change.
  • Errors aren’t thrown: the promise always resolves. If a request fails, the data stays queued and goes out with the next identify() call on the page; after a reload, the data-* attributes or your identify() call send it again.

Method 3: visitor_token (cross-device)

When the same user comes from a phone and a laptop, the anonymous vs_* ID won’t link them: they’re different browsers and different contacts. To get conversations from both devices into one contact, generate an HMAC-signed token with the user_id on your backend and pass it in data-visitor-token or SupportHub.identify({visitor_token}). The full reference is on the HMAC visitor token page.

Where to get the secret

The project secret (widget_signing_secret) is created on the first request and returned by the public API. You need an API key from SettingsAPI Keys:

http
GET /api/v1/widget/signing-secret
Authorization: Bearer sk_xxx

200 OK
{
  "secret": "a random 43-character string"
}

The project comes from the API key. A project admin sees the same secret in SettingsChannelsWeb Widget. The secret doesn’t change on its own (a repeated GET returns the same value) until someone rotates it with the “Rotate secret” button there. Keep it on your backend only: anyone who has it can sign a token for any user_id. How to rotate it if it may have leaked is on the HMAC visitor token page.

How to generate a token (Python)

generate_visitor_token.pypython
import hmac, hashlib, base64, json, time

# Get the secret once via GET /api/v1/widget/signing-secret
# and keep it in env. Never expose it to the client.
SECRET = "PASTE_SECRET_HERE"

def visitor_token(user_id: str, ttl: int = 86400) -> str:
    payload = {
        "user_id": user_id,
        "exp": int(time.time()) + ttl,
    }
    payload_bytes = json.dumps(payload).encode()
    payload_b64 = base64.urlsafe_b64encode(payload_bytes).rstrip(b"=").decode()
    # IMPORTANT: the HMAC is computed over the raw JSON bytes (not the base64),
    # then the base64 part and the hex signature are joined with a dot.
    sig = hmac.new(SECRET.encode(), payload_bytes, hashlib.sha256).hexdigest()
    return f"{payload_b64}.{sig}"

How to generate a token (Node.js)

generate_visitor_token.jsjs
import crypto from "node:crypto";

const SECRET = process.env.SUPPORTHUB_SIGNING_SECRET;

export function visitorToken(userId, ttl = 86400) {
  const payload = { user_id: userId, exp: Math.floor(Date.now() / 1000) + ttl };
  const payloadBytes = Buffer.from(JSON.stringify(payload));
  const payloadB64 = payloadBytes.toString("base64url");
  // HMAC over the raw JSON bytes, NOT the base64 string.
  const sig = crypto
    .createHmac("sha256", SECRET)
    .update(payloadBytes)
    .digest("hex");
  return `${payloadB64}.${sig}`;
}

Endpoint on your side: FastAPI/Python example

your_backend/api/supporthub.pypython
import base64, hashlib, hmac, json, time, os
from fastapi import APIRouter, Depends, HTTPException

router = APIRouter()

# Get the secret once via GET /api/v1/widget/signing-secret
# from SupportHub and store it in env / settings.
SUPPORTHUB_SIGNING_SECRET = os.environ["SUPPORTHUB_SIGNING_SECRET"]

@router.get("/token")
async def supporthub_widget_token(current_user = Depends(get_current_user)):
    """Returns an HMAC token <payload_b64>.<sig> for the cross-device widget."""
    user_id = str(current_user.id)
    if not user_id:
        raise HTTPException(401, "Unauthorized")

    # Compact JSON: separators=(",", ":") drops the extra spaces.
    # The HMAC is computed over the raw JSON bytes (NOT the base64).
    payload = json.dumps(
        {"user_id": user_id, "exp": int(time.time()) + 3600},
        separators=(",", ":"),
    )
    sig = hmac.new(
        SUPPORTHUB_SIGNING_SECRET.encode(),
        payload.encode(),
        hashlib.sha256,
    ).hexdigest()
    payload_b64 = (
        base64.urlsafe_b64encode(payload.encode()).decode().rstrip("=")
    )
    return {
        "token": f"{payload_b64}.{sig}",
        "expires_in": 3600,
    }

One-script install with an automatic token

A single <script> in <body>: it first requests a token from your /token endpoint, then loads the widget with that token. If there’s no token (a guest, a 401, a network error), the widget loads without it, in anonymous mode.

index.htmlhtml
<script>
(function(w, d){
  function loadWidget(token){
    var s = d.createElement('script');
    s.async = 1;
    s.src = 'https://support.forestsnet.com/widget-bundle?ws=YOUR_WORKSPACE_UUID';
    if (token) s.setAttribute('data-visitor-token', token);
    d.head.appendChild(s);
  }
  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>

Pass it to the widget

html
<script
  async
  src="https://support.forestsnet.com/widget-bundle?ws=YOUR_WORKSPACE_UUID"
  data-visitor-token="<%= visitor_token(user.id) %>"
></script>

When the token expires: SupportHub.on('token_expired')

If the server refuses the token (its exp has passed or the signature doesn’t match), the widget stops reconnecting with it, shows the visitor “Session expired — refresh the page” and calls the token_expired handler once per token with { reason: "expired" | "invalid" }. Fetch a fresh token there and pass it to identify: the conversation comes back by itself, no page reload. SupportHub.on is there as soon as the bundle has run, like identify, before the widget has started; the onTokenExpired option of init() does the same.

js
SupportHub.on("token_expired", async function (e) {
  // e.reason: "expired" | "invalid"
  const r = await fetch("/api/supporthub/token", { credentials: "include" });
  if (!r.ok) return;
  const { token } = await r.json();
  await SupportHub.identify({ visitor_token: token });
});

When the user signs out: SupportHub.logout()

When the user signs out of your site, call window.SupportHub.logout(). Without it, a page that doesn’t reload on sign-out (an SPA) keeps showing the previous user’s conversation in the widget, because their token stays in the page’s memory, and the next person at that computer sees it.

js
// onLogout in your SPA, where you clear your own session
await window.SupportHub.logout();

logout():

  • forgets the visitor_token and everything that came from identify() and the data-* attributes: the email, name, phone and Telegram ID;
  • replaces the anonymous vs_* ID with a new one in every store (cookie, localStorage, sessionStorage, IndexedDB), so this browser’s earlier anonymous conversation no longer opens in the widget;
  • closes the WebSocket, takes the widget back to the home tab and closes the panel;
  • is available as soon as the bundle loads, like identify(): a call made before initialization finishes works too; the widget applies it as it starts.

The promise always resolves. Nothing is deleted on the server: the conversation stays with the user and opens again when they sign in and the page passes their token. Other open tabs keep the token in memory until they reload; if your site doesn’t reload them, call logout() there too.

If another user signs in on this browser and logout() wasn’t called, identify() with a token for a different user_id starts from a clean anonymous session by itself: the previous user’s contact and conversation never go to the new one.

What happens on the backend

The widget sends this request after the page loads (if the data-* attributes carry any data) and on every SupportHub.identify(...) call with data. With a token, &visitor_token=… is added to the URL.

http
POST /api/webhooks/widget/{workspace_id}/identify?visitor_id=vs_xxx
Content-Type: application/json

{
  "email": "user@example.com",
  "full_name": "John Smith",
  "phone": "+79161234567",
  "telegram_id": 123456789
}

The backend then:

  1. Finds the contact: without a token, this visitor_id’s anonymous contact (Contact.internal_id); with a valid visitor_token, by the token’s user_id in Contact.verified_user_id. A contact already bound to a user_id isn’t found by visitor_id alone: only that user’s token opens it.
  2. If there’s no contact for the token yet, binds this visitor_id’s anonymous contact to the user_id, so a conversation started before sign-in stays with the user. A contact already bound to another user_id (someone else signed in on this browser earlier) is never re-bound: the new user gets one of their own. Contacts are never looked up by the email or Telegram ID in the request: those fields aren’t signed, and matching on them would let anyone signed in to your site take over someone else’s contact, say one from email or your Telegram bot, together with its conversations.
  3. If there’s no contact, creates one with the fields passed.
  4. If the contact exists, updates its fields: the email always, the name only if there’s none or it’s a placeholder, the phone and Telegram ID only if they’re empty.
  5. If the visitor has an open widget conversation and the request has an email, adds a row to that ticket’s timeline noting that the client introduced themselves.
  6. Sends webhook events: contact.created only if this call created the contact, and contact.identified on every successful call. See the webhook docs.

The backend rejects an invalid email (422), and then nothing from the request is saved. An invalid token gets 401; a request with neither visitor_id nor a token gets 404.

The widget shows widget conversations only. If the same contact also has tickets from Telegram, VK, WhatsApp, email or BillManager, they aren’t in the widget, not in the list and not by a direct link: they can’t be opened, answered, closed or rated, and their events don’t reach the widget. Email replies to messages sent from a widget conversation (the email bridge) stay in that conversation and show there. A mail without such a thread, or a message sent through the API, never goes into a widget conversation; it opens a separate ticket. So a visitor who typed someone else’s email or Telegram ID into their own details doesn’t see the real owner’s conversations, even if they end up on the same contact.

Security

The backend doesn’t verify the email, name and phone from data-* and identify(): any visitor can call identify() in their own browser with any data. So they’re only written to that visitor’s own contact and never used to find anyone else’s. Only the visitor_token is verified, and only its user_id binds a visitor to a contact: generate the token on your backend from the current server session, never give a page another user’s token, and call SupportHub.logout() when the user signs out.

See also

  • HMAC visitor token: the token format, the backend checks, errors and edge cases.
  • Widget API reference: the full list of data-* attributes and the controller’s JS API.
  • Webhooks: subscribe to contact.created / contact.identified to get these events on your backend.
Was this page helpful?