Skip to content

WebSocket protocol

Notifly push notifications are delivered over WebSocket. Any client (the web admin, the Android app, your own daemon) keeps a persistent wss:// connection, and every new message arrives as a frame right after the REST endpoint POST /message has stored it.

The socket has two independent halves. The inbound one (client → server) — authorization and the connection registry — is handled by ws-handler. The outbound one (server → client) is delivery itself, done by whichever service produced the notification. They share exactly one thing: the connections table.

Client (browser / phone / your service) ┌──────────────────────────────┐
│ │ api, heartbeat, monitors, │
│ wss://api.notifly.ru/ws?token=… │ email — everything that │
▼ │ produces notifications │
┌──────────────────────┐ management :send └──────────────┬───────────────┘
│ Yandex API Gateway │ ◄──────────────────────────────────┘
└──────────┬───────────┘
│ CONNECT / MESSAGE / DISCONNECT
▼
┌──────────────────────────┐
│ ws-handler │ registers the connection,
│ (serverless container) │ answers the client's frames
└──────────┬───────────────┘
│
▼
┌───────────────────────────────────────────────────┐
│ Table ws_connections │
│ connection_id, user_id, device_id, app_id, │
│ question_id, connected_at, last_activity, … │
└───────────────────────────────────────────────────┘
  • A connection lives as a row in the ws_connections table — one per open socket. It holds the gateway’s connection_id, the owner (user_id, device_id) and the subscriptions (app_id, question_id); that is how delivery finds its addressees. Indexes by user, channel and question keep every lookup targeted — nothing scans the whole set of connections.
  • There is no separate “connected” flag: a connection exists exactly as long as its row does. The row lives for a day from its last refresh. Only the client’s JSON frames (a ping, for instance) refresh it, and at most once an hour; a ping at the WebSocket protocol level never reaches the handler and does not count. If DISCONNECT never arrives (the client died silently), TTL removes the row.
  • Authorization happens on CONNECT, with a token from the ?token=… query parameter or from the X-Notifly-Key / X-Gotify-Key / Authorization: Bearer header. Without a valid token the gateway will not open the connection.
  • Delivery bypasses ws-handler: the service that created the message looks up the addressee’s connections in the table and sends each one a frame through the gateway’s management API. One call, one socket; a “connection is gone” response removes the row so nothing is sent into a dead socket again.
wss://<domain>/ws?token=C<clientToken>

The domain is your API Gateway. For the Notifly cloud that is wss://api.notifly.ru/ws — but the protocol is identical for any self-hosted installation. The address may look different elsewhere: on AWS the socket is a separate API Gateway WebSocket stage URL rather than /ws on the API host (the Go SDK takes it via WithWSURL). A local server running as a single process brings up /ws only with LOCAL_MODE=true.

const ws = new WebSocket(`wss://api.notifly.ru/ws?token=${clientToken}`);
ws.onopen = () => {
console.log('Connected');
// keep-alive every 30 seconds: keeps the socket alive and the row fresh
setInterval(() => ws.send(JSON.stringify({action: 'ping'})), 30_000);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.action) {
// protocol reply (pong, echo)
console.debug('WS:', data);
} else if (data.extras?.['notifly::silent']) {
// service frame: nothing to show to the user (see below)
} else if (data.message !== undefined) {
// an incoming notification
console.log('Notification:', data.title, data.message);
} else {
// question-subscription signal: {"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"}}

Without a token (or with a revoked one) the connection never opens at all: CONNECT is rejected with 401, so the client sees a handshake error rather than an already-open socket being closed.

All frames are JSON.

actionDescription
pingKeep-alive / connection check. Like any JSON frame, refreshes the connection row
get-pendingFor connections with ?id=<question_id>: ask whether an answer for the question is ready
anyEchoed back (for debugging). A non-JSON frame comes back as {"action":"echo","message":{"raw":"…"}}

This is the “payload” — the reason the socket exists at all. The format matches MessageExternal of the REST API exactly (see the API reference):

{
"id": 1714476672123456,
"appid": 12345,
"title": "Deploy finished",
"message": "Build #874 is live.",
"priority": 5,
"date": "2026-04-30T10:11:12Z",
"extras": {}
}

The same socket also carries frames that must not be shown to the user: a channel status change, heartbeat events, a command telling the client to reload its channel list. They have the same MessageExternal shape with id: 0, the message and title keys are present (usually empty), and the frames differ in extras. appid: 0 and priority: -1 belong to the reload_apps command (and to the mfa:approve_request login confirmation); heartbeat and channel:status_changed events carry the channel’s real appid and priority: 0. share:changed has appid 0 as well, with the channel in 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
}
}
Key in extrasMeaning
notifly::silenttrue — a service frame, never surface it as a notification
notifly::eventA server event: heartbeat:ping, heartbeat:alert, channel:status_changed, share:changed, mfa:approve_request
notifly::commandA command for the client, an object with an action field (e.g. reload_apps)

