HMAC visitor token
Widget / HMAC visitor token

HMAC visitor token: full reference

A signed token that identifies visitors across devices: format, generation, backend checks and edge cases.

Why you need the token

The anonymous visitor session ID is stored on the device (cookie, localStorage, sessionStorage, IndexedDB; see multi-tier storage) and doesn’t carry over to other devices: to the backend, a visitor on an iPhone and on a MacBook are two different contacts. The fix: your backend signs a token with a stable user ID (for example, the PK from your database), the widget passes the token to SupportHub, and the backend verifies the HMAC and finds the contact by Contact.verified_user_id on any device.

Token format

text
<base64url(payload)>.<hex(hmac_sha256(secret, payload_bytes))>

Example (shortened):
eyJ1c2VyX2lkIjoiNDIiLCJleHAiOjE3MzMyNTcyMDB9.a1b2c3d4...

Two parts separated by a dot. The first is the JSON payload in base64url. The second is the hex-encoded HMAC-SHA256 computed over the original JSON bytes, not over the base64 string. The backend decodes the first part and checks the signature over exactly those bytes, so compact JSON and dropping the trailing = aren’t required; that’s just what the examples do.

Payload fields

user_id
Type: stringDefault: —
Required. Your stable user identifier: the primary key in your database, a slug, "crm-12345", anything. SupportHub finds the contact by it (Contact.verified_user_id). A string is best; the backend converts a number to a string, so 42 and "42" are the same user. An empty value gets 401.
exp
Type: int (unix timestamp)Default: —
Optional. Expiry time in seconds since the epoch. Once it has passed, the backend returns 401 visitor_token expired; if it isn’t a number, 401 invalid visitor_token exp. 1–24 hours is recommended as a balance between convenience and a short window if the token leaks. Without exp the token never expires (not recommended).

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 can also see and copy the same secret in SettingsChannelsWeb Widget (the “visitor_token signing secret” field). The secret doesn’t change on its own (a repeated GET returns the same value) until someone rotates it (see below). Save it to your backend’s env once, and don’t request it on every /token call.

Never expose the secret to the client: tokens are signed ONLY on your backend. Anyone who has the secret can sign a token for any user_id, that is, read any of your users’ conversations. Don’t let it end up in a JS bundle, a commit or a log, and if it does, rotate it.

Rotating the secret

Rotate the secret if it may have got into the wrong hands: it ended up in a repository or a chat, or someone who had it (an employee, a contractor) no longer works with you.

  1. SettingsChannelsWeb Widget → “Rotate secret”, and confirm. The project owner and admins have the button.
  2. The new secret shows up in the same field right away. Replace SUPPORTHUB_SIGNING_SECRET (or whatever you call it) on every server that issues tokens, and restart them.

The old secret stops working immediately, with no grace period: a leaked secret must not keep working. While your servers still sign with the old one, every widget request with such a token gets 401 invalid visitor_token signature: for signed-in users the conversation doesn’t load and messages don’t send, as with an expired token (see below). Nothing is lost: as soon as a token with the new signature arrives, everything works again. So do both steps together. It’s also the only way to revoke tokens without an exp.

Generating the token

Ready-made snippets of the GET /api/supporthub/token endpoint for FastAPI, Django, Flask, Express, Next.js (App Router), Laravel, Rails, Go, .NET and Spring Boot are on a separate page: examples for popular stacks. They all sign the original JSON bytes with HMAC-SHA256 (not the base64 string), encode the payload in base64url and issue a token for 1 hour. The lifetime is up to you; just don’t make it unlimited.

Passing the token to the widget

The widget accepts the token in one of three ways:

A single <script> in <body>that first requests a token from your endpoint and then loads the widget:

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=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>

credentials: 'include' makes sure the browser sends the session cookie. A failed request, a 401 or a missing endpoint means the widget loads without a token, in anonymous mode. This is the same loader as in the builder’s “Embed snippet” and on the Channels page, where the fetch block is commented out. When you uncomment it, remove the loadWidget(); line below it; otherwise the widget first loads without a token, and the second load, with the token, is ignored.

2. Server-side rendering with a data attribute

If your backend renders the page, put the token straight into the script tag:

