Socket & channels
poolse pushes live events to your app over one WebSocket per user
session. The protocol is Phoenix Channels.
@poolse/sdk speaks it for you. This page is for when you are
debugging or writing your own client.
The socket carries no requests. Everything you do (send a message, mark a conversation read, place a call) goes through the REST API. The socket only tells you what happened.
Endpoint
wss://api.poolse.dev/socket/websocket?token=<user JWT>&vsn=2.0.0
token: an end-user JWT, the same one you send to the REST API asAuthorization: Bearer …. See Authentication. API keys are not accepted.vsn: the Phoenix serializer version.2.0.0(array frames) is what phoenix.js sends.1.0.0(object frames) is also accepted.- Only WebSocket is available. There is no long-polling fallback.
The SDK builds this URL from apiUrl: https:// becomes wss:// and
/socket is appended. Override it with the wsUrl and socketPath
options of new Poolse({...}).
Connecting
The token is checked once, during the WebSocket handshake. The
connection is refused with HTTP 403 when:
- the token is missing, malformed, expired, or revoked (token revocation);
- the user it names no longer exists;
- the user is blocked (
is_blocked: true). - the user is new this month and the workspace's plan already has its
monthly active users
(
GET /v1/methen returns402 mau_cap_exceeded).
Phoenix does not say which of these it was. To find out, call
GET /v1/me with the same token. The REST error code tells you.
When poolse closes a socket
- Blocking a user (
POST /v1/userswithis_blocked: true) closes all of that user's open sockets at once. Reconnects are then refused with403. - Revoking a token (
POST /admin/v1/tokens/revoke) closes all of that user's open sockets, not only the one the revoked token opened. A socket does not record which token opened it. Devices holding another valid token reconnect on their own. A device holding the revoked token is refused until your app gives it a new one.
Channels
After connecting, join one channel per topic you want events for.
| Topic | Join rule | Carries |
|---|---|---|
conversation:<conversation_id> | The user must be a member of the conversation. | Messages, reactions, typing, read receipts, presence, call banners. See Conversation channel. |
user:<user_id> | <user_id> must be the token's own user id (the poolse id, not your external_id). | New and updated conversations for the inbox, mentions, rings ending, calls finishing. See User channel. |
Join params are ignored on both topics. Send {}.
Join replies
| Topic | Reply | When |
|---|---|---|
conversation:<id> | ok, { "conversation_id": "<id>" } | Joined. |
conversation:<id> | error, { "reason": "not_found" } | No such conversation in your tenant, including a deleted one, or <id> is not a UUID. |
conversation:<id> | error, { "reason": "forbidden" } | The conversation exists but the user is not a member. |
user:<id> | ok, {} | <id> is the caller. |
user:<id> | error, { "reason": "forbidden" } | Any other id. |
Removed members are closed out
When a member is removed from a conversation (or leaves), the server
closes their channel for it straight away. Every device of theirs that
had it joined gets a phx_close frame on that topic and stops
receiving its events. phoenix.js does not rejoin a channel closed this
way. An attempt to join again is refused with forbidden. The other
members' channels are not affected, and they get no event about it.
Frames
With vsn=2.0.0, every frame is a JSON array:
[join_ref, ref, topic, event, payload]
Joining a conversation:
["1", "1", "conversation:6f1c…", "phx_join", {}]
The reply:
["1", "1", "conversation:6f1c…", "phx_reply",
{ "status": "ok", "response": { "conversation_id": "6f1c…" } }]
An event the server pushes (ref is null):
["1", null, "conversation:6f1c…", "typing:start",
{ "user_id": "2b7e…", "external_id": "acme-alice" }]
Leave a channel with phx_leave.
Heartbeats
Send a heartbeat on the reserved phoenix topic about every 30
seconds:
[null, "9", "phoenix", "heartbeat", {}]
The server closes a socket that has sent nothing for 60 seconds. phoenix.js (and so the SDK) does this for you.
Rate limits
The only client message the server acts on is typing, on a
conversation channel. It is limited to 10 per second per socket and
conversation. Past that the server replies with an error. See
Conversation channel.
Reconnecting
phoenix.js reconnects on its own after a drop. It backs off 10 ms,
50 ms, 100 ms, 150 ms, 200 ms, 250 ms, 500 ms, 1 s, 2 s, then every 5 s.
It rejoins every channel it had joined. The SDK sends the current
token on each attempt, refreshing it with your getToken when needed.
poolse.realtime.onStatus(cb) (or useRealtimeStatus() in React)
reports idle, connecting, connected, reconnecting, or closed.
Missed events are not replayed
poolse does not buffer events for a disconnected client. Anything
pushed while a socket was down (a dropped network, a phone in the
background, realtime.pause()) is gone. After every reconnect,
reload the state you show from REST:
- messages:
GET /v1/conversations/:id/messages, newest first, bysequence; - the inbox:
GET /v1/conversations(withunread_count); - a live call:
GET /v1/conversations/:id/call.
From 3.1, useMessages and useConversations in @poolse/react do
this on every reconnect. If you use @poolse/sdk directly, do it
yourself in an onStatus listener when the status returns to
connected.
Messages carry a per-conversation sequence, assigned in order as
they are stored. The history you load can still skip numbers, because
deleted messages and thread replies are not in it. Use id (or
sequence) to de-duplicate a message you get from both REST and the
socket.
Pausing in the background
Phones suspend background sockets anyway. Since 3.1 the SDK has
poolse.realtime.pause() and poolse.realtime.resume(). pause()
closes the socket but keeps your channel handles and their listeners.
resume() reconnects with a fresh token and rejoins.
useAppStateLifecycle() in @poolse/react-native calls them when the
app moves to and from the background.
While the app is in the background, it learns about new messages and calls from push notifications, not the socket.
What the socket does not carry
- No "delivered" receipts, only read receipts (
member:read). - No member-added or member-removed events on the conversation topic.
The member who was added gets
conversation:createdon their own user topic. The member who was removed has their channel closed (see above). For everything else, use themember.addedandmember.removedwebhooks. - No message-edit history, and no event for a conversation renamed
with
PATCH /v1/conversations/:id. - No incoming-call ring for phones. Rings go out by push. The socket only tells foreground apps that a call started, changed or ended.