Embed chat reference
The contract behind Put TRaX chat on your own site: the token exchange, the viewer socket, the headless SDK, and the parts that are deliberately uneven.
Availability.
POST /v1/viewer-tokensand the viewer chat socket are live in production. Theembed.jsbundle is not yet published at a public URL — TRaX gives you the script URL when your account is switched on for the embed. The contract below is stable either way.
The trust model
TRaX has no account for the person reading chat on your site, and does not want one. Your backend authenticates its own member, decides what that member may do, and exchanges that decision for a credential.
| Where it lives | What it proves | |
|---|---|---|
sk_live_… API key |
Your server, only ever | You are you, and you may mint viewer tokens |
| Viewer token | The member's browser | One studio, one opaque member, a fixed capability list, minutes of life |
The viewer token is a signed JWT (ES256, iss: trax-studio-api,
aud: trax-viewer). Treat it as opaque — it is verified server-side and its
internal shape is not part of this contract.
memberRef is opaque to TRaX. It is stored on the token, used as a
rate-limit and block key, and never resolved to a person. TRaX holds nothing
else about your member.
Revocation is expiry. There is no revoke call, and that is deliberate: the mechanism is that your server stops vouching and the current token runs out. It is why the lifetime ceiling is an hour and not a day.
POST /v1/viewer-tokens
Also rendered from the machine-readable spec at
/reference/api/v1, and served live at
api.traxstreaming.live/v1/openapi.json.
Call this from your server. It is authenticated with your secret key.
POST https://api.traxstreaming.live/v1/viewer-tokens
Authorization: Bearer sk_live_…
Content-Type: application/json
Required scope: viewer:tokens. It is opt-in and implied by nothing —
an integration that only reads studios can never acquire it by accident.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
studioId |
string | yes | The studio whose chat this token may reach. |
caps |
string[] | yes | At least one of chat.read, chat.send, video.play. Unknown values are rejected, not ignored. Duplicates are collapsed. Max 8. |
memberRef |
string | with any write cap | Your opaque id for the person. Max 128 characters. Required whenever caps contains chat.send. |
displayName |
string | with chat.send |
The name published alongside a relayed message. Max 32 characters after cleaning. Not collected for read-only viewers. |
ttlSeconds |
integer | no | Default 900. Clamped to 3600. |
Response — 201 Created
{
"token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.…",
"expiresAt": "2026-08-21T02:14:07Z",
"caps": ["chat.read", "chat.send"]
}
| Field | Notes |
|---|---|
token |
Hand this to the browser. Nothing else from your server. |
expiresAt |
RFC 3339, after clamping. Schedule your next mint from this value, never from the ttlSeconds you asked for. |
caps |
The capabilities actually granted. May be narrower than requested. Render your interface from this. |
Errors
The standard /v1 envelope — see Errors.
{ "error": { "code": "insufficient_scope", "message": "…", "requestId": "…", "requiredScope": "viewer:tokens" } }
| Status | code |
Means |
|---|---|---|
| 401 | invalid_key |
No credential, or a key that does not resolve. |
| 403 | insufficient_scope |
The key lacks viewer:tokens. requiredScope names it. |
| 404 | not_found |
No such studio, or the studio is not yours. Deliberately the same answer, so the endpoint cannot be used to probe for other tenants' studio ids. |
| 400 | invalid_request |
The request violates a mint rule — see below. |
| 429 | rate_limited |
See Rate limits. |
| 503 | write_disabled / unavailable |
Writes are off on this deployment, or the studio service is down. |
| 502 | bad_gateway |
Upstream fault. |
Two sharp edges in the error mapping, stated plainly because they will cost you time otherwise:
- A
400 invalid_requestcarries the generic message "the request was rejected as invalid". The specific reason is determined upstream and is not passed through. When you get one, work down this list — it is the complete set of mint rules:capsis empty, or names something outsidechat.read/chat.send/video.play, or has more than 8 entries;capscontainschat.sendandmemberRefis empty;capscontainschat.sendanddisplayNameis empty (or cleans to empty — it is stripped of colons, brackets, parentheses and control characters before it is measured);memberRefis longer than 128 characters.
- A viewer the broadcaster has blocked who asks for
chat.sendand nothing else gets a404 not_found, not a 403. Ask for["chat.read", "chat.send"]rather than["chat.send"]and you get the far better behaviour instead: a 201 whosecapscomes back as["chat.read"], and a widget that simply renders without a composer.
Capabilities
| Capability | Grants |
|---|---|
chat.read |
Open the viewer chat socket, receive live messages and backfill. Required to connect at all. |
chat.send |
Submit messages that are relayed to the broadcaster's connected platforms. Requires memberRef and displayName. |
video.play |
Reserved for the playback plane. Not available — the delivery tier is a later phase. Requesting it today mints a capability nothing consumes. |
Capability is checked at three points, not one: at mint, at socket connect, and live on an open socket. A signature proves who; it never proves still welcome.
The viewer chat socket
wss://<embed-host>/api/v1/embed/ws/studios/{studioId}/chat?token=<viewerToken>
The token rides in the query string because the browser WebSocket API cannot set
headers. It is verified before the upgrade, and the studio claim is matched
against the id in the path — a token minted for one studio cannot read another.
Refused before the upgrade:
| Status | Body | Cause |
|---|---|---|
| 400 | missing studio id |
Malformed path. |
| 401 | token required |
No token parameter. |
| 401 | invalid_token |
Bad signature, expired, wrong issuer/audience/scope, or the studio claim does not match the path. |
| 403 | insufficient_capability |
The token has no chat.read. |
| 503 | realtime bus unavailable |
Server-side. |
Origin is deliberately not checked. The whole point is that the page is on a domain TRaX does not know in advance; the token is the authorization, and an origin check could only ever have been advisory.
The server sends a ping frame every 15 s and expects the connection to answer
{"type":"pong"}. It closes a connection that sends nothing for 45 s. Inbound
frames are capped at 64 KB.
Envelope
Every frame in both directions:
{ "type": "…", "requestId": "…", "ok": true, "data": { }, "result": { }, "error": "…" }
requestId is yours to choose on a request; the reply echoes it. ok,
result and error appear only on type: "reply".
Server → client
type |
data |
Meaning |
|---|---|---|
hello |
{ studioId, caps, canSend } |
First frame. The capabilities the server granted. Build your UI from this, not from what you requested. |
chat_message |
a message (below) | A live message from any connected platform, or from the studio itself. |
chat_send_result |
{ clientMessageId, results: [{ platform, ok, reason? }] } |
What the platforms actually did with a message this connection submitted. Filtered per-connection — you never see other viewers' outcomes. |
caps_changed |
{ canSend, reason } |
The broadcaster blocked or unblocked this viewer mid-session. reason is "blocked" on a block, empty on a restore. |
chat_message_removed |
a removal (below) | A message, or a person's history, must stop being displayed. |
ping |
— | Answer {"type":"pong"}. |
reply |
— | The answer to one of your requests. |
An unblock restores only what the token already granted. The blocklist can subtract capability; it can never add it.
Client → server
Anything else is ignored. This socket performs no other mutation.
chat_history — backfill.
{ "type": "chat_history", "requestId": "r1", "data": { "limit": 50 } }
Replies { type: "reply", requestId: "r1", ok: true, result: { messages: [...] } }.
Newest first — that is what "the last 50 messages" means to a database.
Reverse it before rendering, or use the SDK, which sorts by timestamp.
chat_send — relay a message outward.
{ "type": "chat_send", "requestId": "r2", "data": { "text": "hello" } }
A send without a requestId is refused rather than performed: the outcome is
the point, and a caller who has not asked to hear it cannot be told their
message was throttled or dropped.
On acceptance the reply is
{ ok: true, result: { status: "queued", clientMessageId: "…" } }. queued
is not sent — the platforms have not answered yet. Wait for the
chat_send_result carrying that clientMessageId.
Refusals arrive as { ok: false, error: "<code>" }:
error |
Meaning | Whose fault |
|---|---|---|
too_fast |
This viewer is over their own rate limit. | The sender's. Nudge, don't scold. |
room_busy |
The whole embed audience is at the studio ceiling. | Nobody's. Do not tell this person to slow down. |
rate_limited |
Too many RPCs on this connection (any type). | The client's. |
message_too_long |
Does not fit the platform budget once attributed. | The sender's. |
empty_message |
Nothing left after cleaning. | The sender's. |
chat_send_not_granted |
No chat.send, or it was revoked mid-session. |
— |
relay_unavailable |
The relay could not accept it. | Ours. |
no_display_name |
Token carries chat.send with no attested name. Should be impossible; reachable only with a token from an older build. |
— |
Rate limits, all token buckets:
| Bucket | Sustained | Burst |
|---|---|---|
| RPCs per connection (any type) | 1 per 2 s | 20 |
| Relays per viewer | 1 per 4 s | 3 |
| Relays per studio, across every embed connection | 1 per 6 s | 5 |
The studio ceiling exists because every relayed message is an API call against the broadcaster's platform account, under their limits. A thousand well-behaved viewers still add up to a flood, and an embed that consumed the whole quota would silence the person who owns the account.
Message shape
Both chat_message and each row of chat_history carry the same shape. The
author is nested:
{
"id": "…",
"platform": "twitch",
"platformMessageId": "…",
"author": { "id": "…", "displayName": "Ada" },
"text": "…",
"timestamp": "2026-08-21T01:12:33Z"
}
platformis the originating service —twitch,youtube,kick— orstudiofor a message said in the broadcast itself: the broadcaster typing in their chat panel, and every embed visitor's own message coming back.studiois not a platform; do not render it as a platform badge.author.displayNamemay be absent. Render no name rather than a placeholder.- History rows carry some additional internal bookkeeping fields. They are not part of this contract; ignore anything not listed above.
Inbound moderation
A moderator took something down; every surface showing it has to follow, including yours.
chat_message_removed payload:
{ "platform": "twitch", "platformMessageId": "…", "authorId": "…", "channelWide": false, "reason": "deleted" }
reason is a closed set: deleted, banned, timeout, cleared.
The matching rule
Getting this wrong is the whole risk: too narrow and moderated content stays up;
too broad and a removal wipes the wrong messages, which from a viewer's seat is
indistinguishable from the chat breaking. Use shouldRemove from the SDK rather
than reimplementing it. What it does:
- Platform must match, always. Ids are unique only within a platform, and two platforms hand out the same-looking ids. Matching across them would delete a stranger's messages on a different service.
channelWide: trueremoves everything from that platform, and only that platform.- Otherwise, if
platformMessageIdis set, remove the message with that id. - Otherwise, if
authorIdis set, remove every message from that author — which is what a ban or timeout does on the platforms themselves. A message with no author id never matches, so an empty-against-empty comparison cannot wipe every anonymous sender. - Anything else is refused rather than guessed. "Delete nothing" and "delete everything" are the alternatives, and only one of them is recoverable.
If your log de-duplicates by message id, drop removed ids from that set too — otherwise a reconnect that replays backfill is silently swallowed and the removal turns permanent for the wrong reason.
Platform coverage — uneven on purpose
| Platform | A single message deleted natively | A ban or timeout | A room clear |
|---|---|---|---|
| Twitch | yes (CLEARMSG) |
yes (CLEARCHAT) |
yes |
| YouTube | yes (messageDeletedEvent) |
yes (userBannedEvent) |
— |
| Kick | no — see below | yes (moderation.banned; a timeout too) |
— |
| Anything else | no moderation events on the wire | no | no |
Moderation performed through TRaX reflects on every platform, including Kick — a broadcaster deleting a Kick message in the TRaX UI does see it disappear from your embed.
The Kick gap. A single message deleted natively on kick.com is not covered and cannot be. Kick's event-type catalogue publishes no message-deletion event — there is nothing to subscribe to. This was verified against Kick's published event types on 2026-08-20. It is the one remaining hole in the matrix, and it is theirs, not a setting you can turn on.
Headless SDK
The same bundle exports a DOM-free client for building your own interface.
import { TraxChatClient, shouldRemove } from 'https://play.traxstreaming.live/embed.js'
const client = new TraxChatClient({
host: 'https://play.traxstreaming.live',
studioId: 'YOUR-STUDIO-ID',
auth: async () => (await fetch('/my/viewer-token', { method: 'POST' })).json(),
onMessage: (m) => renderMessage(m),
onState: (state, caps) => setComposerVisible(caps.includes('chat.send')),
onRemoval: (rm) => {
for (const m of myLog) if (shouldRemove(m, rm)) removeFromLog(m)
},
onError: (err) => console.warn(err),
})
await client.connect() // resolves once the server has said hello
for (const m of await client.history()) renderMessage(m) // oldest first
const outcome = await client.send('hello') // resolves on real delivery
Options
| Option | Notes |
|---|---|
host |
Origin of the embed plane. |
studioId |
Required. |
auth |
() => Promise<{ token, expiresAt, caps }>. Called on connect and again before each expiry. |
onMessage |
One live message. |
onState |
(state, caps) — fires on connection and capability changes. |
onRemoval |
A removal to apply. |
onError |
Transport or auth failure. The client keeps retrying. |
Methods and properties
| Member | Notes |
|---|---|
connect() |
Opens the socket and waits for the server hello. Resolves on timeout rather than throwing — a refused connection has already scheduled its own retry. |
history(limit = 50) |
Backfill, sorted oldest-first. Returns [] if the socket is not live. |
send(text) |
Resolves with a SendOutcome (below). |
close() |
Tears down and stops reconnecting. |
caps / canSend |
What the server granted, live. |
capsReason |
"blocked" when capability was last narrowed by a block; empty otherwise. Use it to explain a composer that disappeared. |
state |
connecting | live | reconnecting | closed. |
Also exported: shouldRemove, normalizeMessage, oldestFirst,
msUntilRefresh, backoffMs, and the REFRESH_LEAD_MS / MIN_REFRESH_MS /
DELIVERY_TIMEOUT_MS / CONNECT_TIMEOUT_MS constants.
Send outcomes
send() never resolves to a bare boolean. A sender who believes a message was
delivered when it never left is the worst failure this feature has.
status |
Fields | Meaning |
|---|---|---|
relayed |
platforms: string[] |
It reached at least these platforms. |
local-only |
reason: 'rate-limited' | 'room-busy' | 'no-platforms' |
The visitor sees their words in the embed; nothing left. Nothing went wrong, and nothing was delivered. |
rejected |
reason: string |
Refused before it left. |
failed |
reason: string |
Went out and was refused, or we never learned what happened. |
A delivery wait that times out (20 s) resolves to failed, not to success.
The message may well have landed — but we do not know, and "probably delivered"
rendered as delivered is how a sender ends up certain of something false. The
same applies to a socket that drops with a send in flight.
no-platforms means the broadcaster currently has no chat destinations
connected. It is not an error.
Reconnection and refresh
- Reconnect backoff is exponential to 30 s, with jitter so a server restart does not bring every embedded page back in the same millisecond.
- The token is re-minted 60 s before expiry, never closer than 5 s apart, using
the server's
expiresAt. - Every refresh calls your
authendpoint again. That is the revocation path: stop vouching and the next refresh fails.
<trax-chat> element
Attribute studio |
Required. The studio id. Read once at start; changing it later has no effect. |
Attribute host |
Optional. Defaults to the production embed plane. |
Property .auth |
Required. The auth callback. Assigning it starts the element, at any time. Never an attribute — that would put a credential in your markup. |
Property .resolvedHost |
The plane it actually connected to. |
Event trax-state |
detail: { state, caps, host }. Fires on connection and capability changes. |
CSS custom properties: --trax-font, --trax-size, --trax-fg, --trax-bg,
--trax-rule, --trax-muted, --trax-accent, --trax-accent-fg,
--trax-platform-bg, --trax-platform-fg, --trax-warn.
The element renders in a shadow root and writes message text with
textContent, never innerHTML — chat is attacker-controlled by definition and
this runs inside your page.
Limits and caveats
Relayed messages leave on the broadcaster's platform account. There is no
per-visitor platform identity and there cannot be one — a Twitch relay is a
PRIVMSG over the broadcaster's own connection. Every relayed line therefore
carries Name (web): . It is not configurable. Read
the guide's step 4
before enabling the write path; there are platform-terms consequences and they
are the broadcaster's to accept.
The origin tag is web, not your domain. TRaX does not reliably know which
page a socket was opened from, and a tag that is right most of the time is worse
than one that claims nothing it cannot back up.
Messages are composed to the tightest platform budget — 200 characters, YouTube's — minus the prefix, so a message is whole on every platform or refused before it leaves. Nothing is truncated on one service and complete on another.
displayName is public. It goes to the broadcaster's platform audience. It
is server-attested from your backend, so a visitor cannot choose it, but pick
the field accordingly.
Token TTL: 15 minutes default, 1 hour maximum. On expiry the client
re-mints through your callback. If your endpoint refuses, the socket closes and
retries with backoff — the viewer sees reconnecting, then closed.
A viewer token cannot read a different studio, post without chat.send,
outlive an hour, moderate anything, or play video. video.play exists in the
vocabulary but has no consumer yet.
Kick single-message deletes done on kick.com do not reflect. See platform coverage.
Video is not part of this. Playback on the same viewer token is a separate phase and is not available.