# Echtzeit (WebSocket)

> Der WebSocket der Plattform - das Einmal-Ticket, das Subprotokoll memberfy-realtime, automatische und angefragte Räume, invalidate-Hinweise, Limits und Close-Codes.

Die App bekommt Änderungen sofort über einen WebSocket: die Glocke, den Chat, „Jetzt online“, wer auf welcher Seite ist, das Live-Banner von Events, die Kampagnenzahlen. Jedes Mitglied der Community, in jeder Rolle, öffnet den Socket, **mit Chat an oder aus**.

## Die Verbindung öffnen

1. Hol dir ein Ticket mit `POST /api/realtime/ticket`, mit Token und `X-CommunityId` (siehe [Authentifizierung](/api/autenticacao) und [X-CommunityId](/api/x-community-id)). Das Ticket gilt **einmal**, **60 Sekunden** lang und ist an Person, Community und Sitzung gebunden.
2. Öffne `wss://<api>/api/realtime/socket` (`data.path`) und gib das Ticket als **zweites Subprotokoll** nach `memberfy-realtime` mit (`data.protocols`). Der Server antwortet mit `memberfy-realtime`.

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

Das Ticket kommt nie in die URL (in der Query wird es ignoriert). Ohne gültiges Ticket wird die Verbindung abgelehnt, bevor sie zum WebSocket wird (401). Ein Community-Wechsel schließt den Socket: hol dir ein neues Ticket. Ohne den Echtzeit-Dienst liefert das Ticket `503`, und die App lädt weiter über die Routen.

`POST /api/chat/ticket` und `/api/chat/socket` funktionieren noch ein Deploy lang als **Alias** und entfallen dann: nutze die neuen.

## Räume

Jede Verbindung ist schon in drei Räumen:

| Raum | Wer |
|---|---|
| `me` | Die Person selbst |
| `community` | Alle in der Community |
| `staff` | Nur das Team (Eigentümer, Admin, Moderator und Finanzen) |

Die anderen fragt der Client an, mit derselben Regel wie beim passenden `GET`, immer in dieser Community:

| Raum | Wer hineinkommt |
|---|---|
| `space:<id>` | Wer den Bereich öffnen darf |
| `item:event:<id>` | Wer das Event öffnen darf |
| `item:post:<id>` | Wer den Beitrag öffnen darf |

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

Außerdem gibt es Räume für die Editoren, nur für die Rollen, die dort speichern dürfen: `item:course:<id>`, `item:form:<id>` (das Team und wer das Formular verwaltet; ein Mitglied, wenn es veröffentlicht ist und es den Bereich öffnet), `item:campaign:<id>`, `item:email:<id>` (E-Mail der Bibliothek), `item:workflow:<id>`, `item:survey:<id>` (Inhaber und Admin), `item:certificateTemplate:<id>` (wer im Bereich Zertifikate ausstellt), `item:emailOverride:<emailKey>` (Standard-E-Mail, Inhaber und Admin). Und die Bereichsräume, einer pro Abschnittsseite: `area:home` (alle), `area:members`, `area:campaigns`, `area:emails`, `area:workflows` (Team), `area:moderation` (Inhaber, Admin und Moderator), `area:analytics`, `area:dashboard`, `area:settings` (Inhaber und Admin) und `area:finance` (Inhaber, Admin und Finanzen).

Höchstens **20 Räume** pro Verbindung (die automatischen zählen nicht). Die Antwort ist `subscribed` mit den angenommenen Räumen und ein `subscribe.denied` je abgelehntem Raum, **ohne Grund**. Ändert sich der Zugang der Person (ausgetreten, neue Rolle, Gruppe, Bereichszugang), prüft der Server die Räume neu, schickt `subscribe.denied` für die verlorenen und danach `access.changed`.

## Änderungshinweise (`invalidate`)

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

Der Hinweis enthält **nur IDs und das Thema**, nie Titel, Text, Betrag oder Namen: Lade danach das, was auf dem Bildschirm ist, mit dem üblichen `GET` neu.

| Thema | Raum | Wann |
|---|---|---|
| `event.live` | `community`, `space:<id>`, `item:event:<id>` | Ein Event hat die Übertragung begonnen oder beendet |
| `events` | `community`, `space:<id>`, `item:event:<id>` | Ein Event wurde angelegt, bearbeitet oder entfernt |
| `analytics.online` | `staff` (Eigentümer und Admin) | „Jetzt online“ hat sich geändert (höchstens alle 5 Sekunden) |
| `campaigns.stats` | `staff` | Ein Kampagnenversand ist fertig |
| `menu.changed` | `community` | Das Menü hat sich geändert: Abschnitt oder Bereich angelegt, bearbeitet (Name, Symbol, Adresse, Abschnitt, Zugriff, Sichtbarkeit), entfernt oder neu geordnet. Ohne `ids` und ohne Daten des Menüs: Jede Person ruft `GET /api/sections` mit den eigenen Berechtigungen neu ab. Änderungen kurz hintereinander werden innerhalb von 1 Sekunde zu einem einzigen Hinweis |

Event-Hinweise erreichen nur, wer den Bereich sieht; ein privates Event oder eines in Moderation nur das Team.

## Wer ist hier

Die Anwesenheit (die Gesichter oben auf jeder Seite, siehe [Wer ist hier und gemeinsames Bearbeiten](/conceitos/presenca-e-edicao-em-equipe)) nutzt dieselben Räume.

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

