openapi: 3.1.0
info:
  title: Sport feed API
  version: '0.6'
  summary: Fair probabilities for sports markets, pre-match and live.
  description: |
    Fair probabilities for sports markets, pre-match and live, with a state that
    says when to trust each price, and the results of every market. This
    document describes the REST endpoints; the WebSocket stream at `/v1/stream` is
    described in `asyncapi.yaml`.

    Conventions:

    - Bodies are JSON in UTF-8 with `snake_case` field names.
    - Times are RFC 3339 in UTC with milliseconds and `Z`, for example `2026-10-20T19:45:03.120Z`.
    - Probabilities have 4 decimals in `[0.0001, 0.9999]`, and the probabilities of a market sum to exactly 1; fair odds are `1/prob` with 3 decimals, uncertainty has 3 decimals; lines are numbers such as `2.5` or `-1.5`.
    - IDs are opaque strings with the prefixes `cp_`, `ev_` and `mk_`. They never change and are never reused; do not parse them. Sport IDs are fixed lowercase slugs.
    - All paths start with `/v1`. Within v1 changes are only additive: new endpoints, fields, message types and enum values.
    - An event that has had a final status for more than 7 days with every market settled, or has been `ended` or final for more than 30 days, may no longer be served; it and its markets then return 404.

    Rules for clients:

    - Ignore unknown fields and message types.
    - Ignore any market whose `type`, `period` or outcome key you do not know.
    - Treat an unknown market state as `suspended`, and an unknown event status as not tradable.
    - Markets that can push will only ever be added as new market types, never as new lines of `total` or `handicap`.

    Authentication: every request except `GET /v1/health` and the documentation carries `Authorization: Bearer <api_key>`. A missing or invalid key returns 401, a disabled or expired key 403.

    Limits: every response to a request with a key carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds). A request over the limit gets 429 with `Retry-After` in seconds. An address with too many failed authentications in a minute receives 429 for every request for a while.

    Errors use the HTTP status and the `ErrorBody` shape; every response has an `X-Request-Id` header, the same as the body's `request_id`.

    Responses are gzip-compressed when the request allows it.

    Sports listed: football, tennis, basketball (the NBA) and ice hockey (the NHL). Football is priced before and during the match; the other sports are priced before the start only: from the start of play (or its scheduled start, if earlier) every market of the event is `suspended` with reason `match_event` until the event ends, and its status and results are published as for football.

    Football settlement rules in v1:

    - `1x2`, `total`, `handicap`: regular time including stoppage time; extra time and penalty shoot-outs do not count.
    - A match abandoned before the end of regular time: every market is void.
    - A postponed match keeps its markets if it starts within 48 h after its kickoff time as it stood 24 h before that kickoff (or when the match was first listed, if later); otherwise the event is `cancelled` and every market is void.

    Tennis settlement rules:

    - `moneyline`: the player who wins the match. A match that starts (a ball is played) and is not completed, after a retirement, a default or a disqualification, is won by the player who advances.
    - A match that does not start (a walkover, a withdrawal, a cancellation): void.
    - A postponed match keeps its market if it starts within 48 h after its start time as it stood 24 h before that start (or when the match was first listed, if later); otherwise the event is `cancelled` and the market is void.

    Basketball (NBA) settlement rules:

    - `moneyline`, `total`, `handicap`: the full game, overtime included.
    - A game abandoned before its end: the result the league declares official; without one, every market is void.
    - A postponed game keeps its markets under the same 48 h rule as football; otherwise the event is `cancelled` and every market is void.

    Ice hockey (NHL) settlement rules:

    - `moneyline`, `total`: the full game, overtime and shoot-out included; in `total`, a game decided by a shoot-out counts one goal for its winner.
    - A game abandoned before its end: the result the league declares official; without one, every market is void.
    - A postponed game keeps its markets under the same 48 h rule as football; otherwise the event is `cancelled` and every market is void.

    In every sport the 48 h rule applies before the start and to a resumption: a match interrupted after its start (rain, bad light, a crowd incident) keeps its markets and is settled on the completed match if it resumes and is completed within 48 h after its start time as it stood 24 h before that start (or when the match was first listed, if later); otherwise every market is void.

    Change log:

    - 0.1: first release.
    - 0.2: tennis, basketball (NBA) and ice hockey (NHL) listed, with their settlement rules; every sport but football is priced before the start only.
    - 0.3: `GET /v1/prices`: every price in one response, each row complete on its own, and `since` for exactly the rows that changed; at most one large answer per key per `full_answer_interval`.
    - 0.4: a match interrupted and resumed later stays `live` and keeps `started_at`; the 48 h rule applies before the start, and to resumption.
    - 0.6: `POST /v1/prices/join`: gzip current rows for AMQP delivery, with a separate request budget.
    - 0.5: `version` on every event, on every row of `GET /v1/prices` and on its responses, to tell which of two copies is newer.
