reference

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-tokens and the viewer chat socket are live in production. The embed.js bundle 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_request carries 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:
    • caps is empty, or names something outside chat.read / chat.send / video.play, or has more than 8 entries;
    • caps contains chat.send and memberRef is empty;
    • caps contains chat.send and displayName is empty (or cleans to empty — it is stripped of colons, brackets, parentheses and control characters before it is measured);
    • memberRef is longer than 128 characters.
  • A viewer the broadcaster has blocked who asks for chat.send and nothing else gets a 404 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 whose caps comes 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"
}
  • platform is the originating service — twitch, youtube, kick — or studio for a message said in the broadcast itself: the broadcaster typing in their chat panel, and every embed visitor's own message coming back. studio is not a platform; do not render it as a platform badge.
  • author.displayName may 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:

  1. 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.
  2. channelWide: true removes everything from that platform, and only that platform.
  3. Otherwise, if platformMessageId is set, remove the message with that id.
  4. Otherwise, if authorId is 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.
  5. 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 auth endpoint 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.