Skip to content

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:

  1. REST over HTTPS: session exchange, resource reads, and social-graph mutations.
  2. 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) ​

PropertyValue the client usesRequirement
Namespacedefault (/)Serve the default namespace.
Path/socket.io/Serve the Socket.IO handshake on this path.
Transports['polling', 'websocket']: HTTP long-polling first, then upgradeHTTP 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.
Reconnection10 attempts, delay 1 s → max 30 sAfter a successful reconnect, clients re-join rooms and re-fetch state (see §7.4, §10.4).
AcknowledgementsMessenger: 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-chat makes it handshake at https://host/socket.io/ and request the namespace /goma-chat, which a backend serving only the default namespace rejects with Invalid 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 bodyless 204 No Content makes 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/json on every request, including bodyless GET and DELETE calls. That makes every call non-simple, so cross-origin deployments MUST answer OPTIONS preflights with Access-Control-Allow-Methods covering GET/POST/PUT/DELETE and Access-Control-Allow-Headers covering Authorization, Content-Type and any x-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 Authorization and Content-Type (and optionally x-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: 400 invalid request, 401 unauthenticated, 403 forbidden, 404 not found, 409 conflict, 422 semantic rejection, 429 rate limited, 5xx server 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_at is 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 fieldTypeRequiredNotes
providerstringRequiredIdentity-provider key. Current deployments send "everymatrix"; the design SHOULD keep providers pluggable.
sessionIdstringRequiredThe 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).
usernamestringOptionalDisplay-name seed used only when provisioning a brand-new chat user; ignored for existing users.
Response fieldTypeRequiredNotes
accessTokenstringRequiredBearer token for all subsequent REST calls and the realtime handshake. Opaque to the client.
user.idstringRequiredThe 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.friendCodestringRequiredThe user's shareable friend code (§5.1). Note the camelCase.
user.usernamestringOptionalResolved display name.
refreshTokenstringOptionalRequired 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 static x-api-key header 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:

    SlotCarriesSource attribute
    auth.authTokenthe access tokenauth-token
    query ?authToken=…the access tokenauth-token
    auth.tokena separate socket tokensocket-auth-token

    The backend MUST authenticate from auth.authToken or the authToken query parameter: those two always carry the access token. It SHOULD additionally accept auth.token, which carries socket-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 from auth.token rejects every host that does not set socket-auth-token. If you do read auth.token first. As the reference does. Fall back to auth.authToken / ?authToken when it fails to verify, or a host that legitimately sets socket-auth-token to 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) and group (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 are member. 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_start it receives, whether or not the set changed. The receiving client clears a room's indicator 2 s after the last typing_indicator it 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) and friend_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 fieldTypeRequiredNotes
chatrooms[]arrayRequiredMUST 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_cursorstring | nullOptionalPresent for forward compatibility; ignored today.
Chatroom item fieldTypeRequiredNotes
idstringRequiredConversation ID: the room key for every realtime event.
type"dm" | "group"Required
creatorUserIdstringOptionalcamelCase (legacy naming; preserve exactly). Mapped but never read by the widget.
createdAtISO 8601OptionalcamelCase (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_namestring | nullRequirednull for DMs.
last_messageobject | nullRequiredClient reads .body and .created_at for the lobby preview.
unread_countnumberRequiredMessages from others since the user's read cursor (never count the user's own messages).
participants[]arrayRequiredEach: 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 fieldTypeRequiredNotes
messages[]arrayRequiredMUST be sorted newest-first (descending created_at).
next_cursorstring | nullRequiredOpaque, URL-safe; echoed back unencoded.
Message fieldTypeRequiredNotes
idstringRequiredServer-assigned, unique; dedupe key.
conversation_idstringOptionalMapped but never read; the socket path ignores it entirely and uses the room id.
sender_user_idstringRequiredMust equal the sender's user.id from §4.1.
sender_usernamestringRequired
bodystringRequired
bet_card_payloadobject | nullOptionalVerbatim stored attachment (Appendix A). Non-null renders a bet card.
created_atISO 8601Required
status"sent" | "delivered" | "seen"OptionalAggregate delivery state (worst-case across recipients).
reactions[]arrayOptional{ 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.

EndpointBodyResponse 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 with sender_username optional (the client falls back to participant data). The sender's own echo SHOULD carry status: "sent".
EventPayload (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_seconds and 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 a moderation_notice with 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 to active when 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 at 00:00 and the composer stays dead. Muted/banned users' sends and reactions are rejected with USER_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_seconds in 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 fieldTypeRequiredNotes
room_idstringRequiredThe 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_countnumberRequiredCurrent count including this join.
chat_enabledbooleanOptionalWhether 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 fieldTypeRequiredNotes
messages[]arrayRequiredNewest-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 ​

EventPayload (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_state and mute_expires_at, while every published widget reads state and muted_until. The result is an undefined escalation state: the composer never locks, the mute banner never appears, and the mute is unenforced client-side. state / muted_until is 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 at 00:00 and 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_at MUST 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_id against its user-chat-id attribute, Live Chat compares sender_id against user-id. Both of which come from user.id in 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.

SpellingWhere
idsocial.chatrooms.join, social.chatrooms.message, social.chatrooms.messages.read
conversation_idtyping_start, and inbound typing_indicator / read_receipt_updated / message_delivered / conversation_read / group_*
conversationIdthe reaction emit
chatroom_idreaction_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_removed carries 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:

EventWhat over-broad fan-out does
game_chat_message, game_chat_bet_cardAnother room's message is appended to this room's feed and re-emitted to the host: a content leak
moderation_notice, escalation_state_changeA mute earned in another room locks this room's composer
participant_count_updateAnother room's count is displayed
Messenger notificationLeaked 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 ​

  1. Gateway receives social.chatrooms.message; validates membership, block state, rate limit, length.
  2. Persist with server ID + server timestamp; update the conversation's last-message and every other participant's unread count.
  3. Broadcast the message batch on social.chatroom.{id} to the room (sender included); emit message_delivered to the sender.
  4. 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 ​

EntityKey fields
usersid, username (unique), friend_code (unique, retired codes never reused), account_status, provider + external_id (unique pair), created_at
friend_connectionsordered user pair (unique), requester_id, status: pending | active | removed | blocked, timestamps
blocksblocker_user_id + blocked_user_id (unique pair)
conversationsid, type: dm | group, group_id?, creator_user_id, last_message_at, created_at
conversation_participants(conversation_id, user_id), role, joined_at, left_at
messagesid, conversation_id, sender_user_id, type: text | bet_card, body, bet_card_payload (json), created_at
read stateper (conversation, user): last_read_message_id + unread_count; per (message, recipient): delivered/seen receipts
message_reactions(message_id, user_id, emoji)
groupsid, conversation_id, name, creator_user_id, member_limit
notificationsid, user_id, type, title, body, read, metadata (json), created_at
game_chat_roomsid, game_id (unique), status: active | closed, chat_enabled, participant_count, created_at, closed_at
game_chat_messagesid, 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 ​

ControlSuggested valueSurface on limit
Auth exchange60 / min / IPHTTP 429
Social message sends30 / min / userdrop + (optionally) notification; no ack channel exists
Game-chat sends20 / min / userack RATE_LIMITED + retry_after_seconds
Game-chat reactions30 / min / userrejected 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 lookups10 / hour / userdisguised as not-found (§11.5)
Bet-share cooldown600 s / user / roomack TICKET_COOLDOWN + retry_after_seconds
Message length150 chars social · 500 chars game chat (the shipped composers' own caps)reject
Group members50HTTP 4xx on add
Reactions per message20 distinct emojiignore 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.authToken or query authToken. Verify this explicitly with socket-auth-token omitted, since a backend reading only auth.token passes 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.

CodeChannelMeaning / client reaction
RATE_LIMITEDLive Chat send and react acksToo many sends; message rolled back. retry_after_seconds may accompany it but the client ignores it here: only TICKET_COOLDOWN consumes it.
TICKET_COOLDOWNLive Chat send ackBet-share cooldown active; client re-arms its cooldown ring from retry_after_seconds (normative).
ROOM_CLOSEDLive Chat send and react acksRoom is closed; message rolled back.
CHAT_DISABLEDLive Chat send and react acksChat administratively disabled for the room.
USER_MUTED_OR_BANNEDLive Chat send and react acksSender is muted/banned in this room (the state itself arrives via escalation_state_change).
ROOM_NOT_FOUNDLive Chat send and react acksUnknown room ID.
INVALID_PAYLOADLive Chat send and react acksMalformed send.
INTERNAL_ERRORLive Chat send and react acksServer 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_EMPTYMessenger realtime send; Live Chat REST send (HTTP 400)Empty body. The Live Chat realtime send returns INVALID_PAYLOAD for this instead.
MESSAGE_TOO_LONGMessenger realtime sendBody over the 2000-character server cap. The Live Chat send ack has no length code: see INTERNAL_ERROR above.
NOT_A_MEMBER, MESSAGE_NOT_FOUNDLive Chat react ackRejected reaction. The client passes no callback on react, so these are for operators, not clients.
INVALID_REQUESTREST error envelopeThe reference server's actual code for a malformed Live Chat REST request: more common in its logs than VALIDATION_ERROR.
CATALOG_UNAVAILABLEREST error envelopeEmote catalogue unreachable. Reachable but unused: no widget calls the emote endpoint.
ACCOUNT_SUSPENDED, UNAUTHORIZED, VALIDATION_ERROR, NOT_FOUND, FORBIDDEN, …REST error envelopeSuggested 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.