{
  "openapi": "3.1.0",
  "info": {
    "title": "OddsRelay Feed API",
    "version": "1.0.0",
    "summary": "Matched, region-routed odds — one clean REST API.",
    "description": "The OddsRelay feed delivers **matched, exchange/lay-ready odds** across deep UK bookmaker coverage (bet365 included) as one keyed, versioned REST API. Integrate in an afternoon: get a key, send one authenticated `GET`, receive ready-to-use opportunities.\n\n- **Base URL:** `https://api.oddsrelay.io/v1` (path-versioned).\n- **Auth:** an `or_live_*` (or `or_test_*` sandbox) key, sent as `Authorization: Bearer …` or `x-api-key: …`. **Never** in a query string.\n- **Transport:** HTTPS + JSON; `gzip` on `Accept-Encoding`; conditional `ETag`/`If-None-Match` → `304`.\n- **Golden client:** always send `Accept-Encoding: gzip` **and** `If-None-Match` — together they collapse most polls to a tiny `304`.\n- **Stability:** additive-only within `/v1` — clients MUST ignore unknown fields. Removals/renames only ever land in a future `/v2` with a ≥12-month sunset.\n\nThis spec documents only what is **live**. Planned surfaces (a raw feed, SSE, WebSocket) are listed under `x-roadmap`.",
    "contact": {
      "name": "OddsRelay",
      "url": "https://oddsrelay.io/contact"
    },
    "license": {
      "name": "Proprietary — © OddsRelay",
      "url": "https://oddsrelay.io/contact"
    },
    "x-effective-api-version": "2026-06-30"
  },
  "servers": [
    {
      "url": "https://api.oddsrelay.io/v1",
      "description": "Production (path-versioned major v1)."
    }
  ],
  "security": [
    {
      "bearerKey": []
    },
    {
      "apiKeyHeader": []
    }
  ],
  "tags": [
    {
      "name": "Feed",
      "description": "The processed (matched) odds feed — the core product."
    },
    {
      "name": "Discovery",
      "description": "What a key may request, and how fresh each board is."
    },
    {
      "name": "Status",
      "description": "Liveness + per-board freshness."
    }
  ],
  "paths": {
    "/odds/{type}": {
      "get": {
        "tags": [
          "Feed"
        ],
        "operationId": "getOdds",
        "summary": "Processed (matched) opportunities for a feed type + region.",
        "description": "Returns the current matched-opportunity board for `{type}`, filtered to the `region`(s) the key is scoped for. The concrete opportunity shape depends on `{type}` (see the per-type schemas). `rating = 100 × back/lay` (100 = break-even); rows past a time-aware freshness cutoff are hidden (`stale_hidden`); lay legs are gated by a liquidity floor — these are properties of the matched feed, surfaced not re-computed.\n\nbet365 rows are included only when the key carries the `bet365` add-on; a key without it simply receives the same board with **zero** bet365 rows (filtered, never a 403).",
        "parameters": [
          {
            "$ref": "#/components/parameters/TypeParam"
          },
          {
            "$ref": "#/components/parameters/RegionParam"
          },
          {
            "$ref": "#/components/parameters/FormatParam"
          },
          {
            "$ref": "#/components/parameters/IncludeSeqParam"
          },
          {
            "$ref": "#/components/parameters/IfNoneMatchParam"
          },
          {
            "$ref": "#/components/parameters/AcceptEncodingParam"
          }
        ],
        "responses": {
          "200": {
            "description": "OK — the matched board for the requested type + region.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Limit-Hour": {
                "$ref": "#/components/headers/XRateLimitLimitHour"
              },
              "X-RateLimit-Remaining-Hour": {
                "$ref": "#/components/headers/XRateLimitRemainingHour"
              },
              "X-OddsRelay-Api-Version": {
                "$ref": "#/components/headers/XOddsRelayApiVersion"
              },
              "X-OddsRelay-Request-Id": {
                "$ref": "#/components/headers/XOddsRelayRequestId"
              },
              "X-OddsRelay-Seq": {
                "$ref": "#/components/headers/XOddsRelaySeq"
              },
              "X-Processed-At": {
                "$ref": "#/components/headers/XProcessedAt"
              },
              "X-Opportunity-Count": {
                "$ref": "#/components/headers/XOpportunityCount"
              },
              "X-Stale-Hidden": {
                "$ref": "#/components/headers/XStaleHidden"
              },
              "Deprecation": {
                "$ref": "#/components/headers/Deprecation"
              },
              "Sunset": {
                "$ref": "#/components/headers/Sunset"
              },
              "Link": {
                "$ref": "#/components/headers/Link"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OddsEnvelope"
                },
                "examples": {
                  "standard": {
                    "$ref": "#/components/examples/StandardEnvelope"
                  }
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/regions": {
      "get": {
        "tags": [
          "Discovery"
        ],
        "operationId": "getRegions",
        "summary": "Regions this key may request + their coverage.",
        "description": "Lists every region in the enum with whether this key may request it (`requestable`), the bookmaker count, and whether a matched feed is available. Use it to drive a region selector without hard-coding the enum.",
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-OddsRelay-Request-Id": {
                "$ref": "#/components/headers/XOddsRelayRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RegionsResponse"
                },
                "example": {
                  "default": "uk",
                  "regions": [
                    {
                      "code": "uk",
                      "requestable": true,
                      "bookmakers": 64,
                      "matched_coverage": true
                    },
                    {
                      "code": "ie",
                      "requestable": false,
                      "bookmakers": 0,
                      "matched_coverage": false
                    },
                    {
                      "code": "sa",
                      "requestable": false,
                      "bookmakers": 0,
                      "matched_coverage": false
                    },
                    {
                      "code": "ng",
                      "requestable": false,
                      "bookmakers": 0,
                      "matched_coverage": false
                    },
                    {
                      "code": "us",
                      "requestable": false,
                      "bookmakers": 0,
                      "matched_coverage": false
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/coverage": {
      "get": {
        "tags": [
          "Discovery"
        ],
        "operationId": "getCoverage",
        "summary": "Bookmakers covered + per-board freshness (no odds).",
        "description": "A bounded proof surface: how many distinct bookmakers are covered (60+, bet365 included) and the live opportunity count + age of each board. Carries no odds — safe to surface publicly.",
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-OddsRelay-Request-Id": {
                "$ref": "#/components/headers/XOddsRelayRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CoverageResponse"
                },
                "example": {
                  "bookmakers_covered": 64,
                  "bet365_included": true,
                  "boards": {
                    "standard": {
                      "opportunity_count": 21221,
                      "age_s": 6.5
                    },
                    "each_way": {
                      "opportunity_count": 229,
                      "age_s": 10.6
                    },
                    "dutching": {
                      "opportunity_count": 3386,
                      "age_s": 6.5
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/health": {
      "get": {
        "tags": [
          "Status"
        ],
        "operationId": "getHealth",
        "summary": "Liveness + per-board processed_at / age for the key's regions.",
        "description": "Liveness plus, per board, the matcher cycle stamp (`processed_at`), its age in seconds, and the current opportunity count. Poll it to alert on staleness independently of the data endpoints.",
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-OddsRelay-Request-Id": {
                "$ref": "#/components/headers/XOddsRelayRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HealthResponse"
                },
                "example": {
                  "status": "ok",
                  "boards": {
                    "standard": {
                      "processed_at": "2026-06-30T20:09:30.260Z",
                      "age_s": 6.4,
                      "opportunity_count": 21221
                    },
                    "each_way": {
                      "processed_at": "2026-06-30T20:09:26.073Z",
                      "age_s": 10.5,
                      "opportunity_count": 229
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerKey": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "or_live_* / or_test_*",
        "description": "Send `Authorization: Bearer or_live_…`. Trial/sandbox keys use the `or_test_*` prefix. Keys are 256-bit CSPRNG, stored as `sha256` only, shown once at issue. A key is NEVER accepted in a query parameter."
      },
      "apiKeyHeader": {
        "type": "apiKey",
        "in": "header",
        "name": "x-api-key",
        "description": "Alternative to the Bearer scheme: `x-api-key: or_live_…`. Same key, header only — never a query parameter."
      }
    },
    "parameters": {
      "TypeParam": {
        "name": "type",
        "in": "path",
        "required": true,
        "description": "The feed type. Hyphenated public slugs map to the matcher's underscored board names (`each-way`→`each_way`, etc.).",
        "schema": {
          "type": "string",
          "enum": [
            "standard",
            "each-way",
            "extra-place",
            "bog",
            "dutching",
            "2up",
            "price-boost-matcher"
          ]
        },
        "example": "standard"
      },
      "RegionParam": {
        "name": "region",
        "in": "query",
        "required": false,
        "description": "Region filter. A single code or a comma-separated list (e.g. `uk,ie`). The key's `regions` scope gates this: a known region the key isn't scoped for → `403 region_not_in_scope`; a region not in the enum → `404 unknown_region`. Today only `uk` is requestable; the others are reserved.",
        "schema": {
          "type": "string",
          "default": "uk",
          "examples": [
            "uk",
            "uk,ie"
          ]
        }
      },
      "FormatParam": {
        "name": "format",
        "in": "query",
        "required": false,
        "description": "`envelope` (default) returns the full JSON body. `bare` returns the opportunities array only and moves envelope metadata to `X-Processed-At` / `X-Opportunity-Count` / `X-Stale-Hidden` response headers — useful to skip a parse of the metadata.",
        "schema": {
          "type": "string",
          "enum": [
            "envelope",
            "bare"
          ],
          "default": "envelope"
        }
      },
      "IncludeSeqParam": {
        "name": "includeSeq",
        "in": "query",
        "required": false,
        "description": "When `true`, adds an `X-OddsRelay-Seq` response header (the board's monotonic sequence = `processed_at` in ms) for a clean REST→stream handoff once streaming ships.",
        "schema": {
          "type": "boolean",
          "default": false
        }
      },
      "IfNoneMatchParam": {
        "name": "If-None-Match",
        "in": "header",
        "required": false,
        "description": "Echo back the last `ETag` you received. If the board hasn't rolled, you get a bodyless `304` (free, fast). Treat the ETag as opaque.",
        "schema": {
          "type": "string"
        },
        "example": "\"1782850229000-21114-e\""
      },
      "AcceptEncodingParam": {
        "name": "Accept-Encoding",
        "in": "header",
        "required": false,
        "description": "Send `gzip` to receive a gzip-compressed body (the payload is large; this is one of the two golden-client levers).",
        "schema": {
          "type": "string"
        },
        "example": "gzip"
      }
    },
    "headers": {
      "ETag": {
        "description": "Opaque strong validator for the current board snapshot. Echo it in `If-None-Match` to get `304`s. (Form is `\"<processed_at_ms>-<fresh_count>-<flag>\"` but treat it as opaque.)",
        "schema": {
          "type": "string"
        }
      },
      "CacheControl": {
        "description": "Origin caching posture.",
        "schema": {
          "type": "string",
          "example": "private, max-age=15, must-revalidate"
        }
      },
      "Vary": {
        "description": "Responses vary by key and encoding.",
        "schema": {
          "type": "string",
          "example": "Authorization, Accept-Encoding"
        }
      },
      "ContentEncoding": {
        "description": "Present (`gzip`) when the body is compressed.",
        "schema": {
          "type": "string",
          "example": "gzip"
        }
      },
      "XRateLimitLimit": {
        "description": "Per-key request ceiling for the current minute window.",
        "schema": {
          "type": "integer"
        }
      },
      "XRateLimitRemaining": {
        "description": "Requests remaining in the current minute window.",
        "schema": {
          "type": "integer"
        }
      },
      "XRateLimitLimitHour": {
        "description": "Per-key request ceiling for the current hour window.",
        "schema": {
          "type": "integer"
        }
      },
      "XRateLimitRemainingHour": {
        "description": "Requests remaining in the current hour window.",
        "schema": {
          "type": "integer"
        }
      },
      "RetryAfter": {
        "description": "Seconds to wait before retrying (sent on `429`).",
        "schema": {
          "type": "integer"
        }
      },
      "XOddsRelayApiVersion": {
        "description": "The effective API version (dated) serving this response.",
        "schema": {
          "type": "string",
          "example": "2026-06-30"
        }
      },
      "XOddsRelayRequestId": {
        "description": "Per-request id (also echoed in error bodies) — quote it in support requests.",
        "schema": {
          "type": "string",
          "example": "or_req_d2077a3473574f82b36a77b34eb61a94"
        }
      },
      "XOddsRelaySeq": {
        "description": "Board sequence (ms). Only present when `includeSeq=true`.",
        "schema": {
          "type": "integer",
          "format": "int64"
        }
      },
      "XProcessedAt": {
        "description": "Matcher cycle stamp. Only present in `format=bare`.",
        "schema": {
          "type": "string",
          "format": "date-time"
        }
      },
      "XOpportunityCount": {
        "description": "Opportunity count. Only present in `format=bare`.",
        "schema": {
          "type": "integer"
        }
      },
      "XStaleHidden": {
        "description": "Whether any rows were hidden past the freshness cutoff. Only present in `format=bare`.",
        "schema": {
          "type": "boolean"
        }
      },
      "Deprecation": {
        "description": "RFC 9745 — present only when something being used is deprecated.",
        "schema": {
          "type": "string"
        }
      },
      "Sunset": {
        "description": "RFC 8594 HTTP-date — the date a deprecated feature is removed (≥12 months out).",
        "schema": {
          "type": "string"
        }
      },
      "Link": {
        "description": "`rel=\"deprecation\"`/`rel=\"sunset\"` links to the changelog, when applicable.",
        "schema": {
          "type": "string"
        }
      }
    },
    "responses": {
      "NotModified": {
        "description": "Not Modified — your `If-None-Match` matched the current ETag. No body.",
        "headers": {
          "ETag": {
            "$ref": "#/components/headers/ETag"
          },
          "Cache-Control": {
            "$ref": "#/components/headers/CacheControl"
          }
        }
      },
      "BadRequest": {
        "description": "Malformed request (e.g. bad `region` syntax).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "bad_request",
                "message": "Malformed query.",
                "request_id": "or_req_…"
              }
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Auth failed — `missing_api_key` · `invalid_api_key` · `revoked_api_key` · `expired_api_key`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "missing": {
                "value": {
                  "error": {
                    "code": "missing_api_key",
                    "message": "No API key presented. Send Authorization: Bearer or_live_… or x-api-key.",
                    "request_id": "or_req_…"
                  }
                }
              },
              "invalid": {
                "value": {
                  "error": {
                    "code": "invalid_api_key",
                    "message": "API key not recognised.",
                    "request_id": "or_req_…"
                  }
                }
              }
            }
          }
        }
      },
      "Forbidden": {
        "description": "Valid key, but not scoped for what was requested — `type_not_in_scope` · `region_not_in_scope` · `raw_not_in_scope`. (bet365 is enforced by filtering, never a 403.)",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "region_not_in_scope",
                "message": "Key not scoped for region 'sa'.",
                "type": "region",
                "request_id": "or_req_…"
              }
            }
          }
        }
      },
      "NotFound": {
        "description": "Unknown path or param value — `unknown_type` · `unknown_region` (bad `{type}`/`region`), or `not_found` (no such endpoint).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "unknown_type": {
                "value": {
                  "error": {
                    "code": "unknown_type",
                    "message": "Unknown feed type 'nonsense'.",
                    "request_id": "or_req_…"
                  }
                }
              },
              "unknown_region": {
                "value": {
                  "error": {
                    "code": "unknown_region",
                    "message": "Unknown region 'fr'.",
                    "request_id": "or_req_…"
                  }
                }
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "Rate limit hit (per-key fair-use or per-IP flood). See `Retry-After`.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "rate_limited",
                "message": "Rate limit exceeded.",
                "request_id": "or_req_…"
              }
            }
          }
        }
      },
      "ServerError": {
        "description": "Server fault. The error envelope never leaks upstream detail.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "internal_error",
                "message": "Internal error.",
                "request_id": "or_req_…"
              }
            }
          }
        }
      }
    },
    "schemas": {
      "OddsEnvelope": {
        "type": "object",
        "description": "The default (`format=envelope`) response body. In `format=bare` the body is the `opportunities` array alone and the metadata moves to `X-*` headers.",
        "required": [
          "opportunities",
          "opportunity_count",
          "processed_at",
          "region"
        ],
        "properties": {
          "opportunities": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Opportunity"
            },
            "description": "The matched opportunities. Shape depends on `{type}`."
          },
          "opportunity_count": {
            "type": "integer",
            "description": "Number of opportunities served."
          },
          "processed_at": {
            "type": "string",
            "format": "date-time",
            "description": "Matcher cycle stamp (ms precision)."
          },
          "stale_hidden": {
            "type": "boolean",
            "description": "True if rows were hidden past the freshness cutoff."
          },
          "served_total": {
            "type": "integer",
            "description": "Total served (= opportunity_count today)."
          },
          "unreliable_links": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Selections whose deeplink is flagged unreliable."
          },
          "region": {
            "type": "string",
            "description": "The region served (echoes the request)."
          }
        }
      },
      "Opportunity": {
        "description": "A matched opportunity. The concrete shape is selected by the `{type}` path segment — see the per-type schemas. New optional fields may be added within v1; ignore unknown fields. All odds are decimal **strings** (e.g. `\"1.6\"`); money is a string, usually `£`-prefixed (e.g. `\"£11.92\"`), occasionally bare (e.g. dutching `qualifying_loss` `\"0.00\"`) and sometimes negative; `rating`, `ROI` and liquidity are numbers.",
        "anyOf": [
          {
            "$ref": "#/components/schemas/StandardOpportunity"
          },
          {
            "$ref": "#/components/schemas/EachWayOpportunity"
          },
          {
            "$ref": "#/components/schemas/ExtraPlaceOpportunity"
          },
          {
            "$ref": "#/components/schemas/BogOpportunity"
          },
          {
            "$ref": "#/components/schemas/DutchingOpportunity"
          },
          {
            "$ref": "#/components/schemas/TwoUpOpportunity"
          },
          {
            "$ref": "#/components/schemas/PriceBoostOpportunity"
          }
        ]
      },
      "BaseOpportunity": {
        "type": "object",
        "description": "Fields common to every opportunity type.",
        "properties": {
          "_id": {
            "type": "string",
            "description": "Stable opportunity id within the current cycle."
          },
          "sport": {
            "type": "string"
          },
          "market_type": {
            "type": "string"
          },
          "fixture": {
            "type": "string"
          },
          "league": {
            "type": [
              "string",
              "null"
            ]
          },
          "commence_time": {
            "type": "string",
            "format": "date-time"
          },
          "date_and_time": {
            "type": "string",
            "description": "Denormalised display stamp, e.g. \"01/07/26 16:00\"."
          },
          "game_day": {
            "type": "string",
            "description": "e.g. \"01/07/26\"."
          },
          "game_time": {
            "type": "string",
            "description": "e.g. \"16:00\"."
          },
          "datetime_string": {
            "type": "string",
            "description": "Compact sortable stamp, e.g. \"20260630201025\"."
          },
          "odds_read_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the underlying odds were last read by the matcher."
          },
          "region": {
            "type": "string"
          },
          "qualifying_loss": {
            "type": "string",
            "description": "Money string; may be negative (a qualifying profit) and may be bare (no £)."
          },
          "potential_profit": {
            "type": "string",
            "description": "Money string, usually £-prefixed."
          },
          "horse_race_distance": {
            "type": [
              "string",
              "null"
            ],
            "description": "Horse-racing only; null otherwise."
          },
          "horse_race_runners": {
            "type": [
              "string",
              "integer",
              "null"
            ],
            "description": "Horse-racing only; null otherwise."
          }
        }
      },
      "BackLayLeg": {
        "type": "object",
        "description": "Back (bookmaker) vs lay (exchange) fields shared by standard / bog / 2up.",
        "properties": {
          "outcome": {
            "type": "string"
          },
          "bookmaker": {
            "type": "string"
          },
          "bookmaker_link": {
            "type": "string",
            "format": "uri",
            "description": "Deeplink to the bookmaker selection."
          },
          "exchange": {
            "type": "string"
          },
          "exchange_link": {
            "type": "string",
            "format": "uri"
          },
          "back_odds": {
            "type": "string",
            "description": "Decimal odds as a string, e.g. \"1.6\"."
          },
          "lay_odds": {
            "type": "string",
            "description": "Decimal odds as a string, e.g. \"1.51\"."
          },
          "lay_liquidity": {
            "type": [
              "number",
              "null"
            ],
            "description": "Available lay liquidity at lay_odds."
          },
          "rating": {
            "type": "number",
            "description": "100 × back/lay; 100 = break-even."
          }
        }
      },
      "StandardOpportunity": {
        "allOf": [
          {
            "$ref": "#/components/schemas/BaseOpportunity"
          },
          {
            "$ref": "#/components/schemas/BackLayLeg"
          }
        ],
        "description": "type=standard. Back-vs-lay matched bet."
      },
      "TwoUpOpportunity": {
        "allOf": [
          {
            "$ref": "#/components/schemas/BaseOpportunity"
          },
          {
            "$ref": "#/components/schemas/BackLayLeg"
          }
        ],
        "description": "type=2up. Same back-vs-lay shape as standard (2Up offer market)."
      },
      "BogOpportunity": {
        "allOf": [
          {
            "$ref": "#/components/schemas/BaseOpportunity"
          },
          {
            "$ref": "#/components/schemas/BackLayLeg"
          },
          {
            "type": "object",
            "properties": {
              "ROI": {
                "type": "number",
                "description": "Return on investment (%), can be negative."
              }
            }
          }
        ],
        "description": "type=bog. Best-odds-guaranteed (horse racing); adds ROI."
      },
      "EachWayOpportunity": {
        "allOf": [
          {
            "$ref": "#/components/schemas/BaseOpportunity"
          },
          {
            "type": "object",
            "description": "Each-way carries a win+place exchange structure instead of a single back/lay leg.",
            "properties": {
              "horse": {
                "type": "string"
              },
              "bookmaker": {
                "type": "string"
              },
              "bookmaker_link": {
                "type": "string",
                "format": "uri"
              },
              "bookmaker_each_way_odds": {
                "type": "string",
                "description": "Decimal odds as a string."
              },
              "bookmaker_fraction": {
                "type": "string",
                "description": "Place terms fraction, e.g. \"1/5\"."
              },
              "bookmaker_places": {
                "type": "integer"
              },
              "win_exchange": {
                "type": "string"
              },
              "win_exchange_link": {
                "type": "string",
                "format": "uri"
              },
              "exchange_win_odds": {
                "type": "number"
              },
              "exchange_win_liquidity": {
                "type": "number"
              },
              "place_exchange": {
                "type": "string"
              },
              "place_exchange_link": {
                "type": "string",
                "format": "uri"
              },
              "exchange_place_odds": {
                "type": "number"
              },
              "exchange_place_liquidity": {
                "type": "number"
              },
              "exchange_places": {
                "type": "integer"
              },
              "rating": {
                "type": "number"
              },
              "ROI": {
                "type": "number"
              }
            }
          }
        ],
        "description": "type=each-way. Win + place exchange legs (horse racing)."
      },
      "ExtraPlaceOpportunity": {
        "allOf": [
          {
            "$ref": "#/components/schemas/BaseOpportunity"
          },
          {
            "type": "object",
            "properties": {
              "extra_places": {
                "type": [
                  "integer",
                  "string"
                ],
                "description": "Number of extra places offered."
              },
              "implied_odds": {
                "type": [
                  "string",
                  "number"
                ],
                "description": "Implied odds for the extra-place edge."
              }
            }
          }
        ],
        "description": "type=extra-place. Extra-place horse-racing market; adds extra_places/implied_odds and carries no ROI. (Field set per the matcher contract — the board was empty at authoring, so not sampled live.)"
      },
      "DutchingOpportunity": {
        "allOf": [
          {
            "$ref": "#/components/schemas/BaseOpportunity"
          },
          {
            "type": "object",
            "description": "Dutching splits a stake across N mutually-exclusive outcomes; legs are first_*, second_*, … and `outcomes` gives the leg count.",
            "properties": {
              "outcomes": {
                "type": "integer",
                "description": "Number of dutched legs."
              },
              "ROI": {
                "type": "number"
              },
              "rating": {
                "type": "number"
              },
              "first_bookmaker": {
                "type": "string"
              },
              "first_outcome": {
                "type": "string"
              },
              "first_odds": {
                "type": "string"
              },
              "first_link": {
                "type": "string",
                "format": "uri"
              },
              "second_bookmaker": {
                "type": "string"
              },
              "second_outcome": {
                "type": "string"
              },
              "second_odds": {
                "type": "string"
              },
              "second_link": {
                "type": "string",
                "format": "uri"
              }
            }
          }
        ],
        "description": "type=dutching. N-way dutched stake. Additional legs (third_*, …) appear for >2-way markets; read `outcomes` for the count."
      },
      "PriceBoostOpportunity": {
        "allOf": [
          {
            "$ref": "#/components/schemas/BaseOpportunity"
          },
          {
            "$ref": "#/components/schemas/BackLayLeg"
          },
          {
            "type": "object",
            "properties": {
              "original_odds": {
                "type": "string",
                "description": "The pre-boost price (decimal string)."
              }
            }
          }
        ],
        "description": "type=price-boost-matcher. Boosted-price market; adds original_odds. (Field set per the matcher contract — the board was empty at authoring, so not sampled live.)"
      },
      "RegionsResponse": {
        "type": "object",
        "required": [
          "default",
          "regions"
        ],
        "properties": {
          "default": {
            "type": "string",
            "description": "The default region applied when `region` is omitted."
          },
          "regions": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Region"
            }
          }
        }
      },
      "Region": {
        "type": "object",
        "required": [
          "code",
          "requestable",
          "bookmakers",
          "matched_coverage"
        ],
        "properties": {
          "code": {
            "type": "string",
            "description": "Region code (uk, ie, sa, ng, us)."
          },
          "requestable": {
            "type": "boolean",
            "description": "Whether THIS key may request it."
          },
          "bookmakers": {
            "type": "integer",
            "description": "Bookmakers covered in this region."
          },
          "matched_coverage": {
            "type": "boolean",
            "description": "Whether a matched feed is available."
          }
        }
      },
      "CoverageResponse": {
        "type": "object",
        "required": [
          "bookmakers_covered",
          "bet365_included",
          "boards"
        ],
        "properties": {
          "bookmakers_covered": {
            "type": "integer",
            "description": "Distinct bookmakers covered (60+)."
          },
          "bet365_included": {
            "type": "boolean"
          },
          "boards": {
            "type": "object",
            "description": "Per-board liveness, keyed by the underscored board name (standard, each_way, extra_place, bog, dutching, 2up, price_boost).",
            "additionalProperties": {
              "$ref": "#/components/schemas/BoardCoverage"
            }
          }
        }
      },
      "BoardCoverage": {
        "type": "object",
        "properties": {
          "opportunity_count": {
            "type": "integer"
          },
          "age_s": {
            "type": "number",
            "description": "Seconds since the board's last matcher cycle."
          }
        }
      },
      "HealthResponse": {
        "type": "object",
        "required": [
          "status",
          "boards"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ok",
              "degraded"
            ],
            "description": "Overall liveness."
          },
          "boards": {
            "type": "object",
            "description": "Per-board freshness, keyed by the underscored board name.",
            "additionalProperties": {
              "$ref": "#/components/schemas/BoardHealth"
            }
          }
        }
      },
      "BoardHealth": {
        "type": "object",
        "properties": {
          "processed_at": {
            "type": "string",
            "format": "date-time"
          },
          "age_s": {
            "type": "number"
          },
          "opportunity_count": {
            "type": "integer"
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message",
              "request_id"
            ],
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable machine code — branch on this, not the message. New codes are additive."
              },
              "message": {
                "type": "string",
                "description": "Human-readable detail (may change)."
              },
              "type": {
                "type": "string",
                "enum": [
                  "type",
                  "region",
                  "raw",
                  "bet365"
                ],
                "description": "Present on scope errors — which scope axis failed."
              },
              "request_id": {
                "type": "string",
                "description": "Echoes X-OddsRelay-Request-Id — quote it in support."
              }
            }
          }
        }
      }
    },
    "examples": {
      "StandardEnvelope": {
        "summary": "A standard (back-vs-lay) board, trimmed to one opportunity.",
        "value": {
          "opportunities": [
            {
              "_id": "mu5y0PCqeQufUv3K0soTnCvxhiYk5nJx",
              "sport": "Football",
              "market_type": "BTTS",
              "fixture": "Spain U19 (W) v Iceland U19 (W)",
              "league": "UEFA Women's U19 Euro Championship",
              "outcome": "No",
              "bookmaker": "Paddy Power",
              "bookmaker_link": "https://www.paddypower.com/...",
              "exchange": "Betfair Exchange",
              "exchange_link": "https://www.betfair.com/exchange/...",
              "back_odds": "1.6",
              "lay_odds": "1.51",
              "lay_liquidity": 21.39,
              "rating": 105.96,
              "qualifying_loss": "£0.60",
              "potential_profit": "£11.92",
              "commence_time": "2026-07-01T15:00:00Z",
              "date_and_time": "01/07/26 16:00",
              "game_day": "01/07/26",
              "game_time": "16:00",
              "datetime_string": "20260630201025",
              "odds_read_at": "2026-06-30T20:10:21.746441+00:00",
              "horse_race_distance": null,
              "horse_race_runners": null,
              "region": "uk"
            }
          ],
          "opportunity_count": 21114,
          "processed_at": "2026-06-30T20:10:29.000Z",
          "stale_hidden": false,
          "served_total": 21114,
          "unreliable_links": [],
          "region": "uk"
        }
      }
    }
  },
  "x-error-catalog": [
    {
      "status": 400,
      "code": "bad_request",
      "when": "Malformed query (e.g. bad region syntax)."
    },
    {
      "status": 401,
      "code": "missing_api_key",
      "when": "No key presented."
    },
    {
      "status": 401,
      "code": "invalid_api_key",
      "when": "Key not recognised."
    },
    {
      "status": 401,
      "code": "revoked_api_key",
      "when": "Key has been revoked."
    },
    {
      "status": 401,
      "code": "expired_api_key",
      "when": "Key is past its expiry."
    },
    {
      "status": 403,
      "code": "type_not_in_scope",
      "when": "Valid key, not scoped for this feed type."
    },
    {
      "status": 403,
      "code": "region_not_in_scope",
      "when": "Valid key, not scoped for this (known) region."
    },
    {
      "status": 403,
      "code": "raw_not_in_scope",
      "when": "Valid key, not scoped for the raw form."
    },
    {
      "status": 403,
      "code": "bet365_not_in_scope",
      "when": "Reserved — bet365 is enforced by FILTERING (a key without the add-on receives 0 bet365 rows), never a 403."
    },
    {
      "status": 404,
      "code": "unknown_type",
      "when": "Feed type not in the enum."
    },
    {
      "status": 404,
      "code": "unknown_region",
      "when": "Region not in the enum."
    },
    {
      "status": 404,
      "code": "not_found",
      "when": "No such endpoint/path."
    },
    {
      "status": 429,
      "code": "rate_limited",
      "when": "Per-key fair-use or per-IP flood cap (see Retry-After)."
    },
    {
      "status": 500,
      "code": "internal_error",
      "when": "Server fault (no upstream leakage)."
    }
  ],
  "x-roadmap": [
    {
      "item": "GET /v1/raw/odds",
      "status": "planned",
      "note": "Normalised, unmatched odds per selection (raw tier). Returns 404 today."
    },
    {
      "item": "GET /v1/stream (SSE)",
      "status": "planned",
      "note": "Live push — snapshot + delta + seq. Returns 404 today."
    },
    {
      "item": "WSS /v1/ws",
      "status": "planned",
      "note": "Sub-second bidirectional channel (arb tier). Returns 404 today."
    },
    {
      "item": "OddsRelay-Version date-pinning",
      "status": "planned",
      "note": "Optional Stripe-style per-key version header for fine-grained backward-compatible evolution."
    }
  ]
}