Skip to content

WebSocket protocol

Push-уведомления Notifly доставляются по WebSocket. Любой клиент (web-админка, Android-приложение, кастомный desktop) держит постоянное соединение по wss://, и каждое новое сообщение приходит фреймом сразу после того, как REST-эндпоинт POST /message его сохранил.

┌──────────┐ ┌────────────────────┐
│ Client │ wss://api/ws ───► │ Yandex API GW │
└─────┬────┘ └─────────┬──────────┘
│ │ CONNECT / MESSAGE / DISCONNECT
│ receives messages ▼
│ ┌────────────────────┐
│ │ notifly-ws-handler │ Cloud Function
│ │ (Go) │
│ └─────────┬──────────┘
│ │ S3 API
│ ▼
│ ┌────────────────────┐
│ │ Object Storage │ connections/<id>.json
│ │ notifly-ws-conns │
│ └────────────────────┘
│ ───────── after POST /message ───────────────┐
▼ ▼
┌──────────┐ ┌────────────────────┐
│ notifly │ push frame to @connections │ Yandex API GW │
│ -api │ ───────────────────────────► │ Management API │
└──────────┘ └────────────────────┘
  • Соединение хранится в S3 (notifly-ws-connections) как connections/<id>.json, плюс индексные копии by-user/, by-app/ и by-question/ для быстрого поиска адресатов при доставке.
  • Авторизация на CONNECT — токеном из query ?token=... или из заголовка X-Notifly-Key / Authorization: Bearer. Соединение ассоциируется с user_id (и, по типу токена, с каналом или вопросом) и используется как адрес доставки push.
  • Доставка инициируется REST-функцией notifly-api: после INSERT messages она находит все соединения нужного пользователя и шлёт фрейм через Management-API API Gateway: POST /@connections/<id>.
wss://<domain>/ws?token=C<clientToken>

Доменом служит ваш API Gateway. Для облачной версии Notifly это https://api.notifly.ru/ws — но протокол устроен одинаково для любого self-hosted-стенда.

