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:

  1. An API key, created once on the website.
  2. A seat, requested over the REST API. This reserves a buy-in from your balance and tells you which table to join.
  3. 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

  1. Create an account on the website and verify your email.
  2. 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.
  3. 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:

HeaderExample
X-API-KeyX-API-Key: vpp_key_3f9a...
AuthorizationAuthorization: Bearer vpp_key_3f9a...

The website uses short-lived session tokens (vpp_tok_...) from login. These are sent as Authorization: Bearer too.

Endpoint groupAPI keySession token
Tables, balance, fundsYesYes
Creating and deleting API keysNoYes
Keys are stored hashed on the server. If you lose a key, delete it and create a new one. Keep keys out of source control, and use one key per bot so you can revoke them separately.

Requests and errors

Rate limits

ActionLimit
Sign in20 per 5 minutes per IP
Create deposit invoice30 per hour per IP
Confirm deposit30 per 10 minutes per IP
Account sign-ups5 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

GET/api/v1/keysSession token

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

POST/api/v1/keysSession token, verified email
FieldTypeDescription
namestring, optionalA 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." }
StatusMeaning
400You already have 5 keys. Delete one first.
401Not signed in with a session token.
403Your email is not verified yet.

Delete an API key

DELETE/api/v1/keys/{id}Session token

The key stops working immediately. Returns {"success": true}, or 404 if the id is not yours.

List tables

GET/api/v1/tablesPublic
QueryDescription
open=1Only tables with a free seat.
max_latency_ms=50Only tables that accept at least this latency, meaning tables whose limit is 50 ms or more.
bb=20Only 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♠"]
    }
  ]
}
FieldMeaning
idTable number. Use it as table_id.
sb, bbSmall and big blind, in chips.
max_latency_msThe slowest round trip the table accepts. Faster tables have lower limits.
seated / capacitySeats taken and total seats.
phaseIDLE, PREFLOP, FLOP, TURN, RIVER or SHOWDOWN.
seats[].statusACTIVE, FOLDED or OPEN.

Live tables right now:

Loading...

Get one table

GET/api/v1/tables/{id}Public

Returns {"table": { ... }} in the same shape as above, or 404 if the table does not exist.

Request a table

POST/api/v1/tables/requestAPI key or session

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.

FieldTypeDescription
latency_msnumber, optionalYour measured round trip to the server. Tables with a lower limit are skipped.
bbnumber, optionalOnly consider tables with this big blind.
buy_in_bbnumber, optionalBuy-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
  }
}
StatusMeaning
400Balance too low for the buy-in, or buy_in_bb out of range.
401Missing or invalid key.
404No open table matches. Remove filters or try another latency.
Measure latency honestly. Use the time for a small request to the server, such as 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

POST/api/v1/tables/joinAPI key or session
FieldTypeDescription
table_idnumber, requiredThe table to join.
buy_in_bbnumber, optional1 to 1000. Default 50.
latency_msnumber, optionalIf 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

GET/api/v1/stream/tablesPublic

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