There is a single rule for your own client: if extras["notifly::silent"] === true, do not display it. New kinds of service frames appear silently and are filtered out by the same check without any code change.

A regular message may carry a hint about how to show it:

{
"id": 1714470000123456,
"appid": 12,
"title": "Deploy: api restarted",
"priority": 5,
"extras": {
"notifly::delivery": {"silent": true, "reason": "maintenance"}
}
}
FieldMeaning
silenttrue — show the notification (feed, notification shade) but without sound, vibration or a heads-up banner
reasonWhy: maintenance — the channel is in an active maintenance window. Treat unknown reasons as plain silent

The server sets the flag itself for messages in channels covered by an active maintenance window. Unlike notifly::silent, this is not a service frame: the message must still be displayed. Quiet hours are not signalled in the frame — each client applies them from GET /current/user/quiet-hours (see Quiet hours).

{"action": "pong", "timestamp": "2026-04-30T10:11:12Z", "connection_id": "c057..."}
{"action": "echo", "message": {"action": "foo", "data": 42}}

These arrive on a connection opened with ?id=<question_id> (see below). They carry no action field — they are short signals about the fate of an ask question:

FrameWhen
{"id":12345,"status":"answered","fetch":true}The answer is ready. Fetch it with GET /ask-question/12345
{"id":12345,"status":"answered","fetch":true,"nextQuestionId":12346}Same, but the dialog continues with the next question
{"id":12345,"status":"expired"}Time ran out, no answer is coming
{"id":12345,"status":"cancelled"}The question was cancelled
{"id":12345,"status":"pending","expiresAt":1714476672000}The deadline was extended, keep waiting (expiresAt in epoch ms)

This is notify-then-fetch: only the signal travels over the socket, while the answer body is retrieved with a separate GET /ask-question/<id> — that request is also what marks the question as delivered.

  1. The client opens wss://.../ws?token=….
  2. API Gateway → CONNECT → ws-handler validates the token and writes a row into ws_connections (owner, plus channel or question depending on the token type and ?id=). If the write fails it answers 503: a connection with no row is invisible to delivery, so it is more honest to let the client reconnect.
  3. The client sends frames → MESSAGE → the handler replies over the same socket and refreshes the row’s lifetime — at most once an hour (wsconn.RefreshInterval), so the row is not rewritten on every ping.
  4. The service that created a message finds the addressee’s connections by index and sends each one a frame via the gateway’s management API.
  5. The client closes the socket → DISCONNECT → the row is deleted.

If step 5 never happens (a broken link, a killed process), TTL removes the row, and until then a staleness check excludes it from delivery lookups. Send a {"action":"ping"} JSON frame every 30 seconds — enough for both intermediate proxies and the row refresh. Incoming server frames and a library’s protocol-level pings do not refresh the row: a connection that has sent no JSON frame for a day stops receiving deliveries, even if the socket is still open.

A token must be supplied on CONNECT. Two kinds of token are supported:

Prefix / lengthTypeWhat the connection receives
C... (23)Client tokenMessages from every channel available to the user — their own and those shared with them.
A... / S... (23)Channel token or share tokenOnly messages of its own channel — the one the token was issued for.

A client token must not be revoked. A channel or share token is accepted only if it has read permission: the channel’s main key can read, while a send-only machine key gets 401 on the handshake. An API token (M…) is not accepted by the socket: a connection is bound to a device, and an API token has none. For a realtime stream use a client token or a channel token.

A channel token subscribes the connection to realtime push on every new message in that channel: as soon as POST /message stores a message, the server finds all connections of the channel and sends them a frame. That means messages can be received by more than the target device. Any application, service, script or CI runner can subscribe to a channel with the very same token it uses to send — and handle messages programmatically in real time. A delegated token from a channel share works too, as long as it has read permission.

During the handshake the server looks for the token in the token query parameter first, then in the headers. If several headers carry a token, which one wins is undefined — send just one.

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

Subscribing with a channel token (receiver application)

Section titled “Subscribing with a channel token (receiver application)”

Open channel settings → the Delivery tab → the WebSocket block. It has ready-made examples in 8 languages plus a tab with an LLM prompt. The principle is always the same: connect to wss://api.notifly.ru/ws?token=<channel token>, read JSON frames and send {"action":"ping"} every 30 seconds.

Окно терминала
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 every 30 seconds
$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: only client frames refresh the connection row
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 # service frame
if "message" in data:
print(data.get("title"), "—", data["message"])
except websockets.ConnectionClosed:
continue
finally:
pinger.cancel()
asyncio.run(main())

