WebSocket protocol
Push-уведомления Notifly доставляются по WebSocket. Любой клиент
(web-админка, Android-приложение, кастомный desktop) держит постоянное
соединение по wss://, и каждое новое сообщение приходит фреймом сразу
после того, как REST-эндпоинт POST /message его сохранил.
Architecture
Section titled “Architecture”┌──────────┐ ┌────────────────────┐│ 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>.
Connection
Section titled “Connection”wss://<domain>/ws?token=C<clientToken>Доменом служит ваш API Gateway. Для облачной версии Notifly это
https://api.notifly.ru/ws — но протокол устроен одинаково для любого
self-hosted-стенда.
From browser (JavaScript)
Section titled “From browser (JavaScript)”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');From command line (wscat)
Section titled “From command line (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": "status"}< {"action":"status","connection_id":"c057...","connected_at":"...","source_ip":"..."}Message protocol
Section titled “Message protocol”All frames are JSON.
What the client sends
Section titled “What the client sends”action | Description |
|---|---|
ping | Keep-alive / connection check |
status | Information about the current connection |
list | List of all user’s connections |
broadcast | Broadcast to other connections of the user |
get-pending | For connections with ?id=<question_id>: ask whether an answer for the question is ready |
| any | Echoed back (for debugging) |
What the server sends
Section titled “What the server sends”Push notification
Section titled “Push notification”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"}pong — response to ping
Section titled “pong — response to ping”{"action":"pong","timestamp":"2026-04-30T10:11:12Z","connection_id":"c057..."}status — connection status
Section titled “status — connection status”{ "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", ...} ]}echo — debug echo of unknown actions
Section titled “echo — debug echo of unknown actions”{"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.
Connection lifecycle
Section titled “Connection lifecycle”- The client opens
wss://.../ws?token=.... - API Gateway →
CONNECT→ Cloud Function writesConnectionInfo(includinguser_id) toconnections/<id>.jsonin S3. - The client sends frames →
MESSAGE→ the function responds inResponse.Body. - 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. - The client closes the socket →
DISCONNECT→ the function removes the connection JSON file.
Authorization
Section titled “Authorization”A token must be provided on CONNECT. Two token types are supported:
| Prefix / length | Type | What the connection receives |
|---|---|---|
C... (23) | Client-token | All user messages — across all their channels. |
A... (23) | App-token | Only 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.
How to pass the token
Section titled “How to pass the token”The server looks for the token on the handshake in three ways (in this order):
| Method | Example |
|---|---|
Query parameter token (primary) | wss://api.notifly.ru/ws?token=C... |
Header X-Notifly-Key | X-Notifly-Key: C... |
Header Authorization: Bearer | Authorization: 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.
bash (via wscat)
Section titled “bash (via wscat)”npm install -g wscatwscat -c "wss://api.notifly.ru/ws?token=A..."PowerShell (Windows 7+)
Section titled “PowerShell (Windows 7+)”$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}Python
Section titled “Python”# pip install websocketsimport 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))}Delivery guarantee (catch-up via since)
Section titled “Delivery guarantee (catch-up via since)”Messages can arrive while the socket was down. To avoid losing anything:
-
Store
last_idlocally — the highestidyou’ve seen. -
After reconnecting, fetch missed messages via REST:
Окно терминала curl -s -H "X-Notifly-Key: A..." \"https://api.notifly.ru/message?since=<last_id>" -
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=12345The 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:
- Auto-deploy. Channel
ci-prod— send a message from CI, a CD agent listens on WebSocket and automatically triggers deployment of the specified version. - Restart on alert. Channel
restart-nginx— monitoring sends an alert, a sidecar subscribed to the channel runssystemctl restart nginx. - Self-healing infra. Channel
disk-full— alert from Prometheus, the listener cleans logs and temp files. - LLM agent. A local LLM subscribes to channel
assistant-inbox, treats messages as tasks and writes the answer back to the return channel. - Smart home bridge. Channel
home— a receiver on a Raspberry Pi parses messages and sends commands to Home Assistant / MQTT. - Webhook replacement. A partner sends an event to your channel — a handler service listens via WebSocket. No need to expose a public HTTP endpoint.
- Task queue for workers. Workers subscribe to channel
jobsand receive the full task directly in the frame (the message body is in the socket). - Chat-bot fan-out. A Telegram/WhatsApp bot subscribes to channel
outbound— it forwards each message to the appropriate user chat. - IoT commands. A device (ESP32 + MicroPython
uwebsockets) listens to channeldevice-42and executes commands (toggle a relay, blink an LED). - 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.
Manual testing
Section titled “Manual testing”A ready python script is in the repository:
python3 ws-handler/test_ws.pyIt connects, sends ping, waits for pong and checks that the server
registered the connection in S3.