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):
| Status | Client-settable | Meaning |
|---|---|---|
online | yes | Connected and available |
away | yes | Connected, shown as away |
busy | yes | Connected, shown as busy |
appear_offline | yes | Connected, but shown to others as offline |
offline | no | Not 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
| Operation | Path | Body | Notes |
|---|---|---|---|
| Update status | POST /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 connect | POST /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 snapshot | POST /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.
Related
- Chat — shares the Centrifugo transport and outbox; DM rows in
chat/conversations/recentcarry admPeer.statussnapshot. - WebSocket API — connection token, the
status:channel, and the subscribe proxy. - Configuration —
CENTRIFUGO_*broker settings.