Skip to content

Chat identity and the provider model ​

<goma-live-chat> and <goma-messenger> both take an opaque chat token on an attribute and validate nothing. Neither widget knows what an operator session is, how to log a player in, or how to refresh anything. That is deliberate: it keeps the widgets independent of any one platform.

That leaves exactly one question, and it is the one this page answers: where does the chat token come from?

The answer is a single endpoint on the chat service, POST /auth/exchange, which trades a platform identity for a chat identity. Which platform it understands is pluggable. EveryMatrix ships as the first provider; support for another platform is added behind the same endpoint, which does not change.

The mental model ​

How a chat token is obtained

  1. Operator login
    Your platform, your format. GomaPulse is not involved.
  2. Operator session
    The session id your page already holds after login.
  3. POST /auth/exchange (note)
    The provider verifies that session against the platform, server-side.
  4. Chat token (good)
    Set on the widget's auth-token and socket-auth-token attributes.

The chat service never trusts the browser's claim about who the player is. It re-derives that from the platform, server-side, on every exchange.

These four numbers govern the whole flow and are easy to miss in prose, so they are collected here:

Numbers that govern the flow

Platform verification timeout
5s (caution)
One attempt, no retry
Exchanges per IP
60 / min (caution)
Shared bucket behind a load balancer
Access token lifetime
1 hour (note)
Default; set per deployment
Refresh token lifetime
7 days (note)
Default; not revocable

Two paths, and the rule that separates them ​

This is the part worth understanding before anything else, because it determines whether your integration needs a shared secret.

A provider answers an exchange with an identity carrying a verified flag, and the route reads that flag as a security switch:

PathRequest body carriesverifiedx-exchange-key headerSafe to call from
SessionsessionIdtruenot requireda browser
Caller-assertedexternalId + usernamefalserequiredserver-side only

On the session path the provider proves the identity against the platform, so the caller's own trustworthiness stops mattering and no secret is needed. On the caller-asserted path the service is taking someone's word for it, and the shared key is the only thing standing between the endpoint and anyone who wants a token for any identity.

Never put x-exchange-key in front of a public path

Whoever holds that key can mint a token for any identity. An earlier design had a dev proxy inject it so the browser could assert its own identity; that could never be deployed, because the same injection in a public proxy turns the endpoint into a token-minting oracle.

If your caller is a browser, use the session path. If you use the caller-asserted path, keep the caller server-side and network-restricted, and rotate the key.

Use the session path unless you genuinely have no platform session to offer.

The exchange endpoint ​

Request ​

Paths on this page are relative to the chat service's own origin. In the reference deployment the host proxies the service same-origin under /goma-chat, so a browser actually calls POST /goma-chat/auth/exchange: the same prefix the widgets take as api-url. Adjust for however you mount it.

http
POST /auth/exchange
Content-Type: application/json

{
  "provider": "everymatrix",
  "sessionId": "a1b2c3d4-e5f6-4789-a0b1-c2d3e4f5a6b7",
  "username": "player_one"
}
FieldTypeRequiredNotes
providerstringyesRegistry key. everymatrix is the only value today.
sessionIdstring-The platform session. Present ⇒ session path.
externalIdstring-The platform's stable user id, caller-asserted. Requires the key and a username.
usernamestring-Display seed only. See What username does.

Every field but provider is optional at the schema level, because which fields are needed is the provider's decision rather than the schema's. A body satisfying neither path is rejected by the provider.

Response ​

json
{
  "accessToken":  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…",
  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…",
  "user": {
    "id": "11111111-1111-1111-1111-111111111111",
    "username": "real_name",
    "friendCode": "ABCD-EF23",
    "accountStatus": "active",
    "createdAt": "2026-01-01T00:00:00.000Z"
  }
}

Feed these to the widgets as:

Response fieldWidget attribute
accessTokenauth-token and socket-auth-token
user.iduser-chat-id (messenger) / user-id (live chat)
user.usernameusername
user.friendCodeuser-code (messenger only)

user.id is a chat-side UUID, not the platform's user id. friendCode is an XXXX-XXXX code on an ambiguity-free alphabet (ABCDEFGHJKNPQRSTUVWXYZ2345678, excluding I, L, M, O, 0, 1 and 9) generated once at user creation and stable thereafter. Build any input mask from that charset, not from the exclusion list.

