Viewer tokens
POST /battles/{id}/viewer-tokens scope: viewer:mint → 200
A viewer token is the credential a browser presents to watch a battle live. It is the only SlotBattle credential that reaches untrusted code, which is why it is small, short-lived and scoped to exactly one battle.
{
"token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6ImsxIn0...",
"expires_at": "2026-08-03T03:55:42Z",
"ws_url": "wss://slotbattle.example.com/ws?battle_id=btl_CIkE8lctOrFDe1hJlG0T",
"ws_subprotocol": "slotbattle.jwt"
}
What is inside
An Ed25519-signed JWT. Decoded, from a real instance:
// header
{ "alg": "EdDSA", "typ": "JWT", "kid": "k1" }
// claims
{
"iss": "slotbattle",
"aud": "slotbattle-ws",
"tenant_id": "acme",
"battle_id": "btl_CIkE8lctOrFDe1hJlG0T",
"sub": "docs verification",
"role": "viewer",
"iat": 1785728442,
"exp": 1785729342
}
Lifetime is 15 minutes. battle_id is baked in, so a token for one battle is useless for
another. The kid names the signing key, which is what makes rotation survivable. See
Keys and credentials.
When you need this route
POST /battles and POST /battles/{id}/seats already return a token. Call this route when:
- The token expired. A lobby that sat open twenty minutes outlived its token.
- A new viewer arrives. Someone watching a battle they did not create or join.
- You are re-rendering. A page reload past the 15-minute mark.
Mint one per viewer. They are cheap, and the token is the only revocation granularity available: one token shared across an audience cannot be withdrawn from a single person.
Never put the token in a URL
The token belongs in a response body your front end reads, then in the WebSocket subprotocol
header. Putting it in ws_url as a query parameter, or in any URL, leaks it into access logs,
proxy logs and browser history.
The API never does this: ws_url carries only battle_id, and the token is a separate field.
Failures
| Status | Code | Meaning |
|---|---|---|
403 | forbidden | The key lacks viewer:mint |
404 | not_found | No such battle for this casino |
500 | internal | The instance has no signing key |
503 | unavailable | The instance has no token issuer at all, or is still starting |
The 500 is a configuration problem, not a bug
{ "error": { "code": "internal", "message": "failed to mint viewer token" } }
The instance has no viewer signing key. Battles still open and seats still fill; only live
viewing is unavailable, and ws_token is absent from create and seat responses because the
field is optional.
Report it to your host, quoting that exact message. The signing key is platform-scoped, since one key signs for every casino on the instance, so configuring it is theirs to do.
Handling expiry in a front end
The token outlives most lobbies but not all of them. A workable pattern:
- Use the
ws_tokenfrom the create or seat response for the first connection. - Keep
ws_token_expires_at. Re-mint when it is within a minute of expiring, or when the socket closes and a reconnect is needed. - Route the mint through your backend, which holds the API key. Never call SlotBattle from the browser.
The tutorial implements exactly this.
Next
- WebSocket: presenting the token on the handshake
- Keys and credentials: rotation and its window