{
  "openapi": "3.1.0",
  "info": {
    "title": "odss-api",
    "version": "1.0.0",
    "description": "JSON API of pre-match and in-play (live) sports betting odds from 40+ bookmakers, including Italian ADM books plus bet365, Betfair Exchange and Pinnacle. Odds are normalized into a stable schema with a deterministic event_id and per-book last_update. Use ?state=live for in-play odds with current score and minute, and the SSE /stream for push delivery. An MCP server (JSON-RPC 2.0) is available at POST https://odss-api.com/api/mcp for AI agents and connectors (tools: get_odds, list_sports, list_bookmakers, list_leagues; auth with the same API key). Enterprise plans can also register outgoing webhooks (see the webhooks section).",
    "termsOfService": "https://odss-api.com/",
    "contact": { "name": "PRAXIS 3 S.r.l.", "email": "info@praxis3.it", "url": "https://odss-api.com/" }
  },
  "servers": [{ "url": "https://odss-api.com/api/v1" }],
  "security": [{ "ApiKeyAuth": [] }],
  "tags": [
    { "name": "Odds", "description": "Cross-book odds and live stream" },
    { "name": "Discovery", "description": "Sports, bookmakers, leagues, coverage" }
  ],
  "paths": {
    "/odds": {
      "get": {
        "tags": ["Odds"],
        "summary": "Cross-book odds by event and market",
        "operationId": "getOdds",
        "parameters": [
          { "name": "sport", "in": "query", "schema": { "type": "string" }, "description": "Exact sport key (see /sports), e.g. calcio, tennis, basket." },
          { "name": "market", "in": "query", "schema": { "type": "string" }, "description": "Market key. Canonical (cross-book comparable): 1x2, ou, btts, dc, handicap, moneyline… Plus book-specific extra markets exposed as other:<label> (e.g. other:segna_goal) — full odds coverage, not cross-book normalized." },
          { "name": "league", "in": "query", "schema": { "type": "string" }, "description": "Case-insensitive substring on the league name (e.g. serie a)." },
          { "name": "bookmakers", "in": "query", "schema": { "type": "string" }, "description": "CSV of bookmaker keys (see /bookmakers)." },
          { "name": "event_id", "in": "query", "schema": { "type": "string" }, "description": "Return a single event by its stable id." },
          { "name": "state", "in": "query", "schema": { "type": "string", "enum": ["prematch", "live", "all"], "default": "prematch" }, "description": "prematch (default) = pre-match odds; live = in-play odds with current score and minute; all = both (same event_id correlates the fixture across states). live returns an empty list and live_enabled:false when the in-play engine is off." },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 500, "maximum": 2000 } },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "default": 0 }, "description": "Pagination: repeat while offset + returned < count." }
        ],
        "responses": {
          "200": {
            "description": "Odds page",
            "headers": {
              "X-RateLimit-Limit": { "schema": { "type": "integer" }, "description": "Monthly quota of the plan." },
              "X-RateLimit-Remaining": { "schema": { "type": "integer" }, "description": "Requests left in the current monthly window (account-level)." }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OddsResponse" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/sports": {
      "get": {
        "tags": ["Discovery"], "summary": "Sport keys currently in the feed", "operationId": "getSports",
        "responses": {
          "200": { "description": "Available sports", "content": { "application/json": { "schema": { "type": "object", "properties": { "sports": { "type": "array", "items": { "type": "string" } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/bookmakers": {
      "get": {
        "tags": ["Discovery"], "summary": "Active bookmakers in the feed", "operationId": "getBookmakers",
        "responses": {
          "200": { "description": "Bookmakers", "content": { "application/json": { "schema": { "type": "object", "properties": { "bookmakers": { "type": "array", "items": { "$ref": "#/components/schemas/Bookmaker" } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/leagues": {
      "get": {
        "tags": ["Discovery"], "summary": "Current schedule: sports and leagues with counts", "operationId": "getLeagues",
        "security": [],
        "responses": {
          "200": { "description": "Leagues by sport", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LeaguesResponse" } } } }
        }
      }
    },
    "/coverage": {
      "get": {
        "tags": ["Discovery"], "summary": "Bookmaker catalog count per region", "operationId": "getCoverage",
        "security": [],
        "responses": {
          "200": { "description": "Coverage", "content": { "application/json": { "schema": { "type": "object", "properties": { "regions": { "type": "object", "additionalProperties": { "type": "integer" } }, "total": { "type": "integer" } } } } } }
        }
      }
    },
    "/status": {
      "get": {
        "tags": ["Discovery"], "summary": "Operational status + real freshness distribution", "operationId": "getStatus",
        "description": "Public status: overall status (operational|degraded|down), scan cadence, live-engine flag, and the REAL staleness distribution — per bookmaker: current freshness (seconds), p50/p95 over 24h (sampled every 2 min), ok/error. Human page at /status.",
        "security": [],
        "responses": {
          "200": { "description": "Status", "content": { "application/json": { "schema": { "type": "object", "properties": {
            "status": { "type": "string", "enum": ["operational", "degraded", "down"] },
            "scanIntervalSeconds": { "type": ["integer", "null"] },
            "liveEnabled": { "type": "boolean" },
            "updatedAt": { "type": "string", "format": "date-time" },
            "overall": { "type": "object", "properties": { "p50": { "type": ["integer", "null"] }, "p95": { "type": ["integer", "null"] }, "p99": { "type": ["integer", "null"] }, "samples": { "type": "integer" } } },
            "books": { "type": "array", "items": { "type": "object", "properties": { "book": { "type": "string" }, "records": { "type": "integer" }, "freshnessSeconds": { "type": ["integer", "null"] }, "ok": { "type": ["boolean", "null"] }, "p50": { "type": ["integer", "null"] }, "p95": { "type": ["integer", "null"] } } } }
          } } } } }
        }
      }
    },
    "/stream": {
      "get": {
        "tags": ["Odds"],
        "summary": "Server-Sent Events stream of odds changes (Enterprise only)",
        "description": "Emits a `hello` event, then `odds` events carrying `{changed:[OddsRecord], removed:[EventId]}` as the engine detects changes (each item carries its own `state`). Fetch the initial snapshot via GET /odds?state=<state>. Optional `sport` filter and `state` (prematch|live|all). Max 3 concurrent streams per account.",
        "operationId": "getStream",
        "parameters": [
          { "name": "sport", "in": "query", "schema": { "type": "string" } },
          { "name": "state", "in": "query", "schema": { "type": "string", "enum": ["prematch", "live", "all"], "default": "prematch" }, "description": "Which feed to stream: pre-match, in-play, or both." }
        ],
        "responses": {
          "200": { "description": "SSE stream", "content": { "text/event-stream": { "schema": { "type": "string" } } } },
          "403": { "description": "Not on the Enterprise plan" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    }
  },
  "webhooks": {
    "oddsChanged": {
      "post": {
        "summary": "Outgoing odds-change delivery (Enterprise)",
        "description": "When you register a webhook (from the dashboard), odss-api POSTs odds changes to your URL, same `{changed, removed}` shape as the SSE stream. Each request is signed: header `X-Odss-Signature: t=<unix>,v1=<hex>` is HMAC-SHA256 of `\"{timestamp}.{rawBody}\"` with your webhook secret; `X-Odss-Timestamp` and `X-Odss-Webhook-Id` are also sent. Verify the signature and reject timestamps older than a few minutes. Redirects are not followed; only https targets are accepted. Respond 2xx to acknowledge — repeated non-2xx auto-disables the endpoint.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "webhook_id": { "type": "integer" },
                  "delivered_at": { "type": "string", "format": "date-time" },
                  "changed": { "type": "array", "items": { "$ref": "#/components/schemas/OddsRecord" } },
                  "removed": { "type": "array", "items": { "type": "object" }, "description": "Group ids of records no longer present (sport, league, event_id, market, line, scope, period, state)." },
                  "truncated": { "type": "boolean", "description": "true if changed/removed were capped for this delivery." }
                }
              }
            }
          }
        },
        "responses": { "2XX": { "description": "Acknowledged" } }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": { "type": "apiKey", "in": "header", "name": "x-api-key" }
    },
    "responses": {
      "Unauthorized": { "description": "Missing, invalid or revoked API key", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "RateLimited": { "description": "Rate or monthly quota exceeded", "headers": { "Retry-After": { "schema": { "type": "integer" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
    },
    "schemas": {
      "Error": { "type": "object", "properties": { "error": { "type": "string" } } },
      "Bookmaker": {
        "type": "object",
        "properties": {
          "key": { "type": "string" },
          "country": { "type": "string", "examples": ["IT", "world"] },
          "playable_it": { "type": "boolean", "description": "false for sharp references (e.g. Pinnacle)." },
          "is_exchange": { "type": "boolean" }
        }
      },
      "BookOdds": {
        "type": "object",
        "allOf": [{ "$ref": "#/components/schemas/Bookmaker" }],
        "properties": {
          "outcomes": { "type": "object", "additionalProperties": { "type": "number" }, "description": "Outcome → decimal odds (e.g. HOME: 2.10)." },
          "last_update": { "type": ["string", "null"], "format": "date-time", "description": "ISO timestamp of the book's last successful update." }
        }
      },
      "OddsRecord": {
        "type": "object",
        "properties": {
          "event_id": { "type": "string", "description": "Deterministic id, shared by all markets of the fixture." },
          "event": { "type": "string" },
          "sport": { "type": "string" },
          "league": { "type": ["string", "null"] },
          "home_team": { "type": ["string", "null"] },
          "away_team": { "type": ["string", "null"] },
          "commence_time": { "type": ["string", "null"], "format": "date-time" },
          "market": { "type": "string", "description": "Canonical key (1x2, ou, …) or a book-specific other:<label> extra market (non cross-book comparable)." },
          "line": { "type": ["number", "null"] },
          "scope": { "type": ["string", "null"] },
          "period": { "type": ["string", "null"] },
          "state": { "type": "string", "enum": ["prematch", "live"], "description": "Whether this record is a pre-match or in-play quote." },
          "score": { "type": ["string", "null"], "description": "Current score for live records (e.g. \"1-0\"); null for pre-match." },
          "minute": { "type": ["string", "null"], "description": "Current match minute for live records (e.g. \"63'\"); null for pre-match." },
          "bookmakers": { "type": "array", "items": { "$ref": "#/components/schemas/BookOdds" } }
        }
      },
      "OddsResponse": {
        "type": "object",
        "properties": {
          "count": { "type": "integer", "description": "Total records matching filters (all pages)." },
          "offset": { "type": "integer" },
          "returned": { "type": "integer" },
          "state": { "type": "string", "enum": ["prematch", "live", "all"], "description": "Echoes the requested state." },
          "live_enabled": { "type": "boolean", "description": "Present when state is live or all: whether the in-play engine is currently active." },
          "odds": { "type": "array", "items": { "$ref": "#/components/schemas/OddsRecord" } }
        }
      },
      "LeaguesResponse": {
        "type": "object",
        "properties": {
          "count": { "type": "integer" },
          "books": { "type": "integer" },
          "sports": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "sport": { "type": "string" },
                "events": { "type": "integer" },
                "leagues": {
                  "type": "array",
                  "items": { "type": "object", "properties": { "league": { "type": "string" }, "events": { "type": "integer" }, "books": { "type": "integer" } } }
                }
              }
            }
          }
        }
      }
    }
  }
}
