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.

    **How to call this API**: business endpoints share the base URL
    `https://api.aggregator.gg/v1`. The `/health` and `/ready` probes are
    the one exception: they are served at the API root
    (`https://api.aggregator.gg`), without the `/v1` prefix. Authorize
    every business call with an `Authorization: Bearer` header, keeping
    the key in a protected environment variable. The shell builtin `printf`
    passes the header through stdin so the key is not included in curl's arguments:

        (
          if [ -z "${AGGREGATOR_API_KEY:-}" ]; then
            printf '%s\n' 'Set AGGREGATOR_API_KEY before calling the API.' >&2
            exit 1
          fi
          printf 'Authorization: Bearer %s\n' "$AGGREGATOR_API_KEY" |
            curl -H @- "https://api.aggregator.gg/v1/games?per_page=5"
        )

    **Authentication**: Bearer token via `Authorization` header. New
    operator API keys are prefixed `agg_*`; existing legacy credentials remain
    supported until revoked. If welcome credits are enabled for your account,
    real-money sessions still involve wallet operations. Use keyless demo
    sessions for non-financial testing; see https://hub.aggregator.gg/environments/.

    **Versioning**: path version (`/v1/`) in the URL. See
    https://hub.aggregator.gg/getting-started/.

    **Idempotency**: POST endpoints touching financial state require an
    `Idempotency-Key` header. Reuse the same key and request body when retrying
    one logical operation. `/sessions` uses a 24-hour completed-response cache.
    `/free-rounds` replays its durable grant result for the same request hash
    and returns 409 if the key is reused with a different request.

    This spec is the source of truth. Contract tests check that operations
    declared here have registered routes; request-validation tests cover schema
    handling.
  contact:
    name: Aggregator API support
    url: https://hub.aggregator.gg
    email: support@aggregator.gg
  license:
    name: Proprietary
servers:
  - url: https://api.aggregator.gg/v1
    description: Production

security:
  - bearerAuth: []

