Skip to main content

Presence

Presence tracks whether each user is online in an organization and which status they show. It is org-scoped: a user holds a single status per (user, org), so a user connected only in another org reads as offline here. State lives in Postgres, and every change fans out over Centrifugo through the shared transactional outbox — the same delivery path as chat.

Every endpoint is a session-authenticated POST acting on the caller's active organization only.


Status model

Statuses come from the shared vocabulary in internal/features/shared/presencestatus (the enum derives from the repository catalogue):

StatusClient-settableMeaning
onlineyesConnected and available
awayyesConnected, shown as away
busyyesConnected, shown as busy
appear_offlineyesConnected, but shown to others as offline
offlinenoNot connected — connectivity-only, owned by the backend

appear_offline is asymmetric. To other users it is hidden and reads as offline; the caller reading their own status sees the true appear_offline value — both in the snapshot and in the echo returned by the update call.


Endpoints

OperationPathBodyNotes
Update statusPOST /presence/status{ "status": "online|away|busy|appear_offline" }Sets the caller's status. Publishes status.changed only when the visible status changes (appear_offline publishes as offline). The response carries the caller's own true resulting status. Not a heartbeat.
Report connectPOST /presence/connected(empty)Call once after each successful Centrifugo connect so watchers see the caller online ahead of the poll's next tick. Only offline → online; it never overrides away, busy, or appear_offline.
Read snapshotPOST /presence/snapshot{ "userIds": ["<id>", …] }Returns the status of each requested user in the caller's org (1–100 ids; unknown or foreign ids are silently omitted).

offline is never client-reported: a closing device cannot know whether the user's other devices are still connected. The connectivity poll owns offline and is the fallback in both directions.

Wire model

The snapshot rows and the status.changed event payload are the same DTO:

{ "userId": "<workos-user-id>", "status": "online" }

POST /presence/snapshot returns { "statuses": [ … ] } — one row per requested user found in the caller's org, sorted by user id.


Realtime delivery (snapshot + delta)

Clients hydrate with POST /presence/snapshot, then stay in sync from live status.changed deltas. Deltas are published on the per-user status channel status:<org>:<uid> — the only client-side-subscribed namespace, gated by the subscribe proxy. See the WebSocket API for the connection token and channel scheme.


Connectivity poll

Liveness is owned by the backend, not by client heartbeats. The poll reconciles private.user_presence against Centrifugo's live connections, flipping users to offline when they are no longer connected (and back to online on reconnect, complementing the one-shot POST /presence/connected). There is deliberately no heartbeat endpoint.


Storage

Presence is one row per (user, org) in private.user_presence — renamed from chat_user_presence, with the client heartbeat_at column dropped when liveness moved to the poll. Single-status-per-org is enforced by migration 000040. Delivery for status.changed rides the shared private.centrifugo_outbox.


  • Chat — shares the Centrifugo transport and outbox; DM rows in chat/conversations/recent carry a dmPeer.status snapshot.
  • WebSocket API — connection token, the status: channel, and the subscribe proxy.
  • ConfigurationCENTRIFUGO_* broker settings.