accountStatus is one of active, suspended, banned or deleted, but a successful exchange can only ever return active: the other states are rejected before a token is minted, as the error table below shows. createdAt is informational. Neither needs to be fed to a widget.

user-id means something different on each widget

<goma-live-chat> takes the chat user id as user-id. <goma-messenger> takes it as user-chat-id, and separately accepts an optional user-id that means the platform's user id and is read by nothing.

So setting user-id on the messenger silently leaves its chat identity empty. Every "is this my message?" check compares against user-chat-id, so the player's own messages render as someone else's. Feed both widgets the same user.id, under each one's own attribute name.

Run the exchange once per player, not once per widget. If you mount both chat widgets, exchange once and feed both the same result. This is not an efficiency note: a second exchange mints a second chat identity, and the same person would author messages as two strangers across the two surfaces.

Errors ​

StatuscodeCause
400VALIDATION_ERRORMalformed body. Carries a details object.
400UNKNOWN_PROVIDERNo provider registered under that name. Case-sensitive.
401INVALID_IDENTITYThe provider rejected the identity: see the caveat below.
401UNAUTHORIZEDCaller-asserted path with a missing or wrong x-exchange-key.
403ACCOUNT_SUSPENDEDThe chat account is banned or suspended.
401UNAUTHORIZEDThe chat account was deleted.
429RATE_LIMITEDMore than 60 exchanges from one IP in 60 seconds. No Retry-After header: back off on your own schedule.
500INTERNAL_ERRORA database fault, including a username collision the service could not resolve.

All bodies are shaped { "error": { "code", "message", "details"? } }. The two UNAUTHORIZED rows share a code and differ only in message ("Invalid exchange key" versus "Account no longer exists") so branch on the message if you need to tell them apart.

401 INVALID_IDENTITY is deliberately ambiguous. Every provider-side failure collapses into it: an expired session, a platform returning 500, a network timeout, a missing id field and a completely unconfigured provider all look identical from outside. That leaks nothing, which is the point, but it means you cannot diagnose an exchange failure from the client. Check the chat service's logs.

Two things about the 429 are easy to be caught by. The limiter decides on the count as it stood before the current attempt, then records that attempt regardless of the verdict, so sustained retrying keeps the window full and extends the lockout past 60 seconds. And it fails open on its own dependency: if the service's Redis is unavailable the counter reads as zero, so every exchange is allowed and the endpoint is effectively unthrottled. A 429 therefore always reflects real traffic from that address, but the absence of one proves nothing about whether the limiter is working.

The EveryMatrix provider ​

EM is the first provider and the reference implementation. It is registered unconditionally (there is no feature flag) so provider: "everymatrix" always resolves. What varies is whether it can verify.

What happens on the wire ​

The sessionId you send is the EveryMatrix player session your page already holds: the value EM's own client establishes at login, which the chat service passes straight back to EM for validation. GomaPulse neither mints it nor can obtain it for you; getting hold of it is the one hop you own.

A successful exchange is three hops:

A successful exchange, hop by hop

  1. Host → chat service
    POST /auth/exchange carrying provider, sessionId and a display seed.
  2. Chat service → EveryMatrix (caution)
    GET /v1/player/session/player with the session as X-SessionId. 5s cap, no retry.
  3. Chat service → host (good)
    Token pair plus the chat user, keyed on EM's verified UserID.

In full: your host calls the chat service:

http
POST /auth/exchange
Content-Type: application/json

{ "provider": "everymatrix", "sessionId": "a1b2c3d4-…", "username": "player_one" }

The chat service calls EM, server-side, with the session in a header:

http
GET /v1/player/session/player
Host: em-ndw-api.stage.norway.everymatrix.com
X-SessionId: a1b2c3d4-…
Accept: application/json

EM answers:

json
{ "IsAuthenticated": true, "UserID": 987654, "Username": "real_name" }

And the chat service returns the token pair and user object shown above.

Note the sequencing above holds for a player the service is seeing for the first time. On every later exchange the chat user already exists, so the stored username is returned and EM's Username is read but discarded: see What username does.

