# Realtime (WebSocket)

> The platform WebSocket - the single-use ticket, the memberfy-realtime subprotocol, automatic and requested rooms, invalidate notices, limits and close codes.

The app gets changes right away through a WebSocket: the bell, the chat, "Online now", who is on each screen, the live event banner, campaign numbers. Any member of the community, in any role, opens the socket, **with the chat on or off**.

## Opening the connection

1. Ask for a ticket with `POST /api/realtime/ticket`, with the token and `X-CommunityId` (see [Authentication](/api/autenticacao) and [X-CommunityId](/api/x-community-id)). The ticket is **single-use**, valid for **60 seconds** and tied to the person, the community and the session.
2. Open `wss://<api>/api/realtime/socket` (`data.path`) passing the ticket as the **second subprotocol**, after `memberfy-realtime` (`data.protocols`). The server answers with `memberfy-realtime`.

```js
const { data } = await api.post('/api/realtime/ticket'); // with Authorization and X-CommunityId
const ws = new WebSocket(`wss://api.memberfy.net${data.path}`, data.protocols);
```

The ticket never goes in the URL (in the query it is ignored). Without a valid ticket, the connection is refused before it becomes a WebSocket (401). Switching community closes the socket: ask for another ticket. Without the realtime service, the ticket returns `503`, and the app keeps fetching through the routes.

`POST /api/chat/ticket` and `/api/chat/socket` keep working as **aliases** of these for one deploy and then go away: use the new ones.

## Rooms

Every connection is already in three rooms:

| Room | Who |
|---|---|
| `me` | The person themself |
| `community` | Everyone in the community |
| `staff` | The team only (owner, admin, moderator and finance) |

The others are requested by the client, with the same rule as the matching `GET`, always in this community:

| Room | Who gets in |
|---|---|
| `space:<id>` | Whoever can open the space |
| `item:event:<id>` | Whoever can open the event |
| `item:post:<id>` | Whoever can open the post |

```json
{ "type": "subscribe", "rooms": ["space:5b0e7c1a-…", "item:event:9d2f…"] }
{ "type": "unsubscribe", "rooms": ["space:5b0e7c1a-…"] }
```

There are also rooms for the editors, only for the roles that can save there: `item:course:<id>`, `item:form:<id>` (the team and whoever manages the form; a member when it is published and they open the space), `item:campaign:<id>`, `item:email:<id>` (library e-mail), `item:workflow:<id>`, `item:survey:<id>` (owner and admin), `item:certificateTemplate:<id>` (whoever issues certificates in the space), `item:emailOverride:<emailKey>` (default e-mail, owner and admin). And the area rooms, one per section screen: `area:home` (everyone), `area:members`, `area:campaigns`, `area:emails`, `area:workflows` (team), `area:moderation` (owner, admin and moderator), `area:analytics`, `area:dashboard`, `area:settings` (owner and admin) and `area:finance` (owner, admin and finance).

At most **20 rooms** per connection (the automatic ones don't count). The answer is `subscribed` with the rooms taken and one `subscribe.denied` per refused room, **with no reason**. When the person's access changes (left, new role, group, space access), the server re-checks the rooms, sends `subscribe.denied` for the ones lost and then `access.changed`.

## Change notices (`invalidate`)

```json
{ "type": "invalidate", "room": "community", "topic": "event.live", "ids": ["9d2f…"], "at": "2026-10-23T14:05:00.000Z" }
```

The notice carries **only ids and the topic**, never a title, text, amount or name: when you get it, redo the `GET` of what is on screen through the usual routes.

| Topic | Room | When |
|---|---|---|
| `event.live` | `community`, `space:<id>`, `item:event:<id>` | An event started or ended its stream |
| `events` | `community`, `space:<id>`, `item:event:<id>` | An event was created, edited or removed |
| `analytics.online` | `staff` (owner and admin) | "Online now" changed (at most once every 5 seconds) |
| `campaigns.stats` | `staff` | A campaign send finished |
| `menu.changed` | `community` | The menu changed: a section or space created, edited (name, icon, address, section, access, visibility), removed or reordered. No `ids` and no menu data: each person redoes `GET /api/sections` with their own permissions. Changes in a row become a single notice, within 1 second |

Event notices only reach whoever sees that space; a private event or one under moderation, only the team.

## Who is here

Area presence (the faces at the top of each screen, see [Who is here and team editing](/conceitos/presenca-e-edicao-em-equipe)) uses the same rooms.

```json
{ "type": "presence.join", "room": "area:campaigns", "editing": false }
{ "type": "presence.leave", "room": "area:campaigns" }
{ "type": "presence.active" }
```

- `presence.join` when the screen opens: the room is checked as in `subscribe` (and subscribed if it was not yet), at most **3 places** per connection. The answer is `presence.joined` (`room`, `editing`) followed by a `presence` snapshot, or `presence.denied` (`room`), with no reason.
- `presence.leave` when leaving. There is no heartbeat: the server ping keeps the place, which goes away 45 seconds after the last answer; closing the connection removes it at once.
- `presence.active` on interaction (key, click, focus), at most every 10 seconds. Joining counts as activity.

On each change (at most 1 per second per room), the room gets a snapshot built for each connection:

```json
{
  "type": "presence", "room": "area:campaigns", "area": "area:campaigns",
  "members": [{ "memberId": "…", "name": "Maria", "avatarUrl": "…", "editing": false, "state": "active", "lastActiveAt": "…" }],
  "count": 3, "editingCount": 1, "at": "2026-10-28T14:05:00.000Z"
}
```

- You are **never** in your own list. `members` has at most 5 people (active editors first, then active, then idle); `count` is the total of the other people in the room.
- **Readers:** in space and item rooms, someone not editing there gets only `memberId`, `name`, `avatarUrl` and `state`, without `editing`, `lastActiveAt` or `editingCount`, to protect the editors' privacy. People editing the same item and the team area rooms get the full snapshot.
- `state: "idle"`: connected, no activity for 30 seconds or more. Still listed.
- **Privacy:** someone who turned off **Available to chat** does not appear or count for others, but sees who is there. A chat block hides the two people from each other.
- **Community chat off:** area rooms and readers get nobody (`members` empty, `count` 0), team included. Only people editing the same item still see the other editors; `item.saved` and the version `409` do not change.

### Editing and `item.saved`

In an editor (event, campaign, library e-mail, workflow, course, form, survey, certificate template, default e-mail and the settings, in `area:settings`), join with `"editing": true`. It only counts for whoever can save there (the team and, on a form, whoever manages it). An editor appears with `editing: true` to the **other editors** in the room, even with the chat status off; someone just reading the page is not told. `editingCount` counts only active editors.

On each save, the room gets, for the team and for whoever is editing:

```json
{ "type": "item.saved", "topic": "item.saved", "room": "item:campaign:<id>", "itemType": "campaign", "itemId": "<id>", "by": { "memberId": "…", "name": "Maria" }, "updatedAt": "<new version>", "at": "…" }
```

Compare `updatedAt` with the version you hold to ignore your own save. `itemType`: `event`, `campaign`, `email`, `workflow`, `course` (including modules and lessons), `form`, `survey`, `certificateTemplate`, `emailOverride` (`itemId` = `emailKey`) and, in the `area:settings` room (`itemId` = the community), `settings`, `chatSettings`, `notificationSettings`, `messagingSettings` (SMS + WhatsApp), `creditSettings`, `businessInformation`, `payoutSettings`.

Saves accept the version read and answer `409` when another came first: see [Version conflict](/api/respostas-e-erros#version-conflict).

## Other events

From the server, as JSON `{ type, data }`: `ready` (with `profileId`, `communityId`, `chatAvailable`, `chatStatus` (the chosen chat status), `chatEnabled`, `rooms`, `maxRooms` and `pingIntervalMs`), `notification.new`, `notification.read` and `notification.removed` (the bell, in every community), `chat.settings`, `session.revoked` and `pong`. Chat events only arrive with the chat **on**; with the chat off, the socket stays up and only those stop.

From the client: `subscribe`, `unsubscribe`, `presence.join`, `presence.leave`, `presence.active`, `presence.idle` (`{ "type": "presence.idle", "idle": true }` when the tab has been idle for 10 minutes, `false` when it comes back: the person shows as **Away** while all their tabs are idle), `typing` (only with the chat on) and `ping`.

## Limits and close codes

- Frames up to **4 KB**, no binary, at most **5 per second**.
- The server sends a `ping` every 25 seconds.
- To reconnect, wait a random, growing delay and ask for a **new ticket**.

| Code | What it means | What to do |
|---|---|---|
| `4004` | Session ended | Don't reconnect with the same session |
| `4009` | Replaced: the person opened more than 10 connections, and the oldest closed | Nothing, if it is the old tab |
| `4029` | Server full | Connect again later and use the routes meanwhile |
| `1008` | Too many frames | Slow down |
| `1001` | Server restarting | Reconnect |

## Related

- [Authentication](/api/autenticacao)
- [X-CommunityId](/api/x-community-id)
- [Messages](/membros/mensagens)
