Skip to content

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.

┌──────────┐ ┌────────────────────┐
│ 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) as connections/<id>.json.
  • Authorization — ?token=<clientToken> in the query on CONNECT. The connection is associated with a user_id and is used as the delivery address for pushes.
  • Delivery is initiated by the REST function notifly-api: after INSERT messages it finds all connections for the target user and sends a frame via the API Gateway Management API: POST /@connections/<id>.
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.

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');
Окно терминала
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 the user’s other connections
anyEchoed back (for debugging)

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"
}
{"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", ...}
]
}

echo — debugging echo for unknown actions

Section titled “echo — debugging echo for unknown actions”
{"action":"echo","message":{"action":"foo","data":42}}
  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, having created a new message, enumerates the user’s connections in S3 and sends a frame via the Management API.
  5. The client closes the socket → DISCONNECT → the function deletes the connection JSON file.

On CONNECT a ?token=<token> query parameter is required. Two types of tokens 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).

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.

Окно терминала
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 maximum observed id.

  2. After reconnecting, fetch missed messages via REST:

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

  1. Auto-deploy. Channel ci-prod — send a message from CI, a CD agent listens on the WebSocket and automatically starts deploying 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 — an alert from Prometheus, the listener cleans logs and temporary files.
  4. LLM agent. A local LLM subscribes to channel assistant-inbox, treats messages as tasks and writes replies back to the 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 service handler listens via WebSocket. No need to expose a public HTTP endpoint.
  7. Task queue for workers. Workers subscribe to channel jobs, receive a job and fetch the body via GET /message/:id.
  8. Chat-bot fan-out. A Telegram/WhatsApp bot subscribes to channel outbound — forwards each message to the appropriate user chat.
  9. IoT commands. A device (ESP32 + MicroPython uwebsockets) listens on channel device-42 and executes commands (toggle a relay, blink an LED).
  10. 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.

A ready Python script is in the repository:

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

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