The session travels as X-SessionId, not as a bearer token. There is no API key, client secret or mTLS on the call to EM. The player's session header is the credential, which is why the chat service needs no EM credentials of its own: only the base URL.

What EM's answer must contain ​

Exactly three fields are read, in EM's PascalCase. The casing is exact and unforgiving:

FieldRequirementRole
IsAuthenticatedstrictly boolean trueGate. "true" and 1 are rejected.
UserIDpresent and non-emptyBecomes the chat user's stable external id, stringified. Not UserId, not userId.
Usernameoptional stringDisplay name, preferred over whatever the caller sent.

Everything else EM returns is discarded.

Configuration ​

One environment variable on the chat service:

bash
EM_PLAYER_API_BASE_URL=https://em-ndw-api.stage.norway.everymatrix.com
FacetBehaviour
BootOptional: the service starts without it
RuntimeRequired for any browser-side host. Without it every session exchange 401s
FormatAbsolute URL. A trailing slash is tolerated.
Empty stringTreated as unset: Compose substitutes "" for a variable the host .env omits
Malformed valueCrashes at startup. A non-empty non-URL fails validation at import

An unset EM_PLAYER_API_BASE_URL fails closed, silently, at request time

Without it the provider rejects before making any HTTP call. The service stays healthy, /health returns 200, and every browser-side host is simply signed out of chat with a generic 401 and nothing pointing at the cause. The service logs one warning at boot when the variable is missing: check for it first when chat is signed out everywhere.

Local development. The stock local docker-compose.yml does not pass this variable through, so EM verification is off by default there and every session exchange fails. Add it to that compose file's environment block, or run the service outside Compose with the variable set. The production compose file already passes it through.

Service configuration ​

These variables belong to the service rather than to any provider, and every one of them fails the process closed at import if missing:

VariablePurpose
JWT_SECRETSigns the access and refresh tokens. Minimum 10 characters, no default. Do not share it with another service.
AUTH_EXCHANGE_KEYThe shared key for the caller-asserted path. Minimum 10 characters, no default: never a value published in an example file. Not needed on the EM session path.
ADMIN_API_KEYA second shared secret, sent as x-admin-api-key, gating the whole admin surface: including the account-status changes that are the only revocation lever described above. Minimum 10 characters, no default. Rotate it on the same schedule as AUTH_EXCHANGE_KEY.
DATABASE_URL, REDIS_URLInfrastructure. A missing or malformed value crashes the process at boot rather than at first request.

What the session path guarantees ​

The provider fails closed at every branch, so a player holding a valid EM session for their own account cannot become another player, cannot rename anybody (see What username does), and cannot turn an EM outage into a free token. An externalId in the request body is not merely overridden on the session path: it is never read; the identity comes solely from EM's verified UserID. The caller-asserted path stays shut without the shared key, which is compared in constant time.

The round trip to EM is capped at 5 seconds, so a slow platform cannot pin exchanges open. There is no retry: one attempt per exchange.

The per-IP rate limit is not proxy-aware out of the box. Behind a load balancer the service sees only the balancer's address, so every caller shares one bucket. Fixing this requires a code change to how the service constructs its HTTP server: there is no environment variable or configuration flag for it. Treat it as a known limitation rather than a checklist item.

Rules that apply to every provider ​

What username does ​

It is a display seed, and nothing more. Specifically:

  • It names a brand-new chat user, and is ignored entirely for an existing one. There is no code path that updates a username after creation.
  • The platform's own name wins when the platform supplies one.
  • It is trimmed and truncated to 32 characters.
  • Usernames are globally unique, so a collision is normally de-duplicated rather than rejected: the name is cut to 26 characters and a four-character hex suffix is appended, e.g. player_one-a1b2. The user.username you get back may not be the one you sent.
  • That de-duplication is attempted once. In the rare case the suffixed name also collides, the exchange fails with 500 INTERNAL_ERROR rather than trying again.
  • Absent everything else, EM's provider falls back to player-<UserID>.

So a caller can choose their own first display name, once. They cannot rename anybody, ever.

How a chat user is matched ​

