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 as Authorization: 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/me then returns 402 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/users with is_blocked: true) closes all of that user's open sockets at once. Reconnects are then refused with 403.
  • 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.

TopicJoin ruleCarries
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

TopicReplyWhen
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, by sequence;
  • the inbox: GET /v1/conversations (with unread_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:created on their own user topic. The member who was removed has their channel closed (see above). For everything else, use the member.added and member.removed webhooks.
  • 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.
Socket & channels · Realtime · poolse docs