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.
Architecture
Section titled “Architecture”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_connectionstable — one per open socket. It holds the gateway’sconnection_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. IfDISCONNECTnever arrives (the client died silently), TTL removes the row. - Authorization happens on
CONNECT, with a token from the?token=…query parameter or from theX-Notifly-Key/X-Gotify-Key/Authorization: Bearerheader. 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.
Connection
Section titled “Connection”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.
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'); // 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');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": "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.
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. Like any JSON frame, refreshes the connection row |
get-pending | For connections with ?id=<question_id>: ask whether an answer for the question is ready |
| any | Echoed back (for debugging). A non-JSON frame comes back as {"action":"echo","message":{"raw":"…"}} |
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 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": {}}Service frames (notifly::silent)
Section titled “Service frames (notifly::silent)”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 extras | Meaning |
|---|---|
notifly::silent | true — a service frame, never surface it as a notification |
notifly::event | A server event: heartbeat:ping, heartbeat:alert, channel:status_changed, share:changed, mfa:approve_request |
notifly::command | A 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.
Silent delivery (notifly::delivery)
Section titled “Silent delivery (notifly::delivery)”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"} }}| Field | Meaning |
|---|---|
silent | true — show the notification (feed, notification shade) but without sound, vibration or a heads-up banner |
reason | Why: 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).
pong — response to ping
Section titled “pong — response to ping”{"action": "pong", "timestamp": "2026-04-30T10:11:12Z", "connection_id": "c057..."}echo — debug echo of unknown actions
Section titled “echo — debug echo of unknown actions”{"action": "echo", "message": {"action": "foo", "data": 42}}Question-subscription signals
Section titled “Question-subscription signals”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:
| Frame | When |
|---|---|
{"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.
Connection lifecycle
Section titled “Connection lifecycle”- The client opens
wss://.../ws?token=…. - API Gateway →
CONNECT→ws-handlervalidates the token and writes a row intows_connections(owner, plus channel or question depending on the token type and?id=). If the write fails it answers503: a connection with no row is invisible to delivery, so it is more honest to let the client reconnect. - 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 everyping. - 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.
- 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.
Authorization
Section titled “Authorization”A token must be supplied on CONNECT. Two kinds of token are supported:
| Prefix / length | Type | What the connection receives |
|---|---|---|
C... (23) | Client token | Messages from every channel available to the user — their own and those shared with them. |
A... / S... (23) | Channel token or share token | Only 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.
How to pass the token
Section titled “How to pass the token”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.
| Method | Example |
|---|---|
Query parameter token (primary) | wss://api.notifly.ru/ws?token=C... |
Header X-Notifly-Key | X-Notifly-Key: C... |
Header X-Gotify-Key | X-Gotify-Key: C... |
Header Authorization: Bearer | Authorization: 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.
bash (via wscat)
Section titled “bash (via wscat)”npm install -g wscatwscat -c "wss://api.notifly.ru/ws?token=A..."PowerShell
Section titled “PowerShell”$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() }}Python
Section titled “Python”# pip install websocketsimport 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))}Catching up after a disconnect
Section titled “Catching up after a disconnect”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.
-
Store
last_idlocally — the highestidyou have seen. -
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" -
Take everything with
id > last_idand stop there. If the whole page turned out to be newer thanlast_id, more than a hundred messages were missed: follow thepaging.nextlink (it fills in thesincecursor) until you reach yourlast_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=12345Use 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:
- Auto-deploy. Channel
ci-prod— send a message from CI, a CD agent listens on the WebSocket and automatically deploys the given 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, and the listener cleans up logs and temporary files. - LLM agent. A local LLM subscribes to the
assistant-inboxchannel, treats messages as tasks and writes answers back to a return channel. - Smart home bridge. Channel
home— a receiver on a Raspberry Pi parses messages and issues Home Assistant / MQTT commands. - Webhook replacement. A partner posts an event into your channel and a handler service listens over WebSocket. No public HTTP endpoint needed.
- Task queue for workers. Workers subscribe to the
jobschannel and get the whole task right in the frame (the message body travels in the socket). - Chat-bot fan-out. A Telegram/WhatsApp bot subscribes to the
outboundchannel and forwards each message to the right 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 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’sEventSourcecannot set headers). - Events:
connected(right after connecting),channel:status_changedandshare:changedwith{"appid":N}as data. Every 25 seconds the server sends a keepalive comment. - The events are named, so
onmessagewill not see them — subscribe withaddEventListener. - 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 —
/streamanswers503right 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);});Manual testing
Section titled “Manual testing”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 pongwscat -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 onecurl -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}'