HMAC visitor token
Виджет / HMAC visitor token

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 на любом устройстве.

Формат токена

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

Пример (укорочен):
eyJ1c2VyX2lkIjoiNDIiLCJleHAiOjE3MzMyNTcyMDB9.a1b2c3d4...

Две части через точку. Первая — JSON-payload в base64url. Вторая — HMAC-SHA256 в hex, посчитанный поверх исходных JSON-байтов, а не поверх base64-строки. Бэкенд декодирует первую часть и проверяет подпись ровно над этими байтами, поэтому компактный JSON и отсутствие = в конце не обязательны — так просто сделано в примерах.

Payload поля

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 ключи:

http
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-бандл, коммит или лог, а если это случилось — смените его.

Смена секрета

Меняйте секрет, если он мог попасть в чужие руки: оказался в репозитории или переписке, был у сотрудника или подрядчика, который больше с вами не работает.

  1. НастройкиКаналыWeb Widget → «Сменить секрет» и подтвердите. Кнопка есть у владельца и администраторов проекта.
  2. Новый секрет сразу показывается в том же поле. Замените 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'а, потом загружает виджет:

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' гарантирует, что браузер отправит cookie сессии. Ошибка запроса, 401 или отсутствие endpoint'а — виджет загружается без токена, в анонимном режиме. Это тот же загрузчик, что в «Код для вставки» конструктора и на странице каналов: там блок fetch закомментирован. Раскомментировав его, уберите строку loadWidget(); под ним — иначе виджет сначала загрузится без токена, а вторая загрузка с токеном будет проигнорирована.

2. SSR-инжект через data-атрибут

Если ваш бэкенд рендерит страницу, впишите токен прямо в script-тег:

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. Программно через JS API

Для SPA, где визитор входит после загрузки страницы, — вызов SupportHub.identify({visitor_token: "..."}). Метод доступен сразу после загрузки бандла; вызовы до окончания инициализации копятся и уходят, когда виджет запустится.

Сам по себе токен не вызывает /identify: виджет прикладывает его ко всем следующим запросам и переоткрывает с ним WebSocket, если тот был открыт. Чтобы переписка, начатая анонимно, осталась у пользователя, передайте токен в одном вызове хотя бы с одним полем — например, identify({visitor_token, email}).

Если токен пришёл для пользователя, которого виджет ещё не знал (например, страница перезагрузилась, а ваша авторизация ответила уже после запуска виджета), виджет перерисовывает текущий экран под этим пользователем, а при закрытой панели — открывает его незавершённый диалог. Токен другого user_id вместо прежнего сначала сбрасывает виджет к чистой анонимной сессии (см. «Пользователь сменил аккаунт» ниже).

js
// 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. Бэкенд:

  1. Делит токен по первой точке на payload_b64 и sig_hex и декодирует payload из base64url.
  2. Считает hmac_sha256(secret, payload_bytes) и сравнивает с sig_hex через compare_digest (timing-safe). Не совпало — 401 invalid visitor_token signature.
  3. Разбирает JSON. Токен не разбирается — 401 invalid visitor_token.
  4. Проверяет exp: прошёл — 401 visitor_token expired, не число — 401 invalid visitor_token exp.
  5. Берёт user_id: его нет — 401 visitor_token missing user_id.
  6. Ищет контакт по Contact.verified_user_id, а не по анонимному internal_id, — там и живёт cross-device идентичность.
  7. Если контакта ещё нет, первое сообщение создаёт его с этим 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.

Для долгих сессий обновляйте токен со своей страницы раньше, чем он истечёт:

js
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

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}')
"

Проверить, что бэкенд его принимает

bash
# 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. Поэтому токен можно обновлять заранее: старый работает, пока не придёт новый.

См. также

Была ли страница полезной?