Identify посетителей
Виджет / Identify

Identify посетителей

Привязать визитора к контакту в SupportHub — три способа в зависимости от того, что и когда вы знаете о пользователе.

Зачем это нужно

По умолчанию визитор анонимен — backend узнаёт его по случайному vs_* id, который виджет хранит в cookie, localStorage, sessionStorage и IndexedDB (см. multi-tier storage). Если ваш сайт уже знает пользователя (он вошёл в личный кабинет, CRM, приложение), identify записывает в его контакт email, имя и телефон — оператор сразу видит их в карточке. Чтобы обращения одного пользователя с разных устройств попадали в один контакт, нужен visitor_token (способ 3).

Три способа

СпособКогда использовать
data-* атрибутыСервер рендерит страницу и уже знает пользователя (Blade / EJS / Twig). Просто впишите email и имя в шаблон.
window.SupportHub.identify()SPA — пользователь входит после загрузки страницы. Вызовите из обработчика входа.
visitor_tokenОдин контакт на всех устройствах: пользователь пишет с телефона и с ноутбука. Токен подписывает HMAC ваш бэкенд.

Способ 1: data-* атрибуты

Если ваш бэкенд рендерит страницу, когда пользователь уже известен, впишите данные прямо в <script>-тег. Виджет прочитает атрибуты при загрузке и отправит их в POST /api/webhooks/widget/{workspace_id}/identify — если задан хотя бы один из email, имени, телефона или Telegram ID.

Важно: атрибуты читаются с того <script>, который загружает бандл. Если вы ставите виджет загрузчиком из «Код для вставки», задайте их на элементе, который он создаёт, — так же, как загрузчик уже делает с 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>

Доступные data-* поля

data-email
Тип: stringПо умолчанию: —
Email посетителя. При каждом identify перезаписывает email контакта. Анонимного посетителя бэк ищет по vs_* id, а не по email: тот же email из другого браузера даст отдельный контакт. Один контакт на всех устройствах даёт visitor_token.
data-name
Тип: stringПо умолчанию: —
Полное имя, сохраняется в full_name. Записывается, только если имени у контакта ещё нет или оно служебное («Visitor …», «Гость»).
data-phone
Тип: stringПо умолчанию: —
Номер телефона (лучше в формате E.164: +79161234567). Сохраняется в extra_data.phone, если телефона у контакта ещё нет.
data-telegram-id
Тип: intПо умолчанию: —
Числовой Telegram ID. Записывается в контакт, если Telegram ID у него ещё нет. Искать или объединять контакты по нему бэкенд не будет: контакт из вашего Telegram-бота с тем же ID останется отдельным — см. «Что происходит на бэкенде» ниже.
data-visitor-token
Тип: string (HMAC)По умолчанию: —
HMAC-токен от вашего бэкенда для cross-device идентификации (см. способ 3).

Способ 2: window.SupportHub.identify()

Для SPA — где пользователь входит после загрузки страницы — вызовите JS API. Метод доступен сразу после загрузки бандла, ждать init() не нужно: вызовы до окончания инициализации копятся и уходят, как только виджет запустится. Принимает email, name (или full_name), phone, telegram_id и visitor_token.

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

Как это работает

  • Если в вызове есть хотя бы одно поле (email, имя, телефон или Telegram ID), виджет отправляет его на бэкенд. Контакт создаётся, если его ещё нет, — даже если посетитель не написал ни одного сообщения.
  • Если контакт уже есть, email перезаписывается при каждом вызове; имя записывается, только если его нет или оно служебное; телефон и Telegram ID — только если их ещё нет. Пустые поля ничего не затирают.
  • Каждый вызов с данными — это запрос на бэкенд (не больше 20 в минуту на посетителя), событие contact.identified, а если в нём есть email и у посетителя открыт тикет — ещё и строка «✅ Клиент представился» в ленте тикета. Поэтому вызывайте identify при входе пользователя или когда его данные меняются, а не на каждый переход по страницам.
  • Ошибки не выбрасываются: промис всегда завершается успешно. Если запрос не прошёл, данные остаются в очереди и уйдут со следующим вызовом identify() на этой странице; после перезагрузки их снова отправят data-* атрибуты или ваш вызов identify().

