openapi: 3.1.0
info:
  title: OneHazel Operator Wallet Contract
  version: 1.0.0
  summary: The seamless-wallet endpoints an operator implements so OneHazel can connect it to game studios.
  description: |
    OneHazel connects game studios to an operator's wallet. Each studio speaks its
    own wallet protocol; OneHazel translates every one of them into this single
    contract. The operator implements the six endpoints below and OneHazel calls
    them. The operator stays the system of record for player balances.

    **Direction.** OneHazel is the client; the operator is the server. Every call
    is an HTTPS `POST` with a JSON body.

    **Authentication.** Every call carries `Authorization: Bearer <integration key>`.
    The operator issues the key and OneHazel stores it encrypted per connection.
    There is no request-body signature on these calls; integrity relies on TLS and
    the bearer key. OneHazel can send these calls from a fixed egress address so
    the operator can allowlist it.

    **Money.** Every amount and balance is an integer number of minor units of the
    player's currency (`1050` means 10.50 in a two-decimal currency). Only
    two-decimal currencies are supported today.

    **Idempotency and replay.** Every mutating call carries an `idempotency_key`.
    Keys are opaque strings, unique across bets, wins and rollbacks, and stable
    across a studio's retries of the same transaction. When the operator receives
    a key it has already processed it must not apply the money movement again: it
    returns the original result with `duplicate: true`. This is also the
    serialisation point when two copies of the same transaction arrive at once.

    **Wallet statuses.** A wallet call returns `status` of `accepted`,
    `insufficient_funds` or `rolled_back`. A replay is signalled by
    `duplicate: true` alongside the original status, not by a fourth status value.
    OneHazel reads the outcome in this order: `duplicate` first, then
    insufficient funds (HTTP 402 or `status: insufficient_funds`), then
    `accepted` or `rolled_back` with an HTTP status below 400. Anything else is
    treated as a failure.
  license:
    name: Proprietary
    identifier: LicenseRef-OneHazel
servers:
  - url: https://{operatorHost}/provider/v1
    description: The operator's own production wallet host. OneHazel appends the paths below.
    variables:
      operatorHost:
        default: wallet.operator.example
        description: Hostname the operator gives OneHazel when the connection is set up.
security:
  - integrationKey: []
tags:
  - name: Session
    description: Launch-token minting, launch-token exchange and balance reads.
  - name: Wallet
    description: Money movement. Every call is idempotent on `idempotency_key`.
