Battles
Seven routes drive the whole lobby, and none of them is a start: a battle starts itself once
every seat is READY. Read Battle lifecycle first.
Open a battle
POST /battles scope: battles:write → 201
{
"game_id": "sweet-bonanza",
"seats_total": 3,
"entry_amount": "25.50",
"currency": "BRL",
"creator_player_ref": "alice",
"creator_game_url": "https://provider.example/session/abc"
}
seats_total must be 2 to 8. The creator is seated at index 0 automatically.
creator_game_url decides whether the battle can ever startSupply it and seat 0 is READY. Omit it and seat 0 is FILLED: claimed, but unable to play.
A two-seat battle created without it will not start when the other player joins, because seat
0 is still not READY.
Omit it only if you intend to call POST /battles/{id}/seats for the creator later.
The response is the full battle, including a ws_token for the live feed:
{
"id": "btl_uCHSnowFVUXf73YaSH2j",
"status": "OPEN",
"entry_amount": "25.5",
"ws_url": "wss://slotbattle.example.com/ws?battle_id=btl_uCHSnowFVUXf73YaSH2j",
"ws_token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6ImsxIn0...",
"ws_token_expires_at": "2026-08-03T03:55:42Z",
"ws_subprotocol": "slotbattle.jwt",
"seats": [
{ "seat_index": 0, "seat_ref": "usr_s0", "player_ref": "alice", "is_bot": false, "status": "READY" },
{ "seat_index": 1, "seat_ref": "usr_s1", "player_ref": "", "is_bot": false, "status": "EMPTY" },
{ "seat_index": 2, "seat_ref": "usr_s2", "player_ref": "", "is_bot": false, "status": "EMPTY" }
],
"expires_at": "2026-08-03T03:47:10Z"
}
"25.50" came back as "25.5", because amounts are normalised. expires_at is the lobby
window: unfilled by then, the battle is cancelled.
ws_token can be missingIf the instance has no viewer signing key, the battle is created normally and ws_token is
absent. It is an omitempty field, so there is no error to catch, and live viewing is
unavailable until a key is configured. Check for its presence rather than assuming it.
Failures worth handling
| Status | Code | Meaning |
|---|---|---|
400 | invalid_request | seats_total outside 2..8, missing creator_player_ref or game_id, or an unknown field |
403 | game_not_allowed | The game is not in your allowlist |
409 | conflict | The creator is already in another active battle |
503 | unavailable | At capacity. Retry with backoff |
Seat a player
POST /battles/{id}/seats scope: battles:write → 200
{ "player_ref": "bob", "game_url": "https://provider.example/session/def" }
Takes the lowest-index empty seat, or upgrades the caller's existing FILLED seat to READY.
Both fields are required, so the seat is always READY afterwards, which is what can trigger
auto-start.
A player holding a READY or PLAYING seat cannot join again (409). One holding a FILLED
seat is upgrading, which is allowed and is how a creator who omitted creator_game_url gets
themselves ready.
The response carries a fresh ws_token, so the joining player's browser can start watching
immediately.
Fill the rest with bots
POST /battles/{id}/bot-seats scope: bots:write → 200
Fills every remaining empty seat. Bots get demo sessions minted under your casino's own
provider credentials, so they land READY, which is normally what starts the battle.
{}
An empty body auto-names every bot. To name them:
{
"player_ref": "alice",
"bots": [{ "player_ref": "bot_lucky" }, { "player_ref": "bot_swift" }]
}
Supplying bots means supplying at least as many entries as there are empty seats. Fewer is a
409.
Two gates apply here and nowhere else:
- Creator only. A
player_refthat is not the creator is a403. - The allowlist is re-checked. A game revoked while the lobby sat open stops the battle at this point rather than letting it run.
A 502 means minting a bot's demo session failed at the provider, which is an upstream
problem rather than a bad request.
Leave
POST /battles/{id}/leave scope: battles:write → 200
{ "player_ref": "bob" }
Frees the seat back to EMPTY, clearing its player, video URL and any result.
Leaving does not hand the lobby to someone else. Check creator_player_ref before calling
this on a creator's behalf, and make sure your UI says "cancel", not "leave".
Cancel
POST /battles/{id}/cancel scope: battles:write → 200
Creator only, OPEN only. A battle that already started cannot be cancelled here, and answers
409.
The body is optional. Send {"player_ref": "alice"} when the call comes from a player action,
so the creator check runs. Omit it for an operator-initiated cancel, which skips that check.
Read and list
GET /battles/{id} scope: battles:read
GET /battles?status=OPEN&limit=20 scope: battles:read
limit defaults to 20 and is clamped to 100: asking for more is capped silently, not
rejected. Results are newest first.
Neither route mints a ws_token. Mint one explicitly with
viewer tokens when a viewer needs to connect to a battle they did not
create.
A battle belonging to another casino answers 404, identically to one that never existed.
Do not poll
Do not poll GET /battles/{id} to follow a battle. Subscribe to the
WebSocket feed for live state, and treat the webhook as the
authority on the result. Reserve reads for rendering a page someone just opened.