Способ 3: visitor_token (cross-device)

Когда один пользователь заходит с телефона и с ноутбука, анонимный vs_* id их не свяжет — это разные браузеры и разные контакты. Чтобы обращения с обоих устройств попадали в один контакт, генерируйте на своём бэкенде HMAC-подписанный токен с user_id и передавайте его в data-visitor-token или SupportHub.identify({visitor_token}). Полный референс — на странице HMAC visitor token.

Откуда взять секрет

Секрет проекта (widget_signing_secret) создаётся при первом запросе и выдаётся через публичный API. Нужен API-ключ из НастройкиAPI ключи:

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

200 OK
{
  "secret": "случайная строка из 43 символов"
}

Проект определяется по API-ключу. Администратор проекта видит тот же секрет в НастройкиКаналыWeb Widget. Сам секрет не меняется — повторный GET вернёт то же значение, — пока его не сменят кнопкой «Сменить секрет» там же. Храните его только на своём бэкенде: с ним можно подписать токен для любого user_id. Как менять секрет, если он мог утечь, — на странице HMAC visitor token.

Как сгенерировать токен (Python)

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

# Получите секрет один раз через GET /api/v1/widget/signing-secret
# и храните в env. Никогда не отдавайте его на клиент.
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()
    # ВАЖНО: HMAC считается над raw JSON bytes (не над base64),
    # затем base64-кусок и hex-подпись склеиваются через точку.
    sig = hmac.new(SECRET.encode(), payload_bytes, hashlib.sha256).hexdigest()
    return f"{payload_b64}.{sig}"

Как сгенерировать токен (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 на стороне хоста — пример FastAPI/Python

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

router = APIRouter()

# Получите секрет один раз через GET /api/v1/widget/signing-secret
# на стороне SupportHub и сохраните в env / settings.
SUPPORTHUB_SIGNING_SECRET = os.environ["SUPPORTHUB_SIGNING_SECRET"]

@router.get("/token")
async def supporthub_widget_token(current_user = Depends(get_current_user)):
    """Возвращает HMAC-токен <payload_b64>.<sig> для cross-device виджета."""
    user_id = str(current_user.id)
    if not user_id:
        raise HTTPException(401, "Unauthorized")

    # Компактный JSON: separators=(",", ":") убирает лишние пробелы.
    # HMAC считается над raw JSON bytes (NOT над 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 установка с auto-token

Один <script> в <body>: сначала запрашивает токен у вашего /token-endpoint'а, потом загружает виджет с этим токеном. Если токена нет (гость, 401, сетевая ошибка), виджет загружается без него, в анонимном режиме.

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>

Передать в виджет

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

Когда токен истёк: SupportHub.on('token_expired')

Если сервер отклонил токен (истёк exp или не сошлась подпись), виджет перестаёт переподключаться с ним, показывает посетителю «Сессия истекла — обновите страницу» и один раз на каждый токен вызывает обработчик token_expired с { reason: "expired" | "invalid" }. Получите в нём свежий токен и передайте в identify — переписка вернётся сама, без перезагрузки страницы. SupportHub.on есть сразу после выполнения бандла, как identify, — до того, как виджет запустился; то же самое — опция onTokenExpired в init().

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 });
});

Выход пользователя: SupportHub.logout()

Когда пользователь выходит из аккаунта на вашем сайте, вызовите window.SupportHub.logout(). Без этого страница, которая при выходе не перезагружается (SPA), продолжит показывать в виджете переписку прежнего пользователя — его токен остаётся в памяти страницы, — и её увидит следующий человек за этим компьютером.

js
// onLogout в вашем SPA — там же, где вы сбрасываете свою сессию
await window.SupportHub.logout();