The ready-made channel listener for Go is Subscribe from the Go SDK: keepalive, reconnects and service-frame parsing are already written there. To wait for the answer to a question, use WaitAskAnswer from the same SDK. The snippet below does the same by hand, on 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: a JSON ping every 30 seconds (the only goroutine that writes)
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))
}

The full message body arrives inside the frame (it is the complete MessageExternal), so nothing needs a separate fetch while you are online. What arrived while the socket was down, however, has to be pulled over REST — the socket keeps no history.

  1. Store last_id locally — the highest id you have seen.

  2. After reconnecting, request a fresh page of messages. The response goes newest first:

    Окно терминала
    curl -s -H "X-Notifly-Key: A..." \
    "https://api.notifly.ru/message?limit=100"
  3. Take everything with id > last_id and stop there. If the whole page turned out to be newer than last_id, more than a hundred messages were missed: follow the paging.next link (it fills in the since cursor) until you reach your last_id.

That turns the WebSocket into a reliable bus: “online” means instant delivery, “offline” means catching up with a page of history.

Subscribing to a question’s answer (?id=<question_id>)

Section titled “Subscribing to a question’s answer (?id=<question_id>)”

Besides subscribing to a channel, the socket can wait for the answer to a specific question (ask). Open the connection with an extra id query parameter set to the question_id:

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

Use a channel token with both read and write permission, like the channel’s main key: the socket requires read, while GET /ask-question/<id>, which fetches the answer, requires a channel token with write permission (a client token gets 401 there). In Go, WaitAskAnswer from the SDK covers this subscription end to end.

The connection is bound to question 12345. Once an answer appears (or it expires, is cancelled, or gets extended), the server sends a short signal — see the table above. The answer body does not travel over the socket; fetch it with GET /ask-question/12345. The channel’s ordinary frames reach such a connection too — tell the question signals apart by the absence of action and message.

You do not have to wait passively — ask for the status yourself with get-pending:

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

A reply comes only when the question has already been answered, and without nextQuestionId — learn how the dialog continues from GET /ask-question/<id>. If there is no answer yet, and for an expired or cancelled question, get-pending returns nothing — the connection simply keeps waiting for the realtime signal. A server-side read failure returns {"error":"internal"}. get-pending only makes sense for connections opened with ?id=; without it the action is ignored.

10 scenarios where the receiver is an application

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

A Notifly channel is not just “push to a phone”. It is a universal bus that any handler application can sit on:

  1. Auto-deploy. Channel ci-prod — send a message from CI, a CD agent listens on the WebSocket and automatically deploys the given 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, and the listener cleans up logs and temporary files.
  4. LLM agent. A local LLM subscribes to the assistant-inbox channel, treats messages as tasks and writes answers back to a return channel.
  5. Smart home bridge. Channel home — a receiver on a Raspberry Pi parses messages and issues Home Assistant / MQTT commands.
  6. Webhook replacement. A partner posts an event into your channel and a handler service listens over WebSocket. No public HTTP endpoint needed.
  7. Task queue for workers. Workers subscribe to the jobs channel and get the whole task right in the frame (the message body travels in the socket).
  8. Chat-bot fan-out. A Telegram/WhatsApp bot subscribes to the outbound channel and forwards each message to the right 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 reaches Slack/Teams through a bridge listener while the team keeps working in its own stack.

In every scenario the channel is the same one used for sending — just subscribe to it with a channel token that has read permission and receive JSON in real time.

Alternative: Server-Sent Events (GET /stream)

Section titled “Alternative: Server-Sent Events (GET /stream)”

Besides the socket, the API has an SSE stream, GET /stream. It carries no messages — only signals for a live UI, so it is no substitute for the WebSocket:

  • Authorization: a client token, an API token or a channel token with read permission, in a header or in ?token= (the browser’s EventSource cannot set headers).
  • Events: connected (right after connecting), channel:status_changed and share:changed with {"appid":N} as data. Every 25 seconds the server sends a keepalive comment.
  • The events are named, so onmessage will not see them — subscribe with addEventListener.
  • Delivery is best-effort: the event hub lives in the API process’s memory, and a slow client simply misses an event. On AWS deployments the stream is disabled — /stream answers 503 right away.
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('Channel status changed:', appid);
});

A quick connectivity check from the terminal:

Окно терминала
npm install -g wscat
# 1. Without a token the connection must not open (401 on the handshake)
wscat -c "wss://api.notifly.ru/ws"
# 2. With a token, ping must return pong
wscat -c "wss://api.notifly.ru/ws?token=A..."
> {"action": "ping"}
< {"action":"pong","timestamp":"...","connection_id":"c057..."}
# 3. Leaving the socket open, send yourself a message from another terminal —
# the frame lands in the first one
curl -s -X POST "https://api.notifly.ru/message" \
-H "X-Notifly-Key: A..." \
-H "Content-Type: application/json" \
-d '{"title":"ping","message":"from curl","priority":5}'