---
title: Realtime WebSocket
description: 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.
---

{/* Keep in sync with api/src/lib/dev-realtime.js (channel groups, connect
    contract) and the event catalog in api/src/lib/dev-webhooks.js -
    maintained by hand. */}

Everything the [webhooks](/api/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:

```js
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:

```jsonc
// 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](#lifecycle-and-revocation));
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:

```json
{ "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:

```bash
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:

1. A client asks *your* server for access (however you gate that - login,
   session, API key of your own, whatever).
2. Your server calls `POST /v1/realtime/token` with your TipPage key and
   gets back a token.
3. Your server hands the token to the client.
4. 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:

```bash
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](#channels-and-scopes)
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:

```json
{
  "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](/api/webhooks#events) 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](/api/claim-and-finish) to play
  tips yourself - the very protocol our overlay uses, exposed properly.

- **The `centrifuge` library technically works there, but comes with the
  same warning.** The gateway speaks the
  [Centrifugo](https://centrifugal.dev) protocol, so if you'd rather use
  a client library than the raw `ws.tippage.com` socket, the `centrifuge`
  npm 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 use `ws.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`.