The key is the composite (provider, externalId): never the username, and never externalId alone. Two platforms can therefore issue the same user id without colliding, and a player who changes their display name keeps their chat account, history and friends.

Exchanges are idempotent in effect: the same identity always resolves to the same chat user, minting a fresh token pair rather than creating anything. This is why one exchange per player is enough to drive both widgets.

Tokens and refresh ​

Both tokens are JWTs signed by the chat service with an HMAC secret. The service sets sub (the chat user id), username, status and type; the JWT library adds the standard iat and exp. Read exp if you want to schedule a refresh rather than wait for a failure.

TokenDefault lifetime
accessToken1 hour
refreshToken7 days

The access token is used for both REST and the socket handshake: there is no separate socket token, despite the widgets exposing two attributes for it. Set both to the same value.

To refresh without a full re-exchange:

http
POST /auth/refresh
Content-Type: application/json

{ "refreshToken": "…" }

It returns { "accessToken": "…" } only: the refresh token is not rotated. Write the new access token onto both widget attributes.

Distinguish its failures, because they call for different handling:

StatusCodeWhat to do
400VALIDATION_ERRORMalformed body. Your bug: fix the request.
401INVALID_TOKENThe refresh token is expired, malformed, or not a refresh token. Re-exchange from the platform session.
401UNAUTHORIZEDThe signature verified but the user behind it no longer resolves. Re-exchange.
403ACCOUNT_SUSPENDEDThe account is banned or suspended. Do not retry or re-exchange: a fresh exchange fails the same way. Surface it to the player.

Refresh tokens are not revocable. They are not stored server-side, so the only revocation lever is account status. Treat a leaked token of either kind as valid until it expires, and note two limits on how far a ban reaches in the reference service today:

  • The type claim is only checked at POST /auth/refresh. Every other protected REST route and the socket handshake verify the signature alone, so a refresh token works as a bearer token everywhere: for its full seven days, not the access token's hour.
  • Account status is re-read from the database on REST routes, but the socket handshake trusts the status claim carried inside the token. A ban therefore does not sever a live socket, and does not block a new handshake presented with a token minted before the ban.

If you need a hard revocation boundary, terminate the session at your own edge as well: do not rely on the ban propagating into an open chat connection.

When to refresh, and how to log out ​

