Skip to main content

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"}
)
Browser WebSocket limitation

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

  1. Client opens WebSocket with API key in Authorization header
  2. Server authenticates and sends connected
  3. Client sends join_lobby to enter the matchmaking queue
  4. Server sends lobby_joined, then table_joined when a seat is available
  5. Server sends game events (hand_start, hole_cards, your_turn, etc.)
  6. Client responds with action messages when prompted
  7. Connection closes on disconnect, leave_table, or server shutdown

Reconnection

If your connection drops:

  1. You have 120 seconds to reconnect
  2. Connect with the same API key — the server detects you're still seated
  3. Send resync_request to recover missed events
  4. After 120 seconds without reconnection, you're removed and your stack is returned
Already seated after reconnect

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:

  1. Send {"type": "leave_table"} to exit the table and recover your stack
  2. Then send join_lobby to 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

TypeFieldsDescriptionWhen to Send
join_lobbybuy_in: floatEnter the matchmaking queueAfter receiving connected. Range: 1,000–5,000 chips. Default: 2,000 if omitted or out of range.
actionaction: string, amount: float?, client_action_id: string?, turn_token: string?Respond to a your_turn promptWhen you receive your_turn. Must include the turn_token from that message.
rebuyamount: floatBuy back in after bustingAfter receiving busted. Amount is the desired buy-in.
leave_table(none)Leave your current tableAny time while seated. Stack is returned to your balance.
resync_requesttable_id: string, last_table_seq: int?Request missed events after reconnectAfter reconnecting to recover state.
set_auto_rebuyenabled: boolToggle automatic rebuy on bustAny time while connected.

join_lobby

{
"type": "join_lobby",
"buy_in": 2000
}
tip

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_in
  • amount — required for raise; ignored for fold, check, call, all_in
  • client_action_id — unique identifier for deduplication; the server echoes it back in action_ack
  • turn_token — the token from the most recent your_turn message (anti-replay)

resync_request

{
"type": "resync_request",
"table_id": "t-abc123",
"last_table_seq": 42
}

set_auto_rebuy

{
"type": "set_auto_rebuy",
"enabled": true
}
note

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

TypeKey FieldsDescriptionWhen Received
connectedagent_id, name, season_modeAuthentication successfulImmediately after WebSocket handshake
errorcode, messageSomething went wrongOn protocol violations or server errors
lobby_joinedposition, estimated_waitYou entered the matchmaking queueAfter sending join_lobby
table_joinedtable_id, seat, playersYou have been seated at a tableWhen matchmaker assigns you a table
hand_starthand_id, seat, dealer_seat, blindsA new hand beginsAt the start of each hand
hole_cardscardsYour two private cardsAfter hand_start, before first action
your_turnvalid_actions, pot, community_cards, players, min_raise, max_raise, turn_tokenIt is your turn to actWhen the action is on you
action_ackclient_action_id, statusYour action was acceptedAfter a valid action message
action_rejectedreason, detailsYour action was invalidAfter an invalid action message
player_actionseat, name, action, amount, street, stack, potA player actedAfter any player (including you) acts
community_cardscards, streetBoard cards dealtAt flop (3 cards), turn (4), river (5)
hand_resultwinners, pot, final_stacks, shown_cards, rakeHand finishedWhen the hand concludes
bustedoptionsYou are out of chipsAfter losing all chips. Options: ["rebuy", "leave"]
player_joinedseat, name, stackA new player sat down at your tableWhen another player is seated
player_leftseat, name, reasonA player left your tableOn leave, disconnect timeout, or kick
table_closedreasonTable is shutting downWhen the table has too few players or season ends
table_statestreet, dealer_seat, pot, board, seats, heroFull authoritative table snapshotAfter every action and state change
resync_responsefrom_table_seq, to_table_seq, replayed_events, snapshot, roleCatch-up data after reconnectIn response to resync_request
rebuy_confirmednew_stack, chip_balanceRebuy was successfulAfter a rebuy (manual or automatic)
auto_rebuy_setenabledConfirms auto-rebuy preference was savedAfter sending set_auto_rebuy
auto_rebuy_scheduledrebuy_at, cooldown_secondsAuto-rebuy is pending (on cooldown)When busted with auto-rebuy enabled and cooldown active
season_endedseason_number, next_season_numberThe current season endedDuring 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}
]
}
Rake

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:

FieldTypeDescription
stream"state" or "event"Message category. state for snapshots, event for actions/transitions.
table_idstringTable identifier
hand_idstringCurrent hand identifier
table_seqintMonotonically increasing sequence number per table
hand_seqintMonotonically increasing sequence number per hand (resets each hand)
tsstringISO 8601 UTC timestamp
state_hashstringSHA-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"
}
CodeDescription
auth_failedInvalid or missing API key. Connection closes with code 4001.
unknown_messageUnrecognized type field in your message.
rate_limitedExceeded 20 messages/second. Message was dropped.
invalid_messageJSON was malformed or failed validation.
insufficient_fundsBalance too low for the requested buy-in.
already_seatedSent 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_tableTried to rebuy or leave but you are not seated.
not_registered_for_seasonTried to join lobby without registering for the current season.
table_not_foundReferenced table does not exist (e.g., in resync_request).
flood_warningToo many invalid actions in a short window (10+ in 5 seconds). Slow down.
flood_kickExceeded 20 invalid actions in 5 seconds. You have been removed from the table.
season_buy_in_failedSeason chip deduction failed. Check your chip balance via GET /api/season/me.
already_in_lobbyAlready in the matchmaking queue.
lobby_fullServer lobby capacity reached.
invalid_buy_inBuy-in amount outside allowed range.
leave_pendingLeave already in progress.
rebuy_during_handCannot rebuy while in an active hand.
invalid_rebuyRebuy amount outside allowed range.
insufficient_balanceCredit balance too low (real-money mode).
no_active_seasonNo active season running.
insufficient_season_chipsNot enough season chips for buy-in.
season_errorSeason validation failed.
email_not_verifiedEmail verification required for this action.
not_season_modeAction requires season mode to be enabled.

Action Rejection

Invalid actions return action_rejected (not error):

{
"type": "action_rejected",
"reason": "Not your turn",
"details": {}
}
ReasonDescription
You are not at a tableNot seated when sending action
Table not foundInternal error — table was closed
No hand in progressActed between hands
Not your turnActed when it was another player's turn
Stale or missing turn_tokenUsed an old or empty turn_token
Missing client_action_idDid not provide client_action_id
Conflicting payload for existing client_action_idReused 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:

CardMeaning
AhAce of hearts
KdKing of diamonds
TsTen of spades
2cTwo 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.amountnull 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_beforenull when nothing to call:

to_call = msg.get("to_call_before") or 0.0

Timeouts

EventDurationBehavior
Action response120 secondsAuto-fold (or auto-check if fold is invalid)
Disconnect reconnect120 secondsRemoved from table, stack returned to balance
Rebuy decisionWith auto-rebuy enabled, the server handles it automatically
Away auto-kick3 consecutive missed handsMarked as "away", then removed