index.html.j2 / Blade / EJS / Twightml
<script
  async
  src="https://support.forestsnet.com/widget-bundle?ws=YOUR_WORKSPACE_UUID"
  data-visitor-token="{{ generate_supporthub_token(user.id) }}"
></script>

3. From code with the JS API

In an SPA where the visitor signs in after the page loads, call SupportHub.identify({visitor_token: "..."}). The method is available as soon as the bundle loads; calls made before initialization finishes are queued and sent once the widget starts.

A token on its own doesn’t call /identify: the widget attaches it to every following request and reopens the WebSocket with it, if one was open. To keep a conversation that started anonymously with the user, pass the token in the same call as at least one field, for example identify({visitor_token, email}).

When the token is for a user the widget didn’t know yet (say, the page reloaded and your auth answered after the widget had started), the widget redraws the current screen for that user and, if the panel is closed, opens their unfinished conversation. A token for a different user_id than before first resets the widget to a clean anonymous session (see “The user switched accounts” below).

js
// onLoginSuccess in your SPA:
const resp = await fetch('/api/supporthub/token', { credentials: 'include' });
const { token } = await resp.json();
window.SupportHub.identify({ visitor_token: token, email: user.email });

What the backend does with the token

The token arrives as the visitor_token parameter on every widget request and as the signed_token parameter when the WebSocket connects. The backend:

  1. Splits the token at the first dot into payload_b64 and sig_hex and decodes the payload from base64url.
  2. Computes hmac_sha256(secret, payload_bytes) and compares it with sig_hex using compare_digest (timing-safe). No match → 401 invalid visitor_token signature.
  3. Parses the JSON. If the token can’t be parsed → 401 invalid visitor_token.
  4. Checks exp: in the past → 401 visitor_token expired; not a number → 401 invalid visitor_token exp.
  5. Takes the user_id: missing → 401 visitor_token missing user_id.
  6. Looks up the contact by Contact.verified_user_id, not by the anonymous internal_id: that’s where the cross-device identity lives.
  7. If there’s no contact yet, the first message creates one with this user_id, and /identify first tries to bind this browser’s anonymous contact, but only if it isn’t bound to anyone yet (see Identifying visitors). A contact bound to one user_id is never re-bound to another.

The token proves the user_id and nothing else. The email, Telegram ID and other fields, whether in the request body or even in the signed payload, are never used to find an existing contact; otherwise a user of your site could type someone else’s address and take over a contact from email or your Telegram bot, conversations included. Without a token, the visitor_id only opens anonymous contacts: a contact bound to a user_id is visible only with that user’s token, so a vs_* ID left in the browser won’t open it.

When the WebSocket connects with an invalid token, the server closes the connection with code 4401.

Edge cases

The token has expired (exp is before now)

  • Every widget request with this token gets 401: the history doesn’t load, and when sending, the visitor sees “Couldn’t send — please try again”.
  • The WebSocket is closed with code 4401. The widget keeps reconnecting with a growing pause (1, 2, 4… up to 30 seconds) using the same token. It doesn’t switch to anonymous mode on its own.
  • A new token arrives on the next page load (the loader calls /token again) or when your page calls SupportHub.identify({visitor_token}); the widget then uses it for requests right away and reopens the WebSocket.

For long sessions, refresh the token from your page before it expires:

js
setInterval(() => {
  fetch('/api/supporthub/token', { credentials: 'include' })
    .then((r) => r.json())
    .then((d) => window.SupportHub.identify({ visitor_token: d.token }));
}, 30 * 60 * 1000); // every 30 minutes for a 1-hour token

An anonymous visitor signs in mid-session

Scenario: a visitor arrives anonymously and writes “hi”, which creates a contact with internal_id="vs_abc". Then they sign in, and your page passes a token.

  • Token plus a field (identify({visitor_token, email}), or data-visitor-token together with data-email): the backend finds no contact with this user_id and binds the anonymous contact vs_abc to it. The conversation stays with the user and is visible on their other devices.
  • Token only: the widget doesn’t call /identify, looks up the contact by user_id and doesn’t find the anonymous one, so the next message creates a separate contact. The anonymous conversation stays under vs_abc; you can merge the contacts by hand.
  • If the user already has a contact (from another device, say), the anonymous contact isn’t bound to it and also stays separate.
  • If this browser’s contact is already bound to another user_id (someone else signed in here earlier), it isn’t re-bound: the new user gets a contact of their own and doesn’t see the other person’s conversations.

