{
  "openapi": "3.1.0",
  "info": {
    "title": "Poker Panel Developer API",
    "version": "1.0.0",
    "description": "JSON API exposing live table state, a real-time WebSocket event stream, player stats/profiles, and hand histories from a poker venue running Poker Panel. Keys are issued per venue on the Card Room plan. v1 response shapes only change additively; clients must ignore unknown fields and unknown event kinds. The WebSocket stream at wss://pokerpanel.app/v1/live/events?key=... is described in x-websocket below. Plain-text agent brief: https://pokerpanel.app/developers/agents.txt — it is the single brief for both new and existing integrations, and its CHANGELOG section records what changed and when. GET /v1 reports `updated` (a date) and `changelog` (a URL) so an integration can detect drift without fetching anything else.",
    "x-updated": "2026-09-10",
    "x-changelog": "https://pokerpanel.app/developers/agents.txt#changelog",
    "contact": { "email": "henry@pokerpanel.app", "url": "https://www.pokerpanel.app/developers" }
  },
  "servers": [{ "url": "https://pokerpanel.app/v1" }],
  "security": [{ "bearerKey": [] }],
  "x-websocket": {
    "url": "wss://pokerpanel.app/v1/live/events?key={apiKey}[&since_seq={seq}]",
    "onConnect": "one frame {type:'state', live:boolean, state:LiveState|null}. With ?since_seq=N a {type:'replay'} envelope follows it, then every buffered event after N, in order. Without it, nothing follows — the plain connect is unchanged.",
    "frames": {
      "state": { "type": "state", "live": "boolean", "state": "LiveState|null" },
      "event": {
        "type": "event", "seq": "integer, strictly increasing", "ts": "number",
        "kind": "start_hand | player_action | deal_street | street_advance | end_hand | payout_applied | transaction_applied",
        "payload": "object, forwarded verbatim. 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}]} where stack is what the seat STARTED the hand with (blinds added back). player_action = {hand_number, seat_id, action_type: fold|check|call|bet|raise|all_in|straddle, amount, street, player_id, name, decision_ms}. 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}]}; never hole cards. payout_applied = {hand_number, winner_seat, amount, seat_id, player_id, name, delta, stack_before, stack_after}, one per winning seat, where stack_before is immediately before THIS credit. transaction_applied = {kind: buy_in|cash_out|adjustment, seat_id, player_id, name, amount, delta, stack_before, stack_after, hand_number, source: transaction|seat_edit, session_id} \u2014 a stack change that is money, not poker. See agents.txt for the decision_ms caveat: it is pace, not a shot clock."
      },
      "replay": {
        "type": "replay", "since_seq": "integer you sent", "count": "events that follow this envelope",
        "complete": "boolean — TRUE only when the buffer can prove nothing was dropped after your cursor. FALSE means there is a gap: reconcile against GET /v1/hands rather than assuming you are current.",
        "reset": "boolean — the engine's event counter is per-process and restarted, so your cursor belongs to a previous run. The whole window is replayed; deduplicate on hand_number.",
        "oldest_seq": "integer|null", "newest_seq": "integer|null", "window": "buffered event capacity"
      }
    },
    "notes": "Reconnect with ?since_seq=<last seq you processed> to collect what you missed. The buffer is a short in-memory window on the venue's relay object, not durable history: it holds the last `window` events and is empty after the object is evicted, which is exactly what complete:false reports. State (pot, board, seats) is always healed by the state frame on connect; the EVENT stream is what since_seq recovers. 5 concurrent connections per key."
  },
  "paths": {
    "/": {
      "get": {
        "operationId": "discovery",
        "summary": "Discovery document. Public without a key (generic); with a key includes the venue id and live flag.",
        "security": [],
        "responses": { "200": { "description": "Endpoint list", "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string" }, "version": { "type": "integer" }, "venue": { "type": "string" }, "live": { "type": "boolean" }, "endpoints": { "type": "array", "items": { "type": "string" } } } } } } } }
      }
    },
    "/key": {
      "get": {
        "operationId": "introspectKey",
        "summary": "Which venue this key belongs to. Multi-venue (network) apps use this to label their keys; also a cheap key health-check.",
        "responses": { "200": { "description": "Key info", "content": { "application/json": { "schema": { "type": "object", "properties": { "kid": { "type": "string" }, "venue": { "type": "string" }, "scopes": { "type": "array", "items": { "type": "string" } }, "iat": { "type": ["integer", "null"] }, "live": { "type": "boolean" } } } } } }, "401": { "$ref": "#/components/responses/Unauthorized" } }
      }
    },
    "/venue": {
      "get": {
        "operationId": "getVenue",
        "summary": "Venue info, live flag, current game and stakes.",
        "responses": { "200": { "description": "Venue", "content": { "application/json": { "schema": { "type": "object", "properties": { "venue_id": { "type": "string" }, "name": { "type": ["string", "null"] }, "live": { "type": "boolean" }, "table_name": { "type": ["string", "null"] }, "money_mode": { "type": "string", "enum": ["bb", "dollars", "hidden"] }, "game": { "type": ["object", "null"], "properties": { "mode": { "type": ["string", "null"] }, "blinds": { "$ref": "#/components/schemas/Blinds" }, "structure": { "type": ["string", "null"] } } } } } } } }, "401": { "$ref": "#/components/responses/Unauthorized" } }
      }
    },
    "/live/state": {
      "get": {
        "operationId": "getLiveState",
        "summary": "Current table state (redacted: never hole cards, reveal state, or equity).",
        "responses": { "200": { "description": "Live state", "content": { "application/json": { "schema": { "type": "object", "properties": { "live": { "type": "boolean" }, "ts": { "type": "number" }, "state": { "oneOf": [{ "$ref": "#/components/schemas/LiveState" }, { "type": "null" }] } } } } } }, "401": { "$ref": "#/components/responses/Unauthorized" } }
      }
    },
    "/players": {
      "get": {
        "operationId": "listPlayers",
        "summary": "Roster with headline stats and permanent player ids.",
        "responses": { "200": { "description": "Players index", "content": { "application/json": { "schema": { "type": "object", "properties": { "players": { "type": "array", "items": { "type": "object", "properties": { "player_id": { "type": ["string", "null"] }, "name": { "type": "string" }, "slug": { "type": "string" }, "hands_played": { "type": "integer" }, "file": { "type": "string" } } } }, "aliases": { "type": "object", "description": "old player_id -> canonical player_id after profile merges", "additionalProperties": { "type": "string" } } } } } } }, "404": { "$ref": "#/components/responses/NotPublished" } }
      }
    },
    "/players/{playerId}": {
      "get": {
        "operationId": "getPlayer",
        "summary": "Profile + lifetime stats.",
        "parameters": [{ "name": "playerId", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Permanent player_id (UUID). Survives renames and merges." }],
        "responses": { "200": { "description": "Player", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PlayerStats" } } } }, "404": { "$ref": "#/components/responses/NotPublished" } }
      }
    },
    "/leaderboard": {
      "get": {
        "operationId": "getLeaderboard",
        "summary": "All-time leaderboard, or one calendar month with ?period=.",
        "parameters": [{ "name": "period", "in": "query", "required": false, "schema": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}$" } }],
        "responses": { "200": { "description": "Leaderboard", "content": { "application/json": { "schema": { "type": "object", "properties": { "money_mode": { "type": "string" }, "range": { "type": "string" }, "players": { "type": "array", "items": { "$ref": "#/components/schemas/PlayerStats" } } } } } } }, "404": { "$ref": "#/components/responses/NotPublished" } }
      }
    },
    "/sessions": {
      "get": { "operationId": "listSessions", "summary": "Past nights of poker.", "responses": { "200": { "description": "Sessions list" }, "404": { "$ref": "#/components/responses/NotPublished" } } }
    },
    "/sessions/{sessionUid}": {
      "get": { "operationId": "getSession", "summary": "One night's summary.", "parameters": [{ "name": "sessionUid", "in": "path", "required": true, "schema": { "type": "string" } }], "responses": { "200": { "description": "Session" }, "404": { "$ref": "#/components/responses/NotPublished" } } }
    },
    "/hands": {
      "get": {
        "operationId": "listHands",
        "summary": "Hand index (no params), a page of summaries (?page=N, 1-based), or everything after a cursor (?since= / ?after_id=).",
        "description": "Use a CURSOR to sync, not page numbers. Pages are newest-first, so every publish renumbers them and a remembered page number reads a different set of hands next time. ?since=<unix seconds> and ?after_id=<hand_id> are both exclusive and both anchored to the hand itself, so they survive republishing; ?after_id wins if you send both, because two hands can share a ts. The cursor response is oldest-first and carries `cursor` — send that back next time rather than inferring it from the array. Covers FINISHED nights only: the bundle refreshes when a table session closes, so hands from the night in progress arrive on the WebSocket (see x-websocket), which has its own cursor.",
        "parameters": [
          { "name": "page", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1 } },
          { "name": "since", "in": "query", "required": false, "description": "Unix seconds; returns hands with ts strictly greater.", "schema": { "type": "number", "minimum": 0 } },
          { "name": "after_id", "in": "query", "required": false, "description": "Exclusive hand_id cursor. Preferred over `since`.", "schema": { "type": "string" } }
        ],
        "responses": { "200": { "description": "Index {count, pages, page_size, page_files[]}, page {hands:[HandSummary]}, or cursor {order:'asc', count, complete, hands:[HandSummary], cursor:{ts,hand_id}}. `complete:false` means the walk hit its cap and older matching hands were not returned — fall back to ?page= and re-cursor from the newest hand." }, "400": { "description": "bad_since / bad_after_id" }, "404": { "$ref": "#/components/responses/NotPublished" } }
      }
    },
    "/hands/{handId}": {
      "get": {
        "operationId": "getHand",
        "summary": "Full hand replay. hole_cards present ONLY when showed_cards is true (mucked hands never carry cards).",
        "parameters": [{ "name": "handId", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": { "200": { "description": "Hand detail", "content": { "application/json": { "schema": { "type": "object", "properties": { "hand": { "type": "object" }, "actions": { "type": "array", "items": { "$ref": "#/components/schemas/HandAction" } }, "streets": { "type": "object" } } } } } }, "404": { "$ref": "#/components/responses/NotPublished" } }
      }
    },
    "/transactions/{action}": {
      "post": {
        "operationId": "recordTransaction",
        "summary": "Record a buy-in or a cash-out on the room's ledger.",
        "description": "The API is not read-only, and a room has ONE key that does everything — there is no separate write key and no scope to request, so treat the key as a credential that can move money. `idempotency_key` is REQUIRED 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 leave a duplicate row, it breaks the table mid-hand. A settled answer (refusals included) is replayed verbatim with an `Idempotent-Replay: true` header; a 5xx is deliberately not remembered, because `504 host_timeout` does not mean it did not happen and retrying with the same key is the correct response. 60 writes/min per key, on their own budget.",
        "parameters": [{ "name": "action", "in": "path", "required": true, "schema": { "type": "string", "enum": ["buy-in", "cash-out"] } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["player_name", "idempotency_key"], "properties": { "player_name": { "type": "string", "maxLength": 64 }, "amount": { "type": "number", "exclusiveMinimum": 0, "maximum": 1000000, "description": "Required for buy-in. Omit on cash-out to cash out the whole stack. Must be a JSON number, never a string." }, "idempotency_key": { "type": "string", "pattern": "^[A-Za-z0-9_.:-]{8,128}$" }, "close_session": { "type": "boolean", "description": "cash-out only." } } } } } },
        "responses": {
          "200": { "description": "The engine's own answer, verbatim." },
          "400": { "description": "bad_body, idempotency_key_required, bad_player_name, or bad_amount" },
          "409": { "description": "hand_in_progress (money moves between hands — the engine allows an operator an emergency mid-hand buy-in, this API does not), or in_progress (the same key is already with the Mac)" },
          "429": { "description": "rate_limited" },
          "503": { "description": "host_offline — reads keep serving from the relay, writes need the room's Mac" },
          "504": { "description": "host_timeout — NOT a failure to apply. Retry with the SAME idempotency_key." }
        }
      }
    },
    "/webhooks": {
      "get": { "operationId": "listWebhooks", "summary": "Webhooks registered by this key.", "responses": { "200": { "description": "Webhook list (secrets never included)" } } },
      "post": {
        "operationId": "createWebhook",
        "summary": "Register an HTTPS callback. Deliveries carry X-PokerPanel-Signature = base64url(HMAC-SHA256(body, secret)).",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["url"], "properties": { "url": { "type": "string", "format": "uri", "description": "https:// only" }, "kinds": { "type": "array", "description": "Omit for every kind. An unrecognised name is REFUSED rather than accepted and silently never delivered.", "items": { "type": "string", "enum": ["hand.finished", "payout.applied", "transaction.applied"] } } } } } } },
        "responses": { "201": { "description": "{id, url, kinds, secret} — secret shown once" }, "400": { "description": "bad_url, or unknown_kind {unknown[], supported[]}" }, "409": { "description": "webhook_limit (5 per key)" } }
      }
    },
    "/webhooks/{webhookId}": {
      "delete": { "operationId": "deleteWebhook", "summary": "Remove a webhook.", "parameters": [{ "name": "webhookId", "in": "path", "required": true, "schema": { "type": "string" } }], "responses": { "200": { "description": "{ok:true}" }, "404": { "description": "not_found" } } }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerKey": { "type": "http", "scheme": "bearer", "description": "Per-venue read-only key (pp_v1_...). WebSocket connections pass it as ?key= instead." }
    },
    "responses": {
      "Unauthorized": { "description": "Missing, malformed, or revoked key: {error:'unauthorized'}" },
      "NotPublished": { "description": "The venue has not published this data yet: {error:'not_found'}. Treat as empty; do not retry-loop." }
    },
    "schemas": {
      "Blinds": { "type": ["object", "null"], "properties": { "small_blind": { "type": "number" }, "big_blind": { "type": "number" }, "ante": { "type": "number" }, "straddle": { "type": "number" } } },
      "Seat": { "type": "object", "properties": { "seat_id": { "type": ["integer", "null"] }, "name": { "type": ["string", "null"] }, "player_id": { "type": ["string", "null"], "description": "Permanent UUID; null for seats without a saved profile" }, "stack": { "type": ["number", "null"] }, "in_hand": { "type": ["boolean", "null"] }, "present": { "type": ["boolean", "null"] } } },
      "LiveState": { "type": "object", "description": "Whitelist projection — never contains hole cards, reveal flags, or equity.", "properties": { "ts": { "type": ["number", "null"] }, "hand_number": { "type": ["integer", "null"] }, "table_name": { "type": ["string", "null"] }, "street": { "type": ["string", "null"] }, "board": { "type": "array", "items": { "type": "string" } }, "button": { "type": ["integer", "null"] }, "action_on": { "type": ["integer", "null"] }, "pot": { "type": ["number", "null"] }, "current_bet": { "type": ["number", "null"] }, "game_mode": { "type": ["string", "null"] }, "betting_structure": { "type": ["string", "null"] }, "blinds": { "$ref": "#/components/schemas/Blinds" }, "tournament": {}, "hand_decided": {}, "num_seats": { "type": ["integer", "null"] }, "seats": { "type": "array", "items": { "$ref": "#/components/schemas/Seat" } } } },
      "PlayerStats": { "type": "object", "description": "Money-denominated fields appear only when the venue's money_mode permits.", "properties": { "player_id": { "type": ["string", "null"] }, "player_name": { "type": "string" }, "name": { "type": "string" }, "slug": { "type": "string" }, "hands_played": { "type": "integer" }, "vpip": { "type": "number" }, "pfr": { "type": "number" }, "threebet": { "type": "number" }, "aggression_factor": { "type": "number" }, "wtsd": { "type": "number" }, "wsd": { "type": "number" }, "bb_per_100": { "type": "number" }, "sessions": { "type": "integer" }, "total_hours": { "type": "number" } } },
      "HandAction": { "type": "object", "properties": { "seat_id": { "type": ["integer", "null"] }, "player_id": { "type": ["string", "null"] }, "action_type": { "type": "string", "enum": ["DEAL", "POST_BLIND", "BET", "CALL", "RAISE", "FOLD", "CHECK", "ALL_IN"] }, "amount": { "type": ["number", "null"], "description": "Increment for this action, not a running total" }, "street": { "type": ["string", "null"] }, "position": { "type": ["string", "null"] }, "ts": { "type": ["number", "null"], "description": "Unix seconds when this action committed in the engine" }, "decision_ms": { "type": ["integer", "null"], "description": "Milliseconds since the previous action of this hand. Null on the first action and on any gap over 5 minutes (a break in play is not a decision). Pace, not a shot clock \u2014 see agents.txt." } } }
    }
  }
}