paths:
  /session/start:
    post:
      tags: [Session]
      operationId: sessionStart
      summary: Mint a single-use launch token for a player and game
      description: |
        Used where OneHazel starts a game session on the operator's behalf. The
        returned launch token is single use and is later exchanged for a session
        token via `/session/exchange`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionStartRequest'
      responses:
        '200':
          description: Launch token minted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionStartResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /session/exchange:
    post:
      tags: [Session]
      operationId: sessionExchange
      summary: Exchange a launch token for a session token
      description: |
        Consumes a single-use launch token and returns the session token used on
        every later balance and wallet call, plus the player view. The call is
        idempotent on `idempotency_key`, so a studio retrying the same launch
        converges on one exchange.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionExchangeRequest'
      responses:
        '200':
          description: Session established.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionExchangeResponse'
        '401':
          description: |
            The integration key is invalid (`AUTH_INVALID`), or the launch token
            was never issued (`launch_token_unknown`) or has passed its lifetime
            (`launch_token_expired`). OneHazel branches on `error.code`, never on
            the HTTP status alone.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: The launch token was already consumed (`launch_token_consumed`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/ServerError'
  /session/balance:
    post:
      tags: [Session]
      operationId: sessionBalance
      summary: Read the player's current balance
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionBalanceRequest'
      responses:
        '200':
          description: Current balance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BalanceResponse'
        '401':
          $ref: '#/components/responses/SessionInvalid'
        '500':
          $ref: '#/components/responses/ServerError'
  /wallet/bet:
    post:
      tags: [Wallet]
      operationId: walletBet
      summary: Debit a stake
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BetRequest'
      responses:
        '200':
          $ref: '#/components/responses/WalletOk'
        '402':
          $ref: '#/components/responses/InsufficientFunds'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/SessionInvalid'
        '500':
          $ref: '#/components/responses/ServerError'
  /wallet/win:
    post:
      tags: [Wallet]
      operationId: walletWin
      summary: Credit a win
      description: |
        OneHazel never sends a zero-amount win. Zero-value legs (a losing spin's
        empty win, a round-close acknowledgement) are recorded on the OneHazel
        side and acknowledged to the studio without a wallet call, so the
        operator may reject `amount_minor: 0`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WinRequest'
      responses:
        '200':
          $ref: '#/components/responses/WalletOk'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/SessionInvalid'
        '500':
          $ref: '#/components/responses/ServerError'
  /wallet/rollback:
    post:
      tags: [Wallet]
      operationId: walletRollback
      summary: Reverse an earlier bet or win
      description: |
        Reverses the transaction whose idempotency key is `target_idempotency_key`.
        The rollback carries its own `idempotency_key`, distinct from the target's.
        The operator derives the direction from the target: a reversed bet is a
        credit, a reversed win is a debit. Studios often omit the amount on a
        cancel, so OneHazel forwards the original amount in
        `metadata.amount_minor`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RollbackRequest'
      responses:
        '200':
          $ref: '#/components/responses/WalletOk'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/SessionInvalid'
        '500':
          $ref: '#/components/responses/ServerError'
components:
  securitySchemes:
    integrationKey:
      type: http
      scheme: bearer
      description: 'Integration key issued by the operator, sent as `Authorization: Bearer <key>`.'
  schemas:
    MinorUnits:
      type: integer
      format: int64
      description: Integer minor units of the player's currency. Never a decimal.
    Currency:
      type: string
      pattern: '^[A-Z]{3}$'
      description: ISO 4217 alphabetic code, upper case. Two-decimal currencies only today.
    IdempotencyKey:
      type: string
      minLength: 1
      description: |
        Opaque, unique across bets, wins and rollbacks, stable across retries of
        the same studio transaction. Replaying a processed key returns the
        original result with `duplicate: true` and moves no money.
    SessionStartRequest:
      type: object
      required: [idempotency_key, player_external_id, provider_slug, provider_game_code, currency, language, client_type]
      properties:
        idempotency_key:
          $ref: '#/components/schemas/IdempotencyKey'
        player_external_id:
          type: string
          description: The operator's own player id.
        provider_slug:
          type: string
          description: Identifies the studio the session is for.
        provider_game_code:
          type: string
          description: The studio's game code.
        currency:
          $ref: '#/components/schemas/Currency'
        language:
          type: string
          examples: [en]
        client_type:
          type: string
          examples: [DESKTOP]
        return_url:
          type: string
          format: uri
          description: Optional lobby return URL.
    SessionStartResponse:
      type: object
      required: [launch_token]
      properties:
        launch_token:
          type: object
          required: [token]
          properties:
            token:
              type: string
              description: Single-use launch token.
        player:
          $ref: '#/components/schemas/ExchangePlayer'
        balance_minor:
          $ref: '#/components/schemas/MinorUnits'
          description: Older placement of the balance. OneHazel prefers `player.balance_minor`.
    SessionExchangeRequest:
      type: object
      required: [idempotency_key, launch_token]
      properties:
        idempotency_key:
          $ref: '#/components/schemas/IdempotencyKey'
        launch_token:
          type: string
          description: The single-use token from `/session/start` or the operator's own launch flow.
    SessionExchangeResponse:
      type: object
      required: [session, player]
      properties:
        session:
          type: object
          required: [token]
          properties:
            token:
              type: string
              description: Session token sent as `session_token` on every later call.
        player:
          $ref: '#/components/schemas/ExchangePlayer'
    ExchangePlayer:
      type: object
      description: |
        Player view. OneHazel reads every field defensively; `external_id` and
        `currency` are needed to open a session. When `balance_minor` is absent
        OneHazel falls back to one `/session/balance` call.
      properties:
        external_id:
          type: string
        currency:
          $ref: '#/components/schemas/Currency'
        balance_minor:
          $ref: '#/components/schemas/MinorUnits'
        name:
          type: string
        country:
          type: string
    SessionBalanceRequest:
      type: object
      required: [session_token]
      properties:
        session_token:
          type: string
    BalanceResponse:
      type: object
      required: [player_external_id, currency, balance_minor]
      properties:
        player_external_id:
          type: string
        currency:
          $ref: '#/components/schemas/Currency'
        balance_minor:
          $ref: '#/components/schemas/MinorUnits'
    BetRequest:
      type: object
      required: [idempotency_key, session_token, round_external_id, amount_minor, currency, provider_transaction_id]
      properties:
        idempotency_key:
          $ref: '#/components/schemas/IdempotencyKey'
        session_token:
          type: string
        round_external_id:
          type: string
          description: The studio's round id. Where a studio has no round concept, the transaction id is used.
        amount_minor:
          $ref: '#/components/schemas/MinorUnits'
          minimum: 1
          description: Stake in minor units. Free-round stakes are never debited, so no zero-amount bet is sent.
        currency:
          $ref: '#/components/schemas/Currency'
        provider_transaction_id:
          type: string
          description: The studio's own transaction id, for reconciliation.
    WinRequest:
      type: object
      required: [idempotency_key, session_token, round_external_id, amount_minor, currency, provider_transaction_id]
      properties:
        idempotency_key:
          $ref: '#/components/schemas/IdempotencyKey'
        session_token:
          type: string
        round_external_id:
          type: string
        amount_minor:
          $ref: '#/components/schemas/MinorUnits'
          minimum: 1
        currency:
          $ref: '#/components/schemas/Currency'
        provider_transaction_id:
          type: string
        round_closed:
          type: boolean
          description: True when this credit closes the round.
        bet_idempotency_key:
          $ref: '#/components/schemas/IdempotencyKey'
          description: Optional link to the bet this win settles. Sent by one studio adapter today.
    RollbackRequest:
      type: object
      required: [idempotency_key, session_token, target_idempotency_key, provider_transaction_id, metadata]
      properties:
        idempotency_key:
          $ref: '#/components/schemas/IdempotencyKey'
        session_token:
          type: string
        target_idempotency_key:
          $ref: '#/components/schemas/IdempotencyKey'
          description: Key of the bet or win being reversed.
        round_external_id:
          type: string
        provider_transaction_id:
          type: string
        metadata:
          type: object
          required: [amount_minor]
          properties:
            amount_minor:
              $ref: '#/components/schemas/MinorUnits'
              minimum: 1
              description: Amount of the original transaction being reversed.
          additionalProperties: true
    WalletStatus:
      type: string
      enum: [accepted, insufficient_funds, rolled_back]
    WalletResponse:
      type: object
      required: [status, player_external_id, currency, balance_minor]
      properties:
        status:
          $ref: '#/components/schemas/WalletStatus'
        player_external_id:
          type: string
        currency:
          $ref: '#/components/schemas/Currency'
        balance_minor:
          $ref: '#/components/schemas/MinorUnits'
          description: Balance after this call. On a replay, the current balance.
        transaction_external_id:
          type: string
          description: The operator's own id for the ledger entry.
        duplicate:
          type: boolean
          description: True when `idempotency_key` was already processed. No money moved on this call.
    ErrorResponse:
      type: object
      required: [error]
      properties:
        success:
          type: boolean
          const: false
        error:
          type: object
          required: [code]
          properties:
            code:
              type: string
              examples: [AUTH_INVALID, launch_token_unknown, launch_token_expired, launch_token_consumed, session_unknown, session_expired]
            message:
              type: string
  responses:
    WalletOk:
      description: 'Processed, or replayed with `duplicate: true`.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/WalletResponse'
          examples:
            accepted:
              value: {status: accepted, player_external_id: p-1001, currency: EUR, balance_minor: 98950, transaction_external_id: tx-1}
            replay:
              value: {status: accepted, player_external_id: p-1001, currency: EUR, balance_minor: 98950, transaction_external_id: tx-1, duplicate: true}
            rolledBack:
              value: {status: rolled_back, player_external_id: p-1001, currency: EUR, balance_minor: 100000}
    InsufficientFunds:
      description: The stake exceeds the available balance. No money moved.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/WalletResponse'
          examples:
            insufficient:
              value: {status: insufficient_funds, player_external_id: p-1001, currency: EUR, balance_minor: 500}
    BadRequest:
      description: The request is malformed.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Unauthorized:
      description: The integration key is invalid (`AUTH_INVALID`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    SessionInvalid:
      description: |
        The integration key is invalid (`AUTH_INVALID`), or the session token is
        unknown (`session_unknown`) or expired (`session_expired`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    ServerError:
      description: |
        Unexpected failure. OneHazel treats it as a failed call and reports an
        internal error to the studio. A studio retry arrives with the same
        `idempotency_key`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
