Visitor notifications
Widget / Notifications

Visitor notifications

The visitor wrote and left: tell them the operator replied with a browser push, a message from your Telegram bot, or through your webhook.

How it works

Turn it on in SettingsWidget builderVisitor notifications (visitor_notifications.enabled); it is off by default.

  • The visitor is in the chat: the reply arrives in the widget as usual, no notification.
  • The site's tab is open but the visitor is on another tab: the widget shows a browser notification itself (if the visitor allowed them) and puts “(1)” in the tab title; the server doesn't send a second one. If the page is already frozen (a phone put the browser in the background), the notification comes from the server, as for a closed tab.
  • The widget is closed (they left the site, closed the Mini App): the notification goes one of the three ways below.

At most one notification in 5 minutes per visitor; once they come back to the chat, the count starts over. Only operator replies notify — internal notes, bot messages and system lines (“operator joined”, “conversation closed”) don't. The reply text goes into the notification only with “Show the reply text in the notification” on; otherwise the lock screen shows just that support replied.

1. A website — Web Push

A site's push notifications are handled by a service worker, and it has to live on the site's own domain. So one file is needed: create /supporthub-sw.js at the site root with a single line:

/supporthub-sw.jsjs
importScripts('https://support.forestsnet.com/supporthub-visitor-sw.js');
  • Another name or folder goes into “File path on the site”. If the site already has a service worker, put the line at the top of its file and give its path there.
  • The file must be served as JavaScript, without redirects. The “Check the file on the site” button in the builder tells you whether it's right.
  • The widget registers the file under its own scope …/supporthub-push/: it controls no pages of the site, intercepts no requests and doesn't replace your own worker. It handles only our notifications.

What the visitor sees: after their first message the chat shows “Notify me when they reply? Yes / No”. The browser asks for permission only after “Yes” — nothing on page load. “No” is remembered for 30 days. Once subscribed, the chat shows “We'll let you know when they reply · Turn off”.

Tapping the notification opens the page the visitor wrote from, right on that conversation (?sh_ticket=… in the address), or switches to the site's tab if it's still open.

2. A Mini App of your support bot

The widget page runs as the Mini App of a bot connected in SettingsChannelsTelegram. There's nothing to set up: the widget passes the Mini App's launch data (initData) and SupportHub checks its signature with that bot's token.

  • After the first message Telegram shows its own “Allow the bot to message you?” dialog. If the person has started the bot before or already allowed messages, there is no dialog.
  • The operator's reply comes from the bot: “Support replied” with an “Open chat” button that opens the Mini App right on the conversation. The button is there only when the Mini App's domain is in “Allowed domains” (Widget builderPrivacy): the page address comes from the browser, and the bot never sends a button to someone else's site. Without it the bot writes “open the app to read the reply”.
  • If the person blocked the bot, no more messages are sent.

The permission dialog needs telegram-web-app.js on the page.

3. A Mini App of another bot

The widget is embedded in the Mini App of your own bot, which isn't in SupportHub. We can't write from it, so when the visitor isn't in the chat your webhook gets the visitor.reply_waiting event and your bot sends the message. No write permission is asked here.

  1. Create a webhook with the visitor.reply_waiting event (or with no event filter) — see Webhooks. Signature and retries work as for the other events.
  2. To know whom to write to, give the widget a signed visitor_token with a telegram_user_id field: your backend has already validated initData with its own token and knows the user. Without the token the event has no telegram_user_id — unsigned data from the browser isn't trusted.
  3. On the event, send the message from your bot (examples below).

A token with telegram_user_id

Same format and signature as a regular visitor_token; the telegram_user_id field just goes into the payload.

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

def visitor_token(user_id: str, telegram_user_id: int, secret: str) -> str:
    """Sign after your backend has validated the Mini App's initData."""
    payload = json.dumps(
        {"user_id": user_id, "telegram_user_id": telegram_user_id, "exp": int(time.time()) + 3600},
        separators=(",", ":"),
    ).encode()
    b64 = base64.urlsafe_b64encode(payload).rstrip(b"=").decode()
    return f"{b64}.{hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()}"
js
// On the Mini App page, once your backend returned the token:
window.SupportHub.identify({ visitor_token: token });

The visitor.reply_waiting event

