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.
  • Configuration — CENTRIFUGO_* broker settings.