const ws = new WebSocket(`wss://api.notifly.ru/ws?token=${clientToken}`);
ws.onopen = () => {
console.log('Connected');
// (optional) send keep-alive every 30 seconds
setInterval(() => ws.send(JSON.stringify({action: 'ping'})), 30_000);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.id && data.message) {
// this is an incoming notification
console.log('Notification:', data.title, data.message);
} else {
// service response (pong, status, …)
console.debug('WS:', 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": "status"}
< {"action":"status","connection_id":"c057...","connected_at":"...","source_ip":"..."}

All frames are JSON.

actionDescription
pingKeep-alive / connection check
statusInformation about the current connection
listList of all user’s connections
broadcastBroadcast to other connections of the user
get-pendingFor connections with ?id=<question_id>: ask whether an answer for the question is ready
anyEchoed back (for debugging)

This is the “payload” — the reason the socket exists. The format matches the MessageExternal REST API exactly:

{
"id": 1714476672123456789,
"appid": 12345,
"title": "Деплой завершён",
"message": "Сборка #874 ушла на прод.",
"priority": 5,
"date": "2026-04-30T10:11:12Z"
}
{"action":"pong","timestamp":"2026-04-30T10:11:12Z","connection_id":"c057..."}
{
"action":"status",
"connection_id":"c057...",
"connected_at":"2026-04-30T10:11:12Z",
"source_ip":"95.104.77.29",
"last_activity":"2026-04-30T10:11:42Z"
}

connections — list of connections (in response to list)

Section titled “connections — list of connections (in response to list)”
{
"action":"connections",
"count":2,
"connections":[
{"connection_id":"c057...","connected_at":"...","status":"connected", ...}
]
}
{"action":"echo","message":{"action":"foo","data":42}}

fetch — signal “answer to the question is ready”

Section titled “fetch — signal “answer to the question is ready””

Arrives on a connection opened with ?id=<question_id> (see below) when an answer to the ask appears. This is a notify-then-fetch: the socket only receives a signal, and the client retrieves the actual answer via a separate GET.

{"id":12345,"status":"answered","fetch":true}

status can be answered or delivered. On receiving the signal, fetch the answer via REST GET /ask-question/<id> — this request also marks it as delivered.

  1. The client opens wss://.../ws?token=....
  2. API Gateway → CONNECT → Cloud Function writes ConnectionInfo (including user_id) to connections/<id>.json in S3.
  3. The client sends frames → MESSAGE → the function responds in Response.Body.
  4. The REST function notifly-api, after creating a new message, lists the user’s connections in S3 and sends a frame via the Management API.
  5. The client closes the socket → DISCONNECT → the function removes the connection JSON file.

A token must be provided on CONNECT. Two token types are supported:

Prefix / lengthTypeWhat the connection receives
C... (23)Client-tokenAll user messages — across all their channels.
A... (23)App-tokenOnly messages for the specific channel (to which the token belongs).

An App-token subscribes the connection to realtime-push for every new message in that channel: as soon as POST /message stores a message, the server finds all by-app connections for the channel and sends them a frame. This means that messages can be received not only by the target device. Any application, service, script, or CI runner can subscribe to the channel using the same app-token used to send messages — and handle them programmatically in real time.

The server looks for the token on the handshake in three ways (in this order):

MethodExample
Query parameter token (primary)wss://api.notifly.ru/ws?token=C...
Header X-Notifly-KeyX-Notifly-Key: C...
Header Authorization: BearerAuthorization: Bearer C...

Subscribing with a channel’s app-token (receiver application)

Section titled “Subscribing with a channel’s app-token (receiver application)”

Open channel settings → Delivery tab → WebSocket block. There are ready-made examples in 9 languages. The principle is the same: connect to wss://api.notifly.ru/ws?token=<app-token> and read JSON frames.

Окно терминала
npm install -g wscat
wscat -c "wss://api.notifly.ru/ws?token=A..."
Окно терминала
$ws = [System.Net.WebSockets.ClientWebSocket]::new()
$uri = [Uri]"wss://api.notifly.ru/ws?token=A..."
$ws.ConnectAsync($uri, [Threading.CancellationToken]::None).Wait()
$buf = [byte[]]::new(8192)
$seg = [ArraySegment[byte]]::new($buf)
while ($ws.State -eq 'Open') {
$res = $ws.ReceiveAsync($seg, [Threading.CancellationToken]::None).Result
$msg = [Text.Encoding]::UTF8.GetString($buf, 0, $res.Count)
Write-Host $msg
}
# pip install websockets
import asyncio, json, websockets
URL = "wss://api.notifly.ru/ws?token=A..."
async def main():
async for ws in websockets.connect(URL, ping_interval=30):
try:
async for raw in ws:
data = json.loads(raw)
if "id" in data and "message" in data:
print(data["title"], "", data["message"])
except websockets.ConnectionClosed:
continue
asyncio.run(main())
c, _, _ := websocket.DefaultDialer.Dial("wss://api.notifly.ru/ws?token=A...", nil)
defer c.Close()
for {
_, data, err := c.ReadMessage()
if err != nil { return }
fmt.Println(string(data))
}

Messages can arrive while the socket was down. To avoid losing anything:

  1. Store last_id locally — the highest id you’ve seen.

  2. After reconnecting, fetch missed messages via REST:

    Окно терминала
    curl -s -H "X-Notifly-Key: A..." \
    "https://api.notifly.ru/message?since=<last_id>"
  3. The full message body always arrives inside the frame (it’s the complete MessageExternal), so when online there’s no separate fetch needed — the frame is self-contained. To catch up on missed messages while offline use the same ?since=:

    Окно терминала
    curl -s -H "X-Notifly-Key: A..." \
    "https://api.notifly.ru/message?since=<last_id>"

This turns the WebSocket into a reliable bus: “online” — instant delivery, “offline” — catch-up via since.

Subscription to an answer to a question (?id=<question_id>)

Section titled “Subscription to an answer to a question (?id=<question_id>)”

Besides channel subscription, the socket can wait for an answer to a specific question ask. Open a connection with an additional query parameter id equal to the question_id:

wss://api.notifly.ru/ws?token=C<clientToken>&id=12345

The connection is bound to question 12345. When an answer appears for it, the server will send a fetch signal (see above) — the question’s body is not sent over the socket; retrieve it via GET /ask-question/12345.

You can actively ask for status by sending get-pending:

Окно терминала
wscat -c "wss://api.notifly.ru/ws?token=C...&id=12345"
> {"action": "get-pending"}
< {"id":12345,"status":"answered","fetch":true}

If there is no answer yet, the server returns nothing for get-pending — the connection simply continues waiting for the realtime signal. get-pending only makes sense for connections opened with ?id=; otherwise the action will be ignored.

10 scenarios where the recipient is an application

Section titled “10 scenarios where the recipient is an application”

Notifly’s channel is not just “push to phone”. It’s a universal bus onto which any handler application can subscribe:

  1. Auto-deploy. Channel ci-prod — send a message from CI, a CD agent listens on WebSocket and automatically triggers deployment of the specified version.
  2. Restart on alert. Channel restart-nginx — monitoring sends an alert, a sidecar subscribed to the channel runs systemctl restart nginx.
  3. Self-healing infra. Channel disk-full — alert from Prometheus, the listener cleans logs and temp files.
  4. LLM agent. A local LLM subscribes to channel assistant-inbox, treats messages as tasks and writes the answer back to the return channel.
  5. Smart home bridge. Channel home — a receiver on a Raspberry Pi parses messages and sends commands to Home Assistant / MQTT.
  6. Webhook replacement. A partner sends an event to your channel — a handler service listens via WebSocket. No need to expose a public HTTP endpoint.
  7. Task queue for workers. Workers subscribe to channel jobs and receive the full task directly in the frame (the message body is in the socket).
  8. Chat-bot fan-out. A Telegram/WhatsApp bot subscribes to channel outbound — it forwards each message to the appropriate user chat.
  9. IoT commands. A device (ESP32 + MicroPython uwebsockets) listens to channel device-42 and executes commands (toggle a relay, blink an LED).
  10. Cross-team relay. Channel oncall — an alert is bridged to Slack/Teams by a listener, while the team continues to operate in its own stack.

In all scenarios the channel is the same one used for sending — just subscribe to it with any A... token and receive JSON in real time.

A ready python script is in the repository:

Окно терминала
python3 ws-handler/test_ws.py

It connects, sends ping, waits for pong and checks that the server registered the connection in S3.