# Poker Panel Developer API — agent brief (v1) You are integrating against the Poker Panel Developer API: a JSON API exposing live table state, a real-time event stream, player stats and profiles, and full hand histories from ONE poker venue running Poker Panel. This file is self-contained — you do not need any other documentation. Last updated: 2026-09-16 There is ONE brief, not a "new integration" one and a "catching up" one. This file is always the complete current spec. If you are returning to an integration built before the date above, jump to CHANGELOG at the bottom, read back to your build date, and re-read the sections those entries name. GET /v1 reports the same date in its `updated` field, so you can check for drift without fetching this file. ## Basics Base URL: https://pokerpanel.app/v1 Auth (REST): Authorization: Bearer (keys look like pp_v1_…) Auth (WS): wss://pokerpanel.app/v1/live/events?key= Content type: application/json everywhere. CORS: enabled (*) on all endpoints. Discovery: GET /v1 is PUBLIC (no key) and lists every endpoint. Machine spec: https://pokerpanel.app/developers/openapi.json (OpenAPI 3.1) Rate limits: 240 requests/min per key; 5 concurrent WebSockets per key. 429 responses include retry_after_sec. Writes: POST /v1/transactions/buy-in and /cash-out record money on the room's ledger. ONE key does everything — there is no read-only variant, so treat the key as a credential that can move money, not as a read token. Versioning: v1 shapes only change ADDITIVELY. Always ignore unknown fields and unknown event kinds. Breaking changes = /v2. What changed and when: CHANGELOG at the bottom of this file. GET /v1 carries `updated` (a date) and `changelog` (a URL). Errors: Non-200s return {"error": "", ...}. 401 = bad/revoked key. 404 {"error":"not_found"} on historical endpoints means the venue has not published that data yet — treat as empty, do not retry-loop. ## Two data planes 1. LIVE (/v1/live/*): real-time state of the table, served while the venue's Mac is streaming. GET /v1/live/state returns {"live": bool, "state": {...}|null}. live:false with a non-null state means the venue is offline and state is the last known frame. 2. HISTORICAL (players/leaderboard/sessions/hands): served 24/7 from the venue's published stats bundle, refreshed when a session ("night of poker") ends. Hand detail files are immutable once published — cache hard. Each plane has its own cursor, and they are not interchangeable: `since_seq` on the live socket, `since`/`after_id` on /v1/hands. The seam between them is the night in progress — its hands are on the socket and do not reach /v1/hands until that session closes. A client that wants both keeps both cursors and does not try to blend the two shapes: a live event and a published hand summary are different objects. ## Endpoints GET /v1 → {name, version, venue, live, endpoints[...]} GET /v1/key → {kid, venue, scopes, live} — which venue this key belongs to; use it to label keys in a multi-venue app and to health-check a key GET /v1/venue → {venue_id, name, live, table_name, game:{mode, blinds:{small_blind,big_blind,ante}, structure}, money_mode} GET /v1/live/state → {live, ts, state:{ts, hand_number, table_name, street, board[], button, action_on, pot, current_bet, game_mode, betting_structure, blinds, tournament, hand_decided, num_seats, seats:[{seat_id, name, player_id, stack, in_hand, present}]}} WS /v1/live/events?key=… → frames, see EVENT STREAM below [&since_seq=N] replays the events you missed GET /v1/players → {players:[{player_id, name, slug, hands_played, file}], aliases:{old_id: canonical_id}} GET /v1/players/{player_id} → profile + lifetime stats. Stat fields: hands_played, vpip, pfr, threebet, aggression_factor, wtsd, wsd, bb_per_100, sessions, total_hours (money fields present only when the venue's money_mode allows) GET /v1/leaderboard → {money_mode, players:[{player_name, player_id, slug, hands_played, vpip, pfr, threebet, aggression_factor, wtsd, wsd, bb_per_100, sessions, total_hours, ...}]} GET /v1/leaderboard?period=YYYY-MM → same shape, one calendar month GET /v1/sessions → list of nights: {session_uid, started_at, ended_at, hands_count, player_count, blinds, …} GET /v1/sessions/{uid} → one night's summary GET /v1/hands → {count, pages, page_size, page_files[]} index GET /v1/hands?page=N → {hands:[{hand_id, hand_number, ts, board, final_pot_bb, winners, player_count, session_uid, stakes, game_mode, file}]} Pages are NEWEST-FIRST and renumber on every publish. Browse with them; never sync with them — a remembered page number reads a different set of hands next time. GET /v1/hands?since= → {order:"asc", count, complete, hands:[…], GET /v1/hands?after_id= cursor:{ts, hand_id}}. Both cursors are EXCLUSIVE and anchored to the hand, so they survive republishing. after_id wins if you send both (two hands can share a ts). Send `cursor` back next time rather than working it out from the array. complete:false means the walk hit its cap and older matching hands were left out — fall back to ?page= and re-cursor from the newest hand. FINISHED NIGHTS ONLY: see the two-plane note at the top. Tonight's hands are on the WS. GET /v1/hands/{hand_id} → {hand:{...summary + players[]}, actions[], streets{}}. Per player: player_id, position, showed_cards, went_to_showdown, hand rank; hole_cards PRESENT ONLY IF showed_cards is true. Actions: {seat_id, action_type, amount, street} with action_type ∈ DEAL, POST_BLIND, BET, CALL, RAISE, FOLD, CHECK, ALL_IN; amount is the INCREMENT for that action, not a running total. POST /v1/transactions/buy-in body {player_name, amount, POST /v1/transactions/cash-out idempotency_key, close_session?} → whatever the engine answers, verbatim. cash-out may omit `amount` to cash out all. idempotency_key is REQUIRED (8-128 of A-Za-z0-9_.:-) and must be REUSED on every retry of the same movement. The engine runs a live conservation guard, so a movement applied twice does not make a duplicate row — it breaks the table mid-hand. A settled answer, including a refusal, is replayed verbatim on retry with `Idempotent-Replay: true`; a 5xx is NOT remembered, because retrying is the point. 409 hand_in_progress — money moves between hands. The engine permits an operator standing at the table an emergency buy-in mid-hand; this API does not, because your client cannot see the felt. Retry with the same key when idle. 503 host_offline — reads serve from the relay whether or not the room's Mac is awake; writes need it. 504 host_timeout does NOT mean it did not happen: retry with the SAME key. Rate: 60 writes/min per key, its own budget. GET /v1/webhooks → {webhooks:[{id, url, kinds, disabled, fails}]} POST /v1/webhooks → body {url:"https://…", kinds:[…]} kinds ∈ hand.finished | payout.applied | transaction.applied. Omit for all of them. An unrecognised name is REFUSED (400 unknown_kind, with the supported list) rather than accepted and then silently never delivered. → 201 {id, secret} (secret shown ONCE) DELETE /v1/webhooks/{id} → {ok:true} ## EVENT STREAM (WebSocket) On connect you immediately receive one state frame: {"type":"state", "live":bool, "state":{...same as /v1/live/state...}} Then, as they happen: {"type":"event", "seq":int, "ts":float, "kind":str, "payload":{...}} Kinds and payloads: start_hand {hand_number, button_seat, blinds, game_mode, table_name, betting_structure, tournament, seats:[{seat_id, player_id, name, stack, posted, position, in_hand}]} `stack` is what that seat STARTED the hand with, and `posted` is the blind/ante/straddle already taken off it. Do not reconstruct this from the seats array on a state frame: that frame arrives AFTER the event and is already net of the blinds, so the small blind reads short. player_action {hand_number, seat_id, action_type, amount, street, player_id, name, decision_ms} action_type ∈ fold|check|call|bet|raise|all_in|straddle decision_ms is milliseconds since the previous commit at this table — the deal, the new street, or the last action. null on the first action after the venue's engine restarts, and for any gap over 5 minutes (a chip count or a floor call is not a decision). It is PACE, not a shot clock: the stamp is when the action reached the engine, so at an operator-driven table it carries the operator's own entry cadence. Fine for "this table plays fast"; do not print it as "this player tanked for 41 seconds". deal_street {street, board, hand_number} street_advance {street, hand_number} end_hand {hand_number, winner_seats, total_pot, street, board, boards, rake, seats:[{seat_id, player_id, name, stack, in_hand, position, contributed, won, net}]} The hand's whole money, at the moment it finishes. net = won − contributed, and the nets sum to −rake. NEVER hole cards — those exist only on the history plane, and only for hands shown at the table. payout_applied {hand_number, winner_seat, amount, seat_id, player_id, name, delta, stack_before, stack_after} One per winning seat. `stack_before` is the stack IMMEDIATELY BEFORE this credit — after that seat's own bets left it. The stack at the top of the hand is start_hand.seats[].stack and the hand's net is end_hand.seats[].net; three different questions, three different fields. `winner_seat` and `amount` are the original v1 names and keep their meanings. transaction_applied {kind, seat_id, player_id, name, amount, delta, stack_before, stack_after, hand_number, source, session_id} A stack change that is MONEY, not poker. kind ∈ buy_in | cash_out | adjustment ("the operator corrected this stack"). `delta` is signed, so summing this stream gives the night's money in without branching on the name. `source` is the door it came through: "transaction" (the buy-in / cash-out ledger routes, including your own writes through /v1/transactions/*) or "seat_edit". Tournament re-entries, re-buys and add-ons are NOT reported here — those move chips on a different ledger. seq is strictly increasing. The next state frame heals table STATE (pot, board, stacks) but says nothing about which actions you missed, so if you care about the action stream, reconnect with: wss://pokerpanel.app/v1/live/events?key=&since_seq= After the state frame you get one replay envelope, then the buffered events after your cursor, in order: {"type":"replay","since_seq":4180,"count":2,"complete":true, "reset":false,"oldest_seq":3990,"newest_seq":4182,"window":250} complete true ONLY when the buffer can prove nothing was dropped after your cursor. false means a real gap: reconcile against /v1/hands. Do not treat false as "probably fine". reset the venue's engine restarted and its per-process counter began again, so your cursor belongs to a previous run. The whole window is replayed; deduplicate on hand_number. window buffered capacity. This is a short in-memory window on the venue's relay object, NOT durable history — after an eviction it is empty, which is reported as complete:false. Omit since_seq and the connect behaves exactly as it always has: one state frame and nothing else. Map seat_id → player via the seats array of the latest state frame. Reconnect with plain exponential backoff; the same URL + key always works until revoked. ## Integrity rules (why some data is absent — do not work around these) - The live plane NEVER contains hole cards, card reveal state, or win probability. This is a server-side whitelist; it is not configurable. - Hand histories contain hole_cards only for hands shown at the table. An absent hole_cards field means mucked — render "folded face-down". - Money fields may be in big blinds (money_mode "bb", the default), dollars, or absent entirely ("hidden"), per venue policy. Check money_mode on /v1/venue or leaderboard responses before formatting amounts. - player_id is a permanent UUID that survives renames and profile merges — key your storage on it, never on the display name. If an id you stored stops resolving, check the aliases map on /v1/players: merged profiles leave {old_id: canonical_id} entries there. - Test/simulated hands are excluded server-side. - There is no endpoint that folds, calls, raises, deals or ends a hand, and this is not an oversight. The API is not read-only (buy-in and cash-out move real money), so the line is not reads vs writes: it is whether YOUR CLIENT can be right about the state it is acting on. That is the same reason a buy-in is refused mid-hand with 409 hand_in_progress. A buy-in is between hands, idempotent and matches a physical act at the cage, and an operator notices a wrong one within a minute; a fold is time-critical, not usefully idempotent once action has moved, and invisible to everyone until the hand is over. Poker Panel's own operator panel — a phone in the room — re-dealt a live hand in 2026-09 while running 95 seconds behind on a socket that reported itself healthy. Your client is further away than that phone. If you need player-driven actions to reach a table, the conversation is an action-intent channel where the engine stays the authority and refuses anything that does not match the hand and seat you believed you were acting on; write to the address below. ## Webhook verification Each delivery: POST to your URL with headers X-PokerPanel-Event (kind) and X-PokerPanel-Signature = base64url( HMAC_SHA256(raw_request_body, secret) ), where secret is the one returned at registration. Verify before trusting. Body: {"kind":"hand.finished", "ts":ms, "venue":"", "payload":{...}}. Reply 2xx quickly; 20 consecutive failures auto-disable the webhook. ## Networks of card rooms (multi-venue apps) The API is per-venue: ONE key per card room, identical endpoints at every room. There is no cross-venue endpoint by design — a network app iterates its keys. Pattern: for key in venue_keys: info = GET /v1/key (with that key) → {venue, live} data = GET /v1/leaderboard, /v1/players, ... (same key) merge client-side; player_id is unique WITHIN a venue — namespace composite ids as f"{venue}:{player_id}" when aggregating. Each room's operator issues and revokes keys from their own Poker Panel rig (Card Room plan); revocation is immediate, including live WebSockets. Treat a 401 on a previously-working key as "the venue pulled access" — surface it, don't retry-loop. Rate limits are per key per venue, so a network app's budget scales with its rooms. ## Typical builds - Player app: GET /v1/players for the roster → per-player pages from /v1/players/{id} → their hands via /v1/hands filtered client-side by player_id from hand pages. Live "at the table now" badge from /v1/live/state seats. - Rail screen / second screen: WS /v1/live/events; render actions + board from events, stacks/names from state frames. - Leaderboard site: /v1/leaderboard (all-time) + ?period= for monthly; it's cacheable for 30s, so polling once a minute is plenty. - Recap bot: webhook hand.finished fires in real time and now carries the hand's money — every seat's contributed/won/net, the board, the pot and the rake — so a same-night recap or a live ledger no longer waits for the bundle. What it still does NOT carry is hole cards, and its `hand_number` is the table's sequence, not the historical `hand_id`; for cards and for a stable id, read /v1/hands after the venue's session closes. - Cash ledger / cage integration: subscribe to transaction.applied and sum `delta`. Every stack change that is not a pot arrives there — including the ones your own /v1/transactions/* writes cause, so a ledger built on this stream reconciles without special-casing itself. ## CHANGELOG Entries newest first. Each is tagged, and the tag is the whole point: ADDED New surface. Nothing you already do changes. CHANGED Existing surface behaves differently. Read it. CORRECTED This brief previously told you something WRONG. If you followed it, you have a bug in code that looks correct. Read it FIRST. ### 2026-09-16 CORRECTED — payout_applied. This brief described its payload as "{…per-seat stack deltas…}". It never carried any: the wire shape was {hand_number, winner_seat, amount}. It carries them NOW — stack_before, stack_after, delta, plus seat_id/player_id/name — so the description is finally true. If you wrote code against the documented shape it has been reading undefined fields since the API opened. The two original names, `winner_seat` and `amount`, are unchanged. ADDED — start_hand now carries `seats[]`: who was dealt in, with the stack each STARTED the hand with and what they posted. If you were reconstructing this from the state frame that follows the event, stop — that frame is already net of the blinds, so your small blind was one blind short on every hand, and the frame is a race besides. ADDED — end_hand now carries the hand's money: per-seat contributed/won/net, the board(s), the street it ended on, and the rake. Per-hand results no longer wait for the venue's bundle to refresh at session close. Hole cards are still history-plane only. ADDED — event kind transaction_applied and webhook kind transaction.applied: a buy-in, a cash-out, or an operator correcting a stack. Until now a stack that grew because money came in and a stack that grew because a pot was won were the same thing on the wire. A hook registered WITHOUT a `kinds` filter receives this one too — switch on `kind`. ADDED — player_action now carries `player_id`, `name` and `decision_ms` (how long the table sat on that decision). Read the caveat in EVENT STREAM before showing it to anybody: it is pace, not a shot clock. ADDED — hand detail (GET /v1/hands/{id}) now carries `ts` and `decision_ms` on every action. Same caveat, same numbers, for finished hands. ### 2026-09-10 (later) ADDED — POST /v1/transactions/buy-in and /cash-out. The API is no longer read-only. Your existing key already carries this: there is no separate write key and no scope to request, which also means the key you already hold can move money. Store it accordingly. See ENDPOINTS for the idempotency contract, which is required rather than advisory. ### 2026-09-10 CORRECTED — EVENT STREAM. This brief used to say: "if you miss frames, the next state frame heals you — do not build reconnect logic that replays events." That was wrong. The state frame heals table STATE (pot, board, stacks); it says nothing about which ACTIONS you missed. An integration that followed that advice silently loses hands on every dropped socket and has no way to detect it. Fix: reconnect with ?since_seq=, and read `complete` on the replay envelope — false means a real gap, not "probably fine". See EVENT STREAM. ADDED — GET /v1/hands?since= and ?after_id=. Cursors for incremental sync. If you were syncing by page number you have a quieter version of the same bug: pages are newest-first and renumber on every publish, so a remembered page number reads a different set of hands next time, missing some and repeating others. A cursor is anchored to the hand and survives republishing. Browse with ?page=, sync with a cursor. See ENDPOINTS. ADDED — webhook kind payout.applied, fired once per winning seat just before hand.finished. A hook registered WITHOUT a `kinds` filter subscribes to every kind and now receives this one too, so switch on the `kind` field in the body rather than assuming every delivery is a finished hand. CHANGED — POST /v1/webhooks now refuses an unrecognised `kinds` entry with 400 unknown_kind (the response lists the supported names). It used to accept anything and then never deliver, which looks exactly like a broken endpoint from your side. If a registration call starts failing, the kind name in it was never firing in the first place. ADDED — GET /v1 now carries `updated`, `changelog`, `webhook_kinds` and `planes`. Checking `updated` is the cheap way to know whether to re-read this file. Questions / keys: henry@pokerpanel.app