- `presence.join` beim Öffnen der Seite: Der Raum wird wie bei `subscribe` geprüft (und abonniert, falls noch nicht geschehen), höchstens **3 Plätze** pro Verbindung. Die Antwort ist `presence.joined` (`room`, `editing`) gefolgt von einem `presence`-Abbild, oder `presence.denied` (`room`), ohne Grund.
- `presence.leave` beim Verlassen. Es gibt keinen Heartbeat: Der Server-Ping hält den Platz, der 45 Sekunden nach der letzten Antwort verschwindet; das Schließen der Verbindung entfernt ihn sofort.
- `presence.active` bei Interaktion (Taste, Klick, Fokus), höchstens alle 10 Sekunden. Das Betreten zählt als Aktivität.

Bei jeder Änderung (höchstens 1 pro Sekunde und Raum) bekommt der Raum ein Abbild, das für jede Verbindung gebaut wird:

```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"
}
```

- Du bist **nie** in deiner eigenen Liste. `members` enthält höchstens 5 Personen (zuerst aktive Bearbeiter, dann aktive, dann inaktive); `count` ist die Gesamtzahl der anderen Personen im Raum.
- **Nur Lesende:** In Bereichs- und Elementräumen bekommt, wer dort nicht bearbeitet, nur `memberId`, `name`, `avatarUrl` und `state`, ohne `editing`, `lastActiveAt` und `editingCount`, zum Schutz der Bearbeiter. Wer dasselbe Element bearbeitet, und die Bereichsräume des Teams bekommen das vollständige Abbild.
- `state: "idle"`: verbunden, seit 30 Sekunden oder mehr ohne Aktivität. Bleibt in der Liste.
- **Datenschutz:** Wer **Für Chats verfügbar** ausgeschaltet hat, erscheint und zählt bei den anderen nicht, sieht aber, wer da ist. Eine Chat-Blockierung verbirgt die beiden Personen voreinander.
- **Community-Chat aus:** Bereichsräume und nur Lesende bekommen niemanden (`members` leer, `count` 0), auch das Team. Nur wer dasselbe Element bearbeitet, sieht die anderen Bearbeiter weiter; `item.saved` und der Versions-`409` ändern sich nicht.

### Bearbeiten und `item.saved`

In einem Editor (Event, Kampagne, E-Mail der Bibliothek, Workflow, Kurs, Formular, Umfrage, Zertifikatsvorlage, Standard-E-Mail und die Einstellungen in `area:settings`) mit `"editing": true` beitreten. Das gilt nur für alle, die dort speichern dürfen (das Team und beim Formular, wer es verwaltet). Wer bearbeitet, erscheint mit `editing: true` bei den **anderen Bearbeitern** im Raum, auch mit ausgeschaltetem Chat-Status; wer die Seite nur liest, erfährt davon nichts. `editingCount` zählt nur aktive Bearbeiter.

Bei jedem Speichern bekommt der Raum, für das Team und für alle, die gerade bearbeiten:

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

Vergleiche `updatedAt` mit deiner Version, um dein eigenes Speichern zu ignorieren. `itemType`: `event`, `campaign`, `email`, `workflow`, `course` (auch Module und Lektionen), `form`, `survey`, `certificateTemplate`, `emailOverride` (`itemId` = `emailKey`) und im Raum `area:settings` (`itemId` = die Community) `settings`, `chatSettings`, `notificationSettings`, `messagingSettings` (SMS + WhatsApp), `creditSettings`, `businessInformation`, `payoutSettings`.

Speichervorgänge nehmen die gelesene Version an und antworten mit `409`, wenn ein anderer zuerst kam: siehe [Versionskonflikt](/api/respostas-e-erros#versionskonflikt).

## Weitere Ereignisse

Vom Server, als JSON `{ type, data }`: `ready` (mit `profileId`, `communityId`, `chatAvailable`, `chatStatus` (der gewählte Chat-Status), `chatEnabled`, `rooms`, `maxRooms` und `pingIntervalMs`), `notification.new`, `notification.read` und `notification.removed` (die Glocke, in jeder Community), `chat.settings`, `session.revoked` und `pong`. Chat-Ereignisse kommen nur bei **eingeschaltetem** Chat; ist der Chat aus, bleibt der Socket bestehen, und nur sie fallen weg.

Vom Client: `subscribe`, `unsubscribe`, `presence.join`, `presence.leave`, `presence.active`, `presence.idle` (`{ "type": "presence.idle", "idle": true }`, wenn der Tab 10 Minuten inaktiv ist, `false`, wenn er zurückkommt: Die Person erscheint als **Abwesend**, solange alle ihre Tabs inaktiv sind), `typing` (nur mit Chat an) und `ping`.

## Limits und Close-Codes

- Frames bis **4 KB**, kein Binär, höchstens **5 pro Sekunde**.
- Der Server sendet alle 25 Sekunden ein `ping`.
- Zum Neuverbinden warte eine zufällige, wachsende Zeit und hol dir ein **neues Ticket**.

| Code | Bedeutung | Was tun |
|---|---|---|
| `4004` | Sitzung beendet | Nicht mit derselben Sitzung neu verbinden |
| `4009` | Ersetzt: Die Person hat mehr als 10 Verbindungen geöffnet, die älteste wurde geschlossen | Nichts, wenn es der alte Tab ist |
| `4029` | Server voll | Später neu verbinden und solange die Routen nutzen |
| `1008` | Zu viele Frames | Langsamer senden |
| `1001` | Server startet neu | Neu verbinden |

## Verwandte Artikel

- [Authentifizierung](/api/autenticacao)
- [X-CommunityId](/api/x-community-id)
- [Nachrichten](/membros/mensagens)