logout():

  • забывает visitor_token и всё, что пришло из identify() и data-* атрибутов: email, имя, телефон, Telegram ID;
  • заводит новый анонимный vs_* id вместо прежнего во всех хранилищах (cookie, localStorage, sessionStorage, IndexedDB) — прежняя анонимная переписка этого браузера в виджете больше не откроется;
  • закрывает WebSocket, возвращает виджет на главную вкладку и закрывает панель;
  • доступен сразу после загрузки бандла, как и identify(): вызов до окончания инициализации тоже сработает — виджет применит его при запуске.

Промис всегда завершается успешно. На сервере ничего не удаляется: переписка остаётся у пользователя и снова откроется, когда он войдёт и страница передаст его токен. Другие открытые вкладки держат токен в памяти до перезагрузки — если ваш сайт их не перезагружает, вызовите logout() и там.

Если в этом браузере входит другой пользователь, а logout() не вызывали, identify() с токеном другого user_id сам начинает с чистой анонимной сессии: контакт и переписка прежнего пользователя новому не достанутся.

Что происходит на бэкенде

Виджет отправляет этот запрос после загрузки страницы (если в data-* атрибутах есть данные) и при каждом вызове SupportHub.identify(...) с данными. С токеном к URL добавляется &visitor_token=….

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

{
  "email": "user@example.com",
  "full_name": "Иван Петров",
  "phone": "+79161234567",
  "telegram_id": 123456789
}

Backend:

  1. Ищет контакт: без токена — анонимный контакт этого visitor_id (Contact.internal_id); с действительным visitor_token — по user_id из токена в Contact.verified_user_id. Контакт, уже привязанный к user_id, по одному visitor_id не находится: его открывает только токен этого пользователя.
  2. Если по токену контакта ещё нет, привязывает к user_id анонимный контакт этого visitor_id — переписка, начатая до входа, остаётся у пользователя. Контакт, уже привязанный к другому user_id (в этом браузере раньше входил кто-то другой), не перепривязывается никогда — у нового пользователя будет свой. По email и Telegram ID из запроса контакт не ищется вовсе: эти поля не подписаны, и по ним любой вошедший на вашем сайте мог бы забрать чужой контакт, например из почты или Telegram-бота, вместе с перепиской.
  3. Если контакта нет — создаёт его с переданными полями.
  4. Если контакт есть — обновляет поля: email всегда, имя — только если его нет или оно служебное, телефон и Telegram ID — только если их нет.
  5. Если у посетителя есть открытый диалог в виджете и в запросе есть email — в ленте этого тикета появляется строка «✅ Клиент представился».
  6. Шлёт webhook-события: contact.created — только если этот вызов создал контакт, contact.identified — при каждом успешном вызове. См. webhook docs.

Некорректный email бэкенд отклоняет (422) — тогда не сохраняется ничего из запроса. Недействительный токен — 401; запрос без visitor_id и без токена — 404.

Виджет показывает только диалоги виджета. Если у того же контакта есть тикеты из Telegram, VK, WhatsApp, почты или BillManager, в виджете их нет ни в списке, ни по прямой ссылке: их нельзя открыть, ответить в них, закрыть или оценить, и события по ним в виджет не приходят. Ответы по почте на письма из диалога виджета (email-мост) остаются в этом диалоге и видны в нём. Письмо без такой цепочки и сообщение через API в диалог виджета не попадают — для них открывается отдельный тикет. Так посетитель, вписавший себе чужой email или Telegram ID, не увидит переписку настоящего владельца, даже если она окажется у того же контакта.

Безопасность

Email, имя и телефон из data-* и identify() бэкенд не проверяет: любой посетитель может вызвать identify() в своём браузере с любыми данными. Поэтому они только записываются в контакт этого посетителя и никогда не служат для поиска чужого. Проверяется только visitor_token, и к контакту привязывает только его user_id — генерируйте токен на своём бэкенде из текущей серверной сессии, никогда не отдавайте странице токен другого пользователя и вызывайте SupportHub.logout(), когда пользователь выходит.

См. также

  • HMAC visitor token — формат токена, проверки на бэкенде, ошибки и крайние случаи.
  • API-справочник виджета — полный список data-* атрибутов и JS API контроллера.
  • Webhooks — подписаться на contact.created / contact.identified, чтобы получать события на свой бэкенд.
Была ли страница полезной?