paths:
  /admin/callback-incoming:
    get:
      summary: List internal incoming callback cases
      operationId: listIncomingCallbackCases
      tags: [Internal operations]
      x-internal: true
      description: |
        Reserved admin-runtime API key required; casino/operator/provider keys are forbidden.
        Metadata only, with descending (last_seen_at, id) keyset pagination. All responses
        use Cache-Control no-store. Provider transaction references are unverified claims;
        a case is not a financial transaction or authorization to replay money.
      parameters:
        - {name: status, in: query, schema: {type: string, enum: [open, claimed, closed]}}
        - {name: provider, in: query, schema: {type: string, enum: [truelabs]}}
        - {name: environment, in: query, schema: {type: string, enum: [live, test, unknown]}}
        - {name: transaction_ref, in: query, schema: {type: string, maxLength: 512}}
        - {name: limit, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 50}}
        - {name: cursor, in: query, schema: {type: string, maxLength: 256}, description: Opaque next_cursor from a prior page.}
      responses:
        '200': {description: 'Metadata items and nullable next_cursor; no encrypted or raw evidence.'}
        '400': {description: Invalid filter or cursor.}
        '401': {description: Missing or invalid API key.}
        '403': {description: Caller is not admin-runtime.}
        '503': {description: Queue unavailable; fixed error text.}
  /admin/callback-incoming/{case_id}:
    parameters:
      - {name: case_id, in: path, required: true, schema: {type: string, format: uuid}}
    get:
      summary: Read internal case and paginated audit metadata
      operationId: getIncomingCallbackCase
      tags: [Internal operations]
      x-internal: true
      description: |
        Admin-runtime only; no-store. Returns case, evidence and actions, with each collection
        containing items and next_cursor. No ciphertext, nonce, AAD, signature, raw body or
        internal action receipt is returned. Evidence/actions pages are independent and do
        not assert a cross-page snapshot; mutations require expected_version.
      parameters:
        - {name: limit, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 50}}
        - {name: evidence_cursor, in: query, schema: {type: string, maxLength: 256}}
        - {name: actions_cursor, in: query, schema: {type: string, maxLength: 256}}
      responses:
        '200': {description: Safe case and audit metadata.}
        '400': {description: Invalid UUID or pagination.}
        '401': {description: Missing or invalid API key.}
        '403': {description: Caller is not admin-runtime.}
        '404': {description: Case absent.}
        '503': {description: Queue unavailable.}
  /admin/callback-incoming/{case_id}/claim:
    parameters:
      - {name: case_id, in: path, required: true, schema: {type: string, format: uuid}}
    post:
      summary: Claim or reassign an incoming case
      operationId: claimIncomingCallbackCase
      tags: [Internal operations]
      x-internal: true
      description: |
        Admin-runtime only; JSON follows x-request-schema. Unknown/duplicate keys are rejected.
        Authentication precedes strict handler validation. This internal operation deliberately
        omits executable requestBody validation so the generic pre-auth validator cannot echo
        sensitive submitted values. x-request-schema is documentation, not a validation bypass.
        owner_ref and optional actor_ref are service-attested labels; the calling service must
        authenticate and authorize its human. Core derives machine_actor from the API identity.
        The command UUID is idempotent only for identical input. Stale version, changed command
        or closed case conflicts. Atomic audit and ownership update; no financial operation.
        String limits are UTF-8 bytes, not characters; identifiers/notes exclude controls.
        Every response uses Cache-Control no-store.
      x-request-schema: {$ref: '#/components/schemas/IncomingCaseClaimCommand'}
      responses:
        '200': {description: Confirmed atomic claim receipt.}
        '400': {description: Invalid JSON command.}
        '401': {description: Missing or invalid API key.}
        '403': {description: Caller is not admin-runtime.}
        '404': {description: Case absent.}
        '409': {description: Version, command identity or transition conflict.}
        '503': {description: Action unconfirmed; retry the same command UUID and exact input.}
  /admin/callback-incoming/{case_id}/close:
    parameters:
      - {name: case_id, in: path, required: true, schema: {type: string, format: uuid}}
    post:
      summary: Record an externally evidenced case disposition
      operationId: closeIncomingCallbackCase
      tags: [Internal operations]
      x-internal: true
      description: |
        Admin-runtime only; JSON follows x-request-schema and is validated after authentication,
        as for claim. Requires claimed case, matching owner_ref, current expected_version and
        latest reviewed_evidence_id. This is a service-attested operational disposition, not
        proof that Core independently verified an external ledger, and never permission to replay.
        duplicate_case requires a different already-closed case in the same credential scope,
        resolved_outside_queue or no_payment_due; chains are rejected. Other dispositions require
        a non-case reference. No free-text-only resolution. Atomic audit; all responses no-store.
      x-request-schema: {$ref: '#/components/schemas/IncomingCaseCloseCommand'}
      responses:
        '200': {description: Confirmed atomic close receipt.}
        '400': {description: Invalid JSON command or reference shape.}
        '401': {description: Missing or invalid API key.}
        '403': {description: Caller is not admin-runtime.}
        '404': {description: Case absent.}
        '409': {description: Version, command, ownership, evidence or disposition conflict.}
        '503': {description: Action unconfirmed; retry identical command.}
  /admin/callback-incoming/{case_id}/evidence:
    parameters:
      - {name: case_id, in: path, required: true, schema: {type: string, format: uuid}}
    post:
      summary: Audit and read exact incoming callback evidence
      operationId: readIncomingCallbackEvidence
      tags: [Internal operations]
      x-internal: true
      description: |
        Admin-runtime only; JSON follows x-request-schema and is validated after authentication.
        Requires claimed/closed case, matching owner_ref and current expected_version. The audit
        commits before decryption. Replay of this command revalidates current authority.
        Returns receipt plus evidence containing version, body_base64, signature and
        signature_header. Raw evidence is sensitive and may contain unverified provider claims.
        All responses no-store; admin routes are excluded from request/response-body capture.
        Failed audit or decryption never falls back to an unaudited read. No wallet operation.
      x-request-schema: {$ref: '#/components/schemas/IncomingCaseEvidenceCommand'}
      responses:
        '200': {description: Audited exact evidence envelope and receipt.}
        '400': {description: Invalid JSON command.}
        '401': {description: Missing or invalid API key.}
        '403': {description: Caller is not admin-runtime.}
        '404': {description: Case or evidence absent.}
        '409': {description: Stale version, ownership or command conflict.}
        '503': {description: Unconfirmed audit or unreadable evidence; fixed error text.}
  /games:
    get:
      summary: List games
      description: |
        List available games with optional filtering, search, and pagination.
        Candidate storage pages are read completely before operator filtering and
        response pagination. An incomplete or unavailable catalog read returns
        DATABASE_ERROR (503) for any provider; partial results are not returned.
        Results are scoped to the games enabled for the calling operator's
        configured providers and selections.

        **Platform accounts must send `sub_operator_ref`** - game and provider
        enablement is per brand, so there is no default storefront to render.

        Mobule requires current support approval for the exact organization bound
        to the authenticated operator key. Unbound keys cannot infer an organization.
        For a platform key, the approval of the platform's organization covers
        its brands; a suspended or retired brand is refused.
        Discovery omits unapproved games (direct game/stakes lookup returns 404).
        Launches and launcher re-reads return `PROVIDER_ACCESS_REQUIRED` (403) on
        denial; storage uncertainty returns `PROVIDER_ACCESS_UNAVAILABLE` (503).
        Requests for other providers keep their existing access policy.
      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`, `ebaka`).
        - 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:
        '503':
          description: Catalog read incomplete or unavailable (DATABASE_ERROR), or provider access storage unavailable (PROVIDER_ACCESS_UNAVAILABLE).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '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.

        Mobule requires current support approval for the exact organization bound
        to the authenticated operator key. Unbound keys cannot infer an organization.
        A platform key takes no `sub_operator_ref` here and is evaluated as the
        platform account, admitted by its organization's approval.
        Discovery omits unapproved games (direct game/stakes lookup returns 404).
        Launches and launcher re-reads return `PROVIDER_ACCESS_REQUIRED` (403) on
        denial; storage uncertainty returns `PROVIDER_ACCESS_UNAVAILABLE` (503).
        Requests for other providers keep their existing access policy.
      tags: [Games]
      operationId: getGame
      parameters:
        - name: game_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '503':
          description: Provider access or session authorization storage is temporarily unavailable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '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 valid-stake list. Each entry carries an
          `amount` (and a `coin_level` when `stake_field` is `coin_level`); the
          operator picks one and sends it via `stake_field`. (TrueLabs always;
          Apparat for a currency we hold a bet-step ladder for, e.g. EUR.)
        - `provider_validated` - we have no list; submit a `bet_amount` and the
          provider validates it at issue time. `stakes` is empty. (Apparat for a
          currency with no ladder on file.)
        - `unsupported` - the provider exposes no free-round stake model.

        Mobule answers `unsupported` with `stake_field: null`: we hold no Mobule
        stake ladder and the provider does not validate the stake at issue time.
        Issue with `bet_amount` plus `provider_params.betlevel`/`rate` as described
        under `POST /v1/free-rounds`.

        Mobule requires current support approval for the exact organization bound
        to the authenticated operator key. Unbound keys cannot infer an organization.
        A platform key takes no `sub_operator_ref` here and is evaluated as the
        platform account, admitted by its organization's approval.
        Discovery omits unapproved games (direct game/stakes lookup returns 404).
        Launches and launcher re-reads return `PROVIDER_ACCESS_REQUIRED` (403) on
        denial; storage uncertainty returns `PROVIDER_ACCESS_UNAVAILABLE` (503).
        Requests for other providers keep their existing access policy.
      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:
        '503':
          description: Provider access or session authorization storage is temporarily unavailable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '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 effective
        `supported_currencies`. Use this to learn provider-level currency support
        before fetching games, so you can filter your storefront and avoid
        launch-time dead-ends for player wallet currencies a provider cannot
        settle. Game-level support (`GET /v1/games`) narrows this when a game
        declares its own list.
        Mobule is omitted unless support has approved access for the exact
        organization bound to the caller's operator key. The verified internal
        BFF onboarding registry reader retains its operational capability.
        Authorization storage uncertainty returns `PROVIDER_ACCESS_UNAVAILABLE` (503).
      tags: [Providers]
      operationId: listProviderRegistry
      parameters:
        - name: provider_code
          in: query
          required: false
          description: |
            Return only this provider's entry (case-insensitive). The list is
            empty when the provider is not registered or not visible to the
            caller.
          schema: { type: string }
      responses:
        '503':
          description: Provider access authorization storage is temporarily unavailable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '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 expires in 20 seconds -
        create the session immediately before redirecting.

        Requires `Idempotency-Key` header. Retries with the same key within
        24 hours return the cached response, replayed with its original
        status and an `X-Idempotent-Replayed: true` header. This endpoint
        does not compare request bodies: a key reused with a different body
        replays the original response rather than returning 409, except for
        Mobule: a cached launcher is reauthorized against the persisted session's
        operator, organization, environment, normalized game/player and current
        support approval. Platform keys must retry with the same
        `sub_operator_ref`, or omit it only if the launch omitted it; a different
        or unregistered ref returns 403 and is never provisioned on a replay. A
        conflicting or revoked Mobule retry returns 403; authorization storage
        uncertainty returns 503. Every successful replay reads session storage, so
        missing/unavailable sessions can return 503 for any provider. A new Mobule
        access denial releases the reservation, permitting the same key to be
        retried after approval.

        Mobule requires current support approval for the exact organization bound
        to the authenticated operator key. Unbound keys cannot infer an organization.
        For a platform key, the approval of the platform's organization covers
        its brands; a suspended or retired brand is refused.
        Discovery omits unapproved games (direct game/stakes lookup returns 404).
        Launches and launcher re-reads return `PROVIDER_ACCESS_REQUIRED` (403) on
        denial; storage uncertainty returns `PROVIDER_ACCESS_UNAVAILABLE` (503).
        Requests for other providers keep their existing access policy.
      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, missing/invalid `Idempotency-Key` header,
            unusable player context (`E3009`), missing player context required
            by the provider (`E3010`), or `unsupported_currency`.

            Nested `Error` (`error.code` matching `^E[0-9A-F][0-9]{3}$`) is the
            player-context and generic-validation shape. Currency refusals
            (`unsupported_currency`) are a flat `{error: string, code: string}`
            body, not the nested object.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '402':
          description: |
            Credit limit exhausted (`E4001`): the organization's credit
            balance is at or below its overdraft floor (`-credit_limit`).
            Top up credits to resume launches. Only live sessions are gated;
            in-flight sessions keep operating.

            Note on the code: the error reference lists `E4001` as
            "insufficient balance" in the player-transaction sense. On this
            endpoint it is raised by the organization-level credit gate; the
            player's wallet balance is not involved. Branch on the HTTP
            status (402), not on the code alone.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: |
            Operator not configured for this provider, game not allowed for
            operator, jurisdiction blocked, player self-excluded, or
            `provider_currency_unsupported`, or `PROVIDER_ACCESS_REQUIRED` for
            absent/revoked Mobule approval or a conflicting replay context.

            `provider_currency_unsupported` is a flat `{error: string, code: string}`
            body, not the nested `Error` object used for coded `E*` refusals.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Game not found.
          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 or authorization storage temporarily unavailable (`PROVIDER_ACCESS_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 sandbox testing or
        prospect-facing free-play surfaces. Demo sessions are non-financial
        and do not require an `Idempotency-Key` header.

        Mobule requires current support approval for the exact organization bound
        to the authenticated operator key. Unbound keys cannot infer an organization.
        A platform key takes no `sub_operator_ref` here and is evaluated as the
        platform account, admitted by its organization's approval.
        Discovery omits unapproved games (direct game/stakes lookup returns 404).
        Launches and launcher re-reads return `PROVIDER_ACCESS_REQUIRED` (403) on
        denial; storage uncertainty returns `PROVIDER_ACCESS_UNAVAILABLE` (503).
        Requests for other providers keep their existing access policy.
      tags: [Sessions]
      operationId: createDemoSession
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [game_id]
              properties:
                game_id:
                  type: string
                  format: uuid
      responses:
        '503':
          description: Provider access or session authorization storage is temporarily unavailable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '201':
          description: Demo session created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionResponse'
        '403':
          description: |
            The game's provider is not available to operators - it has not
            completed go-live, or the platform suspended it (`E1003`). Mobule
            additionally requires current approval (`PROVIDER_ACCESS_REQUIRED`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '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.

        **Platform accounts must send the same `sub_operator_ref` they
        launched with** - the session belongs to that brand, not to the
        platform identity, so reading it back without the selector resolves
        your default brand and answers `404`.

        Mobule requires current support approval for the exact organization bound
        to the authenticated operator key. Unbound keys cannot infer an organization.
        For a platform key, the approval of the platform's organization covers
        its brands; a suspended or retired brand is refused.
        Discovery omits unapproved games (direct game/stakes lookup returns 404).
        Launches and launcher re-reads return `PROVIDER_ACCESS_REQUIRED` (403) on
        denial; storage uncertainty returns `PROVIDER_ACCESS_UNAVAILABLE` (503).
        Requests for other providers keep their existing access policy.
      tags: [Sessions]
      operationId: getSession
      parameters:
        - name: session_id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: sub_operator_ref
          in: query
          schema: { type: string, example: brand-01 }
          description: |
            **Platform accounts only.** The brand that owns this session -
            the same ref you sent on `POST /v1/sessions`. Omitting it
            resolves your platform's **default brand** (`404` unless the
            session belongs to that brand); sending it from a non-platform
            key is a `400` (`E6006`).

            Same format and error codes as `sub_operator_ref` on
            `POST /v1/sessions`.
      responses:
        '403':
          description: Mobule access is not approved for the authenticated operator organization or session context.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: Provider access or session authorization storage is temporarily unavailable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '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.
                display_name:
                  # 3.1 union rather than `nullable:`, and NO `maxLength` - this
                  # document is enforced at runtime by
                  # `openapi_request_validator`, so anything declared here is
                  # rejected BEFORE the handler. Both would contradict the
                  # documented behaviour below: `null` is how a caller clears a
                  # label, and an overlong one is truncated rather than refused.
                  # A schema that preempts the handler turns two documented
                  # outcomes into `E1001`.
                  type: [string, "null"]
                  example: Lucky Spin
                  description: |
                    Optional human label for the brand - what it is called in
                    your cabinet and in our admin views. It never identifies the
                    brand: `sub_operator_ref` does that, on every request.

                    **Omitting the field leaves any existing name alone**, so
                    re-running your brand list on deploy does not erase labels
                    someone set by hand. Sending it empty (or `null`) clears the
                    name. Longer than 120 characters is truncated, not refused -
                    a label is not worth failing a brand's creation over.
      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.
                  display_name:
                    type: string
                    nullable: true
                    example: Lucky Spin
                    description: |
                      Present only when the request carried `display_name` -
                      it reports what was stored, so a truncated or cleared
                      label is visible without a second call.
                  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 a free-round grant
      description: |
        Issue free rounds (free spins / free tickets) to a player on one or more
        games (ADR-048). `count` is **per game** - `games:[A,B]` + `count:10`
        issues 10 rounds on each (20 total); the response itemises the provider
        ref per game. Not tied to a session, but each game must pass the same
        compliance + capability gate as a launch.

        Requires the **`write`** API-key scope (the standard operator key scope;
        the granular **`free_rounds.write`** is also accepted for bonus-only keys)
        and an `Idempotency-Key` header (financial idempotency - same key + same
        body replays the cached result; same key + different body → 409). Off by
        default per provider until onboarded.
        Mobule issuance, including a previously issued grant replay, requires
        current support approval for the exact organization bound to the operator
        key. For a platform key, the approval of the platform's organization covers
        its brands; a suspended or retired brand is refused.
        Denial returns `PROVIDER_ACCESS_REQUIRED` (403) before provider I/O;
        authorization uncertainty returns `PROVIDER_ACCESS_UNAVAILABLE` (503).
        Existing free-round history and cancellation remain available after revoke.

        **Mobule free rounds are pull-activated.** Issuing makes no provider call:
        we mint one `freerounds_id` per game (32 lowercase hex, returned as that
        game's `provider_grant_ref`), the player's next `POST /v1/sessions` launch
        of the game carries it to Mobule, and Mobule's server then activates the
        rounds (all rounds of that game are accounted at activation) and, once the
        player has played them, completes them. The win is credited once, as a
        `freespin` transaction linked to `free_round_grant_id`, and reaches your
        wallet as a WIN with `is_free: true` and `free_round_grant_id` set; wins
        are not credited during play. A Mobule grant requires `bet_amount` plus
        integer `provider_params.betlevel` and `provider_params.rate` (both at
        least 1) and rejects `coin_level` (400 `invalid_stake` alongside
        `bet_amount`, `bet_amount_required` without it); a missing or
        non-integer `betlevel` or `rate` is 400 `provider_params_required`. The
        two integers are passed to Mobule as supplied, never derived from
        `bet_amount`. A game can be cancelled until
        Mobule activates its rounds, which follows the player's launch (see
        `POST /v1/free-rounds/{grant_id}/cancel`). The win of rounds Mobule
        activated is credited even when it arrives after `end_at`, after the
        grant expired or after a cancel. Off until enabled for Mobule in the
        environment (403 `free_rounds_disabled`). The same switch governs the
        launch: while free rounds are disabled for the environment, a launch
        carries no `freerounds_id` and is ordinary paid play, even for a grant
        issued earlier; rounds already carried to Mobule still settle.
      tags: [FreeRounds]
      operationId: issueFreeRounds
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/IssueFreeRoundsRequest'
      responses:
        '503':
          description: Provider access authorization storage is temporarily unavailable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '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: Grant 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 (Mobule also `bet_amount_required` / `provider_params_required`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: Scope missing, provider disabled, jurisdiction blocked, or player self-excluded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Game does not support free rounds, abuse limit exceeded, or bet outside limits.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '502':
          description: Provider rejected the issuance.
          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 the operator's free-round grants (our records), newest first.

        **Platform accounts must send `sub_operator_ref`** and get that brand's
        grants. A platform-wide view across all brands is not available yet.
      tags: [FreeRounds]
      operationId: listFreeRounds
      parameters:
        - name: sub_operator_ref
          in: query
          schema: { type: string, example: brand-01 }
          description: |
            **Platform accounts only.** Which brand's grants to list - grants
            are keyed on the brand's identity. Omitting it lists your **default
            brand's** grants; sending it from a non-platform key is a 400
            (`E6006`); with no default brand provisioned the call is a 409
            (`E6010`), and under `require_sub_operator_ref` omitting it is a 400
            (`E6005`).
            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.
            List one brand at a time; a platform-wide aggregate is not
            available yet.
        - name: player_id
          in: query
          schema: { type: string, maxLength: 128 }
        - name: status
          in: query
          schema:
            type: string
            enum: [pending_issue, issue_failed, active, partially_consumed, consumed, pending_cancel, cancel_failed, cancelled, expired, observed]
        - 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 rounds at the provider. Already-played
        rounds are not reversed. Requires the `write` scope (or the granular
        `free_rounds.write`).

        **Mobule:** there is no provider cancel call, and the cut-off is Mobule's
        activation of a game's `freerounds_id`, not the player's launch.
        Cancelling marks every game Mobule has not activated yet `cancelled` on
        our side, a game the player has already launched included (its
        activation is then refused and the player plays without the free
        rounds), and no later launch attaches it. A game Mobule has already
        activated is not reversed: it is reported per game as `consumed` (its
        rounds are in play and their win reaches your wallet, even after the
        cancel), never as cancelled. A game whose activation Mobule itself
        cancelled is reported `cancelled`: Mobule does not run those rounds, so
        issue a new grant to replace them. A game whose win was already
        credited stays `consumed` even if Mobule cancels its activation
        afterwards. Once the grant is issued, whatever its status, when nothing
        is left to cancel the answer is 409 `not_cancellable` with
        `grant_status` and the per-game `games`. While the grant is still being
        issued the answer is 409 `issuance_in_progress` with `grant_status:
        pending_issue` and the per-game `games`, and nothing changed: retry the
        cancel once the grant is `active`. If the issuance stopped after
        creating the grant, the grant stays `pending_issue`: a replay of the
        issue request keeps answering 409 `idempotency_in_progress`, this cancel
        keeps answering 409 `issuance_in_progress`, no launch carries its
        rounds, and the expiry sweep (every 30 minutes) closes it as `expired`
        after its `end_at`. Issue a new grant under a new `Idempotency-Key` once
        it has closed: while it is `pending_issue` an issuance that is merely
        slow can still activate it, and both grants would pay.

        This endpoint's own refusals are the flat `FreeRoundError` body
        (`error` is the code, `status` repeats the HTTP status). The brand
        selector of a platform account (`sub_operator_ref`, sent or omitted)
        refuses with the nested `Error` object instead (the `E60xx` codes
        below), and a key without the `write` or `free_rounds.write` scope gets
        a flat `insufficient_scope` body without `status`.

        **Platform accounts must send `sub_operator_ref`** - the same brand the
        grant was issued to.
      tags: [FreeRounds]
      operationId: cancelFreeRound
      parameters:
        - name: grant_id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: sub_operator_ref
          in: query
          schema: { type: string, example: brand-01 }
          description: |
            **Platform accounts only, and required for them.** The brand the
            grant was issued to - grants are keyed on the brand's identity, so a
            platform must name the same brand it issued to. Omitting it targets
            your **default brand**, which means a grant issued under a named
            brand will simply 404: send the ref you issued with. Sending it from
            a non-platform key is a 400 (`E6006`); with no default brand
            provisioned the call is a 409 (`E6010`), and under
            `require_sub_operator_ref` omitting it is a 400 (`E6005`).

            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.
      responses:
        '200':
          description: Grant cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FreeRoundCancelResponse' }
        '400':
          description: >-
            The brand selector refused (nested `Error`): `sub_operator_ref` sent
            empty or malformed (`E6004`), omitted under
            `require_sub_operator_ref` (`E6005`), sent from a non-platform key
            (`E6006`), or naming an unknown brand while auto-provisioning is off
            (`E6008`). A blank `grant_id` is the flat `FreeRoundError`
            (`grant_id required`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/Error'
                  - $ref: '#/components/schemas/FreeRoundError'
        '403':
          description: >-
            The key has neither the `write` nor the `free_rounds.write` scope
            (`insufficient_scope`, a flat body without `status`), or the brand
            selector refused (nested `Error`): the platform is at its brand cap
            (`E6007`) or the named brand is not active (`E6008`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/Error'
                  - type: object
                    required: [error, message]
                    properties:
                      error: { type: string, example: insufficient_scope }
                      message: { type: string }
        '404':
          description: Grant not found (`grant_not_found`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FreeRoundError' }
        '409':
          description: >-
            Not cancellable: the grant is in a terminal state (`not_cancellable`,
            with `grant_status`). For Mobule also whenever no game is left to
            cancel, whatever the issued grant's status (`not_cancellable`, with
            `grant_status` and the per-game `games`), and while the grant is
            still being issued (`issuance_in_progress`, with `grant_status` and
            `games`; nothing changed: retry once the grant is `active`, and if
            its issuance stopped, issue a new grant under a new
            `Idempotency-Key` once it has closed as `expired`). These are the
            flat `FreeRoundError`. A platform
            account's brand selector answers the nested `Error` instead: no
            default brand, or an inactive one, with `sub_operator_ref` omitted
            (`E6010`); the platform owns no organization yet, with a ref sent
            (`E6009`); or no identity could be allocated for a new brand
            (`E6007`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FreeRoundError'
                  - $ref: '#/components/schemas/Error'
        '502':
          description: >-
            The provider rejected the cancellation (`provider_error`, a
            `FreeRoundError`), or cancelled only some games (a
            `FreeRoundCancelResponse` with `status: cancel_failed` and the
            per-game `games`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FreeRoundError'
                  - $ref: '#/components/schemas/FreeRoundCancelResponse'
        '503':
          description: >-
            The provider configuration or the cancel transition is temporarily
            unavailable (`provider_unavailable`, a `FreeRoundError`); retry. A
            platform account's brand selector can also answer the nested
            `Error` with `sub_operator_ref` sent: the account's type could not
            be read (`E6006`), or its brand count could not be read while
            provisioning a new brand (`E6007`); retry.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FreeRoundError'
                  - $ref: '#/components/schemas/Error'
        '401': { $ref: '#/components/responses/Unauthorized' }

  /health:
    servers:
      - url: https://api.aggregator.gg
        description: Probes are served at the API root, not under the /v1 prefix.
    get:
      summary: Liveness probe
      description: |
        Returns 200 if the API process is up.

        Served at the API root: `https://api.aggregator.gg/health`. The
        `/v1` base URL of the business endpoints does not apply here;
        requesting this probe under the version prefix is a 404.
      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:
    servers:
      - url: https://api.aggregator.gg
        description: Probes are served at the API root, not under the /v1 prefix.
    get:
      summary: Readiness probe
      description: |
        Returns 200 if the API can serve traffic (DB reachable, etc.).

        Served at the API root: `https://api.aggregator.gg/ready`. The
        `/v1` base URL of the business endpoints does not apply here;
        requesting this probe under the version prefix is a 404.
      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: agg_* (legacy sk_live_* and sk_test_* accepted)

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema:
        type: string
        minLength: 1
        maxLength: 256
      description: |
        Unique key for idempotent retry semantics. A retry with the same key
        and the same body returns the cached response.

        Reuse with a DIFFERENT body is endpoint-specific, because the two
        implementations differ. POST /v1/free-rounds hashes the request body
        (src/platform_api/free_rounds_flows.py, _request_hash) and answers a
        mismatch with 409 idempotency_conflict. POST /v1/sessions uses the
        Redis guard (src/infra/idempotency.py), which does not compare
        bodies: the cached response is replayed and the new body is not
        processed, except for a cached Mobule launch, whose retry body (game,
        player and a platform's `sub_operator_ref`) is re-read to reauthorize
        it: a mismatch answers 403 and storage uncertainty 503 (see
        POST /v1/sessions). Never reuse a key across logically different
        operations.

        A request arriving while the same key is still in flight returns
        409 idempotency_in_progress. Over the length limit returns
        400 invalid_idempotency_key; missing entirely returns
        400 missing_idempotency_key.

  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 conflict. Two causes, distinguished by the `error`
        field: `idempotency_in_progress` when a request with the same key is
        still in flight (retry shortly), and `idempotency_conflict` when the
        key was already used with a different request body. Only
        POST /v1/free-rounds detects the second case; POST /v1/sessions does
        not compare bodies and replays its cached response instead, after
        reauthorizing a cached Mobule launch (see POST /v1/sessions).
        POST /v1/free-rounds answers with the flat `FreeRoundError` body;
        POST /v1/sessions with a flat `{error, message}` body without
        `status`, and a `Retry-After` header. A platform's brand selector
        answers the nested `Error` object on either endpoint.

        On POST /v1/free-rounds for Mobule, whose issuance makes no provider
        call, an `idempotency_in_progress` that persists means the issuance
        stopped after creating the grant: its replay never gets a result, and
        the grant stays `pending_issue`, never reaches a launch and is closed
        as `expired` by the expiry sweep (every 30 minutes) after its
        `end_at`. Issue a new grant under a new key once it has closed (see
        `POST /v1/free-rounds/{grant_id}/cancel`).
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds to wait before retrying; sent by POST /v1/sessions only.
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/FreeRoundError'
              - $ref: '#/components/schemas/Error'
              - type: object
                description: The Idempotency-Key guard of POST /v1/sessions.
                required: [error, message]
                properties:
                  error: { type: string, example: idempotency_in_progress }
                  message: { type: string }
                not: { required: [status] }

  schemas:
    IncomingCaseClaimCommand:
      type: object
      additionalProperties: false
      required: [command_id, expected_version, owner_ref, note]
      properties:
        command_id: {type: string, format: uuid}
        expected_version: {type: integer, minimum: 1, maximum: 9223372036854775807}
        owner_ref: {type: string, minLength: 1, maxLength: 256}
        actor_ref: {type: string, nullable: true, minLength: 1, maxLength: 256}
        note: {type: string, minLength: 1, maxLength: 2000}
    IncomingCaseCloseCommand:
      type: object
      additionalProperties: false
      required: [command_id, expected_version, owner_ref, note, disposition, resolution_ref, reviewed_evidence_id]
      properties:
        command_id: {type: string, format: uuid}
        expected_version: {type: integer, minimum: 1, maximum: 9223372036854775807}
        owner_ref: {type: string, minLength: 1, maxLength: 256}
        actor_ref: {type: string, nullable: true, minLength: 1, maxLength: 256}
        note: {type: string, minLength: 1, maxLength: 2000}
        reviewed_evidence_id: {type: string, format: uuid}
        disposition: {type: string, enum: [resolved_outside_queue, no_payment_due, duplicate_case]}
        resolution_ref:
          type: object
          additionalProperties: false
          required: [kind, id]
          properties:
            kind: {type: string, enum: [operator_ledger, provider_confirmation, reconciliation_record, case]}
            id: {type: string, minLength: 1, maxLength: 512}
    IncomingCaseEvidenceCommand:
      type: object
      additionalProperties: false
      required: [command_id, expected_version, owner_ref, note, evidence_id]
      properties:
        command_id: {type: string, format: uuid}
        expected_version: {type: integer, minimum: 1, maximum: 9223372036854775807}
        owner_ref: {type: string, minLength: 1, maxLength: 256}
        actor_ref: {type: string, nullable: true, minLength: 1, maxLength: 256}
        note: {type: string, minLength: 1, maxLength: 2000}
        evidence_id: {type: string, format: uuid}
    Game:
      type: object
      required: [id, provider_code, provider_game_id, name, category, has_demo]
      properties:
        id:
          type: string
          format: uuid
        provider_game_id:
          type: string
        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. `true` means you
            can issue free rounds on it via `POST /v1/free-rounds`; a `false` game
            is rejected at grant time. Filter your free-round campaigns on this.
            For Mobule it mirrors the provider's `is_freerounds_enabled` flag as of
            the last catalogue sync.
        blocked_countries:
          type: array
          items:
            type: string
            pattern: '^[A-Z]{2}$'
        certified_markets:
          type: object
          nullable: true
          description: |
            Informational allow-list of regulated markets the game is certified
            for, as declared by the provider. This is NOT a launch gate - it is a
            licensing hint so you can filter the catalog by what your licence
            permits. `null` when the provider declares no market data. (The
            launch gate is `blocked_countries`, enforced against the
            operator-declared player country.)
          properties:
            regulated:
              type: array
              description: ISO-3166-1 alpha-2 codes of 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.
            low_barrier:
              type: array
              description: ISO 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
            raw:
              type: string
              description: >
                The original provider 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
            (`provider_configs.supported_currencies`). An empty array means no
            declared restriction is known. Filter the catalog with the `currency`
            query parameter, and 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
          format: float
          minimum: 0
          nullable: true
          description: |
            Minimum stake per spin, quoted in `bet_limits_currency` exactly as
            the provider declared it - the platform never converts limits
            between currencies. `null` when the provider has not declared a
            limit.
          example: 0.10
        max_bet:
          type: number
          format: float
          minimum: 0
          nullable: true
          description: |
            Maximum stake per spin, quoted in `bet_limits_currency`. `null`
            when undeclared.
          example: 100.00
        default_bet:
          type: number
          format: float
          minimum: 0
          nullable: true
          description: |
            Suggested default stake the operator may pre-select, quoted in
            `bet_limits_currency`. `null` when undeclared.
          example: 1.00
        bet_limits_currency:
          type: string
          pattern: '^[A-Z]{3,8}$'
          nullable: true
          description: |
            The currency `min_bet`/`max_bet`/`default_bet` are quoted in
            (canonical upper-case code). `null` exactly when no bet value is
            declared. Values are NOT converted to the player's wallet currency.
          example: EUR
        max_win_multiplier:
          type: number
          format: float
          minimum: 0
          nullable: true
          description: |
            Maximum win expressed as a multiple of the stake
            (currency-agnostic). `null` when the provider has not declared a
            cap.
          example: 5000.00
        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: >-
            `enumerated` - `stakes` lists the valid stakes; pick one and send it
            via `stake_field`. `provider_validated` - `stakes` is empty, send a
            `bet_amount` the provider validates at issue time. `unsupported` -
            the provider has no free-round stake model.
        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]
            properties:
              provider_code: { type: string, example: truelabs }
              is_registered:
                type: boolean
                description: Whether a provider adapter is registered in the platform runtime.
              supported_currencies:
                type: array
                description: |
                  ISO-4217 currencies the provider can settle (canonical
                  `provider_configs.supported_currencies`). Empty means no declared
                  restriction is known, including when the provider's
                  configuration could not be read for this response.
                items:
                  type: string
                  pattern: '^[A-Z]{3}$'
                example: [USD, EUR]
        total_count: { type: integer }

    CreateSessionRequest:
      type: object
      required: [game_id, player_id, balance, currency, country]
      properties:
        game_id:
          type: string
          format: uuid
        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,8}$'
          description: |
            ISO-4217 alpha-3 fiat code, or a crypto ticker up to 8 chars
            (e.g. BTC, ETH, USDT). The authoritative allowlist is the platform
            currency registry plus the per-provider/launch gates (see the
            `supported_currencies` on GET /providers/registry), not a static enum -
            so this constrains the format only. Crypto requires the launch gate
            to be enabled.
        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. The host
            must be a public domain name or a globally routable IP address;
            the Aggregator does not resolve or fetch it.
        deposit_url:
          type: string
          format: uri
          maxLength: 2048
          description: |
            HTTPS entry URL for the operator's cashier, used by providers when
            the player selects an in-game deposit or top-up action. Point this
            at a real cashier destination, not a game launch or return route.
            If omitted, it falls back to the effective `return_url`
            (including the Aggregator game-complete fallback) for backward
            compatibility. TrueLabs and Apparat currently
            forward it as a distinct cashier destination; other providers ignore
            it. A provider may top-navigate or reload the browser to this URL;
            this field does not invoke an operator-specific deposit modal.
            Host rules are the same as for `return_url`.
        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.
        client_ip:
          # No `type`. This document is enforced by `openapi_request_validator`
          # before the handler. A type would 400 numbers/booleans/arrays that
          # Pydantic `Any` and player_context drop. Usability is E3009/absent
          # inside the handler, including JSON null.
          description: |
            Public IP address of the player's device, as your own front end saw it
            (IPv4 dotted or IPv6 text; an IPv4-mapped IPv6 address is treated as IPv4).
            Up to 45 characters, no zone index (`%eth0`). Private, loopback, link-local,
            carrier-grade-NAT (100.64.0.0/10), documentation, reserved and multicast
            addresses are not player addresses. Recommended on every launch; a
            provider may require it, and then the launch is refused with E3009/E3010.
            Omitted, JSON `null`, or a non-string value is absence or dropped.
        user_agent:
          description: |
            The player's browser `User-Agent` header, with surrounding whitespace
            trimmed. Up to 512 characters after trimming,
            no control characters. Recommended on every launch; a provider may
            require it, same codes as `client_ip`. Omitted, JSON `null`, or a
            non-string value is absence or dropped.
        device_type:
          description: |
            `mobile`, `desktop` or `tablet`, case-insensitive. Never required. When it
            is omitted, a provider that needs a mobile/desktop flag gets one derived
            from `user_agent` or a documented fallback. Omitted, JSON `null`, or a
            non-string value is absence or dropped.

    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
    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
        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 (ADR-048).
            On the **outbound wallet callback** these are delivered as a standard
            `win`/`bet` action carrying `is_free: true` (operator-wallet-v1.1) -
            see the operator Free Rounds integration guide. Mobule's
            `free_round_use` rows (an activation of a game's rounds, or a
            cancel's tombstone; amount 0) have no wallet callback: only a
            positive `freespin` win is sent (a zero win is recorded without
            one).
        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. Mobule free-round
            rows carry our own ids: `freerounds:<id>:complete` is the `freespin`
            win's (the WIN you received), and `freerounds:<id>:activate` marks
            an activation or a cancel's tombstone, neither of which has a
            callback.
        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 }

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              pattern: '^(E[0-9A-F][0-9]{3}|PROVIDER_ACCESS_REQUIRED|PROVIDER_ACCESS_UNAVAILABLE)$'
              description: Machine-parseable error code. See https://hub.aggregator.gg/error-reference/.
              example: E0001
            message:
              type: string
            request_id:
              type: string
              description: Correlation ID for support escalation.
            details:
              type: object
              additionalProperties: true

    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: apparat }
        player_id: { type: string, minLength: 1, maxLength: 128 }
        sub_operator_ref:
          type: string
          description: |
            **Platform accounts only.** Which brand's players receive the grant.
            Same rules and error codes as `sub_operator_ref` on
            `POST /v1/sessions` (required for platform keys, rejected for
            others); 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.
        games:
          type: array
          minItems: 1
          maxItems: 200
          description: Our `games.id` UUIDs. 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]` + `count:10` ⇒ 20 total.
        currency: { type: string, minLength: 3, maxLength: 10, example: EUR }
        country:
          type: string
          minLength: 2
          maxLength: 2
          description: ISO 3166-1 alpha-2 - gated at issue time (ADR-046).
        coin_level:
          type: integer
          minimum: 1
          maximum: 24
          description: TrueLabs stake level. Provide exactly one of `coin_level` / `bet_amount`. Rejected for Mobule (400).
        bet_amount:
          type: number
          exclusiveMinimum: 0
          description: >-
            Stake per round in MAJOR units. Required for Apparat (voucher stake)
            and for Mobule (nominal stake per round, stored on the grant as
            `bet_amount` and internally on its activation and win records,
            never sent to Mobule; Mobule's `betlevel`/`rate` come from
            `provider_params`, never from this amount).
        start_at: { type: string, format: date-time }
        end_at: { type: string, format: date-time }
        campaign_id: { type: string, maxLength: 128 }
        funded_by: { type: string, enum: [operator, provider_promo] }
        free_round_kind: { type: string, enum: [freespin, freeticket, voucher], default: voucher }
        provider_params:
          type: object
          additionalProperties: true
          description: |
            Provider-specific issuance parameters, passed through as supplied.
            **Mobule (required):** `betlevel` (integer, at least 1, Mobule bet level)
            and `rate` (integer, at least 1, Mobule denomination, the coin value).
            Both are returned to Mobule verbatim when it activates the rounds
            (`freerounds.activate`); missing or non-integer values are a 400
            `provider_params_required`. We never derive them from `bet_amount`.

    FreeRoundGameResult:
      type: object
      properties:
        game_id: { type: string, format: uuid }
        provider_game_id: { type: string }
        count: { type: integer }
        rounds_used: { type: integer }
        provider_grant_ref:
          type: string
          nullable: true
          description: >-
            Per-game provider ref: Apparat voucherId; for Mobule the `freerounds_id`
            we minted (32 lowercase hex), carried on the player's game launch and
            activated by Mobule once.
        status:
          type: string
          enum: [pending_issue, issue_failed, active, partially_consumed, consumed, pending_cancel, cancel_failed, cancelled, expired, observed]
        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 }

    FreeRoundError:
      type: object
      description: >-
        The flat error body of the free-rounds endpoints' own refusals (those
        of `POST /v1/free-rounds/{grant_id}/cancel`, and the `idempotency_*`
        409s of `POST /v1/free-rounds`), not the nested `Error`
        object a platform's brand selector answers with: `error` is the
        machine-readable code and `status` repeats the HTTP status.
      required: [error, status]
      properties:
        error:
          type: string
          description: >-
            The code, e.g. `grant_not_found`, `not_cancellable`,
            `issuance_in_progress`, `provider_error`, `provider_unavailable`.
          example: issuance_in_progress
        message: { type: string }
        status: { type: integer, example: 409 }
        grant_id: { type: string, format: uuid }
        grant_status:
          type: string
          description: The grant's status (409 only).
        games:
          type: array
          description: Mobule 409 only - the state of each game of the grant.
          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 }
              games:
                type: array
                items: { $ref: '#/components/schemas/FreeRoundGameResult' }
