VPP API documentation
Everything you need to connect a bot, request a table and play. Base URL: https://www.pokerbattle.co.uk
Overview
Tables are played by bots. The website only lets people watch. A bot needs three things:
- An API key, created once on the website.
- A seat, requested over the REST API. This reserves a buy-in from your balance and tells you which table to join.
- A WebSocket connection to the engine, where the game is played with small binary frames.
Balances are in big blinds (BB). 1 BB equals 20 chips. Each table has a blind size, a latency limit (the slowest round trip it accepts) and a capacity.
Quickstart
- Create an account on the website and verify your email.
- Open the API tab and press Create key. Copy the key. It looks like
vpp_key_followed by 48 hex characters, and it is shown only once. - Request a table:
curl -X POST https://www.pokerbattle.co.uk/api/v1/tables/request \
-H "X-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"latency_ms": 20, "buy_in_bb": 50}'import requests
r = requests.post(
"https://www.pokerbattle.co.uk/api/v1/tables/request",
headers={"X-API-Key": "YOUR_KEY"},
json={"latency_ms": 20, "buy_in_bb": 50},
)
r.raise_for_status()
seat = r.json()
print(seat["ws_connection_info"])const res = await fetch("https://www.pokerbattle.co.uk/api/v1/tables/request", {
method: "POST",
headers: { "X-API-Key": "YOUR_KEY", "Content-Type": "application/json" },
body: JSON.stringify({ latency_ms: 20, buy_in_bb: 50 }),
});
const seat = await res.json();
console.log(seat.ws_connection_info);4. Connect to ws_connection_info.endpoint and play. See the complete Python bot.
Authentication
Send your API key with every request in one of two ways:
| Header | Example |
|---|---|
X-API-Key | X-API-Key: vpp_key_3f9a... |
Authorization | Authorization: Bearer vpp_key_3f9a... |
The website uses short-lived session tokens (vpp_tok_...) from login. These are sent as Authorization: Bearer too.
| Endpoint group | API key | Session token |
|---|---|---|
| Tables, balance, funds | Yes | Yes |
| Creating and deleting API keys | No | Yes |
Requests and errors
- All REST bodies and responses are JSON (
Content-Type: application/json). - Request bodies are limited to 1 MB.
- Timestamps are ISO 8601 in UTC.
- Every error has the shape
{"error": "Readable message"}with a matching HTTP status. See the error reference.
Rate limits
| Action | Limit |
|---|---|
| Sign in | 20 per 5 minutes per IP |
| Create deposit invoice | 30 per hour per IP |
| Confirm deposit | 30 per 10 minutes per IP |
| Account sign-ups | 5 per 10 minutes per IP |
Going over a limit returns 429. Wait and retry. Table endpoints are not rate limited, but please poll no faster than once per second and prefer the live stream.
List API keys
Returns your keys without the secret. Only a short prefix is shown so you can recognise each one.
{
"keys": [
{ "id": "a1b2c3d4e5f6", "name": "my bot", "prefix": "vpp_key_3f9a1c", "created_at": "2026-10-18T11:02:44.100Z", "last_used": "2026-10-18T11:30:02.512Z" }
]
}
Create an API key
| Field | Type | Description |
|---|---|---|
name | string, optional | A label, up to 40 characters. Default My bot. |
curl -X POST https://www.pokerbattle.co.uk/api/v1/keys \
-H "Authorization: Bearer YOUR_SESSION_TOKEN" -H "Content-Type: application/json" \
-d '{"name": "my bot"}'
Response 201:
{ "id": "a1b2c3d4e5f6", "name": "my bot", "api_key": "vpp_key_3f9a...", "note": "Copy this key now. It is shown only once." }
| Status | Meaning |
|---|---|
| 400 | You already have 5 keys. Delete one first. |
| 401 | Not signed in with a session token. |
| 403 | Your email is not verified yet. |
Delete an API key
The key stops working immediately. Returns {"success": true}, or 404 if the id is not yours.
List tables
| Query | Description |
|---|---|
open=1 | Only tables with a free seat. |
max_latency_ms=50 | Only tables that accept at least this latency, meaning tables whose limit is 50 ms or more. |
bb=20 | Only tables with this big blind. |
curl "https://www.pokerbattle.co.uk/api/v1/tables?open=1&max_latency_ms=100"
{
"tables": [
{
"id": 1, "name": "Table 1",
"sb": 20, "bb": 40,
"max_latency_ms": 100,
"capacity": 6, "seated": 5,
"pot": 240, "phase": "TURN",
"board": ["K♦", "Q♣", "9♥", "4♠"],
"dealer_seat": 3, "active_seat": 0,
"seats": [ { "seat": 0, "player_id": 1008, "name": "Bot 1008", "is_bot": true, "chips": 1920, "bet": 80, "status": "ACTIVE", "last_action": "Bet 80" } ],
"recent_log": ["Hand deal started", "Turn dealt: 4♠"]
}
]
}
| Field | Meaning |
|---|---|
id | Table number. Use it as table_id. |
sb, bb | Small and big blind, in chips. |
max_latency_ms | The slowest round trip the table accepts. Faster tables have lower limits. |
seated / capacity | Seats taken and total seats. |
phase | IDLE, PREFLOP, FLOP, TURN, RIVER or SHOWDOWN. |
seats[].status | ACTIVE, FOLDED or OPEN. |
Live tables right now:
Loading...
Get one table
Returns {"table": { ... }} in the same shape as above, or 404 if the table does not exist.
Request a table
The server picks an open table that fits you, takes your buy-in from your balance and returns how to connect. When several tables fit, the one with the tightest latency limit is chosen.
| Field | Type | Description |
|---|---|---|
latency_ms | number, optional | Your measured round trip to the server. Tables with a lower limit are skipped. |
bb | number, optional | Only consider tables with this big blind. |
buy_in_bb | number, optional | Buy-in in big blinds, 1 to 1000. Default 50. |
Response 200:
{
"success": true,
"table": { "id": 2, "sb": 25, "bb": 50, "max_latency_ms": 20, "seated": 5, "capacity": 6 },
"player_id": 1001,
"chips_allocated": 1000,
"remaining_big_blinds": 50,
"ws_connection_info": {
"endpoint": "wss://www.pokerbattle.co.uk/ws",
"table_id": 2,
"player_id": 1001
}
}
| Status | Meaning |
|---|---|
| 400 | Balance too low for the buy-in, or buy_in_bb out of range. |
| 401 | Missing or invalid key. |
| 404 | No open table matches. Remove filters or try another latency. |
GET /api/v1/status, taking the lowest of a few tries. The value is a hint the web API uses to choose a table. The game itself is timed by the engine.Join a specific table
| Field | Type | Description |
|---|---|---|
table_id | number, required | The table to join. |
buy_in_bb | number, optional | 1 to 1000. Default 50. |
latency_ms | number, optional | If above the table's limit, the join is refused with 400. |
The response is identical to request a table. A missing table returns 404.
Live table stream
A server-sent events stream. The first message contains every table, then each update contains the changed table.
data: {"type":"init","tables":[ ... ]}
data: {"table_id":1,"table":{ ... },"event_type":"action"}
curl -N https://www.pokerbattle.co.uk/api/v1/stream/tables
WebSocket details
{
"ws_endpoint": "wss://www.pokerbattle.co.uk/ws",
"player_id": 1001,
"protocol": { "client_frame_size_bytes": 16, "server_frame_size_bytes": 32, "format": "BINARY_C_STRUCT_LITTLE_ENDIAN" }
}
WebSocket protocol
Gameplay uses binary WebSocket messages. There is no JSON; authentication happens when you open the connection. Every message is a fixed-size little-endian struct, which keeps parsing cheap and round trips short.
- Connect to the
endpointreturned by request a table and send your API key in theX-API-Keyheader of the connection request. Connections with an invalid key are rejected with HTTP 401. Send frames as binary messages. - Client to server frames are exactly 16 bytes. Server to client frames are exactly 32 bytes.
- All integers are little-endian. Ignore any message that is not 32 bytes.
- On connect you receive
WELCOME. Itstarget_player_idis your id for this connection. Use it to recognise messages addressed to you.
player_id field in frames you send and always acts as your own connection, so nobody can act for you or see your hole cards without your key. Limits: 4 connections per account, 200 frames per second per connection, and frames of any size other than 16 bytes are dropped. Connections made without a key are read-only spectators.Frame layouts
Client to server: 16 bytes
| Offset | Size | Type | Field | Notes |
|---|---|---|---|---|
| 0 | 1 | uint8 | action | See action codes. |
| 1 | 1 | uint8 | table_id | The table from the REST reply. 255 means any table. |
| 2 | 1 | uint8 | seat | Your seat once seated. 255 before that. |
| 3 | 1 | uint8 | reserved | Send 0. |
| 4 | 4 | uint32 | player_id | Send 0. The gateway fills in your connection id. |
| 8 | 4 | int32 | amount | Buy-in chips for join, raise-to amount for raise, otherwise 0. |
| 12 | 4 | bytes | padding | Send zeros. |
Python format string: "<BBBB I i 4s"
Server to client: 32 bytes
| Offset | Size | Type | Field | Notes |
|---|---|---|---|---|
| 0 | 1 | uint8 | event | See event codes. |
| 1 | 1 | uint8 | table_id | |
| 2 | 1 | uint8 | seat | Seat the event refers to. |
| 3 | 1 | uint8 | card_count | Number of valid bytes in cards. |
| 4 | 4 | uint32 | target_player_id | Who the message is for. |
| 8 | 4 | uint32 | player_id | Subject of the event, for example the hand winner. |
| 12 | 4 | int32 | amount | Meaning depends on the event: amount to call, pot, or winnings. |
| 16 | 7 | uint8[7] | cards | Packed cards, see card encoding. |
| 23 | 1 | uint8 | action_type | For PLAYER_ACTED: 0 fold, 1 call or check, 2 raise. |
| 24 | 4 | int32 | secondary_amount | For example the actor's remaining chips. |
| 28 | 4 | int32 | padding |
Python format string: "<BBBB I I i 7s B i i"
Action and event codes
Actions you send
| Code | Name | Use |
|---|---|---|
| 0 | JOIN_TABLE | Take your seat. amount is your buy-in in chips (chips_allocated from the REST reply). |
| 1 | LEAVE_TABLE | Stand up. |
| 2 | FOLD | Fold your hand. |
| 3 | CALL | Call, or check when nothing is owed. |
| 4 | RAISE | Raise. amount is the raise size in chips. |
Events you receive
| Code | Name | Meaning |
|---|---|---|
| 0 | WELCOME | Connection accepted. |
| 1 | SEAT_ASSIGNED | You are seated. seat and table_id tell you where. Store the seat. |
| 2 | SEAT_DENIED | The table is full or the request is invalid. |
| 3 | PLAYER_JOINED | Someone sat down. |
| 4 | PLAYER_LEFT | Someone left. |
| 10 | HAND_START | A new hand begins. Clear your stored cards. |
| 11 | DEAL_HOLE_CARDS | Your two private cards are in cards. |
| 12 | DEAL_BOARD | Community cards. card_count is 3, 4 or 5 (flop, turn, river). |
| 20 | ACTION_REQUEST | It is your turn. amount is the amount to call. Reply with fold, call or raise. |
| 21 | PLAYER_ACTED | Another player acted. See action_type. |
| 30 | POT_UPDATE | The pot changed. amount is the new pot. |
| 31 | SHOWDOWN | Hands are revealed. |
| 32 | HAND_RESULT | Hand over. player_id is the winner and amount the winnings. |
| 255 | ERROR_MSG | The engine rejected something you sent. |
Card encoding
A card is one byte, suit * 13 + (rank - 2), in the range 0 to 51.
| Suit index | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| Suit | Hearts | Diamonds | Clubs | Spades |
| Rank index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Rank | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | T | J | Q | K | A |
def decode(card): # 0..51
suit = "HDCS"[card // 13]
rank = "23456789TJQKA"[card % 13]
return rank + suit # 50 -> "KS", 12 -> "AH", 0 -> "2H"
Values above 51 are unused slots. Only read the first card_count bytes.
Hand flow
- Open the WebSocket with your
X-API-Keyheader. You receiveWELCOME; save itstarget_player_idas your id. - Send
JOIN_TABLEwith yourtable_idandamount(your chips, at most the balance you reserved). - You receive
SEAT_ASSIGNED. Saveseatand include it in later frames. - Each hand:
HAND_START, thenDEAL_HOLE_CARDS, then board events as streets are dealt. - When
ACTION_REQUESTarrives with yourtarget_player_id, answer promptly withFOLD,CALLorRAISE. Slow answers are folded for you. - The hand ends with
HAND_RESULT. The next hand starts on its own. - Send
LEAVE_TABLEwhen you are done, then close the socket.
ACTION_REQUEST with target_player_id equal to your id. Other messages are broadcasts for everyone at the table.Repeated disconnects
If your bot drops its connection while seated without sending LEAVE_TABLE, that counts as a strike. Three strikes within 10 minutes remove it from its table and block it from joining or connecting for 5 minutes. Each further removal doubles the wait, up to 24 hours. While blocked, /tables/request, /tables/{id}/join and the WebSocket upgrade return 403 with a Retry-After value in seconds. Always send LEAVE_TABLE before closing, and reconnect with backoff.
Complete Python bot
This script requests a table over REST, connects, and plays a simple call-or-fold policy. Replace choose_action with your own logic. Install the dependencies with pip install requests websockets.
import asyncio, struct, time, requests, websockets
BASE = "https://www.pokerbattle.co.uk"
API_KEY = "YOUR_KEY"
JOIN, LEAVE, FOLD, CALL, RAISE = 0, 1, 2, 3, 4
E_WELCOME, E_SEAT_ASSIGNED, E_SEAT_DENIED, E_HAND_START = 0, 1, 2, 10
E_HOLE, E_BOARD, E_ACTION_REQUEST, E_HAND_RESULT, E_ERROR = 11, 12, 20, 32, 255
CLIENT = struct.Struct("<BBBB I i 4s") # 16 bytes
SERVER = struct.Struct("<BBBB I I i 7s B i i") # 32 bytes
assert CLIENT.size == 16 and SERVER.size == 32
def decode(card):
return "23456789TJQKA"[card % 13] + "HDCS"[card // 13]
def measure_latency():
best = 1e9
for _ in range(3):
t = time.perf_counter()
requests.get(f"{BASE}/api/v1/status", timeout=5)
best = min(best, (time.perf_counter() - t) * 1000)
return best
def request_table():
r = requests.post(
f"{BASE}/api/v1/tables/request",
headers={"X-API-Key": API_KEY},
json={"latency_ms": measure_latency(), "buy_in_bb": 50},
timeout=10,
)
if not r.ok:
raise SystemExit(f"Could not get a table: {r.json().get('error')}")
return r.json()
def choose_action(to_call, hole, board):
# Replace with your own logic. Return (action, amount).
if to_call == 0:
return CALL, 0 # check
if to_call <= 40:
return CALL, 0
return FOLD, 0
async def main():
seat_info = request_table()
player_id = None # learned from WELCOME
table_id = seat_info["ws_connection_info"]["table_id"]
chips = seat_info["chips_allocated"]
my_seat = 255
hole, board = [], []
def frame(action, amount=0):
return CLIENT.pack(action, table_id, my_seat, 0, 0, amount, b"\0\0\0\0")
# websockets >= 14 uses additional_headers; older versions use extra_headers.
async with websockets.connect(seat_info["ws_connection_info"]["endpoint"],
additional_headers={"X-API-Key": API_KEY}) as ws:
await ws.send(frame(JOIN, chips))
async for raw in ws:
if not isinstance(raw, bytes) or len(raw) != 32:
continue
ev, tbl, seat, n, target, pid, amount, cards, act, sec, _ = SERVER.unpack(raw)
cards = [decode(c) for c in cards[:n]]
if ev == E_WELCOME:
player_id = target
elif ev == E_SEAT_ASSIGNED and target == player_id:
my_seat = seat
elif ev == E_SEAT_DENIED:
raise SystemExit("Seat denied. Request a different table.")
elif ev == E_HAND_START:
hole, board = [], []
elif ev == E_HOLE and target == player_id:
hole = cards
elif ev == E_BOARD:
board = cards
elif ev == E_ACTION_REQUEST and target == player_id:
action, amt = choose_action(amount, hole, board)
await ws.send(frame(action, amt))
elif ev == E_HAND_RESULT:
print(f"Hand over. Winner {pid} won {amount} chips")
elif ev == E_ERROR:
print("Engine error")
asyncio.run(main())
The official example client is also available: vpp_bot.py (Python) and example_bot.cpp (C++).
Get your account
curl https://www.pokerbattle.co.uk/api/v1/auth/me -H "X-API-Key: YOUR_KEY"
{
"id": "usr_...", "player_id": 1001, "username": "mybot",
"email": "me@example.com", "email_verified": true,
"big_blinds": 50, "simulation_chips": 1000,
"referral_code": "mybot", "referrals": []
}
big_blinds is your spendable balance. simulation_chips is the total chips you have taken to tables.
Sign in
Returns a session token for managing API keys and funds from a script. Bots should use an API key instead for play.
| Field | Description |
|---|---|
identifier | Username or email. |
password | Your password. |
curl -X POST https://www.pokerbattle.co.uk/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"identifier": "mybot", "password": "..."}'
{ "success": true, "token": "vpp_tok_...", "user": { ... } }
Create your account and verify your email on the website. Sign-up includes a proof-of-work step meant for the browser, so it is not intended for scripts.
Adding funds
Deposits are crypto payments confirmed on-chain. 0.50 USD adds 100 BB. Available amounts and networks come from the config endpoint.
1. Get options
{ "packs_usd": [0.5, 1, 2, 5, 10, 20], "bb_per_usd": 200,
"networks": [ { "key": "USDT_POLYGON", "name": "USDT (Polygon PoS)", "coin": "USDT", "address": "0x..." } ] }
2. Create an invoice
curl -X POST https://www.pokerbattle.co.uk/api/v1/crypto/invoice \
-H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
-d '{"network": "USDT_POLYGON", "usd_amount": 5}'
The reply contains an invoice with id, deposit_address, big_blinds_awarded and expires_at (24 hours). Send the exact amount to the address.
3. Confirm the payment
curl -X POST https://www.pokerbattle.co.uk/api/v1/crypto/verify \
-H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
-d '{"invoice_id": "inv_...", "tx_hash": "0x..."}'
| Status | Meaning |
|---|---|
| 200 | Credited. updated_big_blinds is your new balance. |
| 202 | Transaction not visible yet. pending: true. Wait a minute and call again with the same hash. |
| 400 | Payment does not match: wrong address, amount too low, wrong token, or hash already used. |
| 404 | Invoice not found. |
| 409 | This invoice is being checked right now. |
History
Your 20 most recent invoices, newest first.
Leaderboard
| Query | Values |
|---|---|
timeframe | weekly (default) or overall |
sort | sharpe (default), avg_bb, total_pnl, win_rate, rebuys, hands |
{
"success": true, "timeframe": "weekly", "sortBy": "sharpe",
"summary": { "top_sharpe": {...}, "highest_pnl": {...}, "best_win_rate": {...}, "total_hands_simulated": 12345 },
"leaderboard": [
{ "rank": 1, "name": "bot_name", "sharpe_ratio": 2.41, "avg_bb_per_hand": 0.82, "total_pnl_bb": 410, "total_pnl_chips": 8200,
"hands_played": 500, "hands_won": 240, "win_rate_pct": 48.0, "rebuys": 0, "assigned_table": 0, "status": "active" }
]
}
Every bot is listed. There is no cap on entries.
Platform status
Returns status, uptime, user and table counts and the exchange rate. It is a small request, so it is also a good way to measure your latency.
Error reference
| Status | When | What to do |
|---|---|---|
| 400 | Invalid input, balance too low, latency too high | Read error. It says which value to change. |
| 401 | Missing or invalid key or token | Check the X-API-Key header. Create a new key if it was deleted. |
| 403 | Action not allowed yet, for example unverified email | Verify your email on the website. |
| 404 | Table, invoice or key not found, or no matching open table | List tables and retry. |
| 409 | Conflicting request | Wait a moment and retry. |
| 429 | Rate limit | Wait a few minutes. |
| 500 | Server error | Retry with a delay. Contact us if it persists. |
FAQ
Can I play from the website?
No. The website is for watching. Tables are played by bots through the API.
What does the latency limit do?
Each table accepts players up to a maximum round-trip time. Lower limits mean a faster table. When you pass latency_ms, the server only offers tables you can keep up with.
Which tables can my bot join?
Call list tables with open=1. Use max_latency_ms with your own latency to see only tables you qualify for.
I lost my API key.
Delete it on the website and create a new one. Old keys cannot be shown again.
How do I get more balance?
Use adding funds, or invite friends from the website to earn BB.
Are there client libraries?
Example clients for Python and C++ are linked in the bot section. The protocol is small enough to implement in any language with binary WebSocket support.