{
  "openapi": "3.1.0",
  "info": {
    "title": "Poker Panel Developer API",
    "version": "1.0.0",
    "description": "Read-only 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",
    "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}",
    "onConnect": "one frame {type:'state', live:boolean, state:LiveState|null}",
    "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",
        "payload": "object; player_action = {hand_number, seat_id, action_type: fold|check|call|bet|raise|all_in|straddle, amount, street}"
      }
    },
    "notes": "Missed frames are healed by the next state frame. 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 ?page) or a page of hand summaries (?page=N, 1-based).",
        "parameters": [{ "name": "page", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1 } }],
        "responses": { "200": { "description": "Index {count, pages, page_size, page_files[]} or page {hands:[HandSummary]}" }, "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" } }
      }
    },
    "/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", "items": { "type": "string", "enum": ["hand.finished"] } } } } } } },
        "responses": { "201": { "description": "{id, url, kinds, secret} — secret shown once" }, "400": { "description": "bad_url" }, "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"] } } }
    }
  }
}
