Debug Your Poker Bot: 7 Common WebSocket Errors Fixed
Every bot builder hits the same poker bot websocket errors. I've watched hundreds of bots connect to Open Poker, and the failure modes are remarkably consistent: auth failures, timeouts, malformed JSON, silent disconnects, and race conditions that only appear under load. Here are the seven errors you'll hit and how to fix each one. For a deeper dive on timeouts specifically, see Why Your Poker Bot Times Out. If you're writing your first bot, start with the Python quickstart instead.
Part of: The Complete Guide to Building an AI Poker Bot in 2026 — the full pillar covering frameworks, decision logic, equity, testing, and where to compete.
1. Why does my bot get auth_failed immediately?
The auth_failed error means the server rejected your API key before the WebSocket handshake completed. The socket closes with code 4001, and you'll see this response:
{
"type": "error",
"code": "auth_failed",
"message": "Invalid or missing API key"
}Three things cause this. The most common: a missing Authorization header. The websockets library in Python doesn't send custom headers unless you explicitly pass them.
import websockets
# Wrong: no auth header
ws = await websockets.connect("wss://openpoker.ai/ws")
# Right: pass the header explicitly
headers = {"Authorization": f"Bearer {API_KEY}"}
ws = await websockets.connect("wss://openpoker.ai/ws", additional_headers=headers)Second cause: whitespace in your API key. If you copied the key from a dashboard or email, trailing newlines sneak in. Strip it: API_KEY = os.environ["POKER_API_KEY"].strip().
Third: you regenerated your key with POST /api/me/regenerate-key and forgot to update your bot config. The old key is immediately invalid. There's no grace period.
Check the WebSocket protocol docs for the full authentication flow, including the query-parameter fallback for browser clients.
2. Why does my bot auto-fold every hand?
Your bot is timing out. Open Poker gives you 120 seconds per action. If the server doesn't receive a valid action in that window, it auto-folds for you. 120 seconds sounds generous, but I've seen bots blow through it for two reasons.
Blocking the event loop. If your your_turn handler does synchronous work (HTTP calls, file I/O, heavy computation), the async for loop stalls. The message sits in the buffer while your code blocks.
# Bad: blocks the event loop
def decide(msg):
time.sleep(2) # simulating slow computation
return "call"
# Good: keep it async
async def decide(msg):
await asyncio.sleep(0) # yield control briefly if needed
return "call"Not handling your_turn at all. If your message router doesn't match the your_turn type string, the message gets silently dropped. Add logging for unhandled messages:
async for raw in ws:
msg = json.loads(raw)
t = msg.get("type")
if t == "your_turn":
await handle_turn(ws, msg)
elif t in ("hand_start", "hand_result", "community_cards"):
pass # informational
else:
print(f"Unhandled message type: {t}")The bot lifecycle docs walk through every message your bot needs to handle, with the exact JSON shapes.
3. What causes action_rejected errors?
The server validated your action and found a problem. You still need to send a valid action before the timeout, or you'll auto-fold. The response looks like this:
{
"type": "action_rejected",
"reason": "Invalid raise amount"
}The top three causes:
Stale turn_token. Every your_turn message includes a fresh turn_token. You must echo it back. If you cached the token from a previous turn (or never included it), the action is rejected.
# Always use the token from the CURRENT your_turn
await ws.send(json.dumps({
"type": "action",
"action": "raise",
"amount": 60.0,
"turn_token": msg["turn_token"], # from the your_turn you're responding to
}))Raise amount out of range. The valid_actions array tells you the exact min and max for raises. Send anything outside that range and it's rejected. Don't hardcode raise sizes.
actions = {a["action"]: a for a in msg["valid_actions"]}
if "raise" in actions:
min_raise = actions["raise"]["min"]
max_raise = actions["raise"]["max"]
# Your desired amount, clamped to valid range
amount = max(min_raise, min(your_amount, max_raise))Sending an action that isn't valid. If valid_actions only contains fold and call, sending check is rejected. Always read the array. Never assume what's available.
4. How do I handle null fields without crashing?
Several fields in Open Poker's protocol are present with value null rather than omitted. This is a deliberate design choice (consistent message shapes), but it catches bot builders who use naive type coercion.
The classic crash:
# This raises TypeError when amount is null
amount = float(msg["amount"]) # float(None) -> TypeErrorThe player_action message sets amount to null for folds and checks. The fix is straightforward:
# Safe: handles null
amount = msg.get("amount") or 0.0Same pattern for to_call_before, which is null when there's nothing to call:
to_call = msg.get("to_call_before") or 0.0We lost about 4 hours to a subtle variant of this bug. Our bot tracked opponent bet sizes by accumulating player_action.amount values. When a player checked, the null amount silently broke our running total. The error didn't surface until the pot odds calculation produced inf. If you're building state tracking, validate every field from every message.
See the message handling docs for the complete list of nullable fields and safe access patterns.
5. Why does my bot disconnect and lose its seat?
You have 120 seconds to reconnect after a drop. After that, you're removed from the table and your stack returns to your balance. The server holds your seat, but it won't wait forever.
Common disconnect causes:
No ping/pong handling. The websockets library handles WebSocket pings automatically in most versions, but if you're using a lower-level client or have disabled automatic pong responses, the server closes idle connections.
Network blips without retry logic. Your bot needs a reconnection wrapper:
import asyncio
import json
import websockets
async def connect_with_retry(api_key, max_retries=10):
headers = {"Authorization": f"Bearer {api_key}"}
retries = 0
while retries < max_retries:
try:
async with websockets.connect(
"wss://openpoker.ai/ws",
additional_headers=headers
) as ws:
retries = 0 # reset on successful connection
await play_loop(ws)
except (websockets.ConnectionClosed, ConnectionError) as e:
retries += 1
wait = min(2 ** retries, 60) # exponential backoff, cap at 60s
print(f"Disconnected: {e}. Retrying in {wait}s ({retries}/{max_retries})")
await asyncio.sleep(wait)
print("Max retries reached. Exiting.")Session takeover. If you open a second WebSocket with the same API key, the old connection is replaced immediately. This happens when you restart your bot without the previous process dying cleanly. One agent, one connection. Kill the old process first.
After reconnecting, send a resync_request to recover any missed events:
await ws.send(json.dumps({
"type": "resync_request",
"table_id": stored_table_id,
"last_table_seq": last_seq_number
}))6. What does rate_limited mean and how do I avoid it?
Open Poker enforces two rate limits: 20 messages per second per WebSocket connection, and 10 connection attempts per minute per IP. Hit either one and you'll get:
{
"type": "error",
"code": "rate_limited",
"message": "Too many messages per second"
}The message-per-second limit rarely matters during normal play. You send one action per turn and maybe a join_lobby between tables. But bots that log or echo every received message back to the server (we've seen this) hit it fast.
The connection-attempt limit is the sneaky one. If your reconnection logic has no backoff, a network flap can burn through 10 attempts in seconds, locking you out for a minute. The exponential backoff in the reconnection example above prevents this.
Quick diagnostic: count your outbound messages. If you're sending more than 5 per second during normal play, something is wrong. Likely you're re-sending actions on every received message instead of only on your_turn.
7. Why does my bot join the lobby but never get seated?
This isn't a WebSocket error, it's a matchmaking issue. But it's the most common "my bot is broken" report we see from new builders.
The matchmaker needs at least 2 players in the queue to create a table. If nobody else is playing, your bot waits indefinitely. Check the leaderboard to see if other bots are active.
Other causes:
| Symptom | Error code | Fix |
|---|---|---|
| Already at a table | already_seated | Send leave_table first, then rejoin lobby |
| Already queued | already_in_lobby | Don't send join_lobby twice |
| No active season | no_active_season | Wait for the next season to start |
| Not enough chips | insufficient_season_chips | Check your chip balance |
If you're testing against your own local server, use separate local test identities to get past the 2-player minimum. On production, do not register extra independent agents or accounts as a workaround for strategy testing.
When none of the seven errors above match your situation, run through this quick diagnostic sequence: print every raw message (print(f"<< {raw}") inside your async for loop), check the code field in any error message against the error code table, verify your turn_token, test with the 47-line calling station as a known-good baseline, and check for any time.sleep() or synchronous calls blocking your async handler.
FAQ
My bot works locally but fails in production. What's different?
Three things change: the URL switches from ws://localhost:8000/ws to wss://openpoker.ai/ws (note wss, not ws), TLS certificate validation kicks in, and latency increases. If you're using a self-signed cert locally, your production code needs to trust the real cert chain. Most websockets installations handle this automatically, but custom SSL contexts can break it.
How do I know if my action was actually accepted?
The server sends an action_ack message with your client_action_id echoed back. If you don't include a client_action_id, you won't get a correlation field in the ack. Always include one.
Can I reconnect mid-hand and still act?
Yes. You have 120 seconds. After reconnecting, send a resync_request with your table_id and last_table_seq to recover missed events. If it's still your turn, you'll receive a fresh your_turn message with the current game state.
Why do I get invalid_message errors?
Your JSON is malformed or missing required fields. Common causes: single quotes instead of double quotes (Python's json.dumps handles this, but f-strings don't), missing type field, or sending a Python dict directly instead of serializing it to JSON first.
Got a bug that isn't covered here? Read the full protocol reference or register your bot and start debugging against the live server. The best way to learn the protocol is to print every message and read them.