HMAC visitor token — полный референс
Подписанный токен для cross-device идентификации посетителей: формат, генерация, проверка на бэкенде, крайние случаи.
Зачем нужен токен
Анонимный visitor session ID хранится на устройстве (cookie, localStorage, sessionStorage, IndexedDB — см. multi-tier storage) и на другие устройства не переносится: посетитель с iPhone и с MacBook для бэкенда — два разных контакта. Решение: ваш бэкенд подписывает токен со стабильным user_id (например, PK из вашей БД), виджет передаёт токен в SupportHub, а бэкенд проверяет HMAC и находит контакт по Contact.verified_user_id на любом устройстве.
Формат токена
<base64url(payload)>.<hex(hmac_sha256(secret, payload_bytes))>
Пример (укорочен):
eyJ1c2VyX2lkIjoiNDIiLCJleHAiOjE3MzMyNTcyMDB9.a1b2c3d4...Две части через точку. Первая — JSON-payload в base64url. Вторая — HMAC-SHA256 в hex, посчитанный поверх исходных JSON-байтов, а не поверх base64-строки. Бэкенд декодирует первую часть и проверяет подпись ровно над этими байтами, поэтому компактный JSON и отсутствие = в конце не обязательны — так просто сделано в примерах.
Payload поля
"crm-12345" — что угодно. SupportHub ищет по нему контакт (Contact.verified_user_id). Лучше передавать строку; число бэкенд приведёт к строке, так что 42 и "42" — один и тот же пользователь. Пустое значение — 401.visitor_token expired; если это не число — 401 invalid visitor_token exp. Рекомендуем 1–24 часа — баланс между удобством и коротким окном на случай утечки. Без exp токен не истекает никогда (не рекомендуется).| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
| user_id | string | — | Обязательно. Ваш стабильный идентификатор пользователя — primary key в вашей БД, slug, "crm-12345" — что угодно. SupportHub ищет по нему контакт (Contact.verified_user_id). Лучше передавать строку; число бэкенд приведёт к строке, так что 42 и "42" — один и тот же пользователь. Пустое значение — 401. |
| exp | int (unix timestamp) | — | Опционально. Время истечения в секундах с epoch. Когда оно прошло, бэкенд отвечает 401 visitor_token expired; если это не число — 401 invalid visitor_token exp. Рекомендуем 1–24 часа — баланс между удобством и коротким окном на случай утечки. Без exp токен не истекает никогда (не рекомендуется). |
Откуда взять секрет
Секрет проекта (widget_signing_secret) создаётся при первом запросе и выдаётся через публичный API. Нужен API-ключ из НастройкиAPI ключи:
GET /api/v1/widget/signing-secret
Authorization: Bearer sk_xxx
200 OK
{
"secret": "случайная строка из 43 символов"
}Проект определяется по API-ключу. Администратор проекта видит и копирует тот же секрет в НастройкиКаналыWeb Widget (поле «Секрет подписи visitor_token»). Сам секрет не меняется — повторный GET вернёт то же значение, — пока его не сменят (см. ниже). Сохраните его в env вашего бэкенда один раз и не запрашивайте на каждый вызов /token.
Никогда не показывайте секрет на клиенте: токен подписывается ТОЛЬКО на вашем бэкенде. С секретом можно подписать токен для любого user_id — то есть читать переписку любого вашего пользователя. Не допускайте, чтобы он попал в JS-бандл, коммит или лог, а если это случилось — смените его.
Смена секрета
Меняйте секрет, если он мог попасть в чужие руки: оказался в репозитории или переписке, был у сотрудника или подрядчика, который больше с вами не работает.
- НастройкиКаналыWeb Widget → «Сменить секрет» и подтвердите. Кнопка есть у владельца и администраторов проекта.
- Новый секрет сразу показывается в том же поле. Замените
SUPPORTHUB_SIGNING_SECRET(или как он называется у вас) на всех серверах, которые выдают токены, и перезапустите их.
Старый секрет перестаёт работать сразу, без переходного периода: утёкший секрет не должен продолжать работать. Пока ваши серверы подписывают старым, все запросы виджета с такими токенами получают 401 invalid visitor_token signature: у вошедших пользователей переписка не загружается и сообщения не отправляются — как при истёкшем токене (см. ниже). Ничего не теряется: как только придёт токен с новой подписью, всё снова заработает. Поэтому выполняйте оба шага вместе. Токены без exp можно отозвать только так.
Генерация токена
Готовые сниппеты эндпоинта GET /api/supporthub/token для FastAPI, Django, Flask, Express, Next.js (App Router), Laravel, Rails, Go, .NET и Spring Boot собраны на отдельной странице: примеры на популярных стэках. Все подписывают HMAC-SHA256 исходные JSON-байты (не base64-строку), кодируют payload в base64url и выдают токен на 1 час. Срок выбирайте сами, но не делайте его бесконечным.
Подключение к виджету
Виджет принимает токен одним из трёх способов:
1. Через one-script install (рекомендуется)
Один <script> в <body>, который сначала запрашивает токен у вашего endpoint'а, потом загружает виджет:
<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' гарантирует, что браузер отправит cookie сессии. Ошибка запроса, 401 или отсутствие endpoint'а — виджет загружается без токена, в анонимном режиме. Это тот же загрузчик, что в «Код для вставки» конструктора и на странице каналов: там блок fetch закомментирован. Раскомментировав его, уберите строку loadWidget(); под ним — иначе виджет сначала загрузится без токена, а вторая загрузка с токеном будет проигнорирована.
2. SSR-инжект через data-атрибут
Если ваш бэкенд рендерит страницу, впишите токен прямо в script-тег:
<script
async
src="https://support.forestsnet.com/widget-bundle?ws=YOUR_WORKSPACE_UUID"
data-visitor-token="{{ generate_supporthub_token(user.id) }}"
></script>3. Программно через JS API
Для SPA, где визитор входит после загрузки страницы, — вызов SupportHub.identify({visitor_token: "..."}). Метод доступен сразу после загрузки бандла; вызовы до окончания инициализации копятся и уходят, когда виджет запустится.
Сам по себе токен не вызывает /identify: виджет прикладывает его ко всем следующим запросам и переоткрывает с ним WebSocket, если тот был открыт. Чтобы переписка, начатая анонимно, осталась у пользователя, передайте токен в одном вызове хотя бы с одним полем — например, identify({visitor_token, email}).
Если токен пришёл для пользователя, которого виджет ещё не знал (например, страница перезагрузилась, а ваша авторизация ответила уже после запуска виджета), виджет перерисовывает текущий экран под этим пользователем, а при закрытой панели — открывает его незавершённый диалог. Токен другого user_id вместо прежнего сначала сбрасывает виджет к чистой анонимной сессии (см. «Пользователь сменил аккаунт» ниже).
// onLoginSuccess в вашем SPA:
const resp = await fetch('/api/supporthub/token', { credentials: 'include' });
const { token } = await resp.json();
window.SupportHub.identify({ visitor_token: token, email: user.email });Что делает бэкенд с токеном
Токен приходит параметром visitor_token в каждом запросе виджета и параметром signed_token при подключении WebSocket. Бэкенд:
- Делит токен по первой точке на
payload_b64иsig_hexи декодирует payload из base64url. - Считает
hmac_sha256(secret, payload_bytes)и сравнивает сsig_hexчерезcompare_digest(timing-safe). Не совпало — 401invalid visitor_token signature. - Разбирает JSON. Токен не разбирается — 401
invalid visitor_token. - Проверяет
exp: прошёл — 401visitor_token expired, не число — 401invalid visitor_token exp. - Берёт
user_id: его нет — 401visitor_token missing user_id. - Ищет контакт по
Contact.verified_user_id, а не по анонимномуinternal_id, — там и живёт cross-device идентичность. - Если контакта ещё нет, первое сообщение создаёт его с этим
user_id, а/identifyсначала пробует привязать анонимный контакт этого браузера — только если тот ещё ни к кому не привязан (см. Identify посетителей). Контакт, привязанный к одномуuser_id, к другому не перепривязывается никогда.
Токен подтверждает только user_id. Email, Telegram ID и другие поля — в теле запроса или даже в подписанном payload — никогда не служат для поиска существующего контакта: иначе пользователь вашего сайта, указав чужой адрес, забрал бы контакт из почты или Telegram-бота вместе с перепиской. Без токена visitor_id открывает только анонимные контакты: контакт, привязанный к user_id, виден лишь с токеном этого пользователя, так что оставшийся в браузере vs_* id его не откроет.
При подключении WebSocket с недействительным токеном сервер закрывает соединение с кодом 4401.
Крайние случаи
Токен истёк (exp меньше now)
- Все запросы виджета с этим токеном получают 401: история не загружается, при отправке посетитель видит «Не удалось отправить — попробуйте ещё раз».
- WebSocket закрывается с кодом
4401. Виджет переподключается с нарастающей паузой (1, 2, 4… до 30 секунд) с тем же токеном. В анонимный режим он сам не переходит. - Новый токен появляется при следующей загрузке страницы (загрузчик снова вызовет
/token) или когда ваша страница вызоветSupportHub.identify({visitor_token})— тогда виджет сразу использует его для запросов и переоткрывает WebSocket.
Для долгих сессий обновляйте токен со своей страницы раньше, чем он истечёт:
setInterval(() => {
fetch('/api/supporthub/token', { credentials: 'include' })
.then((r) => r.json())
.then((d) => window.SupportHub.identify({ visitor_token: d.token }));
}, 30 * 60 * 1000); // раз в 30 минут при токене на 1 часАнонимный посетитель входит в аккаунт посреди сессии
Сценарий: посетитель пришёл анонимно и написал «привет» — создан контакт с internal_id="vs_abc". Потом он вошёл в аккаунт, и ваша страница передала токен.
- Токен вместе с полем (
identify({visitor_token, email})илиdata-visitor-tokenвместе сdata-email): бэкенд не находит контакта с этимuser_idи привязывает к нему анонимный контактvs_abc. Переписка остаётся у пользователя и видна на других его устройствах. - Только токен: виджет не вызывает
/identify, ищет контакт поuser_idи анонимный не находит — следующее сообщение создаст отдельный контакт. Анонимная переписка останется подvs_abc; контакты можно объединить вручную. - Если у пользователя уже есть контакт (например, с другого устройства), анонимный контакт к нему не привязывается и тоже остаётся отдельным.
- Если контакт этого браузера уже привязан к другому
user_id(раньше здесь входил кто-то другой), он не перепривязывается: новый пользователь получает свой контакт и чужой переписки не видит.
Несколько вкладок одного браузера
Вкладки делят анонимный session ID (cookie и localStorage; BroadcastChannel не даёт новой вкладке завести второй). visitor_token живёт только в памяти страницы: каждая вкладка получает его от вашей страницы — атрибутом или через identify(). У каждой вкладки свой WebSocket, и все они получают события контакта, поэтому ответ оператора появляется во всех открытых вкладках.
SupportHub.logout() в одной вкладке меняет общий session ID, но токен в памяти других вкладок остаётся до их перезагрузки — если ваш сайт не перезагружает их при выходе, вызовите logout() и там.
Пользователь сменил аккаунт
Когда пользователь выходит, вызовите SupportHub.logout(): виджет забудет токен и данные identify(), заведёт новый анонимный session ID, закроет WebSocket и вернётся на главную (подробно — в Identify посетителей). Это важно на общих компьютерах и в SPA, где страница при выходе не перезагружается: иначе токен прежнего пользователя остаётся в памяти страницы вместе с его перепиской.
Если logout() не вызвали, а в браузере вошёл другой пользователь, identify() с токеном другого user_id сам сбрасывает виджет к чистой анонимной сессии и уже потом привязывает нового пользователя. Бэкенд со своей стороны не отдаст ему чужой контакт: привязанный контакт не перепривязывается, а оставшийся в браузере session ID его не открывает.
Безопасность — что важно
- Никогда не показывайте секрет на клиенте. Токен генерируется только на бэкенде и передаётся готовым.
- Никогда не берите
user_idиз клиентского кода. Источник истины — серверная сессия вашего сайта; иначе посетитель сможет получить токен чужого пользователя. - Авторизация на /api/supporthub/token обязательна: endpoint возвращает токен, только если посетитель действительно вошёл в вашу систему.
- HTTPS — токен в URL и data-атрибуте виден в сети; по HTTPS он зашифрован.
- Короткий
exp— рекомендуем 1–24 часа: утёкший токен дольше не проживёт. - Стабильный
user_id— он должен однозначно указывать на одного человека. Не используйте email, если он может меняться. - Email, имя и телефон, переданные рядом с токеном, бэкенд не проверяет — проверяется только сам токен. Поэтому к контакту привязывает только
user_idиз токена, а эти поля просто записываются в контакт посетителя. SupportHub.logout()при выходе — иначе следующий человек за тем же браузером увидит переписку прежнего пользователя, пока страница не перезагрузится.- Смена секрета — если он мог утечь, смените его в НастройкиКаналыWeb Widget и сразу обновите на своих серверах.
Тестирование
Сгенерировать токен из CLI
# 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}')
"Проверить, что бэкенд его принимает
# 200 — токен принят; 401 с причиной в detail — нет
curl "https://api.support.forestsnet.com/api/webhooks/widget/YOUR_WS/recent?visitor_token=TOKEN"
# Создать или обновить контакт этого 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}После identify повторный запрос /recent вернёт блок contact с этим email. Если в разделе конструктора «Приватность» заполнены «Разрешённые домены», добавьте к POST-запросу заголовок -H "Origin: https://ваш-сайт", иначе будет 403.
FAQ
Q: Можно ли использовать JWT вместо HMAC?
Нет — бэкенд проверяет именно HMAC-SHA256 в формате, описанном выше. JWT-парсера нет. Если у вас уже есть JWT-инфраструктура, отдельно сгенерируйте HMAC-токен из той же серверной сессии и не пытайтесь переиспользовать JWT signing key.
Q: Что если у пользователя нет integer-id?
Подойдёт любая стабильная строка — UUID, slug, hash email'а. Главное, чтобы для одного человека всегда возвращалось одно и то же значение.
Q: Можно ли подписать токен с дополнительными полями (email, name)?
Технически да, но бэкенд их игнорирует — и уж точно не ищет по ним существующий контакт: подписанный вами email ещё не значит, что пользователь владеет этим ящиком. Email и имя передавайте через SupportHub.identify({email, name}) или атрибуты data-email / data-name — они идут в тело /identify, а не в токен.
Q: Сколько токенов можно выдать одному пользователю?
Сколько угодно — бэкенд хранит контакт, а не токены. Каждый вызов /api/supporthub/token выдаёт свежий токен, и каждый действует до своего exp. Поэтому токен можно обновлять заранее: старый работает, пока не придёт новый.
См. также
- Identify посетителей — data-* атрибуты, JS API и что происходит на бэкенде
- HMAC — примеры на стэках — готовые endpoint'ы
/api/supporthub/token - API-справочник виджета — endpoint'ы, API контроллера, multi-tier session storage
- Troubleshooting — типичные проблемы, в том числе 401 из-за токена

