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.jsbundle 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:
- An API key with the
viewer:tokensscope. 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 aresk_live_…and are server-side only. - 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:
displayNameis 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.memberRefbecomes 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
expiresAtyou got back, never from thettlSecondsyou 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.