Realtime events
Chat pushes Realtime events to a signed-in user over a server-sent events stream, so your app does not poll. This page is the contract for every event: when it fires, who receives it, what its payload holds and what your client should do. For the model behind it, read Realtime; for wiring, read Live updates.
All 13 events are typed in ChatNotificationEventMap in the ccs-chat-notifications-client library.
Events at a glance
Section titled “Events at a glance”| Event | Fires when | Payload carries |
|---|---|---|
message.created |
A message is sent. | message |
message.updated |
A message is edited. | message |
message.deleted |
A message is deleted. | message_id |
reaction.added |
A member reacts to a message. | message_id, user_id, emoji |
reaction.removed |
A member removes a reaction. | message_id, user_id, emoji |
typing |
A member signals typing. | user_id |
member.added |
A user joins a conversation. | member |
member.removed |
A user leaves or is removed. | user_id, status |
conversation.created |
A conversation is created. | conversation |
conversation.updated |
A conversation changes. | conversation |
conversation.deleted |
A conversation is deleted. | none |
conversation.access_revoked |
You lose access to a conversation. | none |
presence.updated |
A fellow member goes online or offline. | user_id, status |
Transport
Section titled “Transport”Your app gets a stream address from the Chat API, then opens it with the browser’s native EventSource.
chat:accesscurl "$CHAT_API_URL/tenants/fitness/applications/fitness-app/notifications/realtime-token" \ -H "Authorization: Bearer $CHAT_TOKEN"{ "stream_url": "https://…", "expires": 1791970200000, "user_id": "client-john"}| Field | Type | Meaning |
|---|---|---|
stream_url |
string | A ready-to-open, subscribe-only stream address. Pass it to new EventSource(...). Treat it as opaque: do not parse it, log it or store it. |
expires |
number | When the credential in the address stops being valid, in epoch milliseconds. |
user_id |
string | Your user’s id. Use it to recognise events about the signed-in user, for example their own member.added. |
Know these rules:
- The route is for users only. A machine token gets
principal_not_allowed. SeeRealtimeToken_realtimeToken. - The credential lasts at most 10 minutes. Open a new stream with a fresh token before
expires. The SDK does this 30 seconds early by default. - Chat derives what the credential covers each time you ask for one. It covers your personal channel, up to 100 channels that you are an active member of, and optionally one more PUBLIC channel you pass as
?conversation_id=. A conversation you cannot read answers 404. - A stream cannot grow while it is open. To add a channel, close the stream and open a new one with a new token.
- The response can be an empty object,
{}, when realtime is not available. Stay disconnected and do not retry in a tight loop. - Events always belong to one context. Drop an event whose
tenant_idorapplication_iddiffers from the context your client is in.
Reading the stream without the SDK
Section titled “Reading the stream without the SDK”Each message on the stream is a JSON object. Its name is the event type and its data holds the envelope. When encoding contains json, data is a JSON string, so parse it a second time. ccs-chat-notifications-client does all of this for you and reconnects with a fresh token before expires.
Envelope
Section titled “Envelope”Every event has the same envelope. The payload depends on type.
{ "type": "message.created", "payload": { "tenant_id": "fitness", "application_id": "fitness-app", "conversation_id": "…" }, "published_at": "2026-10-14T09:30:00.123Z"}| Field | Type | Meaning |
|---|---|---|
type |
string | One of the 13 event names on this page. |
payload |
object | The event body, in snake_case like the rest of the API. |
published_at |
string | When Chat published the event, ISO-8601 UTC. |
Every payload carries tenant_id and application_id. Conversation events also carry conversation_id. The one exception is presence.updated, which has no application_id and no conversation_id.
Channels
Section titled “Channels”A stream carries two kinds of channel.
| Channel | Carries | Delivery |
|---|---|---|
| Personal | Events for your DIRECT and GROUP conversations, plus events only about you. | Chat publishes once per active member. |
| Conversation | Events of one CHANNEL conversation. | Chat publishes once; every subscriber receives it. |
A channel’s membership can be unbounded, so a CHANNEL publishes one event to its conversation channel instead of one per member. You hold a CHANNEL’s conversation channel only if it was in your credential when you opened the stream: the channels you belong to, plus the one you passed as conversation_id. The SDK reopens the stream for you when you create or join a CHANNEL.
The recipient lists below follow from this:
| Conversation type | Who receives its events |
|---|---|
| DIRECT, GROUP | Every active member, on their personal channel. |
| CHANNEL | Every client whose stream carries that channel, on its conversation channel. Some events are also copied to named users’ personal channels. |
Events
Section titled “Events”Events are listed in the order in which a conversation usually produces them. In the examples, fitness is the tenant, fitness-app the application, coach-ana and client-john the users, and “Morning squad” a GROUP.
message.created
Section titled “message.created”Fires when a message is sent in a conversation, by a user or by your backend. A SYSTEM message has sender_user_id: null.
Who receives it: every active member of a DIRECT or GROUP, including the sender. For a CHANNEL, every stream carrying the channel, including the sender’s.
| Field | Type | Meaning |
|---|---|---|
tenant_id |
string | The tenant. |
application_id |
string | The application. |
conversation_id |
string | The conversation the message belongs to. |
message |
Message | The complete message, the same body as GET …/messages. |
{ "type": "message.created", "payload": { "tenant_id": "fitness", "application_id": "fitness-app", "conversation_id": "3f7c2a9e-5b1d-4e8a-9c63-1a2b3c4d5e6f", "message": { "message_id": "7a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "conversation_id": "3f7c2a9e-5b1d-4e8a-9c63-1a2b3c4d5e6f", "tenant_id": "fitness", "application_id": "fitness-app", "sender_user_id": "coach-ana", "sender_client_id": null, "sender": { "user_id": "coach-ana", "display_name": "Ana Coach", "avatar_url": null }, "body": "Squad, 6:30 at the track. Bring water.", "message_type": "TEXT", "reply_to_message_id": null, "attachments": [], "status": "ACTIVE", "edited_at": null, "deleted_at": null, "created_at": "2026-10-14T06:00:00.000Z", "updated_at": "2026-10-14T06:00:00.000Z", "reactions": [], "mentioned_user_ids": [] } }, "published_at": "2026-10-14T06:00:00.120Z"}What to do: render payload.message directly; you need no follow-up request. Skip a message you already hold, matching on message_id, because the sender receives its own message and also gets it from the send response. If the conversation is not open, mark it unread and show your alert. Do not use reacted_by_me from this event, because a new message has no reactions.
message.updated
Section titled “message.updated”Fires when a message is edited. status becomes EDITED and edited_at is set.
Who receives it: the same recipients as message.created.
| Field | Type | Meaning |
|---|---|---|
tenant_id |
string | The tenant. |
application_id |
string | The application. |
conversation_id |
string | The conversation. |
message |
Message | The message after the edit. |
{ "type": "message.updated", "payload": { "tenant_id": "fitness", "application_id": "fitness-app", "conversation_id": "3f7c2a9e-5b1d-4e8a-9c63-1a2b3c4d5e6f", "message": { "message_id": "7a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "body": "Squad, 6:45 at the track. Bring water.", "status": "EDITED", "edited_at": "2026-10-14T06:05:00.000Z", "…": "the remaining Message fields, as in message.created" } }, "published_at": "2026-10-14T06:05:00.140Z"}What to do: replace the message you hold with the same message_id. In reactions, the counts are current but reacted_by_me describes the user who made the edit, not you. Keep your own reacted_by_me values.
message.deleted
Section titled “message.deleted”Fires when a message is deleted, by its author or by a moderator. The message stays in the history as a Tombstone: empty body, no attachments, status: "DELETED".
Who receives it: the same recipients as message.created.
| Field | Type | Meaning |
|---|---|---|
tenant_id |
string | The tenant. |
application_id |
string | The application. |
conversation_id |
string | The conversation. |
message_id |
string | The deleted message. |
{ "type": "message.deleted", "payload": { "tenant_id": "fitness", "application_id": "fitness-app", "conversation_id": "3f7c2a9e-5b1d-4e8a-9c63-1a2b3c4d5e6f", "message_id": "7a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d" }, "published_at": "2026-10-14T06:10:00.090Z"}What to do: turn the message into a tombstone in your list. Do not remove it, so replies that point at it still make sense.
reaction.added
Section titled “reaction.added”Fires when a member adds an emoji to a message. Adding a reaction that already exists fires nothing.
Who receives it: the same recipients as message.created, including the member who reacted.
| Field | Type | Meaning |
|---|---|---|
tenant_id |
string | The tenant. |
application_id |
string | The application. |
conversation_id |
string | The conversation. |
message_id |
string | The message reacted to. |
user_id |
string | The member who reacted. |
emoji |
string | The emoji. |
{ "type": "reaction.added", "payload": { "tenant_id": "fitness", "application_id": "fitness-app", "conversation_id": "3f7c2a9e-5b1d-4e8a-9c63-1a2b3c4d5e6f", "message_id": "7a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "user_id": "client-john", "emoji": "💪" }, "published_at": "2026-10-14T06:01:00.050Z"}What to do: add 1 to the count for that emoji on the message, or add the emoji with a count of 1. If user_id is your user, set reacted_by_me to true.
reaction.removed
Section titled “reaction.removed”Fires when a member removes their reaction. Removing a reaction that does not exist fires nothing.
Who receives it: the same recipients as reaction.added.
| Field | Type | Meaning |
|---|---|---|
tenant_id |
string | The tenant. |
application_id |
string | The application. |
conversation_id |
string | The conversation. |
message_id |
string | The message. |
user_id |
string | The member who removed the reaction. |
emoji |
string | The emoji. |
{ "type": "reaction.removed", "payload": { "tenant_id": "fitness", "application_id": "fitness-app", "conversation_id": "3f7c2a9e-5b1d-4e8a-9c63-1a2b3c4d5e6f", "message_id": "7a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "user_id": "client-john", "emoji": "💪" }, "published_at": "2026-10-14T06:02:00.050Z"}What to do: subtract 1 from the count and drop the emoji when it reaches 0. If user_id is your user, set reacted_by_me to false.
typing
Section titled “typing”Fires each time a member sends the typing signal. It is ephemeral: Chat never stores it and never sends it as a push notification. There is no “stopped typing” event.
Who receives it: every other active member of a DIRECT or GROUP; the typist is excluded. For a CHANNEL it goes to every stream carrying the channel, and that includes the typist’s own.
| Field | Type | Meaning |
|---|---|---|
tenant_id |
string | The tenant. |
application_id |
string | The application. |
conversation_id |
string | The conversation. |
user_id |
string | The member who is typing. |
{ "type": "typing", "payload": { "tenant_id": "fitness", "application_id": "fitness-app", "conversation_id": "3f7c2a9e-5b1d-4e8a-9c63-1a2b3c4d5e6f", "user_id": "client-john" }, "published_at": "2026-10-14T06:03:00.010Z"}What to do: ignore events whose user_id is your own user. Otherwise show a typing indicator for that user in that conversation and hide it after a short timeout of your choosing, restarting the timeout on every new event.
member.added
Section titled “member.added”Fires when a user becomes an active member of a conversation, whether your backend adds them or they join a PUBLIC channel themselves. Adding someone who is already an active member fires nothing.
Who receives it: every active member of a GROUP, including the new member. For a CHANNEL, every stream carrying the channel, and also the new member’s personal channel, so their client hears about it before it carries the channel.
| Field | Type | Meaning |
|---|---|---|
tenant_id |
string | The tenant. |
application_id |
string | The application. |
conversation_id |
string | The conversation. |
member |
Member | The new member. |
{ "type": "member.added", "payload": { "tenant_id": "fitness", "application_id": "fitness-app", "conversation_id": "3f7c2a9e-5b1d-4e8a-9c63-1a2b3c4d5e6f", "member": { "user_id": "client-john", "display_name": "John Client", "avatar_url": null, "member_id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b", "role": "MEMBER", "status": "ACTIVE", "joined_at": "2026-10-14T05:55:00.000Z" } }, "published_at": "2026-10-14T05:55:00.080Z"}What to do: add member to the conversation’s member list. If member.user_id equals the user_id from your realtime token, you have joined a conversation your client does not know yet: fetch it with Conversations_get and add it to the list.
member.removed
Section titled “member.removed”Fires when a member leaves or is removed. status says which: LEFT when the user left, REMOVED when someone else removed them or the user stopped being a member of the tenant.
Who receives it: the remaining active members of a DIRECT or GROUP. The removed user gets conversation.access_revoked instead. For a CHANNEL, every stream carrying the channel, which can include the removed user’s until their credential expires.
| Field | Type | Meaning |
|---|---|---|
tenant_id |
string | The tenant. |
application_id |
string | The application. |
conversation_id |
string | The conversation. |
user_id |
string | The user who is no longer a member. |
status |
LEFT or REMOVED |
Why the membership ended. |
{ "type": "member.removed", "payload": { "tenant_id": "fitness", "application_id": "fitness-app", "conversation_id": "3f7c2a9e-5b1d-4e8a-9c63-1a2b3c4d5e6f", "user_id": "client-john", "status": "REMOVED" }, "published_at": "2026-10-14T08:00:00.060Z"}What to do: remove that user from the member list. Keep their earlier messages in the history.
conversation.created
Section titled “conversation.created”Fires when a conversation is created. Creating a DIRECT conversation that already exists fires nothing.
Who receives it: every initial active member of a DIRECT or GROUP. For a CHANNEL, the conversation channel (which nobody carries yet) and each initial member’s personal channel. That is why the SDK reopens its stream when this event announces a CHANNEL.
| Field | Type | Meaning |
|---|---|---|
tenant_id |
string | The tenant. |
application_id |
string | The application. |
conversation_id |
string | The new conversation. |
conversation |
Conversation | The conversation as it was when created. |
{ "type": "conversation.created", "payload": { "tenant_id": "fitness", "application_id": "fitness-app", "conversation_id": "9d2e4b6a-1c3f-4a5b-8d7e-0f1a2b3c4d5e", "conversation": { "conversation_id": "9d2e4b6a-1c3f-4a5b-8d7e-0f1a2b3c4d5e", "tenant_id": "fitness", "application_id": "fitness-app", "type": "CHANNEL", "visibility": "PUBLIC", "name": "announcements", "description": null, "status": "ACTIVE", "created_by_user_id": null, "created_by_client_id": "2f1c9e5a-7f4d-4f0e-8c35-9b1a3d6e4c21", "last_activity_at": "2026-10-14T05:50:00.000Z", "created_at": "2026-10-14T05:50:00.000Z", "updated_at": "2026-10-14T05:50:00.000Z", "member_count": 2, "my_role": null, "has_unread": false } }, "published_at": "2026-10-14T05:50:00.100Z"}What to do: add the conversation to the list. In this event, my_role and has_unread describe whoever created the conversation, not you, so do not read them. Fetch the conversation if you need your own values.
conversation.updated
Section titled “conversation.updated”Fires when a conversation’s name, description, visibility or status changes. Archiving a conversation fires it with status: "ARCHIVED", and reactivating it fires it with status: "ACTIVE".
Who receives it: every active member of a DIRECT or GROUP; every stream carrying the channel for a CHANNEL.
| Field | Type | Meaning |
|---|---|---|
tenant_id |
string | The tenant. |
application_id |
string | The application. |
conversation_id |
string | The conversation. |
conversation |
Conversation | The conversation after the change. |
{ "type": "conversation.updated", "payload": { "tenant_id": "fitness", "application_id": "fitness-app", "conversation_id": "3f7c2a9e-5b1d-4e8a-9c63-1a2b3c4d5e6f", "conversation": { "conversation_id": "3f7c2a9e-5b1d-4e8a-9c63-1a2b3c4d5e6f", "type": "GROUP", "name": "Morning squad", "status": "ARCHIVED", "…": "the remaining Conversation fields, as in conversation.created" } }, "published_at": "2026-10-14T20:00:00.070Z"}What to do: merge the changed fields into the conversation you hold. When status is ARCHIVED, keep showing it but disable the composer; archived conversations are read-only. As with conversation.created, ignore my_role and has_unread.
conversation.deleted
Section titled “conversation.deleted”Fires when a conversation is deleted, by a user with the permission or by an administrator. Chat sends it before the deletion takes effect.
Who receives it: every active member of a DIRECT or GROUP; every stream carrying the channel for a CHANNEL.
| Field | Type | Meaning |
|---|---|---|
tenant_id |
string | The tenant. |
application_id |
string | The application. |
conversation_id |
string | The deleted conversation. |
{ "type": "conversation.deleted", "payload": { "tenant_id": "fitness", "application_id": "fitness-app", "conversation_id": "3f7c2a9e-5b1d-4e8a-9c63-1a2b3c4d5e6f" }, "published_at": "2026-10-14T21:00:00.040Z"}What to do: remove the conversation from your list and leave it if it is open. Do not request it again.
conversation.access_revoked
Section titled “conversation.access_revoked”Fires when a user is no longer a member of a conversation: they left, someone removed them, or an administrator removed them. It also fires when Chat finds that the user is no longer a member of the tenant. It always follows a member.removed.
Who receives it: only the user who lost access, on their personal channel.
| Field | Type | Meaning |
|---|---|---|
tenant_id |
string | The tenant. |
application_id |
string | The application. |
conversation_id |
string | The conversation you lost. |
{ "type": "conversation.access_revoked", "payload": { "tenant_id": "fitness", "application_id": "fitness-app", "conversation_id": "3f7c2a9e-5b1d-4e8a-9c63-1a2b3c4d5e6f" }, "published_at": "2026-10-14T08:00:00.065Z"}What to do: drop the conversation from the UI and close it if it is open. A PUBLIC channel stays readable to everyone in the application, so it can reappear in a channel directory. For a CHANNEL you held, access to its live events can last until your credential expires, which is at most 10 minutes.
presence.updated
Section titled “presence.updated”Fires when a user goes online or offline. See Presence. It fires only on a transition: when a user’s first heartbeat arrives after they were offline, and when they say they are going offline. A user who just stops sending heartbeats does not trigger an event.
Who receives it: the user’s fellow active members, in each application where they share a conversation. The user does not receive their own.
| Field | Type | Meaning |
|---|---|---|
tenant_id |
string | The tenant. |
user_id |
string | The user whose status changed. |
status |
online or offline |
The new status. |
This payload has no application_id and no conversation_id. Presence is tenant-wide.
{ "type": "presence.updated", "payload": { "tenant_id": "fitness", "user_id": "coach-ana", "status": "online" }, "published_at": "2026-10-14T05:45:00.020Z"}What to do: update that user’s status wherever you show it. For the current status of a user you have no event for, ask Presence_statuses.
Delivery notes
Section titled “Delivery notes”- No replay. Chat does not store events for a client that was not connected. After every reconnect, including the planned one before
expires, reload the latest page of the open conversation, and refresh your conversation list if you need unread flags to be right. The sample below does this. - Reconnects lose events. Any change to the stream, whether a refreshed token or a newly joined channel, closes it and opens another. Events published in between are not replayed.
- Typing is ephemeral. Nothing stores it. A client that was not connected never sees it.
- Presence only on transitions. You get
presence.updatedwhen a status changes, not on every heartbeat. - Best effort. If publishing an event fails, the write that caused it still succeeds. The REST API is the source of truth.
- Idempotent handling. The sender receives its own
message.created, and Chat does not promise ordering across events. Match on ids and apply each event so that applying it twice does no harm. - Push is separate. A user who is offline receives push instead of these events.
import { ChatNotificationsClient } from '@central-code-station/ccs-chat-notifications-client';
declare const myUserId: string;declare function getChatToken(): Promise<string>;declare function openConversationId(): string | null;declare function reloadLatestMessages(conversationId: string): Promise<void>;declare function showMessage(conversationId: string, messageId: string): void;declare function showTyping(conversationId: string, userId: string): void;declare function dropConversation(conversationId: string): void;
const notifications = new ChatNotificationsClient({ chatApiUrl: 'https://chat.centralcodestation.com/api/v1', tenantId: 'fitness', applicationId: 'fitness-app', getAccessToken: getChatToken,});
// Nothing is replayed after a reconnect: reload what the user is looking at.let connectedBefore = false;notifications.on('connectionStateChange', (state) => { if (state !== 'connected') return; const open = openConversationId(); if (connectedBefore && open) void reloadLatestMessages(open); connectedBefore = true;});
notifications.on('message.created', ({ conversation_id, message }) => { showMessage(conversation_id, message.message_id);});
notifications.on('typing', ({ conversation_id, user_id }) => { if (user_id !== myUserId) showTyping(conversation_id, user_id);});
notifications.on('conversation.access_revoked', ({ conversation_id }) => dropConversation(conversation_id));
notifications.connect();connectionStateChange
Section titled “connectionStateChange”connectionStateChange is not a wire event. It never appears on the stream and has no envelope. It is an event of the SDK’s ChatNotificationsClient, which reports the state of its own connection.
| Value | Meaning |
|---|---|
disconnected |
Not connected. The initial state, and the state after disconnect(). |
connecting |
Fetching a token or opening the stream, or the browser is retrying a dropped connection. |
connected |
The stream is open. |
error |
Opening failed. The client retries with backoff and then settles here until you call connect() again. |
Use the move to connected as your cue to reload, as in the sample above. See ccs-chat-notifications-client for the Angular and React forms.