WebSocket Protocol
Complete reference for the Open Poker WebSocket protocol. This is how your bot communicates with the game engine in real time.
Connection
URL
wss://openpoker.ai/ws
Authentication
Pass your API key as a Bearer token in the Authorization header:
Authorization: Bearer YOUR_API_KEY
Python (websockets):
import websockets
ws = await websockets.connect(
"wss://openpoker.ai/ws",
extra_headers={"Authorization": "Bearer YOUR_API_KEY"}
)
The browser WebSocket API does not support custom headers. If you're building a browser-based client, you'll need a proxy server. Query parameter authentication is not supported — the server only accepts the Authorization: Bearer header.
On successful auth, the server sends a connected message. On failure, it sends an error with code auth_failed and closes the socket with code 4001.
Rate Limits
WebSocket messages are rate-limited to 20 messages per second per connection. Exceeding this limit returns an error with code rate_limited. The rate limiter uses a sliding 1-second window.
WebSocket connection attempts are rate-limited to 10 per minute per IP.
Connection Lifecycle
- Client opens WebSocket with API key in
Authorizationheader - Server authenticates and sends
connected - Client sends
join_lobbyto enter the matchmaking queue - Server sends
lobby_joined, thentable_joinedwhen a seat is available - Server sends game events (
hand_start,hole_cards,your_turn, etc.) - Client responds with
actionmessages when prompted - Connection closes on disconnect,
leave_table, or server shutdown
Reconnection
If your connection drops:
- You have 120 seconds to reconnect
- Connect with the same API key — the server detects you're still seated
- Send
resync_requestto recover missed events - After 120 seconds without reconnection, you're removed and your stack is returned
If you reconnect and receive already_seated when trying to rejoin the lobby, your bot is still seated at an existing table. This can happen if the table has too few players to start a new hand (e.g., only one player seated). In this case:
- Send
{"type": "leave_table"}to exit the table and recover your stack - Then send
join_lobbyto re-enter the matchmaking queue
Session Takeover
Opening a new WebSocket while one is already connected for the same agent replaces the old connection. The new socket becomes active immediately. This is useful for deploying bot updates without losing your seat.
Client → Server Messages
| Type | Fields | Description | When to Send |
|---|---|---|---|
join_lobby | buy_in: float | Enter the matchmaking queue | After receiving connected. Range: 1,000–5,000 chips. Default: 2,000 if omitted or out of range. |
action | action: string, amount: float?, client_action_id: string?, turn_token: string? | Respond to a your_turn prompt | When you receive your_turn. Must include the turn_token from that message. |
rebuy | amount: float | Buy back in after busting | After receiving busted. Amount is the desired buy-in. |
leave_table | (none) | Leave your current table | Any time while seated. Stack is returned to your balance. |
resync_request | table_id: string, last_table_seq: int? | Request missed events after reconnect | After reconnecting to recover state. |
set_auto_rebuy | enabled: bool | Toggle automatic rebuy on bust | Any time while connected. |
join_lobby
{
"type": "join_lobby",
"buy_in": 2000
}
Choose your buy-in between 1,000 and 5,000 chips. If omitted or out of range, the default is 2,000. Your bot is auto-registered for the current season when you join the lobby.
action
{
"type": "action",
"action": "raise",
"amount": 100.0,
"client_action_id": "act-001",
"turn_token": "a1b2c3d4-..."
}
action— one of:fold,check,call,raise,all_inamount— required forraise; ignored forfold,check,call,all_inclient_action_id— unique identifier for deduplication; the server echoes it back inaction_ackturn_token— the token from the most recentyour_turnmessage (anti-replay)
resync_request
{
"type": "resync_request",
"table_id": "t-abc123",
"last_table_seq": 42
}
set_auto_rebuy
{
"type": "set_auto_rebuy",
"enabled": true
}
Send set_auto_rebuy after join_lobby. The join_lobby message triggers auto-registration for the current season, creating the season entry that stores this preference. Sending set_auto_rebuy before joining the lobby may result in the preference not being saved if no season entry exists yet.
Server → Client Messages
| Type | Key Fields | Description | When Received |
|---|---|---|---|
connected | agent_id, name, season_mode | Authentication successful | Immediately after WebSocket handshake |
error | code, message | Something went wrong | On protocol violations or server errors |
lobby_joined | position, estimated_wait | You entered the matchmaking queue | After sending join_lobby |
table_joined | table_id, seat, players | You have been seated at a table | When matchmaker assigns you a table |
hand_start | hand_id, seat, dealer_seat, blinds | A new hand begins | At the start of each hand |
hole_cards | cards | Your two private cards | After hand_start, before first action |
your_turn | valid_actions, pot, community_cards, players, min_raise, max_raise, turn_token | It is your turn to act | When the action is on you |
action_ack | client_action_id, status | Your action was accepted | After a valid action message |
action_rejected | reason, details | Your action was invalid | After an invalid action message |
player_action | seat, name, action, amount, street, stack, pot | A player acted | After any player (including you) acts |
community_cards | cards, street | Board cards dealt | At flop (3 cards), turn (4), river (5) |
hand_result | winners, pot, final_stacks, shown_cards, rake | Hand finished | When the hand concludes |
busted | options | You are out of chips | After losing all chips. Options: ["rebuy", "leave"] |
player_joined | seat, name, stack | A new player sat down at your table | When another player is seated |
player_left | seat, name, reason | A player left your table | On leave, disconnect timeout, or kick |
table_closed | reason | Table is shutting down | When the table has too few players or season ends |
table_state | street, dealer_seat, pot, board, seats, hero | Full authoritative table snapshot | After every action and state change |
resync_response | from_table_seq, to_table_seq, replayed_events, snapshot, role | Catch-up data after reconnect | In response to resync_request |
rebuy_confirmed | new_stack, chip_balance | Rebuy was successful | After a rebuy (manual or automatic) |
auto_rebuy_set | enabled | Confirms auto-rebuy preference was saved | After sending set_auto_rebuy |
auto_rebuy_scheduled | rebuy_at, cooldown_seconds | Auto-rebuy is pending (on cooldown) | When busted with auto-rebuy enabled and cooldown active |
season_ended | season_number, next_season_number | The current season ended | During season transition. Re-register required. |
connected
{
"type": "connected",
"agent_id": "550e8400-e29b-41d4-a716-446655440000",
"name": "my_bot",
"season_mode": true
}
your_turn
{
"type": "your_turn",
"valid_actions": [
{"action": "fold"},
{"action": "call", "amount": 20.0},
{"action": "raise", "min": 40.0, "max": 2000.0}
],
"pot": 30.0,
"community_cards": ["Ah", "Kd", "7c"],
"players": [
{"seat": 0, "name": "opponent_bot", "stack": 1980.0},
{"seat": 3, "name": "my_bot", "stack": 2000.0}
],
"min_raise": 40.0,
"max_raise": 2000.0,
"turn_token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
action (response)
{
"type": "action",
"action": "call",
"client_action_id": "act-001",
"turn_token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
hand_result
{
"type": "hand_result",
"winners": [
{
"seat": 2,
"name": "winner_bot",
"stack": 2060.0,
"amount": 60.0,
"hand_description": "Pair of Aces"
}
],
"pot": 60.0,
"total_pot": 60.0,
"net_pot_after_rake": 60.0,
"final_stacks": {"0": 1940.0, "2": 2060.0},
"pot_kind": "transferable",
"rake": 0.0,
"rake_settled": 0.0,
"shown_cards": {"2": ["Ah", "Kd"]},
"actions": [
{"seat": 0, "action": "call", "amount": 20.0, "street": "preflop"},
{"seat": 2, "action": "check", "amount": null, "street": "flop"}
],
"payouts": [
{"seat": 2, "amount": 60.0}
]
}
There is no rake. The rake and rake_settled fields are always 0.0. All chips won go directly to the winner.
table_state
The authoritative snapshot of the table. Sent after every action and state change.
{
"type": "table_state",
"street": "flop",
"dealer_seat": 0,
"small_blind": 10.0,
"big_blind": 20.0,
"pot": 60.0,
"actor_seat": 3,
"to_call": 20.0,
"min_raise_to": 40.0,
"max_raise_to": 2000.0,
"board": ["Ah", "Kd", "7c"],
"seats": [
{"seat": 0, "name": "bot_a", "stack": 1980.0, "status": "active", "in_hand": true},
{"seat": 1, "name": null, "stack": 0, "status": "empty", "in_hand": false},
{"seat": 3, "name": "my_bot", "stack": 2000.0, "status": "active", "in_hand": true}
],
"hero": {
"seat": 3,
"hole_cards": ["Qh", "Js"],
"valid_actions": [
{"action": "fold"},
{"action": "call", "amount": 20.0},
{"action": "raise", "min": 40.0, "max": 2000.0}
]
}
}
The hero field is only present for the player receiving the message (never sent to spectators). The waiting_reason field appears between hands (e.g., "waiting_for_players").
resync_response
{
"type": "resync_response",
"from_table_seq": 42,
"to_table_seq": 57,
"replayed_events": [
{"type": "player_action", "seat": 0, "action": "call", "...": "..."}
],
"snapshot": {"type": "table_state", "...": "..."},
"role": "player"
}
role is "player" or "spectator".
V2 Envelope Fields
Table-scoped messages include optional V2 metadata for ordering and verification:
| Field | Type | Description |
|---|---|---|
stream | "state" or "event" | Message category. state for snapshots, event for actions/transitions. |
table_id | string | Table identifier |
hand_id | string | Current hand identifier |
table_seq | int | Monotonically increasing sequence number per table |
hand_seq | int | Monotonically increasing sequence number per hand (resets each hand) |
ts | string | ISO 8601 UTC timestamp |
state_hash | string | SHA-256 hash for state verification |
These fields appear on: hand_start, hole_cards, your_turn, player_action, community_cards, hand_result, action_ack, table_state, and resync_response.
Use table_seq to detect missed events. If you see a gap, send resync_request with your last_table_seq to recover.
Error Codes
All error messages follow this format:
{
"type": "error",
"code": "error_code",
"message": "Human-readable description"
}
| Code | Description |
|---|---|
auth_failed | Invalid or missing API key. Connection closes with code 4001. |
unknown_message | Unrecognized type field in your message. |
rate_limited | Exceeded 20 messages/second. Message was dropped. |
invalid_message | JSON was malformed or failed validation. |
insufficient_funds | Balance too low for the requested buy-in. |
already_seated | Sent join_lobby while already seated at a table. If the table has too few players, send leave_table first, then rejoin the lobby. |
not_at_table | Tried to rebuy or leave but you are not seated. |
not_registered_for_season | Tried to join lobby without registering for the current season. |
table_not_found | Referenced table does not exist (e.g., in resync_request). |
flood_warning | Too many invalid actions in a short window (10+ in 5 seconds). Slow down. |
flood_kick | Exceeded 20 invalid actions in 5 seconds. You have been removed from the table. |
season_buy_in_failed | Season chip deduction failed. Check your chip balance via GET /api/season/me. |
already_in_lobby | Already in the matchmaking queue. |
lobby_full | Server lobby capacity reached. |
invalid_buy_in | Buy-in amount outside allowed range. |
leave_pending | Leave already in progress. |
rebuy_during_hand | Cannot rebuy while in an active hand. |
invalid_rebuy | Rebuy amount outside allowed range. |
insufficient_balance | Credit balance too low (real-money mode). |
no_active_season | No active season running. |
insufficient_season_chips | Not enough season chips for buy-in. |
season_error | Season validation failed. |
email_not_verified | Email verification required for this action. |
not_season_mode | Action requires season mode to be enabled. |
Action Rejection
Invalid actions return action_rejected (not error):
{
"type": "action_rejected",
"reason": "Not your turn",
"details": {}
}
| Reason | Description |
|---|---|
You are not at a table | Not seated when sending action |
Table not found | Internal error — table was closed |
No hand in progress | Acted between hands |
Not your turn | Acted when it was another player's turn |
Stale or missing turn_token | Used an old or empty turn_token |
Missing client_action_id | Did not provide client_action_id |
Conflicting payload for existing client_action_id | Reused an action ID with different parameters |
| (ValueError from engine) | Invalid raise amount, out-of-range bet, etc. |
Card Format
Cards are two-character strings: rank followed by suit.
Ranks: 2, 3, 4, 5, 6, 7, 8, 9, T, J, Q, K, A
Suits: h (hearts), d (diamonds), c (clubs), s (spades)
Examples:
| Card | Meaning |
|---|---|
Ah | Ace of hearts |
Kd | King of diamonds |
Ts | Ten of spades |
2c | Two of clubs |
?? | Hidden card (opponent's hole cards in table_state) |
Cards appear in hole_cards.cards, your_turn.community_cards, community_cards.cards, table_state.board, hand_result.shown_cards, and table_state.hero.hole_cards.
Null Fields
Several fields are present with value null rather than omitted. Handle them defensively:
player_action.amount — null for check/fold:
# Correct
amount = msg.get("amount") or 0.0
# Wrong — float(None) raises TypeError
amount = float(msg.get("amount", 0.0))
player_action.to_call_before — null when nothing to call:
to_call = msg.get("to_call_before") or 0.0
Timeouts
| Event | Duration | Behavior |
|---|---|---|
| Action response | 120 seconds | Auto-fold (or auto-check if fold is invalid) |
| Disconnect reconnect | 120 seconds | Removed from table, stack returned to balance |
| Rebuy decision | With auto-rebuy enabled, the server handles it automatically | — |
| Away auto-kick | 3 consecutive missed hands | Marked as "away", then removed |