{
  "title": "Sports API Reference",
  "updated": "2026-05-17",
  "purpose": "Request and response shapes for the Sports API (Node/Express). For integrators, partner systems, and automated agents. Does not document any demo viewer or internal proxy — call the API from your own backend.",
  "base_url": "https://{host}:{port}",
  "integration": {
    "summary": "Call the Sports API from your own server, not from browsers or untrusted clients. Your stack (PHP, Node, Python, Go, etc.) is your choice; route outbound requests through infrastructure with a fixed public IP.",
    "fixed_ip": "Register that IP in auth_db.external_allowlist via the Access admin (there is no env allowlist). Partner routes (/:sport/external/*) accept an allowlisted IP or an API key; data routes also accept a Firebase Bearer token.",
    "pattern": "Your app → your backend → Sports API. Never expose allowlisted credentials or rely on browser-direct calls for protected data.",
    "errors": "JSON body { \"error\": \"message\" } with HTTP 4xx/5xx. No wrapper envelope."
  },
  "authentication": {
    "ip_allowlist": {
      "header": "none",
      "description": "Request source IP matched against auth_db.external_allowlist (managed in the Access admin). Grants partner access to the data routes and /:sport/external/* routes. Admin routes are not reachable by IP and always require a Firebase Bearer token."
    },
    "firebase_bearer": {
      "header": "Authorization: Bearer <Firebase ID token>",
      "description": "Google sign-in token verified by Firebase Admin. Email must be in auth_db.allowed_users when ENFORCE_AUTH_DB=true."
    },
    "preplay_prices_only": {
      "routes": ["GET /:sport/external/preplay-prices"],
      "description": "No Firebase Bearer token. Partner access only: an allowlisted IP (auth_db.external_allowlist) or an API key (X-Api-Key / Bearer sp_...). Intended for scheduled jobs and partner backends."
    },
    "public": {
      "routes": ["GET /health"],
      "description": "No auth."
    }
  },
  "sports": {
    "path_param": ":sport",
    "values": ["soccer", "tennis", "golf", "cricket"],
    "note": "Configured via API SPORTS env. soccer is internal id; Betfair Exchange URLs use football."
  },
  "endpoints": [
    {
      "method": "GET",
      "path": "/health",
      "auth": "none",
      "response": "JSON status, timestamp, connected databases, redis, supported sports"
    },
    {
      "method": "GET",
      "path": "/api/config",
      "auth": "none (config route has no auth middleware)",
      "response": {
        "requireAuth": "boolean",
        "firebase": "object | null — client config when set",
        "supportedSports": ["tennis"]
      }
    },
    {
      "method": "GET",
      "path": "/api/:sport/markets",
      "auth": "IP allowlist or Bearer",
      "query": {
        "date": "YYYY-MM-DD — filter by market_time date",
        "status": "optional market status",
        "limit": "default 100",
        "offset": "default 0"
      },
      "response": "JSON array of market objects"
    },
    {
      "method": "GET",
      "path": "/api/:sport/markets/:marketId",
      "auth": "IP allowlist or Bearer",
      "response": "single market object"
    },
    {
      "method": "GET",
      "path": "/api/:sport/markets/:marketId/runners",
      "auth": "IP allowlist or Bearer",
      "query": { "format": "optional — flat returns raw DB columns; default maps to runner shape" },
      "response": "JSON array of runner objects (see types.runner)"
    },
    {
      "method": "GET",
      "path": "/api/:sport/markets/:marketId/prices",
      "auth": "IP allowlist or Bearer",
      "query": {
        "selectionId": "optional",
        "priceType": "optional pp | ip",
        "limit": "default 1000"
      },
      "response": "time series rows from runner_prices"
    },
    {
      "method": "GET",
      "path": "/api/:sport/markets/:marketId/volumes",
      "auth": "IP allowlist or Bearer",
      "query": {
        "selectionId": "optional",
        "priceType": "optional",
        "limit": "default 1000"
      },
      "response": "time series rows from runner_volumes (tv, mk_tv, pv)"
    },
    {
      "method": "GET",
      "path": "/api/:sport/preplay-prices",
      "auth": "IP allowlist only",
      "query": {
        "date": "required YYYY-MM-DD",
        "marketId": "optional — single market"
      },
      "response": "JSON array of market objects each with nested runners (see types.preplay_prices_market)"
    },
    {
      "method": "GET",
      "path": "/api/live/:sport/markets",
      "auth": "IP allowlist or Bearer",
      "response": "JSON array of live snapshots from Redis"
    },
    {
      "method": "GET",
      "path": "/api/live/:sport/stream",
      "auth": "IP allowlist or Bearer",
      "response": "text/event-stream — events: connected, update, heartbeat; update data is JSON from collector"
    }
  ],
  "types": {
    "market": {
      "marketId": "string — Betfair market id e.g. 1.258264541",
      "eventId": "string",
      "event_name": "string",
      "competition_name": "string",
      "market_name": "string",
      "market_time": "ISO 8601 UTC",
      "in_play": "boolean | null",
      "in_play_at": "ISO 8601 UTC | null — when the market first went in-play",
      "status": "OPEN | SUSPENDED | CLOSED | …",
      "total_matched": "number | null",
      "created_at": "timestamp",
      "updated_at": "timestamp"
    },
    "runner": {
      "description": "Default response from .../runners (mapRunnerRow).",
      "marketId": "string",
      "selectionId": "string",
      "runner_name": "string",
      "status": "ACTIVE | WINNER | LOSER | REMOVED | …",
      "bsp": "number | null",
      "pp": {
        "back": "number | null",
        "lay": "number | null",
        "ltp": "number | null",
        "matched": "number | null — runner volume (pp_tv), not market total_matched",
        "pv": "number | null",
        "spread": "number | null",
        "updated": "ISO | null"
      },
      "ip": "same shape as pp",
      "compare": { "back": "number | null", "lay": "number | null", "ltp": "number | null", "matched": "number | null" }
    },
    "preplay_prices_market": {
      "description": "One element from GET .../preplay-prices.",
      "marketId": "string",
      "eventId": "string",
      "event_name": "string",
      "competition_name": "string",
      "market_name": "string",
      "market_time": "ISO",
      "in_play": "boolean",
      "in_play_at": "ISO 8601 UTC | null",
      "status": "string",
      "runners": [
        {
          "selectionId": "string",
          "runner_name": "string",
          "runner_status": "string — field name is runner_status, not status",
          "bsp": "number | null",
          "pp": { "back": "number | null", "lay": "number | null", "updated": "ISO | null" },
          "ip": { "back": "number | null", "lay": "number | null", "updated": "ISO | null" }
        }
      ]
    }
  },
  "examples": {
    "markets_list": {
      "request": "GET /api/tennis/markets?date=2026-05-17&limit=50",
      "response": [
        {
          "marketId": "1.258264541",
          "eventId": "35615769",
          "event_name": "Leol Jeanjean v L Fernandez",
          "competition_name": "WTA Strasbourg 2026",
          "market_name": "Match Odds",
          "market_time": "2026-05-17T14:10:00.000Z",
          "in_play": true,
          "in_play_at": "2026-05-17T14:12:03.000Z",
          "status": "OPEN",
          "total_matched": null
        }
      ]
    },
    "runners": {
      "request": "GET /api/tennis/markets/1.258264541/runners",
      "response": [
        {
          "marketId": "1.258264541",
          "selectionId": "9627959",
          "runner_name": "Leolia Jeanjean",
          "status": "ACTIVE",
          "bsp": null,
          "pp": { "back": null, "lay": null, "matched": null, "updated": null },
          "ip": { "back": 12.5, "lay": 14.5, "matched": 5000, "updated": "2026-05-17T15:23:13.291Z" },
          "compare": { "back": null, "lay": null, "matched": null }
        }
      ]
    },
    "preplay_prices": {
      "request": "GET /api/tennis/preplay-prices?date=2026-05-17",
      "response": [
        {
          "marketId": "1.258264541",
          "status": "OPEN",
          "runners": [
            {
              "selectionId": "9627959",
              "runner_status": "ACTIVE",
              "pp": { "back": null, "lay": null, "updated": null },
              "ip": { "back": 12.5, "lay": 14.5, "updated": "2026-05-17T15:23:13.291Z" }
            }
          ]
        }
      ]
    }
  }
}
