WebSocket-протокол
Push-уведомления Notifly доставляются по WebSocket. Любой клиент
(веб-админка, Android-приложение, ваш собственный демон) держит постоянное
соединение по wss://, и каждое новое сообщение приходит кадром сразу
после того, как REST-эндпоинт POST /message его сохранил.
Архитектура
Заголовок раздела «Архитектура»У сокета две независимые половины. Входящая (клиент → сервер) — авторизация
и реестр соединений, ей занят обработчик ws-handler. Исходящая
(сервер → клиент) — сама доставка, её делают те сервисы, которые уведомления
порождают. Общее у половин ровно одно — таблица соединений.
Клиент (браузер / телефон / ваш сервис) ┌──────────────────────────────┐ │ │ api, heartbeat, мониторы, │ │ wss://api.notifly.ru/ws?token=… │ email — всё, что порождает │ ▼ │ уведомления │┌──────────────────────┐ management :send └──────────────┬───────────────┘│ Yandex API Gateway │ ◄──────────────────────────────────┘└──────────┬───────────┘ │ CONNECT / MESSAGE / DISCONNECT ▼┌──────────────────────────┐│ ws-handler │ регистрирует соединение,│ (serverless-контейнер) │ отвечает на кадры клиента└──────────┬───────────────┘ │ ▼┌───────────────────────────────────────────────────┐│ Таблица ws_connections ││ connection_id, user_id, device_id, app_id, ││ question_id, connected_at, last_activity, … │└───────────────────────────────────────────────────┘- Соединение живёт как строка в таблице
ws_connections— по одной на открытый сокет. В ней лежатconnection_idот гейтвея, владелец (user_id,device_id) и подписки (app_id,question_id); по ним рассылка и находит адресатов. Индексы по пользователю, каналу и вопросу делают выборку адресной — полного перебора соединений нет. - Отдельного признака «подключён» нет: соединение существует ровно пока
существует его строка. Срок жизни — сутки с последнего продления. Продлевают
его только JSON-кадры клиента (например,
ping), и не чаще раза в час; ping на уровне самого WebSocket-протокола до обработчика не доходит и не считается. ЕслиDISCONNECTне пришёл (клиент отвалился молча), строку снимет TTL. - Авторизация — на
CONNECT, токеном из query?token=…либо из заголовкаX-Notifly-Key/X-Gotify-Key/Authorization: Bearer. Без валидного токена гейтвей соединение не откроет. - Доставка идёт мимо
ws-handler: сервис, создавший сообщение, находит соединения адресата в таблице и шлёт каждому кадр через management API гейтвея. Один вызов — один сокет; ответ «соединение закрыто» снимает строку, чтобы в мёртвый сокет больше не слали.
Подключение
Заголовок раздела «Подключение»wss://<домен>/ws?token=C<clientToken>Доменом служит ваш API Gateway. Для облачной версии Notifly это
wss://api.notifly.ru/ws — но протокол устроен одинаково для любого
self-hosted-стенда. Адрес на другой установке может выглядеть иначе: на AWS
сокет — это отдельный URL стейджа API Gateway WebSocket, не /ws на хосте
API (в Go SDK его задаёт WithWSURL). Локальный сервер,
запущенный одним процессом, поднимает /ws только с LOCAL_MODE=true.
Из браузера (JavaScript)
Заголовок раздела «Из браузера (JavaScript)»const ws = new WebSocket(`wss://api.notifly.ru/ws?token=${clientToken}`);
ws.onopen = () => { console.log('Connected'); // keep-alive раз в 30 секунд: и сокет живой, и запись о соединении не протухает setInterval(() => ws.send(JSON.stringify({action: 'ping'})), 30_000);};
ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.action) { // служебный ответ протокола (pong, echo) console.debug('WS:', data); } else if (data.extras?.['notifly::silent']) { // служебный кадр: клиенту показывать нечего (см. ниже) } else if (data.message !== undefined) { // входящее уведомление console.log('Notification:', data.title, data.message); } else { // сигнал по подписке на вопрос: {"id":…, "status":…, "fetch":true} console.log('Ask signal:', data); }};
ws.onclose = () => console.log('Disconnected');Из командной строки (wscat)
Заголовок раздела «Из командной строки (wscat)»npm install -g wscat
wscat -c "wss://api.notifly.ru/ws?token=Caw9...XYZ"
> {"action": "ping"}< {"action":"pong","timestamp":"2026-04-30T10:11:12Z","connection_id":"c057..."}
> {"action": "hello"}< {"action":"echo","message":{"action":"hello"}}Без токена (или с отозванным) соединение не откроется вовсе: CONNECT
отбивается статусом 401, и клиент видит ошибку рукопожатия, а не закрытие
уже открытого сокета.
Протокол сообщений
Заголовок раздела «Протокол сообщений»Все кадры — JSON.
Что шлёт клиент
Заголовок раздела «Что шлёт клиент»action | Описание |
|---|---|
ping | Keep-alive / проверка связи. Как любой JSON-кадр, продлевает запись о соединении |
get-pending | Для соединений с ?id=<question_id>: спросить, не готов ли уже ответ на вопрос |
| любое | Эхо обратно (для отладки). Не-JSON кадр вернётся как {"action":"echo","message":{"raw":"…"}} |
Что шлёт сервер
Заголовок раздела «Что шлёт сервер»Push-уведомление
Заголовок раздела «Push-уведомление»Это «полезная нагрузка» — то, ради чего вообще держится сокет.
Формат полностью совпадает с MessageExternal REST API (см.
описание API):
{ "id": 1714476672123456, "appid": 12345, "title": "Деплой завершён", "message": "Сборка #874 ушла на прод.", "priority": 5, "date": "2026-04-30T10:11:12Z", "extras": {}}Служебные кадры (notifly::silent)
Заголовок раздела «Служебные кадры (notifly::silent)»По тому же сокету ходят кадры, которые не надо показывать пользователю:
изменение статуса канала, heartbeat-события, команда клиенту перечитать
список каналов. Все они имеют ту же форму MessageExternal с id: 0, ключи
message и title в них есть (обычно пустые), а отличаются кадры полем
extras. appid: 0 и priority: -1 — у команды reload_apps (и у запроса
подтверждения входа mfa:approve_request); события heartbeat и
channel:status_changed несут настоящий appid канала и priority: 0. У
share:changed appid тоже 0, а канал лежит в extras.appid:
{ "id": 0, "appid": 0, "title": "", "message": "", "priority": -1, "date": "2026-04-30T10:11:12Z", "extras": { "notifly::command": {"action": "reload_apps"}, "notifly::silent": true }}Ключ в extras | Что значит |
|---|---|
notifly::silent | true — кадр служебный, уведомление показывать нельзя |
notifly::event | Событие сервера: heartbeat:ping, heartbeat:alert, channel:status_changed, share:changed, mfa:approve_request |
notifly::command | Команда клиенту, объект с полем action (например reload_apps) |
Правило для своего клиента одно: extras["notifly::silent"] === true — не
показывать. Новые типы служебных кадров появляются молча и по этому же
признаку отсеются без правки кода.
Беззвучная доставка (notifly::delivery)
Заголовок раздела «Беззвучная доставка (notifly::delivery)»Обычное сообщение может нести подсказку, как его показать:
{ "id": 1714470000123456, "appid": 12, "title": "Деплой: api перезапущен", "priority": 5, "extras": { "notifly::delivery": {"silent": true, "reason": "maintenance"} }}| Поле | Что значит |
|---|---|
silent | true — показать уведомление (лента, шторка), но без звука, вибрации и всплывающего баннера |
reason | Почему: maintenance — канал в активном окне обслуживания. Неизвестную причину трактуйте как просто silent |
Флаг ставит сам сервер — сообщениям в каналы, покрытые активным окном
обслуживания. В отличие от notifly::silent, это не служебный кадр:
сообщение нужно показать. Тихие часы в кадре не сигнализируются — клиент
применяет их сам по GET /current/user/quiet-hours (см.
Тихие часы).
pong — ответ на ping
Заголовок раздела «pong — ответ на ping»{"action": "pong", "timestamp": "2026-04-30T10:11:12Z", "connection_id": "c057..."}echo — отладочное эхо неизвестных action
Заголовок раздела «echo — отладочное эхо неизвестных action»{"action": "echo", "message": {"action": "foo", "data": 42}}Сигналы по подписке на вопрос
Заголовок раздела «Сигналы по подписке на вопрос»Приходят на соединение, открытое с ?id=<question_id> (см. ниже). У них нет
поля action — это короткие сигналы о судьбе вопроса
ask:
| Кадр | Когда |
|---|---|
{"id":12345,"status":"answered","fetch":true} | Ответ готов. Забрать через GET /ask-question/12345 |
{"id":12345,"status":"answered","fetch":true,"nextQuestionId":12346} | То же, но диалог продолжается следующим вопросом |
{"id":12345,"status":"expired"} | Время вышло, ответа не будет |
{"id":12345,"status":"cancelled"} | Вопрос отменён |
{"id":12345,"status":"pending","expiresAt":1714476672000} | Срок ответа продлён, ждём дальше (expiresAt — мс эпохи) |
Это notify-then-fetch: по сокету уходит только сигнал, само тело ответа
клиент забирает отдельным GET /ask-question/<id> — на этом запросе вопрос и
помечается доставленным.
Жизненный цикл соединения
Заголовок раздела «Жизненный цикл соединения»- Клиент открывает
wss://.../ws?token=…. - API Gateway →
CONNECT→ws-handlerпроверяет токен и пишет строку вws_connections(владелец, канал или вопрос — по типу токена и?id=). Не смог записать — отвечает503: соединение, о котором нет строки, невидимо для рассылки, честнее дать клиенту переподключиться. - Клиент шлёт кадры →
MESSAGE→ обработчик отвечает клиенту тем же сокетом и продлевает срок жизни строки — не чаще раза в час (wsconn.RefreshInterval), чтобы не переписывать её на каждыйping. - Сервис, создавший сообщение, находит соединения адресата по индексу и шлёт каждому кадр через management API гейтвея.
- Клиент закрывает сокет →
DISCONNECT→ строка удаляется.
Если шаг 5 не случился (обрыв связи, убитый процесс), строку снимет TTL, а до
того протухшую запись отсечёт проверка срока в выборках. Шлите JSON-кадр
{"action":"ping"} раз в 30 секунд — этого достаточно и для промежуточных
прокси, и для продления записи. Входящие кадры сервера и protocol-level ping
библиотеки запись не продлевают: соединение, от которого сутки не было ни
одного JSON-кадра, перестанет получать рассылку, даже если сокет ещё открыт.
Авторизация
Заголовок раздела «Авторизация»При CONNECT нужно передать токен. Поддерживаются два вида токенов:
| Префикс / длина | Тип | Что получает соединение |
|---|---|---|
C... (23) | Токен устройства | Сообщения всех каналов, доступных пользователю, — своих и расшаренных ему. |
A... / S... (23) | Токен канала или шары | Только сообщения своего канала — того, которому выдан токен. |
Токен устройства не должен быть отозван. Токен канала или шары принимается,
только если у него есть право чтения: основной ключ канала читать может,
а машинный ключ «только отправка» получит 401 на handshake.
API-токен (M…) сокет не принимает: соединение
привязано к устройству, а у API-токена устройства нет. Для realtime-потока
берите токен устройства или токен канала.
Токен канала подписывает соединение на realtime-push при каждом новом
сообщении в этом канале: как только POST /message сохранил сообщение,
сервер находит все соединения канала и шлёт им кадр. Это значит, что
получать сообщения может не только устройство-получатель. Любое
приложение, сервис, скрипт или CI-раннер может подписаться на канал прямо по
тому же токену, которым шлёт сообщения, — и обработать их программно в
реальном времени. Годится и делегированный токен из
шары канала, если у него есть право на чтение.
Как передать токен
Заголовок раздела «Как передать токен»Сервер ищет токен на handshake сначала в query-параметре token, затем в
заголовках. Если заголовков с токеном несколько, какой из них победит, не
определено — передавайте один.
| Способ | Пример |
|---|---|
Query-параметр token (основной) | wss://api.notifly.ru/ws?token=C... |
Заголовок X-Notifly-Key | X-Notifly-Key: C... |
Заголовок X-Gotify-Key | X-Gotify-Key: C... |
Заголовок Authorization: Bearer | Authorization: Bearer C... |
Подписка по токену канала (приложение-получатель)
Заголовок раздела «Подписка по токену канала (приложение-получатель)»Откройте настройки канала → вкладка Доставка → блок WebSocket.
Там доступны готовые примеры на 8 языках и вкладка с промптом для LLM. Принцип
одинаковый: подключаемся к wss://api.notifly.ru/ws?token=<токен канала>,
читаем JSON-кадры и раз в 30 секунд шлём {"action":"ping"}.
bash (через wscat)
Заголовок раздела «bash (через wscat)»npm install -g wscatwscat -c "wss://api.notifly.ru/ws?token=A..."PowerShell
Заголовок раздела «PowerShell»$ct = [Threading.CancellationToken]::None$ws = [System.Net.WebSockets.ClientWebSocket]::new()$uri = [Uri]"wss://api.notifly.ru/ws?token=A..."$ws.ConnectAsync($uri, $ct).Wait()
$buf = [byte[]]::new(8192)$seg = [ArraySegment[byte]]::new($buf)$ping = [ArraySegment[byte]]::new([Text.Encoding]::UTF8.GetBytes('{"action":"ping"}'))$text = [System.Net.WebSockets.WebSocketMessageType]::Text$last = [Diagnostics.Stopwatch]::StartNew()$recv = $ws.ReceiveAsync($seg, $ct)while ($ws.State -eq 'Open') { if ($recv.Wait(1000)) { $res = $recv.Result Write-Host ([Text.Encoding]::UTF8.GetString($buf, 0, $res.Count)) $recv = $ws.ReceiveAsync($seg, $ct) } if ($last.Elapsed.TotalSeconds -ge 30) { # keep-alive раз в 30 секунд $ws.SendAsync($ping, $text, $true, $ct).Wait() $last.Restart() }}# pip install websocketsimport asyncio, json, websockets
URL = "wss://api.notifly.ru/ws?token=A..."
async def keepalive(ws): # JSON-ping: только кадры клиента продлевают запись о соединении try: while True: await asyncio.sleep(30) await ws.send(json.dumps({"action": "ping"})) except websockets.ConnectionClosed: pass
async def main(): async for ws in websockets.connect(URL): pinger = asyncio.create_task(keepalive(ws)) try: async for raw in ws: data = json.loads(raw) if data.get("action"): continue # pong / echo if data.get("extras", {}).get("notifly::silent"): continue # служебный кадр if "message" in data: print(data.get("title"), "—", data["message"]) except websockets.ConnectionClosed: continue finally: pinger.cancel()
asyncio.run(main())Готовый слушатель канала на Go — Subscribe из Go SDK:
keepalive, переподключение и разбор служебных кадров там уже написаны. Дождаться
ответа на вопрос помогает WaitAskAnswer из того же SDK. Сниппет ниже —
то же самое вручную, на gorilla/websocket.
c, _, err := websocket.DefaultDialer.Dial("wss://api.notifly.ru/ws?token=A...", nil)if err != nil { log.Fatal(err)}defer c.Close()
// keep-alive: JSON-ping раз в 30 секунд (пишет только эта горутина)go func() { for range time.Tick(30 * time.Second) { if c.WriteMessage(websocket.TextMessage, []byte(`{"action":"ping"}`)) != nil { return } }}()
for { _, data, err := c.ReadMessage() if err != nil { return } fmt.Println(string(data))}Догон пропущенного после обрыва
Заголовок раздела «Догон пропущенного после обрыва»Полное тело сообщения приходит прямо в кадре (это MessageExternal
целиком), поэтому в онлайне ничего дозабирать не нужно. А вот то, что пришло
за время обрыва, надо добрать через REST — сокет истории не хранит.
-
Локально храните
last_id— максимальный увиденныйid. -
После переподключения запросите свежую страницу сообщений. Ответ идёт от новых к старым:
Окно терминала curl -s -H "X-Notifly-Key: A..." \"https://api.notifly.ru/message?limit=100" -
Берите из ответа всё с
id > last_idи на этом останавливайтесь. Если вся страница оказалась новееlast_id— пропущено больше сотни: идите дальше по ссылкеpaging.next(она подставляет курсорsince), пока не дойдёте до своегоlast_id.
Это превращает WebSocket в надёжную шину: «онлайн» — мгновенная доставка, «офлайн» — догон страницей истории.
Подписка на ответ к вопросу (?id=<question_id>)
Заголовок раздела «Подписка на ответ к вопросу (?id=<question_id>)»Кроме подписки на канал, сокет умеет ждать ответ на конкретный вопрос
ask. Откройте соединение с дополнительным query-параметром
id, равным question_id:
wss://api.notifly.ru/ws?token=A<channelToken>&id=12345Берите токен канала с правами и на чтение, и на запись — как основной ключ
канала: сокет требует чтения, а GET /ask-question/<id>, которым забирается
ответ, — токена канала с правом записи (токену устройства он ответит 401).
В Go эту подписку целиком закрывает WaitAskAnswer из SDK.
Соединение привязывается к вопросу 12345. Когда к нему появится ответ (или
он истечёт, будет отменён либо продлён), сервер пришлёт короткий сигнал — см.
таблицу выше. Тело ответа по сокету не едет, забираете его через
GET /ask-question/12345. Обычные кадры канала на такое соединение тоже
приходят — сигналы вопроса отличайте по отсутствию action и message.
Можно не ждать пассивно, а спросить статус самому — отправьте get-pending:
wscat -c "wss://api.notifly.ru/ws?token=A...&id=12345"
> {"action": "get-pending"}< {"id":12345,"status":"answered","fetch":true}Ответ приходит, только если вопрос уже отвечен, и без nextQuestionId —
продолжение диалога узнаете из GET /ask-question/<id>. Если ответа ещё нет,
а также для истёкшего или отменённого вопроса сервер на get-pending ничего
не возвращает — соединение просто продолжает ждать realtime-сигнал. Сбой
чтения на стороне сервера вернёт {"error":"internal"}. get-pending имеет
смысл только для соединений, открытых с ?id=; без него действие
проигнорируется.
10 сценариев, где приёмником выступает приложение
Заголовок раздела «10 сценариев, где приёмником выступает приложение»Канал Notifly — это не только «push на телефон». Это универсальная шина, на которую можно посадить любое приложение-обработчик:
- Auto-deploy. Канал
ci-prod— отправляем сообщение из CI, CD-агент слушает WebSocket и автоматически запускает деплой указанной версии. - Restart on alert. Канал
restart-nginx— мониторинг шлёт алёрт, подписанный на канал sidecar выполняетsystemctl restart nginx. - Self-healing infra. Канал
disk-full— алёрт от Prometheus, слушатель чистит логи и временные файлы. - LLM-агент. Локальный LLM подписан на канал
assistant-inbox, реагирует на сообщения как на задачи и пишет в обратный канал ответ. - Smart home bridge. Канал
home— приёмник на Raspberry Pi парсит сообщения и шлёт команды Home Assistant / MQTT. - Webhook-replacement. Партнёр шлёт событие в ваш канал — сервис- обработчик слушает по WebSocket. Не нужно открывать публичный HTTP.
- Очередь задач для воркеров. Воркеры подписаны на канал
jobsи получают задание целиком прямо в кадре (тело сообщения едет в сокете). - Чат-бот fan-out. Telegram/WhatsApp-бот подписан на канал
outbound— пересылает каждое сообщение в нужный чат пользователя. - IoT-команды. Устройство (ESP32 + MicroPython
uwebsockets) слушает каналdevice-42и исполняет команды (открыть реле, мигнуть LED). - Cross-team relay. Канал
oncall— алерт прилетает в Slack/Teams через слушатель-мост, а команда продолжает работать в своём стеке.
Во всех сценариях канал тот же самый, что используется для отправки, — просто подписываемся на него токеном канала с правом чтения и получаем JSON в реальном времени.
Альтернатива: Server-Sent Events (GET /stream)
Заголовок раздела «Альтернатива: Server-Sent Events (GET /stream)»Кроме сокета у API есть поток SSE GET /stream. Сообщений в нём нет —
только сигналы для живого интерфейса, поэтому как замена WebSocket он не
годится:
- Авторизация — токен устройства, API-токен или токен канала с правом чтения,
в заголовке или в
?token=(браузерныйEventSourceзаголовки не задаёт). - События:
connected(сразу после подключения),channel:status_changedиshare:changedс данными{"appid":N}. Раз в 25 секунд сервер шлёт комментарий-keepalive. - События именованные, поэтому
onmessageих не увидит — подписывайтесь черезaddEventListener. - Доставка best-effort: хаб событий живёт в памяти процесса API, медленному
клиенту событие просто не достанется. На развёртываниях в AWS поток
отключён —
/streamсразу отвечает503.
const es = new EventSource(`https://api.notifly.ru/stream?token=${clientToken}`);es.addEventListener('channel:status_changed', (e) => { const {appid} = JSON.parse(e.data); console.log('Статус канала изменился:', appid);});Ручное тестирование
Заголовок раздела «Ручное тестирование»Быстрая проверка связи из терминала:
npm install -g wscat
# 1. Без токена — соединение не должно открыться (401 на handshake)wscat -c "wss://api.notifly.ru/ws"
# 2. С токеном — ping должен вернуть pongwscat -c "wss://api.notifly.ru/ws?token=A..."> {"action": "ping"}< {"action":"pong","timestamp":"...","connection_id":"c057..."}
# 3. Не закрывая сокет, отправьте себе сообщение из другого терминала —# кадр придёт в первыйcurl -s -X POST "https://api.notifly.ru/message" \ -H "X-Notifly-Key: A..." \ -H "Content-Type: application/json" \ -d '{"title":"ping","message":"из curl","priority":5}'