Realtime WebSocket
Stream every TipPage event live over a plain WebSocket - no SDK, any language. Connect with your API key, or hand short-lived tokens to clients that shouldn't see it.
Everything the webhooks deliver is also
available as a live stream over a WebSocket at wss://ws.tippage.com.
Same events, same payloads, same event ids - the only difference is
delivery:
- Webhooks are durable. We retry for ~44 hours until your endpoint accepts them, so you never miss one - at the cost of running a public HTTPS endpoint.
- The realtime stream is live and ephemeral. You receive what happens while you’re connected and nothing you missed while you weren’t - at the cost of nothing: open a socket and go.
Reach for the stream when you react now and don’t care about history - a
Stream Deck plugin, a “now playing” panel, a light rig, your own overlay.
Reach for webhooks when an event must never be lost - logging, VOD
markers, bookkeeping. Running both is fine: an event carries the same
id on both, so deduping is a one-line Set check.
Connecting
No SDK, no protocol library, no dependencies - any WebSocket client in any language works. Open the socket, send one JSON line with your credential, and events stream in:
const ws = new WebSocket("wss://ws.tippage.com");
ws.onopen = () => {
ws.send(JSON.stringify({ data: { apiKey: process.env.TIPPAGE_API_KEY } }));
};
ws.onmessage = (e) => {
const frame = JSON.parse(e.data);
if (!frame.pub) return; // skip the connect frame and the {} keepalives
const event = frame.pub.data; // the exact envelope a webhook would POST
console.log(event.type, event.data); // e.g. "tip.created", { name, amount, ... }
};
Three frame shapes come down the wire, all JSON, one object per message:
// 1. First frame - you're connected. `subs` lists the channels your key's
// scopes granted. You're already subscribed; there's nothing to send back.
{ "connect": { "client": "...", "subs": { "dev:t_abc123:tts": {} }, "ping": 25 } }
// 2. An event - the webhook envelope, wrapped with the channel it came in on.
{ "channel": "dev:t_abc123:tts",
"pub": { "data": { "id": "evt_...", "type": "tip.created", "created": "...", "actor": null, "data": { ... } } } }
// 3. Keepalive, about every 25s. Ignore it - or treat a long gap between
// keepalives as a dead connection and reconnect.
{}
That’s the entire protocol. The stream is receive-only: after the
first message the server never expects another word from you. Reconnect
with backoff if the socket closes - a close code in the 4500-4999 range
means the credential needs fixing (see Lifecycle);
anything else is a transient blip, just dial back in.
Choosing your credential
There are two ways to authenticate the socket, and which you pick comes down to one question: does the thing opening the connection get to see your API key?
Your API key - for connections you control
When your own backend, a server-side worker, or a private script opens the socket, send your key directly:
{ "data": { "apiKey": "tp_live_..." } }
This is the simple path for first-party use. The key stays in your environment, never leaves your infrastructure, and the connection stays up as long as the key is valid - reconnect with the same key forever, no refresh, nothing to manage. Use this whenever you can keep the key secret.
Never ship your key to a browser, a desktop app you distribute, a
viewer’s device, or anyone else’s server. A leaked tp_live_ key is full
API access until you rotate it.
A handoff token - to let other clients connect without your key
Sometimes you want connections you don’t control to receive your events: a browser dashboard, a mobile app, a viewer’s page, a customer’s integration. You can’t give them your key. Instead, your server mints them a short-lived handoff token and hands that over:
curl -X POST https://api.tippage.com/v1/realtime/token \
-H "Authorization: Bearer tp_live_..." \
-H "Content-Type: application/json" \
-d '{"expires_in": 900}'
The flow is:
- A client asks your server for access (however you gate that - login, session, API key of your own, whatever).
- Your server calls
POST /v1/realtime/tokenwith your TipPage key and gets back a token. - Your server hands the token to the client.
- The client connects with
{ "data": { "token": "..." } }and receives your events.
Your key never leaves your server. The client only ever holds a token that expires, that can only open an event stream (it can’t call any other API endpoint), and that grants exactly the channels your key’s scopes allow - nothing more. This is what makes browsers safe: a page can hold a handoff token; it must never hold your key.
The token is a connect credential only. It must be used within
expires_in (60-3600 seconds, default 900), but once connected the
socket behaves identically to an API-key connection and stays up long
past the token’s expiry. If that socket later drops, the client needs a
fresh token to reconnect - so mint them on demand rather than far ahead
of time. (Daemons that hold the key don’t need tokens at all - they
reconnect with the key and never expire.)
Narrowing a token to fewer groups. By default a token grants everything the key’s scopes allow. Pass
groups to hand out a token that sees only some of them - so a
broad key (even a super-admin one) can give a client a token limited to,
say, tips and Twitch activity and nothing else:
curl -X POST https://api.tippage.com/v1/realtime/token \
-H "Authorization: Bearer tp_live_..." \
-H "Content-Type: application/json" \
-d '{"groups": ["tts", "twitch"], "expires_in": 900}'
A group is the short name from the channel table
below (tts, media, twitch, kick, chat, counters, overlays) - the
part that becomes the channel dev:<your-tenant>:<group> on the wire.
groups can only narrow: every name must be one the key’s own scopes
already grant, or the request is rejected (group_not_granted). The
key’s live scopes stay the ceiling at connect time too, so a token can
never widen itself.
The POST /v1/realtime/token response tells you which channels the token
will actually grant (after any narrowing), so your server can show a
client what it’s about to receive:
{
"url": "wss://ws.tippage.com",
"token": "eyJ...",
"expires_at": "2026-09-02T09:15:00.000Z",
"channels": ["dev:t_abc123:tts", "dev:t_abc123:twitch"]
}
Channels and scopes
Events are organised into groups - tts, media, twitch, and so
on. Each group your key’s scopes allow becomes a channel
dev:<your-tenant>:<group> that your connection is subscribed to. The
server derives the set from your scopes: a handoff token can be narrowed
to fewer groups (see above), never widened past what the scopes grant.
Connecting (or minting
a token) needs at least one group; a key with none is refused with close
code 4507.
| Group | Wire channel | Granted by | Events |
|---|---|---|---|
tts |
dev:<tenant>:tts |
tts:read |
tip.*, tipping.*, queue.tts.* |
media |
dev:<tenant>:media |
media:read |
media.created, queue.media.* |
twitch |
dev:<tenant>:twitch |
tts:read or channel_points:manage |
twitch.* |
kick |
dev:<tenant>:kick |
tts:read |
kick.* |
chat |
dev:<tenant>:chat |
commands:manage or chat:write |
chat.command, chat.moderated - both platforms; data.platform says which chat |
counters |
dev:<tenant>:counters |
counters:manage |
counter.updated |
overlays |
dev:<tenant>:overlays |
overlays:manage |
overlay.connected, overlay.disconnected |
Switch on event.type in your handler; the channel an event arrived on
is mostly there to tell you which group (and scope) let it through.
Platforms are split the same way as the webhook catalog: Twitch alerts
arrive on twitch as twitch.*, Kick alerts on kick as kick.*, and
chat activity from either platform arrives on the single chat group
with data.platform set to twitch or kick - there is no per-platform
chat channel. The
full catalog with payload shapes is on the
Webhooks event catalog and at GET /v1/events.
Overlay connection events
overlay.connected and overlay.disconnected tell you when a browser
source goes online or offline - handy for a “stream is live” indicator or
alerting when an overlay drops mid-stream. Both name the overlay by its
public id (an account can have several), and disconnects carry a
reason: closed (the page told us it was unloading - OBS closed or
the source was removed or hidden with “shutdown when not visible”),
socket_closed (its realtime connection dropped and did not come back -
a crash, a killed process, lost network), heartbeat_timeout (it went
silent without either signal), key_rotated, overlay_deleted, or
tenant_reset. Because an overlay is only considered gone after a short
grace window, a brief reload or network blip won’t fire a spurious
disconnect. A clean close is reported within about 5-10 seconds, a
crash within about 10-15 seconds, and a source that just goes silent
within about 60-80 seconds.
Lifecycle and revocation
The server re-verifies your key about every 30 seconds. Rotating,
disabling, deleting, or re-scoping a key disconnects its live connections
within seconds (close code 4508); after a scope change, reconnect and
the new channel set applies. Codes in the 4500-4999 range are terminal -
fix the credential, then reconnect:
| Code | Meaning |
|---|---|
4501 |
Credential invalid - bad key, or an expired or malformed handoff token |
4507 |
The key (or token’s key) has no realtime-capable scopes |
4508 |
The key was rotated, disabled, deleted, or re-scoped mid-connection |
One caveat, because live means live: while your client is disconnected -
a deploy, a crash, a network drop - events fired in that gap are gone
from the stream. If you can’t tolerate gaps, back the stream with a
webhook endpoint; the shared event id makes reconciling the two
trivial.
A note on realtime.uk-london-1.platform.tippage.com
You may notice a second WebSocket host, wss://realtime.uk-london-1.platform.tippage.com.
That’s TipPage’s own realtime gateway - the transport our stream overlays
and the dashboard run on. It is not a developer surface, and we don’t
recommend building on it. We change how the overlay talks to our API
and socket server often, without warning and without calling it a
breaking change - so anything you connect there today is likely to break
later. For anything you depend on, use ws.tippage.com above: it’s the
versioned, documented, reliable surface, and it’s covered by the API’s
compatibility promise.
Two things follow from that:
-
Don’t imitate an overlay. An overlay’s credentials, channels, and message format are private wiring between our own components - undocumented and unversioned. Reverse-engineering it is the most fragile thing you can build, and when it breaks that won’t count as a regression. If what you want is your own overlay, build it on the documented surfaces: this event stream to react live, plus the claim-and-finish protocol to play tips yourself - the very protocol our overlay uses, exposed properly.
-
The
centrifugelibrary technically works there, but comes with the same warning. The gateway speaks the Centrifugo protocol, so if you’d rather use a client library than the rawws.tippage.comsocket, thecentrifugenpm package can connect to it using an overlay key. Treat it as a convenience that may break without warning, never something to build on. We highly recommend you usews.tippage.com.
Moving from dev-ws.tippage.com
Before 07/09/2026 the stream lived at wss://dev-ws.tippage.com. That
hostname still answers - it’s the same endpoint under an alias, so
existing integrations keep working - but new code should connect to
wss://ws.tippage.com, which is what POST /v1/realtime/token now
returns in url.