Several tabs in one browser

Tabs share the anonymous session ID (cookie and localStorage; a BroadcastChannel keeps a new tab from creating a second one). The visitor_token lives only in the page’s memory: each tab gets it from your page, as an attribute or through identify(). Each tab has its own WebSocket, and all of them receive the contact’s events, so an operator’s reply shows up in every open tab.

SupportHub.logout() in one tab replaces the shared session ID, but the token in the other tabs’ memory stays until they reload; if your site doesn’t reload them on sign-out, call logout() there too.

The user switched accounts

When the user signs out, call SupportHub.logout(): the widget forgets the token and the identify() data, starts a new anonymous session ID, closes the WebSocket and goes back to the home tab (details in Identifying visitors). This matters on shared computers and in SPAs, where the page doesn’t reload on sign-out; otherwise the previous user’s token stays in the page’s memory, and their conversations with it.

If logout() wasn’t called and another user signs in on the browser, identify() with a token for a different user_id resets the widget to a clean anonymous session by itself before binding the new user. The backend won’t give them the other person’s contact either: a bound contact is never re-bound, and the session ID left in the browser doesn’t open it.

Security essentials

  • Never expose the secret to the client. Tokens are generated only on the backend and passed on ready-made.
  • Never take the user_id from client-side code. Your site’s server-side session is the source of truth; otherwise a visitor could get a token for someone else’s user.
  • Authorization on /api/supporthub/token is required: the endpoint returns a token only if the visitor is actually signed in to your system.
  • HTTPS: a token in a URL or a data attribute is visible on the network; over HTTPS it’s encrypted.
  • A short exp: 1–24 hours is recommended, so a leaked token doesn’t live long.
  • A stable user_id: it must point to exactly one person. Don’t use the email if it can change.
  • The backend doesn’t verify an email, name or phone passed next to the token; only the token itself is checked. So only the token’s user_id binds a visitor to a contact, and those fields are simply written to the visitor’s own contact.
  • SupportHub.logout() on sign-out: otherwise the next person at the same browser sees the previous user’s conversations until the page reloads.
  • Rotating the secret: if it may have leaked, rotate it in SettingsChannelsWeb Widget and update it on your servers right away.

Testing

Generate a token from the CLI

bash
# Python one-liner
python3 -c "
import base64, hashlib, hmac, json, time
SECRET = 'PASTE_SECRET_HERE'
payload = json.dumps({'user_id': 'test-42', 'exp': int(time.time()) + 3600}, separators=(',', ':'))
sig = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
b64 = base64.urlsafe_b64encode(payload.encode()).decode().rstrip('=')
print(f'{b64}.{sig}')
"

Check that the backend accepts it

bash
# 200: the token is accepted; 401 with the reason in detail: it isn't
curl "https://api.support.forestsnet.com/api/webhooks/widget/YOUR_WS/recent?visitor_token=TOKEN"

# Create or update the contact for this user_id
curl -X POST "https://api.support.forestsnet.com/api/webhooks/widget/YOUR_WS/identify?visitor_token=TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com"}'
# {"ok": true}

After identify, a repeated /recent request returns a contact block with that email. If you’ve listed allowed domains in the builder’s Privacy section, add an -H "Origin: https://your-site" header to the POST request; otherwise you get 403.

FAQ

Q: Can I use a JWT instead of HMAC?

No. The backend checks HMAC-SHA256 in exactly the format described above; there’s no JWT parser. If you already have JWT infrastructure, generate a separate HMAC token from the same server session, and don’t try to reuse the JWT signing key.

Q: What if the user has no integer ID?

Any stable string works: a UUID, a slug, an email hash. What matters is that the same person always gets the same value.

Q: Can I sign the token with extra fields (email, name)?

Technically yes, but the backend ignores them, and it certainly doesn’t use them to find an existing contact: an email you signed doesn’t prove the user owns that mailbox. Pass the email and name with SupportHub.identify({email, name}) or the data-email / data-name attributes: they go into the /identify body, not into the token.

Q: How many tokens can one user have?

As many as you like: the backend stores the contact, not the tokens. Every /api/supporthub/token call issues a fresh token, and each one is valid until its exp. So you can refresh a token early: the old one keeps working until the new one arrives.

See also

Was this page helpful?