GET/api/v1/session/wsOptional
{
  "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.

Keep your API key secret. It is the only credential. The gateway ignores the 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

OffsetSizeTypeFieldNotes
01uint8actionSee action codes.
11uint8table_idThe table from the REST reply. 255 means any table.
21uint8seatYour seat once seated. 255 before that.
31uint8reservedSend 0.
44uint32player_idSend 0. The gateway fills in your connection id.
84int32amountBuy-in chips for join, raise-to amount for raise, otherwise 0.
124bytespaddingSend zeros.

Python format string: "<BBBB I i 4s"

Server to client: 32 bytes

OffsetSizeTypeFieldNotes
01uint8eventSee event codes.
11uint8table_id
21uint8seatSeat the event refers to.
31uint8card_countNumber of valid bytes in cards.
44uint32target_player_idWho the message is for.
84uint32player_idSubject of the event, for example the hand winner.
124int32amountMeaning depends on the event: amount to call, pot, or winnings.
167uint8[7]cardsPacked cards, see card encoding.
231uint8action_typeFor PLAYER_ACTED: 0 fold, 1 call or check, 2 raise.
244int32secondary_amountFor example the actor's remaining chips.
284int32padding

Python format string: "<BBBB I I i 7s B i i"

Action and event codes

Actions you send

CodeNameUse
0JOIN_TABLETake your seat. amount is your buy-in in chips (chips_allocated from the REST reply).
1LEAVE_TABLEStand up.
2FOLDFold your hand.
3CALLCall, or check when nothing is owed.
4RAISERaise. amount is the raise size in chips.

Events you receive

CodeNameMeaning
0WELCOMEConnection accepted.
1SEAT_ASSIGNEDYou are seated. seat and table_id tell you where. Store the seat.
2SEAT_DENIEDThe table is full or the request is invalid.
3PLAYER_JOINEDSomeone sat down.
4PLAYER_LEFTSomeone left.
10HAND_STARTA new hand begins. Clear your stored cards.
11DEAL_HOLE_CARDSYour two private cards are in cards.
12DEAL_BOARDCommunity cards. card_count is 3, 4 or 5 (flop, turn, river).
20ACTION_REQUESTIt is your turn. amount is the amount to call. Reply with fold, call or raise.
21PLAYER_ACTEDAnother player acted. See action_type.
30POT_UPDATEThe pot changed. amount is the new pot.
31SHOWDOWNHands are revealed.
32HAND_RESULTHand over. player_id is the winner and amount the winnings.
255ERROR_MSGThe engine rejected something you sent.

Card encoding

A card is one byte, suit * 13 + (rank - 2), in the range 0 to 51.

Suit index0123
SuitHeartsDiamondsClubsSpades
Rank index0123456789101112
Rank23456789TJQKA
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

  1. Open the WebSocket with your X-API-Key header. You receive WELCOME; save its target_player_id as your id.
  2. Send JOIN_TABLE with your table_id and amount (your chips, at most the balance you reserved).
  3. You receive SEAT_ASSIGNED. Save seat and include it in later frames.
  4. Each hand: HAND_START, then DEAL_HOLE_CARDS, then board events as streets are dealt.
  5. When ACTION_REQUEST arrives with your target_player_id, answer promptly with FOLD, CALL or RAISE. Slow answers are folded for you.
  6. The hand ends with HAND_RESULT. The next hand starts on its own.
  7. Send LEAVE_TABLE when you are done, then close the socket.
Your turn is signalled to you by 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

GET/api/v1/auth/meAPI key or session
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

POST/api/v1/auth/loginPublic

Returns a session token for managing API keys and funds from a script. Bots should use an API key instead for play.

FieldDescription
identifierUsername or email.
passwordYour 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

GET/api/v1/crypto/configPublic
{ "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

POST/api/v1/crypto/invoiceAPI key or session
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

POST/api/v1/crypto/verifyAPI key or session
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..."}'
StatusMeaning
200Credited. updated_big_blinds is your new balance.
202Transaction not visible yet. pending: true. Wait a minute and call again with the same hash.
400Payment does not match: wrong address, amount too low, wrong token, or hash already used.
404Invoice not found.
409This invoice is being checked right now.

History

GET/api/v1/crypto/historyAPI key or session

Your 20 most recent invoices, newest first.

Payments are not refundable and balances cannot be withdrawn. Make sure you use the right network. Funds sent on the wrong network cannot be recovered.

Leaderboard

GET/api/v1/leaderboardPublic
QueryValues
timeframeweekly (default) or overall
sortsharpe (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

GET/api/v1/statusPublic

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

StatusWhenWhat to do
400Invalid input, balance too low, latency too highRead error. It says which value to change.
401Missing or invalid key or tokenCheck the X-API-Key header. Create a new key if it was deleted.
403Action not allowed yet, for example unverified emailVerify your email on the website.
404Table, invoice or key not found, or no matching open tableList tables and retry.
409Conflicting requestWait a moment and retry.
429Rate limitWait a few minutes.
500Server errorRetry 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.