# Poker Panel Developer API — agent brief (v1) You are integrating against the Poker Panel Developer API: a read-only 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. ## 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. Versioning: v1 shapes only change ADDITIVELY. Always ignore unknown fields and unknown event kinds. Breaking changes = /v2. 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. ## 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 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}]} 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. GET /v1/webhooks → {webhooks:[{id, url, kinds, disabled, fails}]} POST /v1/webhooks → body {url:"https://…", kinds:["hand.finished"]} → 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} player_action {hand_number, seat_id, action_type, amount, street} action_type ∈ fold|check|call|bet|raise|all_in|straddle deal_street {street, board, hand_number} street_advance {street, hand_number} end_hand {hand_number, winner_seats, total_pot} payout_applied {…per-seat stack deltas…} seq is strictly increasing. If you miss frames, the next state frame heals you — do not build reconnect logic that replays events. 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. ## 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, but its payload carries hand_number (table sequence), not the historical hand_id — full hand detail lands in /v1/hands when the venue's bundle refreshes at session end. For same-night recaps, accumulate live end_hand events; for morning-after recaps, read /v1/hands. Questions / keys: henry@pokerpanel.app