guides

Put TRaX chat on your own site

A TRaX broadcast usually has several chat rooms running at once — Twitch, YouTube, Kick — and TRaX already merges them into one stream of messages. The embed puts that merged room on your website, for your members, without sending anyone to a third-party platform first.

Two pieces:

  • <trax-chat> — a drop-in custom element. One script tag, one element, no framework, no iframe. It renders in a shadow root, so your CSS cannot break it and its CSS cannot leak into your page.
  • POST /v1/viewer-tokens — the credential exchange your server calls. You have already authenticated your own member; this turns that decision into a short-lived token that is safe in their browser.

Availability. The token exchange 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. Everything else on this page is the real contract and does not change when that URL turns on.

The one idea to get right

TRaX never sees your users, and your API key never reaches a browser.

  browser (your page)          your server              TRaX
  ───────────────────          ───────────              ────
  <trax-chat> needs a
  token → calls .auth()
        │
        ├── POST /my/viewer-token ──►
        │   (your session cookie)    │
        │                            │ you decide: is this member
        │                            │ entitled? may they post?
        │                            │
        │                            ├── POST /v1/viewer-tokens ──►
        │                            │   Authorization: Bearer      │ mints a
        │                            │   sk_live_…                  │ 15-min token
        │                            │◄── { token, expiresAt, caps }┤
        │◄── { token, expiresAt, caps }
        │
        └── opens the chat socket with the viewer token ────────────►

The member reference you send (memberRef) is an opaque string. TRaX stores it, rate-limits on it, and never resolves it to a person — it has no idea who your users are, and holds nothing about them beyond that string.

Revocation is token expiry. When someone's membership ends, your endpoint stops vouching, and their current token dies within the hour. That is why the ceiling on token lifetime is short and non-negotiable.

Before you start

You need two things:

  1. An API key with the viewer:tokens scope. Create it in your TRaX account under Developer → API keys; the scope is listed under Embed as Mint viewer tokens. It is opt-in — no other scope implies it, and no default key has it. Keys are sk_live_… and are server-side only.
  2. The studio id of the broadcast whose chat you want to show.

Step 1 — mint a token from your server

One endpoint on your own backend, behind your own login. It authenticates your member the way it already does, then calls TRaX.

// Node / Express. Your framework, your session — this part is yours.
app.post('/my/viewer-token', async (req, res) => {
  const member = req.session.member          // however YOU authenticate
  if (!member) return res.status(401).json({ error: 'sign in first' })

  const upstream = await fetch('https://api.traxstreaming.live/v1/viewer-tokens', {
    method: 'POST',
    headers: {
      authorization: `Bearer ${process.env.TRAX_API_KEY}`,  // sk_live_… — server only
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      studioId: process.env.TRAX_STUDIO_ID,
      memberRef: `member:${member.id}`,   // opaque to TRaX
      displayName: member.name,           // PUBLIC — see step 4
      caps: ['chat.read'],                // read-only to start
      ttlSeconds: 900,
    }),
  })

  if (!upstream.ok) {
    // Log the body. It names what is wrong with the request.
    return res.status(502).json({ error: 'could not mint a viewer token' })
  }
  res.json(await upstream.json())          // { token, expiresAt, caps }
})

Pass the response through to the browser unchanged. The client reads expiresAt to schedule its own refresh and caps to decide what to draw.

Full field-by-field contract: Embed chat reference → the token endpoint.

Step 2 — drop in the widget

<script type="module" src="https://play.traxstreaming.live/embed.js"></script>

<trax-chat studio="YOUR-STUDIO-ID"></trax-chat>

<trax-chat> fills its container, so give it a height:

trax-chat { height: 480px; display: block; }

Set studio in the markup. Changing the attribute after the element is in the page does nothing — the element reads it once, when it starts.

Step 3 — wire the auth callback

The element takes a function, not a token. This is the load-bearing design decision: tokens last minutes and broadcasts last hours, so a token-shaped API would work perfectly in your testing and then die mid-stream.

<script type="module">
  const chat = document.querySelector('trax-chat')

  chat.auth = async () => {
    const r = await fetch('/my/viewer-token', { method: 'POST' })
    if (!r.ok) throw new Error('not entitled')
    return r.json()          // { token, expiresAt, caps }
  }
</script>

Assigning .auth is what starts the element, and you can assign it at any time — including long after the element is in the DOM, which is the normal case when your script runs after the markup. It is a property, never an attribute: an attribute would mean a credential sitting in your HTML.

The element calls auth again before each expiry, using the expiresAt the server returned. If your endpoint starts refusing, the chat stops. That is revocation working.

That is the whole read-only integration. Messages from every platform the broadcaster has connected now appear on your page, tagged with where they came from.

