GomaChat Backend Service: Integration Specification
Who this page is for
This is the normative contract for anyone building a chat backend rather than running the GomaChat service. A reference implementation team, or an operator replacing it. It is written from the client's point of view: every requirement is derived from what the shipped widgets actually send and read on the wire.
If you are integrating the widgets against an existing service, you do not need this page. Start with Chat identity, and see Using a different backend for the short version of what a replacement has to satisfy.
Specification of the backend service required by the GomaPulse Messenger and Live Chat widgets, for implementation in any language and on any internal architecture.
1. Introduction & Scope
GomaPulse ships two embeddable chat widgets as framework-agnostic web components:
- Messenger (
<goma-messenger>). A global social dock: friends by invite code, direct messages, group chats, reactions, read receipts, presence, typing indicators, notifications, and bet-ticket sharing. - Live Chat (
<goma-live-chat>). A per-match public chat room: join/leave, message history, emoji reactions, participant counts, bet-card sharing, server-side content moderation with an escalation ladder, and a room lifecycle tied to the match.
This document specifies the backend service those widgets require. It is written from the client's point of view: every requirement is derived from what the widgets actually send and read on the wire. It deliberately does not describe any existing server implementation.
1.1 The drop-in rule
The widgets are deployed to production sites and will not be modified. Consequently:
- The client-facing edge of the backend (URLs, HTTP methods, JSON field names, realtime event names, payload shapes, and the behavioral semantics in this document) is normative. A conforming backend accepts exactly what the widgets send and returns exactly what they read.
- Everything behind that edge is the implementer's free choice: programming language, framework, database, message broker (e.g. Kafka), process topology, internal protocols. Section 12 offers a suggested shape, and it is explicitly non-normative.
Normative language
The key words MUST, MUST NOT, SHOULD, and MAY are used as described in RFC 2119. Fields marked Required in contract tables are fields the client code reads: omitting them breaks the widget. Fields marked Optional may be omitted or extended freely; the client ignores unknown fields everywhere.
1.2 Out of scope
- The widgets' own embedding API (attributes, theming, DOM events toward the host page): covered by the widget documentation, not needed to build the backend.
- The sportsbook platform ("My Bets" / bet placement). The bet-sharing feature embeds a separate widget that talks to the sportsbook backend directly; the chat backend only stores and relays an opaque payload (Appendix A).
- Admin / back-office tooling. A real deployment needs operational controls (close a room, moderate a user, manage users); their API shape is entirely free since no widget consumes it.
2. System Overview
Both widgets speak to one logical backend over two channels:
- REST over HTTPS: session exchange, resource reads, and social-graph mutations.
- A Socket.IO realtime connection: live message delivery, receipts, presence, typing, reactions, moderation pushes, and (for Live Chat) message sending. See below for the protocol revision, which is not what the client library's major version implies.
The widgets bundle socket.io-client 4.x, which speaks Socket.IO protocol revision 5 carried over Engine.IO protocol 4. The realtime edge MUST implement that pair: in practice a socket.io server 4.x, or any server implementing protocol rev 5. Plain WebSocket, SSE, or a proprietary framing will not connect.
Mind the version overlap when choosing a non-Node implementation: "Socket.IO protocol v4" is what socket.io 2.x speaks and is wire-incompatible with these widgets. Match the protocol revision, not the library's major version. Socket.IO server implementations exist for Node.js, Go, Python, Java, and others; alternatively, a thin Socket.IO gateway process can front a core service written in any language (see §12). Message brokers such as Kafka fit naturally behind this edge; they cannot replace it.
2.1 Connection profile (both widgets)
| Property | Value the client uses | Requirement |
|---|---|---|
| Namespace | default (/) | Serve the default namespace. |
| Path | /socket.io/ | Serve the Socket.IO handshake on this path. |
| Transports | ['polling', 'websocket']: HTTP long-polling first, then upgrade | HTTP long-polling MUST work: it is the only transport attempted on a fresh connection (the client does not set tryAllTransports), so a WebSocket-only edge never connects at all. WebSocket SHOULD work: a failed upgrade leaves the client on polling and fully functional. With multiple gateway instances, polling requires sticky routing or a shared session layer. |
| Reconnection | 10 attempts, delay 1 s → max 30 s | After a successful reconnect, clients re-join rooms and re-fetch state (see §7.4, §10.4). |
| Acknowledgements | Messenger: none. Live Chat: only on game_chat_send_message (10 s client timeout). | See §10.2. |
| Multi-instance fan-out | - | Room broadcasts MUST reach every connected client regardless of which gateway instance holds its socket (use a shared adapter/bus). |
3. Protocol Conventions
3.1 Base URL
The host page configures each widget with two separate URLs, and they have different constraints.
- The REST base URL: paths in this document are appended verbatim (no normalization), so the REST surface MAY live under any origin and path prefix; deployments commonly proxy it under a path such as
/goma-chat. It MUST NOT end in a trailing slash, which would produce a doubled//. All paths below start at that base. - The realtime URL MUST be a bare origin, with no path component. Socket.IO parses a URL's path as a namespace, not a prefix: pointing the widget at
https://host/goma-chatmakes it handshake athttps://host/socket.io/and request the namespace/goma-chat, which a backend serving only the default namespace rejects withInvalid namespace. The handshake path is hard-coded to/socket.io/in both widgets and is not host-configurable.
3.2 Requests and responses
- All bodies are JSON; clients always send
Content-Type: application/json. - Every 2xx response MUST carry a JSON body: at minimum
{}. The clients unconditionally parse the body of successful responses, including for DELETE/PUT/POST actions. A bodyless204 No Contentmakes a successful call fail client-side. Do not use 204. - Field naming is
snake_case, with the exact exceptions called out in the contract tables (§6.1 in particular). Field names are byte-exact contracts: reproduce them including their inconsistencies. - Unknown extra fields are always ignored by the clients; the backend MAY extend any response.
- Clients abort REST calls after 10 seconds. Respond well within that budget.
- Both clients set
Content-Type: application/jsonon every request, including bodylessGETandDELETEcalls. That makes every call non-simple, so cross-origin deployments MUST answerOPTIONSpreflights withAccess-Control-Allow-MethodscoveringGET/POST/PUT/DELETEandAccess-Control-Allow-HeaderscoveringAuthorization,Content-Typeand anyx-api-key. It also means a bodyless request still arrives under a JSON content type: a parser that rejects an empty body will 400 the join and leave calls. - The backend MUST support cross-origin browser calls: CORS with the
AuthorizationandContent-Type(and optionallyx-api-key) request headers allowed, for both REST and the Socket.IO handshake.
3.3 Errors
The widgets branch on HTTP status codes in no place at all: a non-2xx becomes a generic Error carrying the status, and no call site anywhere reads it. REST error bodies are never parsed either. Any failure is therefore undifferentiated to the client: status codes and error bodies are an observability surface for operators, not a way to steer client behaviour. Therefore:
- Use conventional status codes:
400invalid request,401unauthenticated,403forbidden,404not found,409conflict,422semantic rejection,429rate limited,5xxserver fault. Any non-2xx is treated as failure. - An error body of the shape
{ "error": { "code": "SOME_CODE", "message": "…" } }SHOULD be returned. The widgets do not parse it, but it is the primary debugging surface during integration. - Realtime errors are different: Live Chat does read structured error objects in acknowledgements (§10.2) and both widgets rely on specific realtime error semantics. Those are normative where stated.
3.4 Identifiers, timestamps, text
- IDs are opaque strings. Clients never parse them. They MUST be URL-safe (they are interpolated into URL paths and cursors without encoding) and unique per entity. The server MUST assign its own message IDs: clients tag in-flight messages with self-generated UUIDs and rely on the server ID being different to reconcile (§11.2).
- Timestamps are ISO 8601 strings with timezone (e.g.
2026-08-24T14:03:07.512Z) in every payload, REST and realtime. - Text. The composers cap sends at 150 characters (Messenger) and 500 (Live Chat), and group names at 64, so a backend can never receive more than that from these widgets. Accept at least those lengths in UTF-8; anything larger is unreachable through the UI.
- Server clocks must be accurate. Both widgets match a server-echoed message to their optimistic local copy only if the server's
created_atis within ±10 seconds of the client's wall clock (§11.2). - Cursors are opaque, URL-safe strings chosen by the server. Clients echo them back verbatim and unencoded.
4. Identity & Authentication
4.1 Session exchange
The widgets never log a user in. The host page exchanges the operator-platform session for a chat identity once per login, then feeds the resulting token to both widgets. The exchange endpoint is called directly from the browser:
POST /auth/exchange
Exchange an operator session for a chat access token, provisioning the chat user on first sight.
| Body field | Type | Required | Notes |
|---|---|---|---|
provider | string | Required | Identity-provider key. Current deployments send "everymatrix"; the design SHOULD keep providers pluggable. |
sessionId | string | Required | The operator-platform session ID. The backend MUST verify it server-side against the operator platform (never trust it as-is, this endpoint is called from the browser with no shared secret). |
username | string | Optional | Display-name seed used only when provisioning a brand-new chat user; ignored for existing users. |
| Response field | Type | Required | Notes |
|---|---|---|---|
accessToken | string | Required | Bearer token for all subsequent REST calls and the realtime handshake. Opaque to the client. |
user.id | string | Required | The chat user ID. The host feeds it back to the widgets; it MUST equal the sender_user_id / sender_id the backend stamps on this user's messages (own-message detection depends on the match). |
user.friendCode | string | Required | The user's shareable friend code (§5.1). Note the camelCase. |
user.username | string | Optional | Resolved display name. |
refreshToken | string | Optional | Required only if you implement §4.3. No widget reads it; the host stores it to obtain a new access token without a fresh exchange. |
Failure map: 400 unknown provider / invalid body · 401 session verification failed · 403 the chat account is suspended or banned · 429 rate limited (suggested: 60/min per IP).
The 403 is the only channel by which the backend can tell a host "this player must not chat", so a backend with any moderation story needs it.
This table describes the browser-safe path only
The Required column elsewhere in this document means the widget reads it. Here it cannot: no widget calls this endpoint or parses its response. The host page does, then feeds the values to the widgets as attributes. Required here means "the host needs it to configure a widget".
A backend MAY additionally offer a server-to-server path where a trusted caller asserts an identity directly instead of presenting a sessionId. That path needs its own authentication (a shared key, mTLS, network isolation) and MUST NOT be reachable from a browser; a caller that can assert any identity can mint a token for anyone. This document specifies only the browser-safe path.
4.2 Using the token
REST: every call carries
Authorization: Bearer {accessToken}. Deployments MAY additionally configure a staticx-api-keyheader on the widgets; implementations that do not use one should ignore the header.Realtime handshake: the client presents credentials in three slots, and they are not all the same value:
Slot Carries Source attribute auth.authTokenthe access token auth-tokenquery ?authToken=…the access token auth-tokenauth.tokena separate socket token socket-auth-tokenThe backend MUST authenticate from
auth.authTokenor theauthTokenquery parameter: those two always carry the access token. It SHOULD additionally acceptauth.token, which carriessocket-auth-token: a host may set that to the same value, to a different one, or omit it entirely, in which case the slot is absent from the handshake. A backend that authenticates only fromauth.tokenrejects every host that does not setsocket-auth-token. If you do readauth.tokenfirst. As the reference does. Fall back toauth.authToken/?authTokenwhen it fails to verify, or a host that legitimately setssocket-auth-tokento some other value has that value verified and the handshake rejected while the valid token sits unread in the next slot. Unauthenticated connections MUST be rejected at the handshake.
4.3 Token lifecycle
The widgets never refresh, re-request, or introspect the token, and they have no 401-specific handling: an expired token mid-session degrades into generic errors until the host performs a new exchange (typically at next login). Access tokens therefore SHOULD remain valid for at least a full user session (hours, not minutes). A refresh endpoint MAY exist for host-level use, but no widget will call it. If you provide one, the shape the reference deployment uses is POST /auth/refresh taking { refreshToken } and returning { accessToken }: the refresh token is not rotated. Return a refreshToken from §4.1 or the host has nothing to refresh with.
Revoking a token mid-session has no graceful path. Because neither widget has any 401 handling, a revoked token leaves both in a generic error loop with no recovery until the host performs a new exchange. If you revoke on ban, the host must be told to re-exchange or tear the widget down.
5. Messenger: Functional Requirements
What the backend must functionally provide for <goma-messenger>. The wire-level contract follows in §6–§7.
5.1 Friends
- Every user has a unique, shareable friend code (current format:
XXXX-XXXX; the client treats it as an opaque string and interpolates it into a lookup URL, so keep it URL-safe). - Users look up a user by friend code, then send a friend request (by code or by user ID). The target sees a pending request and approves or rejects it. A request toward a user who has already sent the reverse request SHOULD auto-accept (mutual intent).
- Establishing a friendship MUST create (or resurface) the pair's direct-message conversation.
- Users can remove a friend and can block a user. Blocking ends the friendship for both sides. Anti-enumeration invariant: every failed friend-request attempt (unknown code, self-add, a block in either direction, target deleted, …) MUST produce byte-identical rejections, and friend removal notifications MUST carry no reason: the product deliberately makes "blocked" indistinguishable from "not found" and "unfriended" (§7.3
friend_removed).
5.2 Conversations
- Two types:
dm(exactly two participants, created by friendship) andgroup(named, member-managed). - Groups: any user creates a group with a name (1–64 chars) and friends as initial members. The creator is
admin; others aremember. Admins rename the group, add members, remove members, and delete the group; any member can leave. A member cap SHOULD exist (suggested: 50). - The client loads the user's entire conversation list in one response (§6.1) with, per conversation: participants, last-message preview, and the user's unread count.
5.3 Messages
- Text messages plus an optional bet-card attachment (an opaque JSON payload, Appendix A). Sending happens over the realtime channel only: there is no REST send.
- The composer enforces 150 characters client-side; the backend MUST accept bodies of at least 150 characters and SHOULD enforce its own generous cap (suggested: 2000) against non-widget callers.
- History is fetched over REST with opaque-cursor pagination, newest-first (§6.2).
- Per-message emoji reactions: toggle semantics, aggregated as
{ emoji, userIds, count }. Reaction aggregates MUST be hydrated into history responses and broadcast authoritatively on every change. - Delivery states per message, per recipient:
sent → delivered → seen. The client aggregates per-recipient receipts; in groups a message shows "read" only when every recipient has seen it. - Unread counts are server-owned per (conversation, user) and reset when the client reports a read position (§7.2).
5.4 Presence and typing
- Binary online/offline presence. On joining a conversation's realtime room the client expects an online-users snapshot, then incremental updates. A user is "online" while they hold at least one realtime connection; brief disconnects SHOULD be debounced.
- Typing indicators: the client signals "typing" at most every 1.5 s while composing and never signals stop. The server MUST expire typing state itself (suggested TTL ≈ 3 s) and broadcast the complete current set of typing users on every
typing_startit receives, whether or not the set changed. The receiving client clears a room's indicator 2 s after the lasttyping_indicatorit saw, and only an inbound event re-arms that timer; a server that suppresses redundant broadcasts makes a continuously-typing peer flicker off every two seconds.
5.5 Notifications
- A persistent per-user notification feed (id, type, title, body, read flag, metadata, timestamp) plus realtime pushes of new entries.
- The client reacts specially to two types:
friend_request(re-fetches pending requests) andfriend_request_accepted(re-fetches the friend list, and this is the only signal when a re-added friendship reuses an existing DM, §11.6). Other types are displayed generically. - Read marking is per-notification (no bulk endpoint; the client fans out).
6. Messenger: REST Contract normative
All endpoints require Authorization: Bearer. Response tables list the fields the client reads; Required fields must be present with the exact name shown.
6.1 Conversation list
GET /social/v1/chatrooms
The user's complete conversation lobby. Called on every realtime (re)connect and re-called to hydrate newly created rooms.
| Response field | Type | Required | Notes |
|---|---|---|---|
chatrooms[] | array | Required | MUST contain all of the user's conversations: the client ignores next_cursor and never paginates this list. The reference server paginates this endpoint at 20 (max 50) and so does not satisfy this requirement: a user with more conversations silently loses rooms from the lobby, and never joins their socket rooms. |
next_cursor | string | null | Optional | Present for forward compatibility; ignored today. |
| Chatroom item field | Type | Required | Notes |
|---|---|---|---|
id | string | Required | Conversation ID: the room key for every realtime event. |
type | "dm" | "group" | Required | |
creatorUserId | string | Optional | camelCase (legacy naming; preserve exactly). Mapped but never read by the widget. |
createdAt | ISO 8601 | Optional | camelCase (legacy naming). The lobby sort falls back to it when a conversation has no last_message; an unparseable value degrades to zero rather than breaking. (The client's sort also names an updatedAt field, but nothing in the contract populates it, do not add one expecting it to be used.) |
group_name | string | null | Required | null for DMs. |
last_message | object | null | Required | Client reads .body and .created_at for the lobby preview. |
unread_count | number | Required | Messages from others since the user's read cursor (never count the user's own messages). |
participants[] | array | Required | Each: user_id, username (required); friend_code, avatar, role ("admin" | "member", default member) optional. |
6.2 Message history
GET /social/v1/chatrooms/{chatroomId}/messages ?cursor=…
Cursor-paginated history. First call carries no cursor; each response's next_cursor fetches the next older page (client infinite-scrolls upward), null ends history. Page size is the server's choice (suggested: 50).
| Response field | Type | Required | Notes |
|---|---|---|---|
messages[] | array | Required | MUST be sorted newest-first (descending created_at). |
next_cursor | string | null | Required | Opaque, URL-safe; echoed back unencoded. |
| Message field | Type | Required | Notes |
|---|---|---|---|
id | string | Required | Server-assigned, unique; dedupe key. |
conversation_id | string | Optional | Mapped but never read; the socket path ignores it entirely and uses the room id. |
sender_user_id | string | Required | Must equal the sender's user.id from §4.1. |
sender_username | string | Required | |
body | string | Required | |
bet_card_payload | object | null | Optional | Verbatim stored attachment (Appendix A). Non-null renders a bet card. |
created_at | ISO 8601 | Required | |
status | "sent" | "delivered" | "seen" | Optional | Aggregate delivery state (worst-case across recipients). |
reactions[] | array | Optional | { emoji: string, userIds: string[], count: number }; note the camelCase userIds, which may itself be absent and is defaulted. The client tolerates the whole array being absent, but it MUST be present whenever the message actually has reactions, or they will not render. |
6.3 Friends & blocks
GET /social/v1/friends
Accepted friends. Response: { "friends": [ … ] }; each item has these fields: user_id, username, friend_code, created_at (required); avatar (optional). Presence is not read from this response: it comes from the realtime channel only.
GET /social/v1/friends/pending
Incoming friend requests. Response: { "requests": [ … ] } (a missing/null array is tolerated); each item has these fields: id (the request ID, used for approve/reject), user_id (requester), username, friend_code, created_at.
POST /social/v1/friends
Send a friend request by code. Body: { "friend_code": string }. Response: { "connection": …, "conversation": { "id": string } | null }: the client only checks whether conversation is non-null (meaning the friendship completed immediately, e.g. mutual request) and then re-fetches §6.1. Every rejection whose cause would reveal whether the target exists or is reachable MUST be byte-identical. Unknown code, self-add, a block in either direction, a deleted or banned target, and rate-limit exhaustion all collapse to one constant response (suggested: a single 404 with a constant body). Rejections that only restate state the caller already holds (already friends, request already sent, a malformed code) may be distinguishable.
POST /social/v1/friends/by-user
Send a friend request by user ID (used to re-add a former friend). Body: { "user_id": string }. Response and anti-enumeration semantics identical to the by-code request.
POST /social/v1/friends/pending/{requestId}/approve
Approve a pending request (path param = the request ID from /friends/pending). Body: {}. Response: { "success": boolean, "conversation": { "id": string } | null }: non-null conversation triggers a lobby re-fetch.
DELETE /social/v1/friends/pending/{requestId}/reject
Reject a pending request. Response: any JSON (e.g. { "success": true }).
DELETE /social/v1/friends/{friendUserId}
Remove an accepted friend (path param = the friend's user ID, note the ID duality vs. pending requests). Side effect: emit friend_removed to the other party (§7.3).
POST /social/v1/blocks
Block a user. Body: { "blocked_user_id": string }. MUST end any friendship both sides; the other party learns of it only through the reason-less friend_removed event. Blocks MUST make future contact attempts fail indistinguishably from "not found".
DELETE /social/v1/blocks/{blockedUserId}
Unblock. Wired in the client API layer but not reachable from the current UI: implement for completeness (MAY).
GET /users/v1/code/{code}
Friend-code lookup. Not merely a preview: the client calls this immediately before sending the friend request and uses the result for both the success card and the host event, so a failure here means the request is never sent. Rate-limit it well above a realistic add-friend rate, or add-by-code stops working. The client sends the code untrimmed and un-normalised, so the backend SHOULD case-normalise. Response: { "user": { "id", "username", "friend_code", "avatar"? } }. Client reads id, username, avatar. SHOULD be rate-limited and MUST reject unknown/self/blocked codes identically (suggested: 10 lookups/hour/user, over-limit disguised as not-found). The reference server implements only the unknown/banned/deleted branches here: looking up your own code, or the code of someone who has blocked you, returns 200 with the user. The add-friend path checks both, so the leak is in the lookup alone.
6.4 Groups
Groups are addressed by their conversation ID throughout.
| Endpoint | Body | Response the client reads |
|---|---|---|
POST /social/v1/chatrooms | { "name": string, "friend_user_ids": string[] } | { "group": …, "conversation": { "id": string } }: client takes conversation.id, re-fetches the lobby, joins the room. |
PUT /social/v1/chatrooms/{id} | { "name": string } | Any JSON. Rename: admin only. Also broadcast group_updated (§7.3). |
POST /social/v1/chatrooms/{id}/leave | - | Any JSON. Broadcast member_removed (reason "left"). |
DELETE /social/v1/chatrooms/{id} | - | Any JSON. Delete group: admin only. Broadcast member_removed (reason "group_deleted"). |
POST /social/v1/chatrooms/{id}/members | { "friend_user_ids": string[] } | Any JSON. Admin only. Broadcast group_member_added per member. |
DELETE /social/v1/chatrooms/{id}/members/{userId} | - | Any JSON. Admin only. Broadcast member_removed (reason "removed" semantics; the field value the client branches on is described in §7.3). |
6.5 Notifications
GET /social/v1/notifications
Recent notifications, newest first (suggested cap: 50). Response: { "notifications": [ { "id", "type", "title", "body", "read": boolean, "metadata"?: object, "created_at" } ] }. Client-recognized type values: friend_request, friend_request_accepted (special handling), plus group_invite / mention / system rendered generically. The client tolerates a failure of this endpoint; implementations should not rely on this.
PUT /social/v1/notifications/{id}/read
Mark one notification read. No body. Response: any JSON. "Mark all" fans out one call per notification.
7. Messenger: Realtime Contract normative
7.1 Room model
After authenticating a connection the server associates it with the user. The client then explicitly joins one realtime room per conversation. Every conversation-scoped broadcast targets that conversation's room; user-scoped events (notifications, friend/DM lifecycle) target all of the user's connections.
7.2 Client → server events
None of these carry acknowledgement callbacks: outcomes flow back as separate events.
C → S social.chatrooms.join
Payload: { "id": string } (conversation ID). Join the conversation's room after verifying membership. The server MUST reply on the dynamic event social.chatroom.{id} with the online-users snapshot: { "users": [userId, …] }, and MUST NOT combine that snapshot with a messages key (§7.3). Emitted once per room per connection, and re-emitted for every room after a reconnect.
C → S social.chatrooms.message
Send a message. Payload: { "id": string, "message": string, "repliedMessage": null, "attachment": object | null, "toAi": false }. id is the conversation ID; attachment, when present, is the opaque bet-card object (Appendix A) to be stored as the message's bet_card_payload; repliedMessage and toAi are reserved fields, always null/false today.
On acceptance the server MUST: persist with a server-assigned ID and server timestamp; broadcast the message to the conversation room including the sender (the echo, §11.2) via social.chatroom.{id}; and SHOULD additionally emit message_delivered to the sender. There is no per-send ack; a silently dropped message leaves the sender's UI stuck at "sending".
C → S social.chatrooms.messages.read
Payload: { "id": string, "message_id": string }. Advance the user's read cursor in conversation id up to and including message_id, reset their unread_count, then emit read_receipt_updated to the room and conversation_read to the reader's other connections.
C → S social.chatrooms.message.react
Payload: { "conversationId": string, "messageId": string, "emoji": string }. Note: camelCase keys, unlike every other emit payload; preserve exactly. Toggle semantics: add the (user, emoji) reaction if absent, remove it if present. Then broadcast the authoritative aggregate via reaction_updated. Note: the client lets reaction emits queue through reconnects, so they may arrive in a burst after reconnection.
C → S typing_start
Payload: { "conversation_id": string }. Sent at most every 1.5 s while the user types; there is no stop event. Maintain a short-TTL typing set (≈3 s) per conversation and broadcast typing_indicator with the full current set on every received typing_start, changed or not (the receiver's own indicator expires after 2 s of silence), excluding the typist from their own broadcast if convenient (the client filters itself out anyway).
7.3 Server → client events
S → C social.chatroom.{conversationId} dynamic event name
One event name per conversation, carrying either of two payload variants: MUST NOT be combined in one payload (the client discriminates on which key is present):
- Presence snapshot (join reply):
{ "users": [userId, …] }. The online user IDs among the room's participants. - Message batch:
{ "messages": [message, …] }. One or more new messages in chronological order (the last element drives the lobby preview). Message shape = §6.2 withsender_usernameoptional (the client falls back to participant data). The sender's own echo SHOULD carrystatus: "sent".
| Event | Payload (fields the client reads) | Semantics |
|---|---|---|
message_delivered | { conversation_id, message_id, status } | To the sender: their message persisted. Redundant with the room echo; both are expected. |
read_receipt_updated | { conversation_id, message_id, user_id, status: "sent"|"delivered"|"seen" } | Per-recipient receipt, broadcast to the room. The client aggregates: a group message renders "read" only when every recipient reports seen (seen implies delivered). |
conversation_read | { conversation_id, last_read_message_id } | To the reader's other connections (multi-device sync): bulk-mark own view read up to the cursor. |
presence_updated | { user_id, is_online: boolean } | Incremental presence, sent to friends and conversation co-participants of the user. |
typing_indicator | { conversation_id, typing_users: string[] } | Full replacement of the conversation's typing set. An empty array clears. |
reaction_updated | { chatroom_id, message_id, reactions: [{emoji, userIds, count}] } | Authoritative full replace of one message's reactions. Note: the key is chatroom_id, not conversation_id; preserve exactly. |
notification | { id, type, title, body, metadata?, created_at } | New notification push (arrives unread). See §5.5 for the two specially-handled types. |
conversation_created | { conversation_id } | To both users when a DM is created. The client re-fetches the lobby and joins the room; idempotent. See §11.6 for the deliberate exception. |
friend_removed | { user_id } | To the party who did not act: the actor learns the outcome from its own HTTP response. MUST NOT carry a reason (unfriend vs. block indistinguishable). |
group_updated | { conversation_id, update_type: "name_changed", new_name } | Group rename. The client resolves the room by conversation_id; a payload identifying only the group cannot be applied. |
group_member_added | { conversation_id, member: { user_id, username?, avatar? } } | To the room and to the added user. If the receiving client is the added member, it re-fetches the lobby and joins the room. |
member_removed | { conversation_id, user_id?, reason? } | Client behavior: reason == "group_deleted" or user_id == self ⇒ drop the whole conversation locally; otherwise prune that participant. Suggested reason values: "left", "kicked", "group_deleted". |
7.4 Reconnect behavior
On every (re)connect the Messenger client re-fetches the lobby (§6.1), friends, pending requests, and notifications over REST, and re-joins every conversation room. The server needs no replay/backfill mechanism for Messenger, but must tolerate this burst on reconnection storms, and one detail makes it stricter than that sounds.
The four fetches run together and the room joins happen after they resolve, so a failure of any one of /social/v1/chatrooms, /social/v1/friends or /social/v1/friends/pending aborts the whole load and the client joins zero rooms: connected, but deaf until the next reconnect. Those three are effectively MUST-succeed on every reconnect; only the notifications call is insulated.
Note also that the client does not re-fetch history for an already-open room on reconnect, so messages sent during the outage are not backfilled into it. That is a known client gap rather than a licence to drop delivery.
8. Live Chat: Functional Requirements
8.1 Rooms and lifecycle
- One public chat room per match, keyed by a host-supplied game ID (an opaque string, in current deployments, the sportsbook event ID).
- Joining is explicit and MUST be idempotent: the widget silently re-joins on every page load once a user has joined a room (it persists a local flag). Join SHOULD auto-create the room on first join; join on a closed room MUST fail.
- When the match ends, the room closes: broadcast
game_chat_closed(§10.3), reject further joins/sends. Close MAY be triggered by an operator/admin action or a sports-data feed: that trigger is out of scope. - Participant counts are visible before joining (REST) and pushed live while joined.
8.2 Messages
- Text messages up to 500 characters (client-enforced; the backend SHOULD enforce the same cap) and bet-card messages (opaque payload, Appendix A). Message bodies are always non-empty: bet shares carry a default caption.
- Sending is realtime-only with a mandatory acknowledgement (§10.2): the client waits 10 s for the ack and rolls the message back on timeout.
- The client supplies an
idempotency_key(UUID) per send; the server SHOULD deduplicate on it and acknowledge replays as successful (deduplicated: true). - History: last 100 approved messages, newest-first, reactions hydrated. No scroll-back pagination is used by the widget.
- Reactions: same toggle + authoritative-aggregate model as Messenger, with its own event names (§10.2, §10.3).
- Bet-share cooldown: one bet-card per user per room per cooldown window (suggested: 600 s). The client keeps its own timer but the server is the authority: reject with
TICKET_COOLDOWN+retry_after_secondsand the client re-syncs to the server's value.
8.3 Moderation
- Text messages pass a server-side content-moderation decision before broadcast: approved messages broadcast to the room; rejected messages are acked as
blocked, never broadcast, and the sender receives amoderation_noticewith the violation category. (Whether the decision engine is rules, ML, or a vendor is the implementer's choice; suggestions in §12.5.) Bet-card messages skip content moderation. - Escalation ladder per (room, user), fully server-owned: repeated violations escalate
active → warned → muted (timed) → banned. Suggested policy: 1st violation warns, 2nd–3rd mute (e.g. 300 s), 4th+ bans for the room; a clean message while warned de-escalates to active. - Every state change MUST be pushed via
escalation_state_change(§10.3): including the transition back toactivewhen a mute expires. The client's own countdown is cosmetic: the mute banner ticks down to zero but nothing local releases the lock, which keys purely off the pushed state. If the server never pushes the un-mute, the banner sits at00:00and the composer stays dead. Muted/banned users' sends and reactions are rejected withUSER_MUTED_OR_BANNED. - Rate limiting: message sends and reactions are per-user rate-limited (suggested: 20 sends/min, 30 reactions/min) with
RATE_LIMITED+retry_after_secondsin the ack.
9. Live Chat: REST Contract normative
All endpoints require Authorization: Bearer. {gameId} is the host-supplied game ID.
Bodyless POSTs still carry Content-Type: application/json
The client sets Content-Type: application/json on every request, then sends no body at all on the bodyless POSTs below (/join, /leave). Fastify, NestJS and most strict JSON parsers reject that combination with a 400 before your handler runs.
Your backend MUST accept a zero-length body on these routes: treat it as {}. This is not theoretical: the reference server needed an explicit content-type parser override to survive it. Get this wrong and the join 400s, the client emits JOIN_FAILED, and the user never enters the room.
POST /game-chat/{gameId}/join
Join (or re-join) the room; auto-create on first join. No body.
| Response field | Type | Required | Notes |
|---|---|---|---|
room_id | string | Required | The room's internal ID: the client passes it back on realtime sends (§10.2). (On a message object the same field is informational and never read; it is required here, on the join response.) |
participant_count | number | Required | Current count including this join. |
chat_enabled | boolean | Optional | Whether chat is administratively enabled for the room. The client ignores it, so a disabled room still returns 2xx and renders a live composer: the user only discovers the state on their first send, which is rejected with CHAT_DISABLED. Return it for forward compatibility. |
MUST be idempotent per user. Join on a closed room MUST return a non-2xx: a 2xx would drop the user into a dead composer. Note the client cannot distinguish why a join failed: it never reads the REST error body, shows a generic error and stays on the join prompt. The ended state is reached only through the game_chat_closed socket event or the host's own call.
POST /game-chat/{gameId}/leave
Leave the room. No body; response any JSON. Client calls it best-effort (failures ignored).
GET /game-chat/{gameId}/messages ?limit=100
Message backfill, called on join and after every reconnect: always with limit=100. (A before cursor parameter is reserved in the client but never sent; the reference server's own cursor parameter is spelled cursor, so implement that name rather than before if you add pagination.)
| Response field | Type | Required | Notes |
|---|---|---|---|
messages[] | array | Required | Newest-first; only approved messages; reactions hydrated. Shape = the realtime message object (§10.3). A missing array is tolerated but return [] explicitly. |
GET /game-chat/{gameId}/participants/count
Pre-join participant count (shown on the join prompt). Response: { "count": number }.
The client calls this at bootstrap for every game, before anyone has joined, so for a match whose room does not exist yet it MUST return { "count": 0 } rather than a 404. (A failure here is swallowed silently, so a 404 is not fatal; it just means the join prompt shows no count. The reference server returns 404 ROOM_NOT_FOUND here and does not satisfy this requirement.)
POST /game-chat/{gameId}/messages reserved
A REST send. { "content", "idempotency_key"?, "attachment"? } → { "message": … }. Exists in the client's API layer but is never invoked at runtime (all sends are realtime). Implement MAY.
10. Live Chat: Realtime Contract normative
10.1 The message object
Used in broadcasts and REST backfill alike:
js
{
"id": "string", // server-assigned, unique
"room_id": "string",
"sender_id": "string", // MUST equal user.id from §4.1 for that user
"sender_username": "string",
"type": "text" | "bet_card",
"body": "string",
"bet_card_payload": { … }, // optional; verbatim relay (Appendix A)
"moderation_status": "approved", // optional; "blocked" is the other value,
// and the client renders it as a redacted message
"created_at": "ISO 8601",
"reactions": [ { "emoji": "…", "userIds": ["…"], "count": 1 } ] // optional
}10.2 Client → server events
C → S game_chat_join
Payload: { "game_id": string }. No ack is read. Emitted right after the REST join succeeds, and re-emitted on every reconnect. Associates this connection with the game's broadcast room.
C → S game_chat_send_message ack required
Payload: { "room_id": string, "body": string, "idempotency_key": string, "attachment"?: { "type": "bet_card", "payload": { … } } }. Note: addressed by room_id (from the REST join), not game_id. body is always non-empty.
The server MUST invoke the acknowledgement callback within the client's 10 s window, and MUST broadcast the persisted message to the room on every "ok" ack: including an idempotent replay marked deduplicated: true. Skipping the re-broadcast on a replay (to avoid showing others a duplicate) is the intuitive choice and it is wrong: the sender's optimistic bubble is cleared only by the echo, so it stays pending forever, cannot be reacted to, and then duplicates on the next reconnect backfill. The reference server takes exactly that wrong branch: both its realtime and REST paths return immediately after acking deduplicated: true, without broadcasting.
The ack takes one of three shapes.
Success: on an idempotent replay it MAY additionally carry message_id, approved and deduplicated: true:
json
{ "status": "ok" }Moderation-rejected:
json
{ "status": "blocked" }Failure: retry_after_seconds is normative only for TICKET_COOLDOWN:
json
{
"status": "error",
"error": {
"code": "RATE_LIMITED",
"message": "…",
"retry_after_seconds": 42
}
}Client behavior: "ok" keeps the optimistic message and waits for the room echo; anything else rolls it back. On error.code == "TICKET_COOLDOWN" with numeric retry_after_seconds, the client re-arms its bet-share cooldown to the server's value. Error codes in use: ROOM_NOT_FOUND, INVALID_PAYLOAD, RATE_LIMITED, TICKET_COOLDOWN, ROOM_CLOSED, CHAT_DISABLED, USER_MUTED_OR_BANNED, INTERNAL_ERROR (Appendix B).
On "ok" the server MUST then broadcast the persisted message to the room including the sender (§11.2), as game_chat_message or game_chat_bet_card. On "blocked" it MUST NOT broadcast, and SHOULD emit moderation_notice (+ escalation_state_change if the ladder advanced) to the sender.
C → S game_chat_react
Payload: { "game_id": string, "message_id": string, "emoji": string }. Toggle semantics. The client sends no ack callback: a rejected reaction is corrected only by the next game_chat_reaction_updated broadcast, so the server SHOULD broadcast the authoritative aggregate even when the toggle was a no-op.
C → S game_chat_leave
Payload: { "game_id": string }. Detach from the broadcast room; decrement the participant count. The REST leave follows separately.
10.3 Server → client events
| Event | Payload (fields the client reads) | Semantics |
|---|---|---|
game_chat_message | { message: <§10.1> } | New text message, to the whole room including the sender (the echo). |
game_chat_bet_card | { message: <§10.1> } | New bet-card message; identical client handling, type: "bet_card" + payload. |
game_chat_reaction_updated | { message_id, reactions: [...] } | Authoritative full replace of one message's reaction aggregate, to the whole room including the toggler. |
participant_count_update | { count: number } | Live count. The client applies it without filtering by game; the server MUST emit it only to connections joined to that room. SHOULD be debounced (suggested: ≤1 per 5 s per room). |
game_chat_closed | { game_id, room_id } | Room closed (match ended). Client latches a terminal state; idempotent on re-delivery. The client does filter this one by game_id. |
moderation_notice | { category: string } (message_id, decision, reason_text may accompany, all unread) | To the sender of a blocked message. Note the widget renders nothing from this event: it stores the category and re-emits it to the host page. The in-widget warning is driven entirely by escalation_state_change, so a backend that sends only this one gives the user no visible feedback and the message simply vanishes. |
escalation_state_change | { state: "active"|"warned"|"muted"|"banned", violation_count: number, muted_until: ISO 8601 when muted, room_id } | To the affected user. muted_until MUST be present whenever state is "muted": without it the mute banner renders a zero timer. The field names are exactly state and muted_until. The client mirrors this state verbatim into its composer lock; it never times out a mute locally, so the muted → active push at expiry is mandatory (§8.3). No published widget build reads any other spelling. |
The reference server does not currently conform on this event
Two divergences, both on the path that gates the composer lock. Verify them against your own build before assuming the moderation ladder works end-to-end:
- Field names. The reference emits
new_stateandmute_expires_at, while every published widget readsstateandmuted_until. The result is anundefinedescalation state: the composer never locks, the mute banner never appears, and the mute is unenforced client-side.state/muted_untilis the normative spelling: it is what the widgets read. - No un-mute push. The reference has no timer or queue keyed on
muted_until, and its only de-escalation path runs when a user sends a clean message, which a muted user cannot do, because the send is rejected before moderation runs. A mute therefore never expires on its own: the banner sits at00:00and the composer stays dead until something else resets the state.
Both are requirements of this document (§8.3). Do not treat the reference implementation as a worked example of this section.
10.4 Reconnect behavior
On reconnect the client re-emits game_chat_join and re-fetches GET /game-chat/{gameId}/messages?limit=100, merging by message ID. No server-side replay is needed; gaps longer than 100 messages are accepted product behavior.
11. Behavioral Invariants
Most requirements in this section are enforced by client behaviour and can be verified against a running widget; §11.5 is the exception, being a server-side discipline the widgets document rather than detect. They are grouped here because violations tend not to surface in basic testing.
11.1 JSON on every 2xx
Both widgets parse every successful REST response body unconditionally. A 204 No Content, or any bodyless 2xx, makes the successful operation register as a failure. Always return a JSON body.
11.2 Message echo and clock tolerance
Both widgets render sent messages optimistically and reconcile when the server's copy arrives in the room broadcast. That imposes three hard rules:
- The room broadcast for a new message MUST include the sender's own connection. Without the echo, Messenger messages hang at "sending" and Live Chat messages can never be reacted to.
- The echo MUST carry the body byte-identical to what the client sent. Server-side text rewriting (masking, trimming, entity-encoding) breaks the match. The symptom is not an immediate duplicate: the server's copy is discarded silently, the sender's own bubble stays at "sending" indefinitely, and a duplicate only surfaces later when a history page or reconnect backfill is merged alongside the still-pending local copy. Testing for a duplicate bubble will therefore suggest rewriting is safe when it is not. Moderate by accept/reject, never by rewrite.
- The echoed
created_atMUST be within ±10 s of the client's wall clock: i.e. stamp at receipt time with an NTP-accurate clock. - The echo's sender id MUST be byte-identical to the id the host configured the widget with. Reconciliation is gated on an exact string comparison before the body and timestamp checks run: Messenger compares
sender_user_idagainst itsuser-chat-idattribute, Live Chat comparessender_idagainstuser-id. Both of which come fromuser.idin the §4.1 exchange. Echo an equivalent-but-different form (an internal id rather than the exchange id, a numeric rather than a string, different casing) and the sender's own message is classified as a peer's: appended as a genuine duplicate bubble, and counted into the unread badge.
11.3 Newest-first history
Every history/backfill response (/social/v1/chatrooms/{id}/messages and /game-chat/{gameId}/messages) MUST be sorted newest-first by created_at. The two clients then behave differently, and both requirements are real. Messenger does not sort: it simply reverses the page, so a page in any other order renders in that wrong order, and its cursor pagination is anchored on this ordering. Live Chat does sort ascending, so ordering cannot break it; its requirement is instead that a ?limit=100 backfill returns the newest hundred, not the oldest: returning the oldest ascending would show ancient messages with no visible ordering defect at all.
11.4 Exact names, including inconsistencies
The contract contains naming inconsistencies that MUST be reproduced as specified rather than normalized: camelCase creatorUserId/createdAt inside the chatroom list (§6.1); camelCase keys on the social.chatrooms.message.react emit (§7.2); chatroom_id (not conversation_id) on reaction_updated (§7.3); userIds inside reaction aggregates; state/muted_until on escalation_state_change (§10.3); Live Chat sends addressed by room_id while every other game event uses game_id.
The worst of them, because it is the one most likely to be "tidied" by an implementer: the conversation id is spelled four different ways across the Messenger socket contract, and every spelling is load-bearing.
| Spelling | Where |
|---|---|
id | social.chatrooms.join, social.chatrooms.message, social.chatrooms.messages.read |
conversation_id | typing_start, and inbound typing_indicator / read_receipt_updated / message_delivered / conversation_read / group_* |
conversationId | the reaction emit |
chatroom_id | reaction_updated |
social.chatrooms.messages.read mixes two of them in one payload: { id, message_id }.
Two further traps: the message emit is camelCase like the reaction emit ({ id, message, repliedMessage, attachment, toAi }, note message rather than body) and the group events are asymmetrically named. Additions are group_member_added, but removals are member_removed, not group_member_removed. Normalising that one silently breaks group leave, kick and delete pruning.
11.5 Privacy invariants
- All friend-request rejections byte-identical; friend-code lookup failures (including rate-limit) indistinguishable from not-found (§6.3).
friend_removedcarries no reason: ever.
11.6 DM lifecycle corner case
Emit conversation_created to both users when a DM conversation is created. When a former friendship is re-established and the old DM is reused, do not emit it: the requester's client learns of the restored friendship only via the friend_request_accepted notification, and that path is intentional.
11.7 Scoping and fan-out
Only one Live Chat listener filters by room: game_chat_closed. Every other inbound event is applied unconditionally to the room the widget is currently showing, so over-broad fan-out is not merely noisy, it is incorrect:
| Event | What over-broad fan-out does |
|---|---|
game_chat_message, game_chat_bet_card | Another room's message is appended to this room's feed and re-emitted to the host: a content leak |
moderation_notice, escalation_state_change | A mute earned in another room locks this room's composer |
participant_count_update | Another room's count is displayed |
Messenger notification | Leaked notifications, plus spurious REST refetches on the friend-request branches |
All of the above MUST be scoped to the intended recipients. Two events are self-filtering and need only a SHOULD: reaction updates no-op when the message is not held locally, and friend_removed is matched against the local friend list.
Two further fan-out requirements apply to the deployment rather than to any one event:
- All room broadcasts MUST traverse gateway instances (shared adapter / bus). A broadcast limited to one node behaves correctly in single-instance environments and drops messages once multiple instances are deployed.
- Presence updates go to the user's friends and conversation co-participants; presence offline SHOULD fire only when the user's last connection drops (multi-tab).
11.8 No client retries
Messenger has deliberately no resend queue and no idempotency key: a send emitted while the socket is already down is surfaced immediately as a failed message rather than risking duplicates. Note the gap this leaves: Messenger has no ack and no send timeout (unlike Live Chat's 10 s ack window), so a send that leaves a live socket and is then dropped server-side sits at "sending" forever, with nothing surfaced to either the user or the host. The manual "tap to try again" affordance is inert. The transport between gateway and persistence SHOULD therefore use acknowledged writes; a message that is accepted but not persisted cannot be recovered by any client. Live Chat does send idempotency keys; honor them (§8.2).
12. Suggested Implementation non-normative
This section is informative. It describes one workable shape for the service; none of it is required.
12.1 Component sketch
- HTTP API service: stateless, horizontally scalable; owns REST, auth exchange, and social-graph writes.
- Realtime gateway: terminates Socket.IO connections. Either use a native Socket.IO server library (Node.js, Go, Python, Java implementations exist) or run a thin Node gateway whose only job is protocol termination, forwarding domain events to/from the core over the bus. Requires a shared fan-out layer (e.g. a Redis-based Socket.IO adapter, or bus-driven room routing) when more than one instance is deployed, and sticky routing for the polling transport.
- Event bus (e.g. Kafka). A natural fit behind the edge: topics per domain (
chat.messages,chat.receipts,presence,gamechat.messages,moderation.decisions,notifications), with the gateway consuming broadcast events and the workers consuming writes. Note the latency budget: a send must complete moderation, persistence, and broadcast well inside the Live Chat 10 s ack window; a target under 500 ms is recommended. - Persistence: a relational store fits the data (entities in §12.3); message tables are append-heavy, index on
(conversation_id, created_at desc). - Ephemeral state: a TTL store (Redis or equivalent) for presence keys, typing sets, rate-limit windows, cooldowns, and idempotency keys.
- Moderation service: see §12.5.
12.2 Delivery pipeline for one social message
- Gateway receives
social.chatrooms.message; validates membership, block state, rate limit, length. - Persist with server ID + server timestamp; update the conversation's last-message and every other participant's unread count.
- Broadcast the message batch on
social.chatroom.{id}to the room (sender included); emitmessage_deliveredto the sender. - For each participant not currently in the room, apply any offline/notification treatment the implementation supports (optional; the widgets only require the room path).
12.3 Suggested data model
| Entity | Key fields |
|---|---|
| users | id, username (unique), friend_code (unique, retired codes never reused), account_status, provider + external_id (unique pair), created_at |
| friend_connections | ordered user pair (unique), requester_id, status: pending | active | removed | blocked, timestamps |
| blocks | blocker_user_id + blocked_user_id (unique pair) |
| conversations | id, type: dm | group, group_id?, creator_user_id, last_message_at, created_at |
| conversation_participants | (conversation_id, user_id), role, joined_at, left_at |
| messages | id, conversation_id, sender_user_id, type: text | bet_card, body, bet_card_payload (json), created_at |
| read state | per (conversation, user): last_read_message_id + unread_count; per (message, recipient): delivered/seen receipts |
| message_reactions | (message_id, user_id, emoji) |
| groups | id, conversation_id, name, creator_user_id, member_limit |
| notifications | id, user_id, type, title, body, read, metadata (json), created_at |
| game_chat_rooms | id, game_id (unique), status: active | closed, chat_enabled, participant_count, created_at, closed_at |
| game_chat_messages | id, room_id, sender_user_id, type: text | bet_card, body, bet_card_payload, moderation_status: approved | blocked, moderation_category, created_at |
| game_chat_reactions | (message_id, user_id, emoji) |
| moderation_states | (room_id, user_id): escalation_state, violation_count, banned_at, mute_expires_at |
Blocked game-chat messages are worth persisting (flagged, hidden from history) for audit and appeal workflows.
12.4 Suggested limits
| Control | Suggested value | Surface on limit |
|---|---|---|
| Auth exchange | 60 / min / IP | HTTP 429 |
| Social message sends | 30 / min / user | drop + (optionally) notification; no ack channel exists |
| Game-chat sends | 20 / min / user | ack RATE_LIMITED + retry_after_seconds |
| Game-chat reactions | 30 / min / user | rejected silently, and the client passes no ack callback on react, so nothing surfaces. The server SHOULD still broadcast the authoritative aggregate on a rejection, or the client's optimistic pill stays visibly wrong indefinitely |
| Friend-code lookups | 10 / hour / user | disguised as not-found (§11.5) |
| Bet-share cooldown | 600 s / user / room | ack TICKET_COOLDOWN + retry_after_seconds |
| Message length | 150 chars social · 500 chars game chat (the shipped composers' own caps) | reject |
| Group members | 50 | HTTP 4xx on add |
| Reactions per message | 20 distinct emoji | ignore beyond cap (clients cap locally too) |
12.5 Moderation pipeline
Run the decision synchronously in the send path (the ack depends on it): normalize → classify (keyword/rule lists are sufficient for a first version; an ML or vendor classifier can replace them behind the same interface) → decide. On rejection: persist flagged, ack blocked, push moderation_notice, advance the ladder, push escalation_state_change. Schedule the un-mute push at muted_until: a timer/queue entry per mute, surviving process restarts. Fail-closed on classifier errors (reject rather than let unclassified content through), but do not advance the ladder on classifier faults.
12.6 Presence and typing mechanics
Presence: a TTL key per user (e.g. 30 s) refreshed by connection heartbeats; broadcast presence_updated on first connection up and last connection down (coalesce brief disconnects, ~1 s). Typing: TTL key per (conversation, user) of ~3 s, refreshed on each typing_start; broadcast the full set on every received typing_start and on expiry. Broadcasting only on insert/expiry would let a continuously-typing peer's indicator lapse after the receiver's 2 s local timeout.
13. Compliance Checklist
13.1 Self-verification with the real widgets
The implementation can be verified end-to-end against the production widgets, without a dedicated test harness. Serve a page that embeds them:
html
<goma-messenger
api-url="https://your-backend.example" socket-url="https://your-backend.example"
auth-token="{accessToken}" socket-auth-token="{accessToken}"
user-chat-id="{user.id}" username="{displayName}"
></goma-messenger>
<goma-live-chat
api-url="https://your-backend.example" socket-url="https://your-backend.example"
auth-token="{accessToken}" socket-auth-token="{accessToken}"
game-id="test-match-1" user-id="{user.id}" username="{displayName}"
></goma-live-chat>where {accessToken} and {user.id} are obtained from /auth/exchange. Verify the checklist below using two browser sessions signed in as different users.
13.2 Checklist
- Auth: exchange returns a working token; REST calls with it succeed; the realtime handshake authenticates from
auth.authTokenor queryauthToken. Verify this explicitly withsocket-auth-tokenomitted, since a backend reading onlyauth.tokenpasses an all-attributes-set test and still breaks real hosts; bad tokens are rejected at handshake. - Friends: code lookup shows the user; request → pending on the other side → approve creates the DM on both screens without a reload; reject works; mutual requests auto-accept; remove disappears on both sides; block behaves exactly like remove from the other side's view; a blocked user's new request fails identically to a wrong code.
- DMs: a sent message appears instantly for both users; the sender's message leaves "sending" (the echo, §11.2); ticks advance sent → delivered → read as the peer opens the room; unread badge counts only the peer's messages and resets after opening; history scroll-back pages correctly and ends cleanly.
- Groups: create (appears for all members live), rename (updates live), add member (the new member sees the room live), remove member / leave / delete (rooms prune or vanish correctly per §7.3).
- Reactions: toggling updates both screens with correct counts and own-highlighting; the toggler's own screen matches after the broadcast; history reload preserves reactions.
- Presence & typing: opening a DM shows the peer online iff connected; second tab doesn't flap presence; typing indicator appears within ~2 s and clears by itself.
- Notifications: friend request creates a push + a feed entry; accepted request notifies the requester (and restores a re-added friendship's DM per §11.6); mark-read persists.
- Live Chat join: join prompt shows a pre-join count; join succeeds, history (≤100, oldest at top after render) appears with reactions; reload auto-rejoins silently; counts tick live in other participants' views.
- Live Chat send: messages appear for all participants including the sender (echo); a clean message never duplicates the sender's own bubble (clock within ±10 s); a rejected message rolls back with the right error; rate-limit and cooldown acks carry
retry_after_seconds. - Moderation: a violating message is acked
blocked, never appears for others, warns the sender with a category; repeated violations mute (composer locks) and the composer unlocks by itself when the mute expires (the push, §8.3); further violations ban. - Bet cards: a shared ticket renders identically for sender and receivers after echo, reload, and history backfill (payload relayed verbatim); a second share inside the cooldown is rejected with
TICKET_COOLDOWN: note the client enforces its own cooldown first (default 600 s, persisted), so to reach the server's ack you must clear local storage or set the client cooldown below the server's. - Room close: closing the room flips every participant to the ended state; joins and sends are rejected afterwards; re-delivered close events are harmless.
- Resilience. Interrupt the connection: both widgets reconnect, re-join, and backfill without duplicates; with two gateway instances, a message sent through one reaches users connected to the other; every DELETE/PUT success returns JSON (no 204 anywhere).
A. Appendix: Bet-Card Payload
The bet-card payload is opaque to the backend: store the object exactly as received (message attachment / attachment payload) and return it verbatim as bet_card_payload in broadcasts and history. Never validate, normalize, or rewrite its fields: widget versions read it with tolerant parsers, and server-side modification of the payload is the main compatibility risk. Size guidance: accept payloads up to a few KB (a 10 KB limit is sufficient).
Representative example (for sizing and storage decisions only, not a schema to enforce):
json
{
"fromUser": "9f1c…",
"type": "bet",
"value": "ticket-812",
"content": {
"id": "ticket-812",
"total_odds": 4.35,
"bet_type": "multiple",
"status": "OPEN",
"totalBetAmount": 10,
"max_winning": 43.5,
"betShareToken": "…",
"copied_count": 3,
"bet_selections": [
{
"eventId": "…",
"outcomeId": "…",
"bettingTypeId": "…",
"eventPartId": "…",
"home_team": "…",
"away_team": "…",
"sport": "Football",
"sportId": "1",
"sport_icon_id": "…",
"market_name": "Match Result",
"outcome_name": "Home",
"competition": "…",
"status": "open",
"home_score": null,
"away_score": null,
"price_value": 2.1,
"venue_id": "PT",
"event_date": "2026-08-30T19:00:00Z"
}
]
}
}bettingTypeId and eventPartId are the load-bearing pair: the recipient host puts them straight into its place-bet payload, and the provider rejects the entire slip if bettingTypeId is missing. They are frequently absent on cards shared from a source that had no sportsbook identity to give, so treat every one of them as optional on read, but never drop one that is present.
B. Appendix: Error Code Registry
Codes the clients react to by name (realtime acks, normative), plus the suggested REST vocabulary.
| Code | Channel | Meaning / client reaction |
|---|---|---|
RATE_LIMITED | Live Chat send and react acks | Too many sends; message rolled back. retry_after_seconds may accompany it but the client ignores it here: only TICKET_COOLDOWN consumes it. |
TICKET_COOLDOWN | Live Chat send ack | Bet-share cooldown active; client re-arms its cooldown ring from retry_after_seconds (normative). |
ROOM_CLOSED | Live Chat send and react acks | Room is closed; message rolled back. |
CHAT_DISABLED | Live Chat send and react acks | Chat administratively disabled for the room. |
USER_MUTED_OR_BANNED | Live Chat send and react acks | Sender is muted/banned in this room (the state itself arrives via escalation_state_change). |
ROOM_NOT_FOUND | Live Chat send and react acks | Unknown room ID. |
INVALID_PAYLOAD | Live Chat send and react acks | Malformed send. |
INTERNAL_ERROR | Live Chat send and react acks | Server fault; message rolled back. Also the code an over-long game-chat body surfaces as, since the realtime send performs no length check and the database column rejects it. |
MESSAGE_BODY_EMPTY | Messenger realtime send; Live Chat REST send (HTTP 400) | Empty body. The Live Chat realtime send returns INVALID_PAYLOAD for this instead. |
MESSAGE_TOO_LONG | Messenger realtime send | Body over the 2000-character server cap. The Live Chat send ack has no length code: see INTERNAL_ERROR above. |
NOT_A_MEMBER, MESSAGE_NOT_FOUND | Live Chat react ack | Rejected reaction. The client passes no callback on react, so these are for operators, not clients. |
INVALID_REQUEST | REST error envelope | The reference server's actual code for a malformed Live Chat REST request: more common in its logs than VALIDATION_ERROR. |
CATALOG_UNAVAILABLE | REST error envelope | Emote catalogue unreachable. Reachable but unused: no widget calls the emote endpoint. |
ACCOUNT_SUSPENDED, UNAUTHORIZED, VALIDATION_ERROR, NOT_FOUND, FORBIDDEN, … | REST error envelope | Suggested vocabulary only: the widgets read neither REST status codes nor bodies (§3.3). |
Codes no backend should send
A third family exists that implementers mistake for part of this contract. These are synthesised by the widgets themselves and surfaced to the host page as gc:error; no backend emits them and none should try:
SOCKET_CONNECT_ERROR · SOCKET_RECONNECT_FAILED · SOCKET_SEND_DROPPED · API_LOAD_FAILED · MISSING_ATTRIBUTE · SEND_TIMEOUT (the client's own 10 s ack timer) · SEND_FAILED (the fallback when an ack carries no error.code) · JOIN_FAILED
GomaChat Backend Service: Integration Specification · v1.0 · 2026-08-24 · Goma Gaming (GomaPulse team). Derived from the deployed behavior of <goma-messenger> 0.7.x and <goma-live-chat> 0.2.x.