2. Protocol and API

The full message reference is CROWDPLAY.md. This is the part a partner integrates against: the two sockets, and how each proves who it is.

Everything is JSON, one object per frame, both directions.

The game socket

Server to server, so a secret is safe on it.


wss://<host>/ws?role=game&tenant=<slug>&channel=<channel>&key=<secret>

key is the secret we exchanged. It is checked in constant time; a wrong one closes with 4001 unauthorised and a wrong or missing channel with 4003 no channel.

One game per channel. A second connection replaces the first, and the replacement gets a fresh session: everybody already in the room is seated again, and any round that was open is refunded rather than left hanging.

Send your catalog as soon as you receive ready. Then you receive one actions batch per tick, and you open and settle whatever rounds you want.

The viewer socket

Browsers cannot hold a shared secret, and you are not going to hand us your user database. So a viewer arrives holding a token your backend minted with your own secret:


wss://<host>/ws?role=crowd&tenant=<slug>&channel=<channel>&token=<viewer token>

We verify the signature, read the id and display name out of it, and that is the entire extent of what we know about that person. No account is created, no lookup happens, nothing is stored against them beyond the session.

A viewer with no token is allowed and becomes a spectator: they see everything and can do nothing. That is deliberate — the honest state, rather than a balance that is a fiction.

The viewer token

You mint these. The format is fixed and small on purpose:


cp1.<base64url(payload)>.<base64url(hmac-sha256)>

The payload is:


{ "sub": "your-user-id", "name": "Display Name", "exp": 1787050000000 }

The MAC is HMAC-SHA256(secret, "cp1." + base64url(payload)).

hours. Long enough to open a page, short enough that a leaked one is worthless.

It is deliberately not a JWT. A JWT invites alg: none, key confusion, and a parser with a decade of CVEs behind it. This is one algorithm, one version, and no negotiation: if the first field is not cp1, it is not a token. The signature is verified before the payload is parsed, so malformed input never reaches a JSON parser on an unauthenticated path.

Minting is about fifteen lines in any language. In Node:


const crypto = require('crypto');

function mintViewerToken(secret, { id, name = '', ttlMs = 600000 }) {
  const payload = Buffer
    .from(JSON.stringify({ sub: id, name: name.slice(0, 40), exp: Date.now() + ttlMs }))
    .toString('base64url');
  const mac = crypto.createHmac('sha256', secret)
    .update(`cp1.${payload}`).digest().toString('base64url');
  return `cp1.${payload}.${mac}`;
}

Interaction without video


wss://<host>/ws?role=view&channel=<name>&media=0

Chat and crowdplay, no video. This is the shape where somebody else owns the player and the audience, and only the interaction layer comes from us.

What the server enforces

So you do not have to, and so a modified client cannot bypass it:

Currency and settlement

Each tenant has its own wallet and its own ledger, and no tenant's engine can see another's. A viewer's balance under your slug is yours: it is opened, debited and settled entirely within your tenancy.

Movements are written durably as they happen, so a settlement survives a restart, and each carries an idempotency key so a replayed one moves nothing twice.

Part of the IMPAKT crowdplay integration pack. All documents.