openapi: 3.1.0
info:
  title: The Aggregator Machine API
  version: '1'
  description: |
    B2B game aggregation platform. Server-to-server REST API for operators to
    browse the game catalog, launch real-money and demo sessions, and query
    transactions.

    **Authentication**: Send the credential issued for your integration unchanged
    as a Bearer token in the `Authorization` header. Examples do not select an
    environment or grant permissions. Confirm organization access, provider
    availability, callback setup and funding or any agreed welcome credits before
    real-session validation. Credential status and lifecycle follow the deployed
    policy. See https://docs.aggregator.gg/get-started/authentication/.

    **Versioning**: Machine API operations use the `/v1/` path. A date header
    does not select a historical response schema. See
    https://docs.aggregator.gg/resources/versioning/.

    **Idempotency**: Supply `Idempotency-Key` where the operation requires it.
    Reuse a key only for the same logical request and unchanged body. Replay
    behavior and retention depend on the operation; see the idempotency guide.

    Use the operation reference to configure clients, and validate behavior
    against the deployment authorized for your integration.
  contact:
    name: Aggregator API support
    url: https://docs.aggregator.gg/
  license:
    name: Proprietary
servers:
  - url: https://api.aggregator.gg/v1
    description: Production

security:
  - bearerAuth: []

paths:
  /games:
    get:
      summary: List games
      description: |
        List available games with optional filtering, search, and pagination.
        Results are scoped to the games enabled for the calling operator's
        configured providers and selections.
      tags: [Games]
      operationId: listGames
      parameters:
        - name: sub_operator_ref
          in: query
          schema: { type: string, example: brand-01 }
          description: |
            **Platform accounts only, and required for them.** Which brand's
            storefront to render. Game/provider enablement is per brand, so
            omitting this returns your platform's **default brand's** catalog
            rather than an error. Sending it from a non-platform key is a 400
            (`E6006`). If your platform has no default brand provisioned, the
            call is a 409 (`E6010`); if it is configured with
            `require_sub_operator_ref`, omitting the parameter is a 400
            (`E6005`).

            Single-casino integrations never send this field. See the
            **Platform accounts** guide if you are a platform partner.

            Omit it to get the default brand; sending it **empty** is a 400
            (`E6004`), not a fallback — see `sub_operator_ref` on
            `POST /v1/sessions` for why the two differ.

            A brand's **first** call with an unseen ref auto-provisions it and
            gives it the same games and providers your platform already has
            available — so this call returns a usable catalog, and the
            `game_id`s in it can be launched immediately via `POST /v1/sessions`
            with the same ref. No manual onboarding step.

            Same format and matching rules as `sub_operator_ref` on
            `POST /v1/sessions`.
        - name: provider
          in: query
          schema: { type: string, example: truelabs }
          description: Filter by provider code (e.g. `truelabs`, `bgaming`).
        - name: provider_game_id
          in: query
          schema: { type: string }
          description: Filter by the provider's own game identifier.
        - name: type
          in: query
          schema: { type: string, example: slots }
          description: Filter by game type (`slots`, `table`, `crash`, ...).
        - name: volatility
          in: query
          schema: { type: string, enum: [low, medium, high] }
        - name: rtp_min
          in: query
          schema: { type: number, format: float, minimum: 0, maximum: 100, example: 95.0 }
        - name: rtp_max
          in: query
          schema: { type: number, format: float, minimum: 0, maximum: 100, example: 97.5 }
        - name: features
          in: query
          schema: { type: string }
          description: Comma-separated feature tags (e.g. `bonus_buy,megaways`).
        - name: search
          in: query
          schema: { type: string }
          description: Full-text search across game names.
        - name: sort
          in: query
          schema:
            type: string
            enum: [name, -name, rtp, -rtp, release_date, -release_date, sort_order, -sort_order, created_at, -created_at]
          description: Sort field; prefix with `-` for descending.
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: per_page
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: currency
          in: query
          schema: { type: string, minLength: 3, maxLength: 3, example: EUR }
          description: |
            Filter to games playable in this ISO-4217 currency. A game matches
            when the currency is in its effective `supported_currencies` (the
            game's own list if set, else the provider default). Use this to build
            a storefront that never shows a player a game their wallet currency
            cannot open.
      responses:
        '200':
          description: Page of games.
          headers:
            X-API-Mode: { $ref: '#/components/headers/XApiMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GameList'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /games/{game_id}:
    get:
      summary: Get a game
      description: Retrieve a single game by its UUID.
      tags: [Games]
      operationId: getGame
      parameters:
        - name: game_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The requested game.
          content:
            application/json:
              schema:
                type: object
                required: [game]
                properties:
                  game: { $ref: '#/components/schemas/Game' }
        '400':
          description: '`game_id` is not a valid UUID.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: No game found with that ID.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /games/{game_id}/stakes:
    get:
      summary: Get valid free-round stakes for a game
      description: |
        The valid stakes you may use to issue free rounds on this game in a given
        currency (`POST /v1/free-rounds`). Stakes are **per game, per currency**.
        Branch on `mode`, not on provider:

        - `enumerated` — we hold the authoritative stake list for this game in
          this currency, so `stakes` is populated; pick one entry and send it via
          `stake_field`. (TrueLabs always returns its stakes; Apparat returns them
          per-currency where its API exposes them.)
        - `provider_validated` — we don't have the exact stakes for this game in
          this currency, because the provider's API exposes no list for it.
          `stakes` is empty: submit any `bet_amount`, and the **provider** checks
          it on its own side when the round is issued — we mirror its accept/reject
          response back to you. Provider capabilities differ, so the same game may
          be `enumerated` in one currency and `provider_validated` in another.
        - `unsupported` — the provider has no free-round stake model at all
          (`stake_field` is `null`).
      tags: [Games]
      operationId: getGameStakes
      parameters:
        - name: game_id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: currency
          in: query
          required: true
          description: ISO-4217 currency the stakes are quoted in (e.g. EUR). Stakes are per-currency.
          schema: { type: string, minLength: 3, maxLength: 10 }
      responses:
        '200':
          description: Valid free-round stakes for the game in the requested currency.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GameStakes' }
        '400':
          description: Invalid `game_id` or missing/invalid `currency`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: No game found with that ID.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '502':
          description: Stakes temporarily unavailable (the provider stakes source was unreachable). Retry.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /providers/registry:
    get:
      summary: List registered providers
      description: |
        List the providers registered on the platform with their
        `supported_currencies` and `blocked_countries` defaults. Use this to learn
        provider-level currency support and blocked countries before fetching
        games, so you can filter your storefront and avoid launch-time dead-ends.
        Game-level values (`GET /v1/games`) override these when a game declares
        its own list.
      tags: [Providers]
      operationId: listProviderRegistry
      responses:
        '200':
          description: The provider registry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProviderRegistry'
        '401': { $ref: '#/components/responses/Unauthorized' }

  /sessions:
    post:
      summary: Create a real-money session
      description: |
        Create a real-money game session. The player is redirected to the
        returned `game_url` to begin playing. The URL is single-use — open it
        once and do not cache or reload it; create the session immediately
        before redirecting.

        Requires `Idempotency-Key` header. Retries with the same key within
        24 hours return the cached response.
      tags: [Sessions]
      operationId: createSession
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateSessionRequest'
      responses:
        '201':
          description: Session created.
          headers:
            X-Idempotent-Replayed: { $ref: '#/components/headers/XIdempotentReplayed' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionResponse'
        '400':
          description: Validation failed or missing/invalid `Idempotency-Key` header.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: |
            Game or player not eligible. The `error.code` distinguishes:
            `E2002` game not enabled for the operator, `E2003` game blocked in the
            player's `country` (jurisdiction), `E3004` player self-excluded, or
            `operator_settings_not_configured` when the operator has no wallet
            callback configured for the provider.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: 'Game not found (`error.code` = `E2001`).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '502':
          description: Provider returned an error when creating the session.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: Provider temporarily unavailable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /demo-sessions:
    post:
      summary: Create a demo session
      description: |
        Launch a demo game session. No real-money wallet involvement; no bet
        or win callbacks emitted. Useful for demo previews or
        prospect-facing free-play surfaces. Demo sessions are non-financial
        and do not require an `Idempotency-Key` header.
      tags: [Sessions]
      operationId: createDemoSession
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [game_id]
              properties:
                game_id:
                  type: string
                  format: uuid
                  description: |
                    The Aggregator catalog game UUID — the `id` from
                    `GET /v1/games`. NOT the provider's `provider_game_id`.
      responses:
        '201':
          description: Demo session created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionResponse'
        '404':
          description: Game not found or does not support demo mode.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /sessions/{session_id}:
    get:
      summary: Get session details
      description: Retrieve metadata about a previously-created session.
      tags: [Sessions]
      operationId: getSession
      parameters:
        - name: sub_operator_ref
          in: query
          schema: { type: string, example: brand-01 }
          description: |
            **Platform accounts only.** Which brand this call acts as — the same
            ref the session or grant was created with. Same format and error
            codes as `sub_operator_ref` on `POST /v1/sessions`: omit it to use
            your default brand, and note that sending it **empty** is a 400
            (`E6004`), not a fallback.
        - name: session_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Session details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionDetail'
        '404':
          description: Session not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /sub-operators:
    post:
      summary: Create a brand (sub-operator)
      description: |
        **Platform accounts only.** Create one brand under your platform, before
        any traffic reaches it.

        Auto-provision still works — a launch or `GET /v1/games` carrying an
        unseen `sub_operator_ref` mints the brand on the spot. This endpoint adds
        the ability to do it *deliberately*, which matters in three cases:

        - a platform running `require_sub_operator_ref` has no lazy path at all,
          so every brand must exist before it is named;
        - creating a brand at launch time makes the first player pay the cold-path
          latency (mapping insert, settings seed, selection clone) — pre-creating
          moves that off the money path;
        - a typo'd ref and a genuine new brand are indistinguishable to
          auto-provision. Here they are not: creation is a request you made.

        **Idempotent.** An existing ref returns the same identity with 200 rather
        than a conflict — re-running your brand list on every deploy is a normal
        thing to do, and turning it into an error would push integrators toward
        ignoring errors.

        The **first** brand a platform creates becomes its default: the brand a
        request that omits `sub_operator_ref` resolves to. There is no reserved
        `default` ref.

        A new brand starts with the same games and providers your platform already
        has available, so its catalog is usable immediately.
      tags: [Sessions]
      operationId: createSubOperator
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              # `sub_operator_ref` is REQUIRED (see its description) but is
              # deliberately NOT in `required:`. This spec is enforced at runtime
              # by the request-validation middleware, which answers a schema
              # violation with `E1001` — so declaring it here would make "you did
              # not name a brand" answer `E1001` on this endpoint and `E6004`
              # everywhere else it can happen. The handler owns the check and
              # returns `E6004` uniformly. Same reasoning as the missing
              # `pattern=` on `CreateSessionPayload.sub_operator_ref`.
              properties:
                sub_operator_ref:
                  type: string
                  example: brand-01
                  description: |
                    The brand to create. Format: 1-32 characters of
                    `[A-Za-z0-9_-]`, starting and ending alphanumeric; `:` is not
                    allowed. Matched **case-insensitively** — `Brand-01` and
                    `brand-01` are one brand, not two.

                    Missing, empty or malformed is a 400 (`E6004`). Refs are
                    validated and never silently repaired: a repaired typo would
                    mint a phantom brand and split its money across two
                    identities.
      responses:
        '200':
          description: |
            The brand exists and is yours. Returned whether it was created by
            this call or already present — see the idempotency note above.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sub_operator_ref:
                    type: string
                    example: brand-01
                    description: The canonical (lower-cased, trimmed) ref.
                  operator_user_id:
                    type: string
                    example: 'platform:brand-01'
                    description: |
                      The brand's internal identity — what its sessions,
                      transactions and analytics are attributed to. Reported back
                      so you can join our records to yours; it is not a parameter
                      you ever send.
        '400':
          description: |
            Invalid or missing `sub_operator_ref` (`E6004`), or your platform is
            at its brand cap (`E6007`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: |
            This key is not a platform key (`E6006`). Sub-operators exist only
            under platform accounts; an ordinary operator key already identifies
            a single operator.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: |
            Your platform is not fully provisioned and owns no organization to
            bill (`E6009`). Contact support.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /transactions:
    get:
      summary: List transactions
      description: |
        List your most recent wallet transactions (bets, wins, refunds),
        newest first. Used for reconciliation against your wallet ledger and
        for debugging an integration: every row echoes your own wallet's
        reply (`operator_response`, `operator_status`) so you can see why a
        transaction failed without contacting support.

        Pagination is `limit` + `offset` only. There is no server-side
        filtering yet — filter client-side after fetching.
      tags: [Transactions]
      operationId: listTransactions
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: Page of transactions.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionList'
        '401': { $ref: '#/components/responses/Unauthorized' }

  /free-rounds:
    post:
      summary: Issue free rounds
      description: |
        Issue free rounds (free spins) to a player on one or more games. `count`
        is **per game** — `games: [A, B]` with `count: 10` issues 10 rounds on
        each (20 total). Not tied to a session; every game must support free
        rounds and passes the same jurisdiction check as a launch.

        Requires the `write` scope and an `Idempotency-Key` header (same key +
        same body replays the cached result; same key + different body → 409).
        Free rounds must be enabled for the provider before issuance succeeds.
      tags: [FreeRounds]
      operationId: issueFreeRounds
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/IssueFreeRoundsRequest' }
      responses:
        '201':
          description: Grant issued for all games.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FreeRoundGrantResponse' }
        '200':
          description: Idempotent replay of a previously issued grant.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FreeRoundGrantResponse' }
        '207':
          description: Partially issued — some games failed (see per-game `status`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FreeRoundGrantResponse' }
        '400':
          description: Validation failed, missing `Idempotency-Key`, or invalid stake.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: Free rounds disabled for the provider, jurisdiction blocked, or player self-excluded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: A game does not support free rounds, or the stake is outside its limits.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
    get:
      summary: List free-round grants
      description: List your free-round grants, newest first.
      tags: [FreeRounds]
      operationId: listFreeRounds
      parameters:
        - name: sub_operator_ref
          in: query
          schema: { type: string, example: brand-01 }
          description: |
            **Platform accounts only.** Which brand this call acts as — the same
            ref the session or grant was created with. Same format and error
            codes as `sub_operator_ref` on `POST /v1/sessions`: omit it to use
            your default brand, and note that sending it **empty** is a 400
            (`E6004`), not a fallback.
        - name: player_id
          in: query
          schema: { type: string, maxLength: 128 }
        - name: status
          in: query
          description: Filter by grant status.
          schema:
            type: string
            enum: [active, partially_consumed, consumed, cancelled, expired]
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: List of grants.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FreeRoundGrantList' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /free-rounds/{grant_id}/cancel:
    post:
      summary: Cancel a free-round grant
      description: |
        Cancel a grant's outstanding (unplayed) rounds. Already-played rounds are
        not reversed. Requires the `write` scope.
      tags: [FreeRounds]
      operationId: cancelFreeRound
      parameters:
        - name: sub_operator_ref
          in: query
          schema: { type: string, example: brand-01 }
          description: |
            **Platform accounts only.** Which brand this call acts as — the same
            ref the session or grant was created with. Same format and error
            codes as `sub_operator_ref` on `POST /v1/sessions`: omit it to use
            your default brand, and note that sending it **empty** is a 400
            (`E6004`), not a fallback.
        - name: grant_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Grant cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FreeRoundCancelResponse' }
        '404':
          description: Grant not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: Grant is in a terminal state and cannot be cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /health:
    get:
      servers:
        - url: https://api.aggregator.gg
      summary: Liveness probe
      description: Returns 200 if the API process is up.
      tags: [Health]
      operationId: getHealth
      security: []
      responses:
        '200':
          description: Service is alive.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    enum: [ok]

  /ready:
    get:
      servers:
        - url: https://api.aggregator.gg
      summary: Readiness probe
      description: Returns 200 if the API can serve traffic (DB reachable, etc.).
      tags: [Health]
      operationId: getReady
      security: []
      responses:
        '200':
          description: Service is ready.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    enum: [ready]
        '503':
          description: Service is not ready (one or more dependencies unreachable).

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Issued API key

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema:
        type: string
        maxLength: 128
        pattern: '^[A-Za-z0-9_-]+$'
      description: |
        Persist one key per logical operation and reuse it only with the
        unchanged request body. Replay behavior and retention depend on the
        endpoint; see https://docs.aggregator.gg/guides/idempotency/.

  headers:
    XApiMode:
      schema:
        type: string
        enum: [live, test]
      description: Echoes the active environment for this key.
    RateLimitLimit:
      schema: { type: integer }
      description: Rate-limit ceiling for this key on this endpoint family.
    RateLimitRemaining:
      schema: { type: integer }
      description: Calls remaining in the current window.
    RateLimitReset:
      schema: { type: integer }
      description: Unix timestamp when the current rate-limit window resets.
    XIdempotentReplayed:
      schema:
        type: string
        enum: ["true"]
      description: Present when the response was served from the idempotency cache.

  responses:
    Unauthorized:
      description: Missing, invalid, or revoked API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Rate limit exceeded; consult `Retry-After`.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds to wait before retry.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    IdempotencyConflict:
      description: Idempotency key reused with a different request body.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Game:
      type: object
      required: [id, provider_code, provider_game_id, name, category, has_demo]
      properties:
        id:
          type: string
          format: uuid
          description: |
            The Aggregator catalog UUID. Pass THIS as `game_id` when creating a
            real or demo session.
        provider_game_id:
          type: string
          description: |
            The provider's own game code. For display/reference only — do NOT
            use it to create sessions (use `id`).
        provider_code:
          type: string
          example: truelabs
        name:
          type: string
        brand:
          type: string
        category:
          type: string
        game_type:
          type: string
          example: slots
        rtp:
          type: number
          format: float
          minimum: 0
          maximum: 100
        volatility:
          type: string
          enum: [low, medium, high]
        has_mobile:
          type: boolean
        has_desktop:
          type: boolean
        has_demo:
          type: boolean
        thumbnail_url:
          type: string
          format: uri
        free_rounds_support:
          type: boolean
          description: |
            Whether this game supports free rounds / free spins. When `true` you
            can issue free rounds on it via `POST /v1/free-rounds`; issuance on a
            game where this is `false` is rejected at grant time. Filter your
            free-round campaigns on this field.
        blocked_countries:
          type: array
          description: |
            Effective ISO-3166-1 alpha-2 countries this game may NOT be launched
            in — the provider-declared block carried to you. It is the per-game
            override when the provider sets one, otherwise the whole-provider
            default (`GET /v1/providers/registry`). A player whose `country` is in
            this list is rejected at session create; filter your storefront on it
            too. Empty = no country restriction.
          items:
            type: string
            pattern: '^[A-Z]{2}$'
          example: [TR, FR]
        certified_markets:
          type: object
          nullable: true
          description: |
            Informational allow-list of the regulated markets the game is
            certified for, as declared by the provider. Use it to filter your
            storefront to games your licence permits.

            This is **not** a launch gate and is a different axis from
            `blocked_countries`: `blocked_countries` is a block-list checked
            against the player `country` at session create, whereas
            `certified_markets` describes which licences may carry the game. We do
            not enforce it — choosing which games to offer under your licence is
            your responsibility. `null` when the provider declares no market data.
          properties:
            regulated:
              type: array
              description: ISO-3166-1 alpha-2 codes of the certified regulated markets.
              items:
                type: string
                pattern: '^[A-Z]{2}$'
              example: [DE, EE, GI, IM, MT]
            dotcom:
              type: boolean
              description: |
                `true` when the game is approved for the international / offshore
                (".COM", rest-of-world) market — a market *type*, not a country, so
                it is never expanded into a country list.
            low_barrier:
              type: array
              description: ISO-3166-1 alpha-2 codes of certified low-barrier markets.
              items:
                type: string
                pattern: '^[A-Z]{2}$'
              example: [AM, LV, MX, ME, RS]
            other:
              type: array
              description: |
                Certified markets with no ISO country mapping (e.g. territory
                licensing authorities), preserved verbatim. Omitted when empty.
              items:
                type: string
              example: [Kahnawake]
            raw:
              type: string
              description: |
                The provider's original markets string. Omitted when the data came
                only from structured certification fields.
        features:
          type: array
          items: { type: string }
        release_date:
          type: string
          format: date
        supported_currencies:
          type: array
          description: |
            Effective ISO-4217 currencies this game can be launched in: the
            game's own override if set, else the provider default. An empty array
            means no declared restriction is known. Filter the catalog with the
            `currency` query parameter; treat this as the source of truth for which
            player wallet currencies a game supports before launch.
          items:
            type: string
            pattern: '^[A-Z]{3}$'
          example: [USD, EUR]
        min_bet:
          type: number
          nullable: true
          description: |
            Provider-declared minimum stake per spin, quoted in
            `bet_limits_currency`. `null` when the provider has not declared it.
            No FX conversion is applied — the value is exactly as the provider
            quotes it.
          example: 0.10
        max_bet:
          type: number
          nullable: true
          description: |
            Provider-declared maximum stake per spin, quoted in
            `bet_limits_currency`. `null` when undeclared.
          example: 100.0
        default_bet:
          type: number
          nullable: true
          description: |
            Provider-declared default stake per spin, quoted in
            `bet_limits_currency`. `null` when undeclared.
          example: 1.0
        bet_limits_currency:
          type: string
          nullable: true
          pattern: '^[A-Z]{3,8}$'
          description: |
            The currency the `min_bet` / `max_bet` / `default_bet` values are
            quoted in (canonical code, fiat or crypto). Present whenever any bet
            limit is set; `null` when no bet limits are declared. These values are
            NOT converted to the player's wallet currency — treat them as the
            provider's reference quote.
          example: EUR
        max_win_multiplier:
          type: number
          nullable: true
          description: |
            Maximum win as a multiple of the stake (e.g. `5000` = 5000×). A
            currency-agnostic stake multiplier, NOT a money amount. `null` when
            the provider has not declared it.
          example: 5000
        bet_steps:
          nullable: true
          description: |
            The discrete valid-stake ladder for **one** currency, when known —
            e.g. for issuing free rounds. Single-currency (no FX), so it answers
            only for `currency`. `null` when no ladder is on file (then use
            `GET /v1/games/{game_id}/stakes` / provider-side validation).
          type: object
          required: [currency, steps]
          properties:
            currency:
              type: string
              example: EUR
            steps:
              type: array
              items: { type: number }
              example: [0.20, 0.40, 0.60, 0.80, 1.00, 2.00, 4.00]

    GameList:
      type: object
      required: [games, total, page, per_page]
      properties:
        games:
          type: array
          items: { $ref: '#/components/schemas/Game' }
        total: { type: integer }
        page: { type: integer }
        per_page: { type: integer }

    GameStakes:
      type: object
      required: [game_id, provider_code, currency, stake_field, mode, stakes]
      properties:
        game_id: { type: string, format: uuid }
        provider_code: { type: string, example: truelabs }
        currency: { type: string, example: EUR }
        stake_field:
          type: string
          nullable: true
          enum: [coin_level, bet_amount]
          description: >-
            Which field to send to `POST /v1/free-rounds` for this game.
            `coin_level` — pick a `stakes` entry and send its `coin_level`
            (e.g. TrueLabs). `bet_amount` — send a money amount; when `stakes`
            is non-empty pick one of its `amount` values, otherwise send any
            valid amount (e.g. Apparat). `null` for `unsupported`.
        mode:
          type: string
          enum: [enumerated, provider_validated, unsupported]
          description: >-
            How to choose a stake. `enumerated` — we hold the authoritative list
            for this game+currency; `stakes` is populated, pick one and send it via
            `stake_field`. `provider_validated` — we don't know the exact stakes in
            this currency for this provider (its API has no endpoint that returns
            them); `stakes` is empty, so send any `bet_amount` and the provider
            validates it on its own side when the round is issued, mirroring its
            accept/reject back to you. `unsupported` — the provider has no
            free-round stake model at all. Provider capabilities differ: TrueLabs
            always returns its stakes (`enumerated`), while a provider like Apparat
            may be `provider_validated` for currencies it doesn't enumerate.
        stakes:
          type: array
          description: >-
            For `enumerated`, the valid stakes (pick one). Empty for the other
            modes.
          items:
            type: object
            required: [amount]
            properties:
              coin_level:
                type: integer
                description: >-
                  1-based level to send as `coin_level`. Present only when
                  `stake_field` is `coin_level`.
              amount:
                type: number
                description: >-
                  The money stake, in `currency`. Send it as `bet_amount` when
                  `stake_field` is `bet_amount`.

    ProviderRegistry:
      type: object
      required: [providers, total_count]
      properties:
        providers:
          type: array
          items:
            type: object
            required: [provider_code, is_registered, supported_currencies, blocked_countries]
            properties:
              provider_code: { type: string, example: truelabs }
              is_registered: { type: boolean }
              supported_currencies:
                type: array
                description: ISO-4217 currencies the provider can settle. Empty = no declared restriction.
                items: { type: string, pattern: '^[A-Z]{3}$' }
                example: [USD, EUR]
              blocked_countries:
                type: array
                description: |
                  The provider's whole-provider blocked-country default
                  (ISO-3166-1 alpha-2). A game's effective `blocked_countries`
                  (`GET /v1/games`) is this unless the game declares its own
                  override. Empty = no provider-level country restriction.
                items: { type: string, pattern: '^[A-Z]{2}$' }
                example: [TR, FR]
        total_count: { type: integer }

    CreateSessionRequest:
      type: object
      required: [game_id, player_id, balance, currency, country]
      properties:
        game_id:
          type: string
          format: uuid
          description: |
            The Aggregator catalog game UUID — the `id` field returned by
            `GET /v1/games`. This is NOT the provider's own game code
            (`provider_game_id`); always pass The Aggregator `id`.
        player_id:
          type: string
          maxLength: 128
          description: Operator's internal player identifier.
        balance:
          type: integer
          minimum: 0
          description: |
            Player's current real-money balance in minor units (e.g. cents
            for USD/EUR/GBP). The Aggregator does not hold balances —
            this is informational only, used for limit checks and game UX.
        currency:
          type: string
          pattern: '^[A-Z]{3}$'
          enum: [USD, EUR, GBP, BRL, CAD, AUD, NOK, SEK, PLN, CZK, TRY, RUB, KZT, UAH]
        country:
          type: string
          pattern: '^[A-Z]{2}$'
          description: ISO 3166-1 alpha-2 country code of the player.
        lang:
          type: string
          pattern: '^[a-z]{2}$'
          example: en
        return_url:
          type: string
          format: uri
          maxLength: 2048
          description: |
            HTTPS URL to redirect the player after the session ends. When
            omitted, the Aggregator's game-complete page is used.
        deposit_url:
          type: string
          format: uri
          maxLength: 2048
          example: https://casino.example/game/abc#deposit
          description: |
            Optional HTTPS cashier destination for providers that support a
            separate in-game deposit or Top-up URL, including TRUE LABS and
            Apparat. Other providers may ignore it. If omitted, it falls back
            to the effective return_url, including the Aggregator game-complete
            fallback. This URL does not invoke a cashier modal by itself.
            For a same-document fragment hook, match the current top-level
            origin, path and query exactly and handle the fragment in the host
            page. Other destinations may reload the casino page and game iframe.

        sub_operator_ref:
          type: string
          description: |
            **Platform accounts only.** Selects which brand (sub-operator) under
            your platform this launch belongs to. Omitting it bills and reports
            the launch under your platform's **default brand** rather than
            failing — so a code path that forgets the parameter still launches,
            but its spins are attributed to `default` instead of the real brand.
            Wire it everywhere you launch. Sending it from a non-platform key is
            a 400 (`E6006`); with no default brand provisioned the call is a 409
            (`E6010`), and a platform configured with `require_sub_operator_ref`
            gets a 400 (`E6005`) instead of the default — opt into that if a
            missing parameter should be loud.

            The first request bearing an unseen ref — a launch, or a
            `GET /v1/games` — creates the brand with the same games and providers your
            platform already has available; afterwards the
            same ref always resolves to the same brand. Matched
            **case-insensitively** — `Brand-01` and `brand-01` are one brand, not
            two.

            Format: 1-32 characters of `[A-Za-z0-9_-]`, starting and ending
            alphanumeric. `:` is not allowed. A ref that does not match is a 400
            (`E6004`) — refs are validated, never silently repaired, because a
            repaired typo would mint a phantom brand and split its money.

            **Omit it, never send it empty.** An absent field resolves to the
            default brand; `""` (or whitespace) is a 400 (`E6004`). The two are
            different on purpose: a field you never sent cannot have been meant
            to name a brand, whereas a field you sent blank means your caller
            *has* the parameter and the value behind it came out empty — that
            request believes it selected a brand, and answering it with the
            default one would bill a different brand's ledger and return 201 as
            if nothing were wrong.
    SessionResponse:
      type: object
      required: [session_id, game_url, provider_code]
      properties:
        session_id:
          type: string
          format: uuid
        game_url:
          type: string
          format: uri
          description: One-time-use signed URL the player is redirected to.
        provider_session_uid:
          type: string
        provider_code:
          type: string
        warning:
          type: string
          description: Reserved for legacy use. Currently never set.

    SessionDetail:
      type: object
      required: [session_id, status, game_id, provider_code, created_at]
      properties:
        session_id:
          type: string
          format: uuid
        status:
          type: string
          enum: [open, closed, expired]
        game_id:
          type: string
          format: uuid
          description: The Aggregator catalog game UUID (matches `Game.id`).
        provider_code:
          type: string
        player_id:
          type: string
        currency:
          type: string
        country:
          type: string
        created_at:
          type: string
          format: date-time
        closed_at:
          type: string
          format: date-time
          nullable: true

    Transaction:
      type: object
      required: [id, session_id, transaction_type, amount, currency, created_at]
      properties:
        id:
          type: string
          format: uuid
        session_id:
          type: string
          format: uuid
        operator_user_id:
          type: string
          description: Your operator account id (rows are always scoped to the caller).
        transaction_type:
          type: string
          enum: [bet, win, refund, freespin, free_round_use]
          description: >-
            Ledger transaction type. `freespin` is a free-round win credit and
            `free_round_use` the (zero-stake) free-round consumption. On the
            **outbound wallet callback** these are delivered as a standard
            `win`/`bet` action carrying `is_free: true` — see the Free Rounds
            guide.
        amount:
          type: integer
          nullable: true
          description: >-
            Amount as an INTEGER in minor units (`150` = 1.50 EUR) — the same
            unit as the wallet-callback `amount` and the `balance` your wallet
            returns. Always non-negative; `transaction_type` discriminates
            direction. `null` only when the stored value could not be parsed
            (defensive; not expected in practice).
        currency:
          type: string
        provider_code:
          type: string
        provider_transaction_id:
          type: string
          description: >-
            Provider-unique transaction id — matches the `transaction_id` of
            the wallet callback you received for this movement.
        status:
          type: string
          description: >-
            Lifecycle status. Typical values: `completed`, `failed`,
            `cancelled`, `permanent_error`. Not a closed set — treat unknown
            values as non-final.
        operator_status:
          type: integer
          nullable: true
          description: >-
            HTTP status returned by your callback handler (`null` when the
            forward never reached your endpoint).
        operator_response:
          type: object
          nullable: true
          additionalProperties: true
          description: Your wallet's own JSON reply, echoed back for debugging.
        processing_time_ms:
          type: integer
          nullable: true
        error_message:
          type: string
          nullable: true
        environment:
          type: string
          enum: [test, live]
        created_at:
          type: string
          format: date-time

    TransactionList:
      type: object
      required: [transactions, limit, offset]
      properties:
        transactions:
          type: array
          items: { $ref: '#/components/schemas/Transaction' }
        limit: { type: integer }
        offset: { type: integer }

    IssueFreeRoundsRequest:
      type: object
      required: [provider_code, player_id, games, count, currency, country, start_at, end_at]
      properties:
        provider_code: { type: string, minLength: 1, maxLength: 64, example: truelabs }
        player_id: { type: string, minLength: 1, maxLength: 128 }
        games:
          type: array
          minItems: 1
          maxItems: 200
          description: Catalog game UUIDs (from `GET /v1/games`). Every game must support free rounds.
          items: { type: string, format: uuid }
        count:
          type: integer
          minimum: 1
          description: 'Rounds **per game** (not total). `games:[A,B]` with `count:10` issues 20 total.'
        currency: { type: string, minLength: 3, maxLength: 10, example: EUR }
        country:
          type: string
          minLength: 2
          maxLength: 2
          description: ISO 3166-1 alpha-2. Checked against jurisdiction rules at issue time.
        coin_level:
          type: integer
          minimum: 1
          maximum: 24
          description: Stake level (provider-dependent). Provide exactly one of `coin_level` / `bet_amount`.
        bet_amount:
          type: number
          exclusiveMinimum: 0
          description: Stake per round in major units (provider-dependent).
        start_at: { type: string, format: date-time, description: When the rounds become playable. }
        end_at: { type: string, format: date-time, description: When unplayed rounds expire. }
        campaign_id: { type: string, maxLength: 128, description: Your campaign reference (optional). }
        free_round_kind:
          type: string
          enum: [freespin, freeticket, voucher]
          default: voucher
          description: The kind of free round; echoed back on the settlement callback.
        sub_operator_ref:
          type: string
          example: brand-01
          description: |
            **Platform accounts only.** Which brand's players receive the grant.
            Same rules and error codes as `sub_operator_ref` on
            `POST /v1/sessions`; the grant lands on the brand's identity so
            settlement routes to the same brand the session did.

            Because the grant is keyed on the brand, the **same ref** must be
            sent to `GET /v1/free-rounds` and
            `POST /v1/free-rounds/{grant_id}/cancel` to see or cancel it.
    FreeRoundGameResult:
      type: object
      properties:
        game_id: { type: string, format: uuid }
        count: { type: integer }
        status: { type: string, description: 'Per-game outcome, e.g. active or issue_failed.' }
        error: { type: string, nullable: true }
    FreeRoundGrantResponse:
      type: object
      properties:
        grant_id: { type: string, format: uuid }
        status: { type: string, enum: [active, issue_failed] }
        provider_code: { type: string }
        player_id: { type: string }
        currency: { type: string }
        count_per_game: { type: integer }
        total_rounds: { type: integer }
        start_at: { type: string, format: date-time }
        end_at: { type: string, format: date-time }
        games:
          type: array
          items: { $ref: '#/components/schemas/FreeRoundGameResult' }
    FreeRoundCancelResponse:
      type: object
      properties:
        grant_id: { type: string, format: uuid }
        status: { type: string, enum: [cancelled, cancel_failed] }
        games:
          type: array
          items:
            type: object
            properties:
              game_id: { type: string, format: uuid }
              status: { type: string }
              error: { type: string, nullable: true }
    FreeRoundGrantList:
      type: object
      properties:
        count: { type: integer }
        grants:
          type: array
          items:
            type: object
            properties:
              grant_id: { type: string, format: uuid }
              status: { type: string }
              provider_code: { type: string }
              player_id: { type: string }
              currency: { type: string }
              count: { type: integer }
              rounds_used: { type: integer }
              start_at: { type: string, format: date-time }
              end_at: { type: string, format: date-time }
              created_at: { type: string, format: date-time }

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              pattern: '^E[0-9A-F][0-9]{3}$'
              description: Machine-parseable error code. See https://docs.aggregator.gg/resources/error-codes/.
              example: E0001
            type:
              type: string
              description: |
                Snake-case mnemonic for the `code` (e.g. `game_blocked_jurisdiction`,
                `player_self_excluded`). Branch on `code`; `type` is for readability.
              example: game_blocked_jurisdiction
            message:
              type: string
            request_id:
              type: string
              description: Correlation ID for support escalation.
            details:
              type: object
              additionalProperties: true