json
{
  "id": "5d0c9a…",
  "type": "visitor.reply_waiting",
  "timestamp": "2026-09-25T12:00:05.123456+00:00",
  "workspace_id": "27744f73-…",
  "data": {
    "contact_id": "9b1e0c52-…",
    "external_id": "42",
    "telegram_user_id": 123456789,
    "ticket_id": "0f04c7aa-…",
    "ticket_short_id": "0f04c7aa",
    "message_id": "c3a1b7e0-…",
    "operator_name": "Anna",
    "preview": null,
    "locale": "ru",
    "surface": "telegram_mini_app",
    "open_url": "https://app.example.com/support?sh_ticket=0f04c7aa-…",
    "replied_at": "2026-09-25T12:00:00+00:00"
  },
  "_links": {
    "ticket": "/api/v1/tickets/0f04c7aa-…",
    "messages": "/api/v1/tickets/0f04c7aa-…/messages",
    "contact": "/api/v1/contacts/9b1e0c52-…",
    "tickets": "/api/v1/contacts/9b1e0c52-…/tickets"
  }
}
contact_id
Type: uuidDefault: —
The contact in SupportHub.
external_id
Type: string | nullDefault: —
Your id for the person: the user_id of the signed visitor_token, otherwise the contact's external_id set through the API.
telegram_user_id
Type: int | nullDefault: —
A Telegram id — only a verified one: from the signed visitor_token or from initData of your support bot.
ticket_id / ticket_short_id
Type: uuid / stringDefault: —
The conversation; the short number is the first 8 characters, as in the dashboard.
message_id
Type: uuidDefault: —
The operator's reply.
operator_name
Type: string | nullDefault: —
The operator's name, if the widget shows operator names.
preview
Type: string | nullDefault: —
The start of the reply (up to 120 characters) — only with “Show the reply text in the notification” on.
locale
Type: stringDefault: —
The visitor's language: ru / en.
surface
Type: string | nullDefault: —
telegram_mini_app or web — where the widget last ran.
open_url
Type: string | nullDefault: —
The visitor's page with ?sh_ticket=: the widget opens on that conversation. For a web_app button.
replied_at
Type: ISO 8601Default: —
When the operator replied.

The event also comes for site visitors (surface: web), in case you have your own way to reach them — an email, your app's push.

Examples: the bot sends the notification

hook.pypython
# pip install aiogram aiohttp
import hashlib
import hmac
import json
import os

from aiohttp import web
from aiogram import Bot
from aiogram.exceptions import TelegramForbiddenError
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo

bot = Bot(os.environ["BOT_TOKEN"])
SECRET = os.environ["SUPPORTHUB_WEBHOOK_SECRET"].encode()
TEXTS = {"ru": ("Поддержка ответила", "Открыть чат"), "en": ("Support replied", "Open chat")}


async def supporthub_hook(request: web.Request) -> web.Response:
    body = await request.read()
    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(request.headers.get("X-Webhook-Signature", ""), expected):
        return web.Response(status=401)

    event = json.loads(body)
    data = event["data"]
    if event["type"] != "visitor.reply_waiting" or not data.get("telegram_user_id"):
        return web.Response(status=204)

    text, button = TEXTS.get(data.get("locale"), TEXTS["en"])
    if data.get("preview"):
        text += "\n\n" + data["preview"]
    markup = None
    if data.get("open_url"):
        markup = InlineKeyboardMarkup(inline_keyboard=[[
            InlineKeyboardButton(text=button, web_app=WebAppInfo(url=data["open_url"])),
        ]])
    try:
        await bot.send_message(data["telegram_user_id"], text, reply_markup=markup)
    except TelegramForbiddenError:
        pass  # the user blocked the bot or never started it
    return web.Response(status=204)


app = web.Application()
app.router.add_post("/hooks/supporthub", supporthub_hook)
web.run_app(app, port=8080)

What doesn't work where

  • iPhone and iPad, Safari. Web Push exists only for a site added to the home screen (iOS 16.4+). In a regular Safari tab the widget offers nothing.
  • In-app browsers (a link opened in Telegram, Instagram, Facebook, app WebViews) — no Web Push, no offer.
  • Telegram Mini Apps — no Web Push: a message from your support bot or the webhook event (sections 2 and 3).
  • The browser blocked notifications for the site — the widget doesn't offer; a hidden tab gets only “(1)” in the title.
  • A private window — the subscription goes away with the window.
  • Signing out (SupportHub.logout() or another user signing in) unsubscribes the browser: replies to the previous visitor no longer come here.
Was this page helpful?