servers:
  - url: 'https://{host}'
    variables:
      host:
        default: api.example.com
        description: The API host name given with your key.
security:
  - bearer: []
tags:
  - name: catalog
    description: Sports, competitions, events and markets.
  - name: stream
    description: Access to the WebSocket stream.
  - name: service
    description: Health and documentation.
paths:
  /v1/health:
    get:
      tags: [service]
      operationId: getHealth
      summary: Service health
      description: Needs no key. `degraded` when the feed is serving but impaired; 503 when it is not serving.
      security: []
      responses:
        '200':
          description: The feed is serving.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Health'
        '403':
          $ref: '#/components/responses/AddressForbidden'
        '429':
          $ref: '#/components/responses/AddressBlocked'
        '503':
          $ref: '#/components/responses/Unavailable'
  /v1/sports:
    get:
      tags: [catalog]
      operationId: listSports
      summary: Sports with listed events
      description: Every sport with at least one listed event, with its counts of scheduled and live events. Not paginated.
      responses:
        '200':
          description: The sports.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SportList'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /v1/competitions:
    get:
      tags: [catalog]
      operationId: listCompetitions
      summary: Competitions with listed events
      description: Competitions with at least one listed event, sorted by name, paginated.
      parameters:
        - $ref: '#/components/parameters/sport'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: One page of competitions.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompetitionPage'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /v1/events:
    get:
      tags: [catalog]
      operationId: listEvents
      summary: Events
      description: |
        Events without markets, sorted by `scheduled_start` then `id`, paginated. Filters combine with AND; each accepts a comma-separated list, combined with OR.

        `status` defaults to `scheduled,live`, or to every status when `updated_since` is given, so that a synchronization sees events that just ended or were cancelled.
      parameters:
        - $ref: '#/components/parameters/sport'
        - name: competition
          in: query
          description: Competition IDs, comma-separated.
          schema:
            type: string
        - name: status
          in: query
          description: Event statuses, comma-separated. Default `scheduled,live`, or every status with `updated_since`.
          schema:
            type: string
          example: scheduled,live
        - name: from
          in: query
          description: Only events with `scheduled_start` at or after this time.
          schema:
            $ref: '#/components/schemas/Timestamp'
        - name: to
          in: query
          description: Only events with `scheduled_start` before this time.
          schema:
            $ref: '#/components/schemas/Timestamp'
        - name: updated_since
          in: query
          description: Only events whose `updated_at` is at or after this time. A change of the event's status, of one of its markets' status, or of a result moves `updated_at`.
          schema:
            $ref: '#/components/schemas/Timestamp'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: One page of events.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventPage'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /v1/events/{event_id}:
    get:
      tags: [catalog]
      operationId: getEvent
      summary: One event with its markets
      description: The event with its markets, each with its current price and result. One request gives the full current state of a match. An event final for more than 7 days with every market settled, or `ended` or final for more than 30 days, may no longer be served and returns 404.
      parameters:
        - name: event_id
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/EventId'
        - name: market_types
          in: query
          description: Market types to return, comma-separated; every type by default.
          schema:
            type: string
          example: 1x2,total
        - name: periods
          in: query
          description: Periods to return, comma-separated; `match` by default.
          schema:
            type: string
          example: match
      responses:
        '200':
          description: The event with its markets.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventDetail'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /v1/markets/{market_id}:
    get:
      tags: [catalog]
      operationId: getMarket
      summary: One market
      description: The market with its current price and result. Cheap polling of a single market. A market of an event no longer served returns 404.
      parameters:
        - name: market_id
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/MarketId'
      responses:
        '200':
          description: The market.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Market'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /v1/prices:
    get:
      tags: [catalog]
      operationId: listPrices
      summary: Every price, then what changed
      description: |
        One row per market, every row in one response, without pagination. A row is complete on its own: the market, its event, its price with each outcome's name, and its result.

        Without `since`, the rows are the markets that are not removed of events `scheduled` or `live`. Every response carries `since`, an opaque token for the state it was read from. Pass it on the next request, with the same filters, to get exactly the rows that changed after that state: a new price or state, a market status, a result, or a change of the event's name, status, scheduled start or competition. Those rows include settled and removed markets and the markets of events that became final, so a client that keeps polling with each response's token, within `since_ttl`, sees every end and every result. Replace each row you hold by `market_id` with the one received. A `removed` market is withdrawn: do not use its price. You may drop the rows of removed markets and of events that ended.

        A token the server cannot check or honour gets every row as without `since`, with `full: true`: replace your whole state with it. Such a token is one from before a restart (it cannot be checked), altered, older than `since_ttl` (10 minutes by default), or sent with other filters. A row you hold that such an answer lacks is of a market that was removed or of an event no longer `scheduled` or `live`: its end and result are at `GET /v1/markets/{market_id}`. A full answer is always a valid answer to `since`: when more than half of the rows changed, the answer is every row with `full: true`, and it also carries the changed rows of removed markets and of events no longer `scheduled` or `live`, so the ends and results are not missed. Otherwise `full` is false. A `since` that is not a token at all is refused with 400.

        Every row carries `version`, and so does the response: the version of the state it was read from. Of two copies of one market's row, the one with the greater `version` is the newer, also across restarts.

        A client polls with the latest `since`, for example once a second; each request counts as one for the key's limit. An answer of more than `large_answer_rows` rows (2,000 by default), of any kind (every row, or many changed rows), is large: a key gets at most one large answer per `full_answer_interval` (10 seconds by default), and another one sooner is refused with 429 and `Retry-After`. The `since` token stays valid meanwhile: retry with it after `Retry-After` and nothing is lost. Smaller answers, such as the changes of one event, never count. To follow busy periods, when more rows change between polls than a small answer holds, use the WebSocket stream. When the server is too busy to start the answer within a second, it answers 503 with `Retry-After`. Responses are not cached (`Cache-Control: no-store`). Filters combine with AND; each accepts a comma-separated list, combined with OR. Rows come in no guaranteed order.
      parameters:
        - $ref: '#/components/parameters/sport'
        - name: competition
          in: query
          description: Competition IDs, comma-separated.
          schema:
            type: string
        - name: event
          in: query
          description: Event IDs, comma-separated.
          schema:
            type: string
        - name: market_types
          in: query
          description: Market types to return, comma-separated; every type by default.
          schema:
            type: string
          example: 1x2,total
        - name: periods
          in: query
          description: Periods to return, comma-separated; `match` by default.
          schema:
            type: string
          example: match
        - name: since
          in: query
          description: The `since` of the previous response, with the same filters. Opaque; valid for `since_ttl`, 10 minutes by default.
          schema:
            type: string
      responses:
        '200':
          description: The rows, and the token for the next request.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PriceList'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
        '503':
          $ref: '#/components/responses/Busy'
  /v1/prices/join:
    post:
      tags: [catalog]
      operationId: joinPrices
      summary: Current prices for joining AMQP delivery
      description: |
        Requires an API key enabled for AMQP delivery and `Accept-Encoding: gzip`.
        Returns the markets not removed of scheduled or live events matching any
        filter, each once, read from one state after the request arrived. Fields
        within a filter combine with AND; values in a field combine with OR.
        Empty fields select everything; periods default to match. An empty filter
        selects every row. At most 4 filters and 500 sport, competition and event
        IDs in total by default. Unknown filter fields and values are refused.
        Always gzip-compressed with Cache-Control: no-store.

        A key receives at most 2 join answers per 10 seconds by default, separately
        from REST request limits and large price answers. Excess joins get 429
        with Retry-After. A request that cannot start within 1 second gets 503
        with Retry-After. Identical requests waiting for rendering share one answer.
      parameters:
        - name: Accept-Encoding
          in: header
          required: true
          schema:
            type: string
          example: gzip
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [filters]
              properties:
                filters:
                  type: array
                  maxItems: 4
                  items:
                    $ref: '#/components/schemas/JoinFilter'
      responses:
        '200':
          description: Gzip-compressed current rows with their state version.
          headers:
            Content-Encoding:
              schema:
                type: string
                const: gzip
            Cache-Control:
              schema:
                type: string
                const: no-store
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JoinAnswer'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
        '503':
          $ref: '#/components/responses/Busy'
  /v1/stream/ticket:
    post:
      tags: [stream]
      operationId: createStreamTicket
      summary: A ticket for the stream
      description: A single-use ticket for the caller's key, for clients such as browsers that cannot set a header on a WebSocket. Pass it as `?ticket=` when opening `/v1/stream`; it is valid for one connection within 30 s.
      responses:
        '200':
          description: The ticket.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StreamTicket'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /v1/stream:
    get:
      tags: [stream]
      operationId: openStream
      summary: Open the WebSocket stream
      description: >-
        The WebSocket upgrade of the stream; the messages are described in
        `asyncapi.yaml`. Authenticate with `Authorization: Bearer <api_key>`, or with
        `?ticket=` from `POST /v1/stream/ticket`. Every refusal is an HTTP response and
        no connection is opened.
      security:
        - bearer: []
        - ticket: []
      parameters:
        - name: ticket
          in: query
          description: A ticket from `POST /v1/stream/ticket`, instead of the header.
          schema:
            type: string
      responses:
        '101':
          description: Switching to the WebSocket protocol.
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/AddressBlocked'
        '503':
          $ref: '#/components/responses/Unavailable'
  /docs:
    get:
      tags: [service]
      operationId: getDocs
      summary: This documentation
      description: A page rendered from this file and `asyncapi.yaml`. Needs no key.
      security: []
      responses:
        '200':
          description: The documentation page.
          content:
            text/html:
              schema:
                type: string
        '304':
          description: Not modified since the `ETag` given in `If-None-Match`.
        '403':
          $ref: '#/components/responses/AddressForbidden'
        '429':
          $ref: '#/components/responses/AddressBlocked'
  /docs/openapi.yaml:
    get:
      tags: [service]
      operationId: getOpenApi
      summary: This file
      security: []
      responses:
        '200':
          description: The REST contract in OpenAPI 3.1.
          content:
            application/yaml:
              schema:
                type: string
        '304':
          description: Not modified since the `ETag` given in `If-None-Match`.
        '403':
          $ref: '#/components/responses/AddressForbidden'
        '429':
          $ref: '#/components/responses/AddressBlocked'
  /docs/asyncapi.yaml:
    get:
      tags: [service]
      operationId: getAsyncApi
      summary: The stream contract
      security: []
      responses:
        '200':
          description: The stream contract in AsyncAPI 3.0.
          content:
            application/yaml:
              schema:
                type: string
        '304':
          description: Not modified since the `ETag` given in `If-None-Match`.
        '403':
          $ref: '#/components/responses/AddressForbidden'
        '429':
          $ref: '#/components/responses/AddressBlocked'
  /docs/asyncapi-amqp.yaml:
    get:
      tags: [service]
      operationId: getAmqpAsyncApi
      summary: The AMQP delivery contract
      security: []
      responses:
        '200':
          description: The AMQP delivery contract in AsyncAPI 3.0.
          content:
            application/yaml:
              schema:
                type: string
        '304':
          description: Not modified since the `ETag` given in `If-None-Match`.
        '403':
          $ref: '#/components/responses/AddressForbidden'
        '429':
          $ref: '#/components/responses/AddressBlocked'
