WebSocket Protocol
Notifly push notifications are delivered over WebSocket. Any client
(web admin, Android app, custom desktop) keeps a persistent
connection via wss://, and each new message arrives as a frame immediately
after the REST endpoint POST /message has stored it.
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 │└──────────┘ └────────────────────┘- Connections are stored in S3 (
notifly-ws-connections) asconnections/<id>.json. - Authorization —
?token=<clientToken>in the query on CONNECT. The connection is associated with auser_idand is used as the delivery address for pushes. - Delivery is initiated by the REST function
notifly-api: afterINSERT messagesit finds all connections for the target user and sends a frame via the API Gateway Management API:POST /@connections/<id>.
Connecting
Section titled “Connecting”wss://<domain>/ws?token=C<clientToken>The domain is your API Gateway. For the cloud version of Notifly this is
https://api.notifly.ru/ws — but the protocol works the same for any
self-hosted instance.
From the browser (JavaScript)
Section titled “From the browser (JavaScript)”const ws = new WebSocket(`wss://api.notifly.ru/ws?token=${clientToken}`);
ws.onopen = () => { console.log('Connected'); // (optional) 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 the command line (wscat)
Section titled “From the 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 the user’s other connections |
| 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 fully matches MessageExternal in the REST API:
{ "id": 1714476672123456789, "appid": 12345, "title": "Деплой завершён", "message": "Сборка #874 ушла на прод.", "priority": 5, "extras": null, "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 state
Section titled “status — connection state”{ "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 — debugging echo for unknown actions
Section titled “echo — debugging echo for unknown actions”{"action":"echo","message":{"action":"foo","data":42}}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, having created a new message, enumerates the user’s connections in S3 and sends a frame via the Management API. - The client closes the socket →
DISCONNECT→ the function deletes the connection JSON file.
Authorization
Section titled “Authorization”On CONNECT a ?token=<token> query parameter is required. Two types of tokens 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). |
This means that the recipient of messages doesn’t have to be the device itself. Any application, service, script or CI runner can subscribe to a channel using the same app-token that is used to send messages — and process them programmatically in real time.
Alternatively — use a regular Authorization: Bearer ... in the initial
HTTP request during CONNECT (not all clients can provide headers during the
WebSocket handshake; the query parameter is always supported).
Subscribing with a channel app-token (application as receiver)
Section titled “Subscribing with a channel app-token (application as receiver)”Open channel settings → Delivery tab → WebSocket block.
Ready-made examples for 9 languages are available there. 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 maximum observedid. -
After reconnecting, fetch missed messages via REST:
Окно терминала curl -s -H "X-Notifly-Key: A..." \"https://api.notifly.ru/message?since=<last_id>" -
If you want to operate in a “signal + GET” mode (minimal socket traffic), the full body is still available via REST:
Окно терминала curl -s -H "X-Notifly-Key: A..." \"https://api.notifly.ru/message/<id>"
This makes WebSocket a reliable bus: “online” — instant delivery,
“offline” — catch-up via since.
10 scenarios where the receiver is an application
Section titled “10 scenarios where the receiver is an application”A Notifly channel is not only “push to phone”. It’s a universal bus that any handler application can subscribe to:
- Auto-deploy. Channel
ci-prod— send a message from CI, a CD agent listens on the WebSocket and automatically starts deploying 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— an alert from Prometheus, the listener cleans logs and temporary files. - LLM agent. A local LLM subscribes to channel
assistant-inbox, treats messages as tasks and writes replies back to the 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 service handler listens via WebSocket. No need to expose a public HTTP endpoint.
- Task queue for workers. Workers subscribe to channel
jobs, receive a job and fetch the body viaGET /message/:id. - Chat-bot fan-out. A Telegram/WhatsApp bot subscribes to channel
outbound— forwards each message to the appropriate user chat. - IoT commands. A device (ESP32 + MicroPython
uwebsockets) listens on channeldevice-42and executes commands (toggle a relay, blink an LED). - Cross-team relay. Channel
oncall— an alert arrives and is bridged to Slack/Teams via a listener-bridge, while the team continues to work in their own stack.
In all scenarios the channel is the same one used for sending —
just subscribe 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 verifies that the server
registered the connection in S3.