Перейти к содержимому

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.

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');
Окно терминала
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Описание
pingKeep-alive / проверка связи. Как любой JSON-кадр, продлевает запись о соединении
get-pendingДля соединений с ?id=<question_id>: спросить, не готов ли уже ответ на вопрос
любоеЭхо обратно (для отладки). Не-JSON кадр вернётся как {"action":"echo","message":{"raw":"…"}}

Это «полезная нагрузка» — то, ради чего вообще держится сокет. Формат полностью совпадает с MessageExternal REST API (см. описание API):

{
"id": 1714476672123456,
"appid": 12345,
"title": "Деплой завершён",
"message": "Сборка #874 ушла на прод.",
"priority": 5,
"date": "2026-04-30T10:11:12Z",
"extras": {}
}

По тому же сокету ходят кадры, которые не надо показывать пользователю: изменение статуса канала, 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::silenttrue — кадр служебный, уведомление показывать нельзя
notifly::eventСобытие сервера: heartbeat:ping, heartbeat:alert, channel:status_changed, share:changed, mfa:approve_request
notifly::commandКоманда клиенту, объект с полем action (например reload_apps)

Правило для своего клиента одно: extras["notifly::silent"] === true — не показывать. Новые типы служебных кадров появляются молча и по этому же признаку отсеются без правки кода.

Обычное сообщение может нести подсказку, как его показать:

{
"id": 1714470000123456,
"appid": 12,
"title": "Деплой: api перезапущен",
"priority": 5,
"extras": {
"notifly::delivery": {"silent": true, "reason": "maintenance"}
}
}
ПолеЧто значит
silenttrue — показать уведомление (лента, шторка), но без звука, вибрации и всплывающего баннера
reasonПочему: maintenance — канал в активном окне обслуживания. Неизвестную причину трактуйте как просто silent

Флаг ставит сам сервер — сообщениям в каналы, покрытые активным окном обслуживания. В отличие от notifly::silent, это не служебный кадр: сообщение нужно показать. Тихие часы в кадре не сигнализируются — клиент применяет их сам по GET /current/user/quiet-hours (см. Тихие часы).

{"action": "pong", "timestamp": "2026-04-30T10:11:12Z", "connection_id": "c057..."}
{"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> — на этом запросе вопрос и помечается доставленным.

  1. Клиент открывает wss://.../ws?token=….
  2. API Gateway → CONNECT → ws-handler проверяет токен и пишет строку в ws_connections (владелец, канал или вопрос — по типу токена и ?id=). Не смог записать — отвечает 503: соединение, о котором нет строки, невидимо для рассылки, честнее дать клиенту переподключиться.
  3. Клиент шлёт кадры → MESSAGE → обработчик отвечает клиенту тем же сокетом и продлевает срок жизни строки — не чаще раза в час (wsconn.RefreshInterval), чтобы не переписывать её на каждый ping.
  4. Сервис, создавший сообщение, находит соединения адресата по индексу и шлёт каждому кадр через management API гейтвея.
  5. Клиент закрывает сокет → 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-KeyX-Notifly-Key: C...
Заголовок X-Gotify-KeyX-Gotify-Key: C...
Заголовок Authorization: BearerAuthorization: Bearer C...

Подписка по токену канала (приложение-получатель)

Заголовок раздела «Подписка по токену канала (приложение-получатель)»

Откройте настройки канала → вкладка Доставка → блок WebSocket. Там доступны готовые примеры на 8 языках и вкладка с промптом для LLM. Принцип одинаковый: подключаемся к wss://api.notifly.ru/ws?token=<токен канала>, читаем JSON-кадры и раз в 30 секунд шлём {"action":"ping"}.

Окно терминала
npm install -g wscat
wscat -c "wss://api.notifly.ru/ws?token=A..."
Окно терминала
$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 websockets
import 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 — сокет истории не хранит.

  1. Локально храните last_id — максимальный увиденный id.

  2. После переподключения запросите свежую страницу сообщений. Ответ идёт от новых к старым:

    Окно терминала
    curl -s -H "X-Notifly-Key: A..." \
    "https://api.notifly.ru/message?limit=100"
  3. Берите из ответа всё с id > last_id и на этом останавливайтесь. Если вся страница оказалась новее last_id — пропущено больше сотни: идите дальше по ссылке paging.next (она подставляет курсор since), пока не дойдёте до своего last_id.

Это превращает WebSocket в надёжную шину: «онлайн» — мгновенная доставка, «офлайн» — догон страницей истории.

Кроме подписки на канал, сокет умеет ждать ответ на конкретный вопрос 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 на телефон». Это универсальная шина, на которую можно посадить любое приложение-обработчик:

  1. Auto-deploy. Канал ci-prod — отправляем сообщение из CI, CD-агент слушает WebSocket и автоматически запускает деплой указанной версии.
  2. Restart on alert. Канал restart-nginx — мониторинг шлёт алёрт, подписанный на канал sidecar выполняет systemctl restart nginx.
  3. Self-healing infra. Канал disk-full — алёрт от Prometheus, слушатель чистит логи и временные файлы.
  4. LLM-агент. Локальный LLM подписан на канал assistant-inbox, реагирует на сообщения как на задачи и пишет в обратный канал ответ.
  5. Smart home bridge. Канал home — приёмник на Raspberry Pi парсит сообщения и шлёт команды Home Assistant / MQTT.
  6. Webhook-replacement. Партнёр шлёт событие в ваш канал — сервис- обработчик слушает по WebSocket. Не нужно открывать публичный HTTP.
  7. Очередь задач для воркеров. Воркеры подписаны на канал jobs и получают задание целиком прямо в кадре (тело сообщения едет в сокете).
  8. Чат-бот fan-out. Telegram/WhatsApp-бот подписан на канал outbound — пересылает каждое сообщение в нужный чат пользователя.
  9. IoT-команды. Устройство (ESP32 + MicroPython uwebsockets) слушает канал device-42 и исполняет команды (открыть реле, мигнуть LED).
  10. Cross-team relay. Канал oncall — алерт прилетает в Slack/Teams через слушатель-мост, а команда продолжает работать в своём стеке.

Во всех сценариях канал тот же самый, что используется для отправки, — просто подписываемся на него токеном канала с правом чтения и получаем JSON в реальном времени.

Кроме сокета у 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 должен вернуть pong
wscat -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}'