components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      description: >-
        An API key, `sf_live_` followed by 43 characters, as
        `Authorization: Bearer <api_key>`. Keep it secret; never put it in a URL.
    ticket:
      type: apiKey
      in: query
      name: ticket
      description: A single-use ticket from `POST /v1/stream/ticket`, for `/v1/stream` only.
  parameters:
    sport:
      name: sport
      in: query
      description: Sport IDs, comma-separated.
      schema:
        type: string
      example: football,tennis
    limit:
      name: limit
      in: query
      description: Page size, 100 by default; larger values are capped at 500.
      schema:
        type: integer
        minimum: 1
        default: 100
    cursor:
      name: cursor
      in: query
      description: The `next_cursor` of the previous page, with the same filters. Opaque; valid for 10 minutes.
      schema:
        type: string
  headers:
    X-Request-Id:
      description: The request's ID, also in error bodies; quote it to support.
      schema:
        type: string
    RateLimit-Limit:
      description: Requests allowed per minute for the key.
      schema:
        type: integer
    RateLimit-Remaining:
      description: Requests left in the current minute.
      schema:
        type: integer
    RateLimit-Reset:
      description: Seconds until the current minute ends.
      schema:
        type: integer
    Retry-After:
      description: Seconds to wait before retrying.
      schema:
        type: integer
  responses:
    InvalidRequest:
      description: A parameter is unknown, repeated or invalid (`invalid_request`).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    Unauthorized:
      description: The key is missing or invalid (`unauthorized`).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    Forbidden:
      description: The key is disabled or expired, or not allowed from this address (`forbidden`).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    NotFound:
      description: No such object (`not_found`).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    RateLimited:
      description: Over the key's request limit, the address is blocked after failed authentications, or, on `GET /v1/prices`, a second large answer within `full_answer_interval` (`rate_limited`).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        Retry-After:
          $ref: '#/components/headers/Retry-After'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    AddressBlocked:
      description: The address is blocked after failed authentications (`rate_limited`).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        Retry-After:
          $ref: '#/components/headers/Retry-After'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    AddressForbidden:
      description: Requests from this address are refused (`forbidden`).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    Internal:
      description: An internal error (`internal`).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    Busy:
      description: Too busy to start the answer now (`unavailable`); retry after `Retry-After` seconds.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        Retry-After:
          $ref: '#/components/headers/Retry-After'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    Unavailable:
      description: The service is not serving, in maintenance, or at its connection limits (`unavailable`).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
  schemas:
    Timestamp:
      type: string
      format: date-time
      description: RFC 3339 in UTC with milliseconds and `Z`.
      pattern: '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$'
      examples: ['2026-10-24T14:23:52.004Z']
    Version:
      type: integer
      minimum: 0
      maximum: 9007199254740991
      description: Tells which of two copies of one market's row, or of one event, is newer; a greater `version` is a newer state, also across restarts of the feed, and one state has the same `version` everywhere it is shown. A row's `version` grows whenever something the row shows changes, an event's whenever any of its fields does. It is close to the time of the change in microseconds since the Unix epoch, but compare versions only; never read them as times. Below 2^53, so it is exact in every JSON parser.
      examples: [1792851832004001]
    CompetitionId:
      type: string
      description: A competition's opaque ID; do not parse it.
      pattern: '^cp_[A-Za-z0-9]+$'
    EventId:
      type: string
      description: An event's opaque ID; do not parse it.
      pattern: '^ev_[A-Za-z0-9]+$'
    MarketId:
      type: string
      description: A market's opaque ID; do not parse it.
      pattern: '^mk_[A-Za-z0-9]+$'
    SportId:
      type: string
      description: A sport's fixed slug.
      enum: [football, basketball, tennis, baseball, ice_hockey, american_football, mma, cricket, rugby, table_tennis, cs2, lol, dota2, valorant, r6, overwatch]
    SportSummary:
      type: object
      description: A sport with its counts of listed events.
      required: [id, name, events_scheduled, events_live]
      properties:
        id:
          $ref: '#/components/schemas/SportId'
        name:
          type: string
          examples: [Football]
        events_scheduled:
          type: integer
          minimum: 0
        events_live:
          type: integer
          minimum: 0
    SportList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/SportSummary'
    Competition:
      type: object
      required: [id, sport, name, country]
      properties:
        id:
          $ref: '#/components/schemas/CompetitionId'
        sport:
          $ref: '#/components/schemas/SportId'
        name:
          type: string
          examples: [Premier League]
        country:
          type: [string, 'null']
          description: Null for international competitions.
          examples: [England]
    CompetitionPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Competition'
        next_cursor:
          $ref: '#/components/schemas/NextCursor'
    NextCursor:
      type: [string, 'null']
      description: The cursor of the next page; null on the last page. Opaque, valid for 10 minutes.
    CompetitionRef:
      type: object
      required: [id, name]
      properties:
        id:
          $ref: '#/components/schemas/CompetitionId'
        name:
          type: string
    Role:
      type: string
      description: A participant's side. For sports without a home side (tennis, MMA, esports) `home` is a fixed one of the two participants, the same in every message and every market.
      enum: [home, away]
    Participant:
      type: object
      required: [name, role]
      properties:
        name:
          type: string
        role:
          $ref: '#/components/schemas/Role'
    EventStatus:
      type: string
      description: |
        - `scheduled`: not started.
        - `live`: in play. For sports with fixed start times the event is `live` from about 6 minutes after its start time (or earlier, once play is confirmed; up to 10 minutes later after a feed restart); until play is confirmed it can still return to `scheduled` or become `postponed` or `cancelled`.
        - `ended`: finished; results not all known yet.
        - `resolved`: finished; every market has a result. Final.
        - `postponed`: will not start at the scheduled time; a new time may follow.
        - `cancelled`: will not take place; markets are void. Final.
        - `abandoned`: stopped after the start; markets are settled under the settlement rules. Final.
      enum: [scheduled, live, ended, resolved, postponed, cancelled, abandoned]
    EndReason:
      type: string
      description: Why the event finished; set when it reaches `ended`, `resolved`, `cancelled` or `abandoned`.
      enum: [completed, retired, walkover, abandoned, postponed, cancelled]
    FootballPeriod:
      type: string
      enum: ['1H', HT, '2H', ET, PEN, FT]
    Score:
      type: object
      required: [home, away]
      properties:
        home:
          type: integer
          minimum: 0
        away:
          type: integer
          minimum: 0
    Clock:
      type: object
      description: The match clock. Compute the current clock from `seconds`, `running` and `as_of`.
      required: [seconds, running, as_of]
      properties:
        seconds:
          type: integer
          minimum: 0
        running:
          type: boolean
        as_of:
          $ref: '#/components/schemas/Timestamp'
    Live:
      type: object
      description: Live match data, football only in v1. Any field may be null. An `event` message is sent only when the score, the period or `running` changes.
      required: [score, period, clock]
      properties:
        score:
          oneOf:
            - $ref: '#/components/schemas/Score'
            - type: 'null'
        period:
          oneOf:
            - $ref: '#/components/schemas/FootballPeriod'
            - type: 'null'
        clock:
          oneOf:
            - $ref: '#/components/schemas/Clock'
            - type: 'null'
    Event:
      type: object
      description: An event. `started_at` is null again if a `live` event returns to `scheduled` or `postponed` or is cancelled before play was confirmed. A match interrupted and resumed later stays `live` and keeps its `started_at`, while its `scheduled_start` may move to the resumption. `live` is null outside live play, for sports other than football, and when no live data is available.
      required: [id, sport, competition, name, participants, scheduled_start, status, started_at, ended_at, end_reason, resolved_at, live, updated_at, version]
      properties:
        id:
          $ref: '#/components/schemas/EventId'
        sport:
          $ref: '#/components/schemas/SportId'
        competition:
          $ref: '#/components/schemas/CompetitionRef'
        name:
          type: string
          examples: [Arsenal vs Chelsea]
        participants:
          type: array
          items:
            $ref: '#/components/schemas/Participant'
        scheduled_start:
          $ref: '#/components/schemas/Timestamp'
        status:
          $ref: '#/components/schemas/EventStatus'
        started_at:
          $ref: '#/components/schemas/NullableTimestamp'
        ended_at:
          $ref: '#/components/schemas/NullableTimestamp'
        end_reason:
          oneOf:
            - $ref: '#/components/schemas/EndReason'
            - type: 'null'
        resolved_at:
          $ref: '#/components/schemas/NullableTimestamp'
        live:
          oneOf:
            - $ref: '#/components/schemas/Live'
            - type: 'null'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
        version:
          $ref: '#/components/schemas/Version'
          description: The version of this state of the event; grows whenever any of its fields changes.
    NullableTimestamp:
      oneOf:
        - $ref: '#/components/schemas/Timestamp'
        - type: 'null'
    EventPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Event'
        next_cursor:
          $ref: '#/components/schemas/NextCursor'
    MarketType:
      type: string
      description: |
        - `1x2`: result of the match, three-way; outcomes `home`, `draw`, `away`; no line.
        - `moneyline`: winner of the match, two-way; outcomes `home`, `away`; no line.
        - `total`: total goals, points or games against the line; outcomes `over`, `under`.
        - `handicap`: `home` wins with the line added to its score; the line is from the home side, so `away` holds the opposite line; outcomes `home`, `away`.
        v1 lines are always a whole number plus 0.5, so no market can push.
      enum: ['1x2', moneyline, total, handicap]
    Period:
      type: string
      description: The part of the event a market settles on; v1 has only the full match.
      enum: [match]
    OutcomeKey:
      type: string
      enum: [home, draw, away, over, under]
    MarketStatus:
      type: string
      description: '`active` while the market is listed; `removed` when it is withdrawn before the start of the event, after which no price follows; `settled` once it has a result.'
      enum: [active, removed, settled]
    Outcome:
      type: object
      required: [key, name]
      properties:
        key:
          $ref: '#/components/schemas/OutcomeKey'
        name:
          type: string
          examples: [Over 2.5]
    MarketState:
      type: string
      description: |
        - `open`: the price is reliable.
        - `caution`: the price is usable, but uncertainty is raised; widen margins or reduce limits.
        - `suspended`: do not use the price; `prob`, `fair_odds` and `uncertainty` are null.
      enum: [open, caution, suspended]
    Reason:
      type: string
      description: |
        Why the market is not `open`; null when `open`.
        - `match_event`: something happened in the match (goal, card, break, end), or the match has started in a sport priced before the start only; `suspended`.
        - `unstable`: the price is moving fast or is not yet settled; `caution` or `suspended`.
        - `low_confidence`: the price is less certain than usual; `caution` or `suspended`.
        - `insufficient_data`: not enough data to price the market; `suspended`.
        - `starting`: the market was just listed or the feed restarted; `suspended`.
        - `event_not_active`: the event is not scheduled or live; `suspended`.
        - `maintenance`: planned maintenance; `suspended`.
      enum: [match_event, unstable, low_confidence, insufficient_data, starting, event_not_active, maintenance]
    OutcomePrice:
      type: object
      required: [key, prob, fair_odds]
      properties:
        key:
          $ref: '#/components/schemas/OutcomeKey'
        prob:
          type: [number, 'null']
          minimum: 0.0001
          maximum: 0.9999
          description: The fair probability, 4 decimals; null while suspended.
        fair_odds:
          type: [number, 'null']
          minimum: 1
          maximum: 10000
          description: '`1/prob`, 3 decimals; null while suspended.'
    Price:
      type: object
      description: A market's price. The 4-decimal `prob` values of a market sum to exactly 1.
      required: [state, reason, outcomes, uncertainty, ts]
      properties:
        state:
          $ref: '#/components/schemas/MarketState'
        reason:
          oneOf:
            - $ref: '#/components/schemas/Reason'
            - type: 'null'
        outcomes:
          type: array
          items:
            $ref: '#/components/schemas/OutcomePrice'
        uncertainty:
          type: [number, 'null']
          minimum: 0
          description: How far the probability may be from the truth, in probability units, 3 decimals; null while suspended.
        ts:
          $ref: '#/components/schemas/Timestamp'
          description: The time of the newest input behind the price. A `suspended` price with reason `starting` shown before any input for the market (a new listing, or after a restart) carries the time it was made, so the next price's `ts` can be earlier.
    MarketResult:
      type: object
      description: >-
        Who won a market. A void market has `"winner": null, "void": true`.
        `revision` starts at 1 and increases with each correction.
      required: [winner, void, revision, resolved_at]
      properties:
        winner:
          oneOf:
            - $ref: '#/components/schemas/OutcomeKey'
            - type: 'null'
        void:
          type: boolean
        revision:
          type: integer
          minimum: 1
        resolved_at:
          $ref: '#/components/schemas/Timestamp'
    Line:
      type: [number, 'null']
      multipleOf: 0.5
      description: The line, such as `2.5` or `-1.5`; null for types without a line.
    Market:
      type: object
      description: A market with its current price and result.
      required: [id, event_id, type, period, line, status, outcomes, price, result]
      properties:
        id:
          $ref: '#/components/schemas/MarketId'
        event_id:
          $ref: '#/components/schemas/EventId'
        type:
          $ref: '#/components/schemas/MarketType'
        period:
          $ref: '#/components/schemas/Period'
        line:
          $ref: '#/components/schemas/Line'
        status:
          $ref: '#/components/schemas/MarketStatus'
        outcomes:
          type: array
          items:
            $ref: '#/components/schemas/Outcome'
        price:
          oneOf:
            - $ref: '#/components/schemas/Price'
            - type: 'null'
        result:
          oneOf:
            - $ref: '#/components/schemas/MarketResult'
            - type: 'null'
    EventDetail:
      type: object
      description: An event with its markets; the fields of `Event` and `markets`.
      required: [id, sport, competition, name, participants, scheduled_start, status, started_at, ended_at, end_reason, resolved_at, live, updated_at, version, markets]
      properties:
        id:
          $ref: '#/components/schemas/EventId'
        sport:
          $ref: '#/components/schemas/SportId'
        competition:
          $ref: '#/components/schemas/CompetitionRef'
        name:
          type: string
        participants:
          type: array
          items:
            $ref: '#/components/schemas/Participant'
        scheduled_start:
          $ref: '#/components/schemas/Timestamp'
        status:
          $ref: '#/components/schemas/EventStatus'
        started_at:
          $ref: '#/components/schemas/NullableTimestamp'
        ended_at:
          $ref: '#/components/schemas/NullableTimestamp'
        end_reason:
          oneOf:
            - $ref: '#/components/schemas/EndReason'
            - type: 'null'
        resolved_at:
          $ref: '#/components/schemas/NullableTimestamp'
        live:
          oneOf:
            - $ref: '#/components/schemas/Live'
            - type: 'null'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
        version:
          $ref: '#/components/schemas/Version'
          description: The version of this state of the event, as in `Event`.
        markets:
          type: array
          items:
            $ref: '#/components/schemas/Market'
    PriceRowMarket:
      type: object
      description: The market of a price row.
      required: [type, period, line, status]
      properties:
        type:
          $ref: '#/components/schemas/MarketType'
        period:
          $ref: '#/components/schemas/Period'
        line:
          $ref: '#/components/schemas/Line'
        status:
          $ref: '#/components/schemas/MarketStatus'
    PriceRowEvent:
      type: object
      description: The event of a price row.
      required: [id, name, sport, competition_id, competition, status, scheduled_start]
      properties:
        id:
          $ref: '#/components/schemas/EventId'
        name:
          type: string
          examples: [Arsenal vs Chelsea]
        sport:
          $ref: '#/components/schemas/SportId'
        competition_id:
          $ref: '#/components/schemas/CompetitionId'
        competition:
          type: string
          description: The competition's name.
          examples: [Premier League]
        status:
          $ref: '#/components/schemas/EventStatus'
        scheduled_start:
          $ref: '#/components/schemas/Timestamp'
    PriceRowOutcome:
      type: object
      description: An outcome of a price row, with its name and its price.
      required: [key, name, prob, fair_odds]
      properties:
        key:
          $ref: '#/components/schemas/OutcomeKey'
        name:
          type: string
          examples: [Over 2.5]
        prob:
          type: [number, 'null']
          minimum: 0.0001
          maximum: 0.9999
          description: The fair probability, 4 decimals; null while suspended.
        fair_odds:
          type: [number, 'null']
          minimum: 1
          maximum: 10000
          description: '`1/prob`, 3 decimals; null while suspended.'
    PriceRow:
      type: object
      description: One market with its event, its price and its result, complete on its own. `state`, `reason`, `uncertainty` and `ts` are the market's price as in `Price`; `outcomes` are the market's outcomes in its order, each with its probability. The 4-decimal `prob` values of a market sum to exactly 1.
      required: [market_id, market, event, state, reason, uncertainty, ts, outcomes, result, version]
      properties:
        market_id:
          $ref: '#/components/schemas/MarketId'
        market:
          $ref: '#/components/schemas/PriceRowMarket'
        event:
          $ref: '#/components/schemas/PriceRowEvent'
        state:
          $ref: '#/components/schemas/MarketState'
        reason:
          oneOf:
            - $ref: '#/components/schemas/Reason'
            - type: 'null'
        uncertainty:
          type: [number, 'null']
          minimum: 0
          description: How far the probability may be from the truth, in probability units, 3 decimals; null while suspended.
        ts:
          $ref: '#/components/schemas/Timestamp'
          description: The time of the newest input behind the price, as in `Price`.
        outcomes:
          type: array
          items:
            $ref: '#/components/schemas/PriceRowOutcome'
        result:
          oneOf:
            - $ref: '#/components/schemas/MarketResult'
            - type: 'null'
        version:
          $ref: '#/components/schemas/Version'
          description: The version of this state of the row; grows whenever something the row shows changes.
    JoinFilter:
      type: object
      additionalProperties: false
      properties:
        sports:
          type: array
          items:
            $ref: '#/components/schemas/SportId'
        competitions:
          type: array
          items:
            $ref: '#/components/schemas/CompetitionId'
        events:
          type: array
          items:
            $ref: '#/components/schemas/EventId'
        market_types:
          type: array
          items:
            $ref: '#/components/schemas/MarketType'
        periods:
          type: array
          default: [match]
          items:
            $ref: '#/components/schemas/Period'
        statuses:
          type: array
          items:
            $ref: '#/components/schemas/EventStatus'
    JoinAnswer:
      type: object
      required: [version, data]
      properties:
        version:
          $ref: '#/components/schemas/Version'
        data:
          type: array
          items:
            $ref: '#/components/schemas/PriceRow'
    PriceList:
      type: object
      description: The body of `GET /v1/prices`.
      required: [since, version, full, data]
      properties:
        since:
          type: string
          description: An opaque token for the state the rows were read from; pass it as `since` on the next request. Valid for `since_ttl`, 10 minutes by default.
        version:
          $ref: '#/components/schemas/Version'
          description: The version of the state the rows were read from; every later change of any row has a greater `version`.
        full:
          type: boolean
          description: True when `data` holds every row, as without `since` (and, in answer to a usable `since`, the changed rows of removed markets and of events no longer `scheduled` or `live`); replace your whole state. False otherwise.
        data:
          type: array
          items:
            $ref: '#/components/schemas/PriceRow'
    Health:
      type: object
      required: [status, ts]
      properties:
        status:
          type: string
          enum: [ok, degraded]
        ts:
          $ref: '#/components/schemas/Timestamp'
    StreamTicket:
      type: object
      required: [ticket, expires_at]
      properties:
        ticket:
          type: string
        expires_at:
          $ref: '#/components/schemas/Timestamp'
    ErrorCode:
      type: string
      description: |
        - `invalid_request`: 400.
        - `unauthorized`: 401.
        - `forbidden`: 403.
        - `not_found`: 404.
        - `rate_limited`: 429.
        - `internal`: 500.
        - `unavailable`: 503.
      enum: [invalid_request, unauthorized, forbidden, not_found, rate_limited, internal, unavailable]
    ErrorBody:
      type: object
      description: The body of every error response.
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code:
              $ref: '#/components/schemas/ErrorCode'
            message:
              type: string
              examples: [Event not found]
            request_id:
              type: string
              description: The same as the `X-Request-Id` header.