Neither widget refreshes anything on its own, and neither emits a session-expiry event. Since the access token lasts an hour, the workable policy is to refresh proactively (off the token's exp, before it lapses) and treat a connection failure as the reactive backstop. Each widget names its own failure signals in its Authentication section.

Logging out is entirely host-side, because the chat token cannot be revoked. When the player's platform session ends, clear the widgets' token attributes and let the chat token expire on its own. The two widgets differ here: <goma-live-chat> honours attribute removal, while <goma-messenger> does not and needs an empty string instead: see its logout section and live chat's.

Clearing the token does not close <goma-live-chat>'s socket

Only <goma-messenger> disconnects when its token is cleared. In <goma-live-chat> the re-bootstrap path returns as soon as it sees no token, before it reaches the socket teardown, so the UI empties and the store resets while the Socket.IO connection stays open, still authenticated with the old token, until the element leaves the DOM.

To sign out of live chat completely, remove the element (or the container it lives in) from the page rather than only clearing its attributes.

Supporting a platform other than EveryMatrix ​

The chat service knows nothing about betting platforms. Everything platform-specific lives behind a provider. A small adapter that answers one question: does this session belong to a real player, and what is that player's stable id? Adding support for another platform means supplying another answer to that question. The exchange endpoint, the token format and the widgets are all unchanged.

A provider is identified by a name, which is the string callers put in the request's provider field. Given the contents of an exchange request, it must return one of two things: a rejection, or an identity made up of three parts. A stable id for the player on that platform, a display name, and a flag saying whether the identity was proved against the platform or merely taken on trust. That flag is what the endpoint reads to decide whether the caller also needs the shared key, so it is the single most consequential thing a provider decides.

Whatever the platform, five properties have to hold. They are not stylistic preferences: each one closes a hole that would otherwise be exploitable.

Never let a failure become trust. If verification cannot be completed (the platform is unreachable, times out, answers with an error, or returns something unrecognisable) the answer is a rejection, not a fallback to whatever the caller claimed. A provider that degrades into trusting its caller on failure hands out exactly the impersonation it exists to prevent, and it does so precisely when nobody is watching the platform.

Key on the platform's id, not on a name. The id is stable across display-name changes and it is the one field a caller cannot choose. Keying on a username means a player who renames themselves loses their chat account, and it means a caller who can pick a name can pick an identity.

Fail closed when unconfigured. If the provider has no platform endpoint to call, it must reject before doing anything else. Treating "not configured" as "accept the claim" turns a deployment oversight into an open door.

Bound the round trip. An unbounded call to the platform lets a slow or hanging platform pin exchange requests open indefinitely. The EveryMatrix provider caps this at five seconds and does not retry.

Be strict about the platform's success signal. Compare against the actual success value rather than accepting anything truthy. The string "false" is truthy in most languages, and a platform that reports failure as a string would otherwise be read as a success.

Using a different backend ​

Everything above describes how the GomaChat service solves identity. None of it is binding. The widgets' contract is far narrower than the length of this page suggests, and an operator running their own chat backend can satisfy it without an exchange endpoint, a provider, or any of the machinery above.

What the widgets actually require ​

They take a token and never inspect it. There is no decode, no expiry check, no shape check: a widget cannot tell a JWT from a random string. It sends whatever it is given as a bearer header on REST calls and in the socket handshake, and treats any rejection as a connection failure.

So identity is entirely yours. Mint tokens however you like, from whatever session system you already run. The POST /auth/exchange endpoint, the verified flag, the shared key and the identity-provider model exist only because this service needed a way to trust a browser's claim. If your backend already knows who the caller is, none of that applies.

The full contract is written up separately: Chat backend specification is the normative document, covering every endpoint, payload, realtime event and behavioural invariant a conforming service has to satisfy.

At a glance, what you have to implement is the two data surfaces the widgets speak. Both are documented in full on the widget pages themselves: the REST endpoints and Socket.IO events under Data sources on <goma-live-chat> and <goma-messenger>.

The parts that are not negotiable ​

Most of the surface is ordinary REST and Socket.IO, but a handful of details are compiled into the widgets and will not adapt to your service. These are the ones that cause a working-looking backend to fail in confusing ways:

ConstraintWhy it bites
Socket.IO path is fixed at /socket.io/Not configurable. A backend serving its socket elsewhere is unreachable, and pointing socket-url at a path makes Socket.IO request that path as a namespace instead
Long-polling must work before the upgradeTransports are ['polling', 'websocket'] in that order. A websocket-only server never completes a handshake
The send acknowledgement decides the message's fate: in <goma-live-chat>Only status: 'ok' keeps it; blocked, error, a malformed ack, or no ack within ten seconds all roll the message back and raise an error. <goma-messenger> sends fire-and-forget with no ack callback at all, and settles on the server's message_delivered push or the room echo instead: a message it cannot deliver is never rolled back and sits at sending indefinitely
The widget never retriesA dropped or failed send is surfaced, not re-attempted. Delivery guarantees are the backend's problem
<goma-live-chat> sends carry an idempotency keyReconnects can re-deliver; deduplicate on idempotency_key or messages double up. <goma-messenger> sends carry no such field: its optimistic id is local only and never reaches the wire, so its reconnect replays cannot be deduplicated client-side
Inbound wire fields are snake_caseBoth widgets map them to camelCase internally, so returning camelCase yields empty message bodies rather than an error. Outbound is not symmetrical: live chat emits snake_case, but the messenger's send payload uses id / message / repliedMessage / toAi. Implement the reference field names literally rather than assuming one casing
Message history comes back newest-firstThe widgets re-sort ascending. Returning oldest-first silently inverts the feed
Field names must match exactly, inconsistencies includedThe widgets read literal keys. A name that looks like a typo in the reference service is still the name to implement

What you take on ​

Two guarantees the exchange model provides for free become yours to enforce.

One identity per player, across both widgets. The chat service keys a user on the composite of provider and external id, so repeated exchanges always land on the same chat user. Issuing your own tokens means you own that stability: if the same person can end up with two chat identities, they will author messages as two strangers across the two surfaces.

A display name that cannot be used to impersonate. Here, the name a caller sends is a seed for a brand-new user and is ignored for an existing one. If your backend takes a name from the client on every request, decide explicitly whether it can overwrite an established one.

Everything else in this guide (the two paths, the shared key, the EveryMatrix provider, the fail-closed rules) describes one implementation of a problem you may not have.

Troubleshooting ​

Most chat-identity failures present as the same symptom (everyone is signed out) with a generic 401 and a healthy-looking service. These are the causes worth ruling out, in order of how often they bite.

SymptomCheck firstCovered in
Every player signed out; /health still returns 200EM_PLAYER_API_BASE_URL unset on the chat service: look for the boot warningConfiguration
The exchange returns 200 but the body is HTMLThe proxy has no REST location, so an SPA catch-all answered insteadDeployment notes
Chat never connects in local developmentThe stock local compose file does not pass the provider URL throughConfiguration
One player signed out, everyone else fineTheir platform session expired: re-exchangeTwo paths
400 UNKNOWN_PROVIDERThe provider value's spelling or casing: the registry key is case-sensitiveRequest
401 UNAUTHORIZED on a server-to-server callx-exchange-key missing or wrong on the caller-asserted pathTwo paths
429 under obviously light loadGenuine traffic from that address: behind a load balancer every caller shares one bucketErrors
Widget renders nothing and makes no network callsRequired widget attributes are empty; the widget never left its idle stateLive chat states
A player's own messages look like someone else'suser-chat-id was not set on the messengerResponse
One player appears as two people across the two widgetsThe exchange ran twiceResponse

Observability ​

The exchange is deliberately opaque to the caller, which puts the burden of diagnosis on logs and on your own instrumentation. Two things are worth knowing before you rely on either.

The service logs successes and refusals, but not rejections. A successful exchange writes an audit line carrying the caller IP, the provider, the external id, the resulting chat user id, and the verified flag: that last field is what lets you scope an incident to one path if the shared key ever leaks. Rate-limited calls, bad-key attempts and exchanges refused because the account is suspended are logged as warnings: the last of these carries the caller IP, provider, external id and chat user id. But the most common failure (401 INVALID_IDENTITY, the provider refusing the identity) writes nothing at all. An expired session, an unreachable platform and a misconfigured provider are equally silent on both sides of the call.

So instrument the exchange yourself, host-side. It is the only place where the request and its outcome are both visible:

js
const res = await fetch('/goma-chat/auth/exchange', { /* … */ })
track('chat_exchange', { status: res.status, ok: res.ok })

Recording just the status code separates the cases the response body will not: a spike of 401 is a session or provider problem, a spike of 429 is real traffic from that address, and 400 is your own request shape. Note which way the limiter fails: because it fails open (above), a Redis outage removes 429s rather than causing them. A sudden drop to zero is the signal worth alerting on, not a spike.

A blank chat is not a chat outage. The widgets render nothing when they have no token, so a failed exchange and a healthy-but-empty chat look identical to a user, and nearly identical in analytics. Pair the exchange metric above with each widget's own instrumentation: see the Analytics section on the <goma-live-chat> and <goma-messenger> pages , so you can tell "never got a token" apart from "got a token, never used it".

Deployment checklist ​

  • [ ] EM_PLAYER_API_BASE_URL (or your provider's equivalent) is set on the chat service: the production compose file passes it through, but the value still has to exist in the host environment
  • [ ] AUTH_EXCHANGE_KEY is set to a real generated secret, never a published example value
  • [ ] JWT_SECRET is set and is not shared with any other service
  • [ ] ADMIN_API_KEY is set to its own generated secret: it gates the account-status changes that are the only revocation lever
  • [ ] Sign-out removes <goma-live-chat> from the DOM rather than only clearing its attributes, so its authenticated socket actually closes
  • [ ] The proxy in front of /auth/exchange injects no headers: it is a pass-through
  • [ ] The chat REST path and /socket.io/ are both proxied: they are separate locations, and the transport is never served under the REST prefix
  • [ ] Boot logs are checked for the missing-provider-URL warning
  • [ ] It is understood that the per-IP rate limit is global behind a load balancer until the service is changed