Step 4 — letting your visitors post (read this part twice)

Add chat.send to caps and the widget grows a composer. Before you do, understand exactly what happens to the words:

A visitor's message leaves on the broadcaster's platform account. TRaX relays it to Twitch, YouTube and Kick over the broadcaster's own connections, because those are the only credentials in the system. There is no per-visitor platform identity and there cannot be one.

So every relayed message carries an attribution prefix, and it is not decoration:

Ava (web): when does the second set start?

Without it, a stranger's words are published under the broadcaster's name, to the broadcaster's audience. That is a reputational problem for them and a platform-terms problem for everyone. The prefix is not configurable, and a visitor cannot forge it — colons, newlines and control characters are stripped from both the name and the message.

Two consequences to design around:

  • displayName is public by construction. It is published to the broadcaster's Twitch/YouTube/Kick audience. Send a display name — never an email, an internal id, or anything your member would not want on a public channel. It is attested by your server, so a visitor cannot choose it.
  • memberRef becomes mandatory. A write capability with no member behind it is refused at mint. There is no anonymous path to the broadcaster's accounts, by design: you vouch, so you can stop vouching.
caps: ['chat.read', 'chat.send'],
displayName: member.name,     // required with chat.send, and public
memberRef: `member:${member.id}`,  // required with chat.send

Messages are budgeted to the tightest platform limit (200 characters, YouTube's), minus the attribution prefix — so a message is whole everywhere or refused before it leaves, never truncated on one service and complete on another.

Render from what the server granted

Requesting a capability does not grant it. The response's caps is the truth, and it can be narrower than you asked for — a viewer the broadcaster has blocked gets chat.send stripped at mint, and revoked live on an open socket if the block lands mid-conversation.

<trax-chat> already does the right thing: it hides the composer unless the server granted chat.send, and hides it again the moment a block arrives. If you build your own UI, do the same. A control that is visible and always fails is worse than no control.

Styling

Theme through CSS custom properties on the element — that is the whole surface:

trax-chat {
  --trax-bg: #14110f;
  --trax-fg: #ece4d9;
  --trax-muted: #a1948a;
  --trax-rule: #322b27;
  --trax-accent: #e0913c;
  --trax-accent-fg: #1a1207;
  --trax-platform-bg: #221e1b;
  --trax-platform-fg: #a1948a;
  --trax-warn: #d4715a;
  --trax-font: 'Public Sans', system-ui, sans-serif;
  --trax-size: 15px;
}

Watching what it is doing

The element emits trax-state whenever the connection or the granted capabilities change:

chat.addEventListener('trax-state', (ev) => {
  const { state, caps, host } = ev.detail
  // state: 'connecting' | 'live' | 'reconnecting' | 'closed'
  console.log(state, caps, host)
})

host is the embed plane the element actually connected to. It is reported rather than left implicit so an embed pointed at the wrong environment is visible instead of mysterious.

When it does not work

What you see Almost always
Not configured: a studio is required. No studio attribute on the element.
Waiting for the auth callback… and it stays .auth was never assigned, or your script never ran.
Connects, then disconnects in a loop Your /my/viewer-token endpoint is throwing, or returning something that is not { token, expiresAt, caps }.
Connects, but no composer The server did not grant chat.send. Check caps in the mint response — you may have omitted displayName or memberRef, or the viewer is blocked.
Mint returns 401 The key is wrong, or missing.
Mint returns 403 The key exists but lacks the viewer:tokens scope. The response names the scope it wants in requiredScope.
Mint returns 404 The studio id is wrong, or the key's owner does not own that studio — deliberately the same answer. Also what a blocked viewer gets if you asked for chat.send and nothing else; ask for ['chat.read','chat.send'] instead.
Mint returns 400 A mint rule was violated, but the message is generic — the specific reason is not passed through. The complete checklist is in the reference; usually it is chat.send without a displayName or without a memberRef.
Messages arrive with no sender name The upstream platform gave us no display name. TRaX renders no name rather than a made-up one.

Building your own interface instead

The same package ships a headless client with no DOM opinions — TraxChatClient, plus the shouldRemove rule so your own message log applies moderation removals exactly the way the widget does. See Embed chat reference → headless SDK.

Also worth knowing

  • Token lifetime is capped at 1 hour and defaults to 15 minutes, whatever you ask for. Schedule from the expiresAt you got back, never from the ttlSeconds you sent.
  • Moderation flows inbound too: a message deleted on YouTube or Twitch, or a ban on any of the three, stops being displayed on your site. Coverage is uneven because the platforms are — the exact matrix, including a Kick gap we cannot close, is in the reference.
  • Chat only, for now. Video playback on the same viewer token is a separate, later phase and is not available.