openapi: 3.1.0
info:
  title: Plexus Arena Operator API (Outbound)
  version: "1"
  description: |
    This spec describes the HTTP endpoints **the operator must implement**.
    Plexus Arena acts as the HTTP client and calls these endpoints during
    gameplay to authenticate players and move funds. Player-funded rain is
    funded by a normal `/withdraw` call against the giver player's wallet;
    Plexus Arena manages the claim slots and free-bet ledger locally after the
    debit succeeds.

    ## Authentication & Signing

    All outbound requests from Plexus Arena include:

    | Header | Description |
    |--------|-------------|
    | `X-Api-Key` | Platform API key for your operator account |
    | `X-Client-ID` | Signing client identifier |
    | `X-Client-TS` | Unix timestamp (seconds, UTC) |
    | `X-Client-Signature` | HMAC-SHA256 hex signature over `timestamp + canonical_uri + raw_body` |

    Money-moving POST requests (`/withdraw`, `/deposit`, `/rollback`)
    additionally include:

    | Header | Description |
    |--------|-------------|
    | `Idempotency-Key` | Deterministic key derived from platform identities (see below) |

    Signature input:

    `timestamp + canonical_request_uri + raw_request_body_bytes`

    Rules:

    - `canonical_request_uri` is path plus sorted, URL-encoded query string.
    - For requests with no body, body bytes are empty.
    - For JSON requests, sign the exact raw body bytes sent on the wire. Do not parse and re-encode JSON before verification.
    - Validate timestamp freshness against the configured replay window.

    Example:

    | Field | Value |
    |-------|-------|
    | X-Client-ID | `test-client` |
    | Secret | `test-secret` |
    | Timestamp | `1700000000` |
    | Idempotency-Key | `bet:bet-789:stake_debit` |
    | Canonical URI | `/withdraw` |
    | Raw body | `{"wallet_id":"wallet-123","player_id":"player-123","operation":"stake_debit","amount":1000,"round_id":"round-456","transaction_id":"bet:bet-789:stake_debit","client_seed":"client-seed"}` |
    | Signature | `e4b5a2d7f4c4e03307bbcf0ab4fef40ae4a5d049ec9653c8116969768595991a` |

    `X-Client-ID` selects the shared HMAC secret; it is not included in the signature input.

    ## Idempotency Keys

    | Operation | Key format |
    |-----------|-----------|
    | Stake debit | `bet:{external_id}:stake_debit` |
    | Cashout credit | `bet:{external_id}:cashout_credit` |
    | Pre-start cancel | `bet:{external_id}:stake_void_cancel` |
    | Started-round void | `bet:{external_id}:void_rollback` |
    | Player-funded rain debit | `rain:{rain_id}:withdraw` |
    | Player-funded rain debit rollback | `rain:{rain_id}:rollback` |

    Retries reuse the same key. Respond with `409` and treat the request as
    a no-op (return the original outcome) if you have already processed it.

    ## Amount Representation

    All monetary amounts on the wire are **integer minor units**.
    The unit scale is integration-specific (configured via `OPERATOR_WIRE_SCALE`).
    Example when scale = 1000: `1.00` → `1000`.

servers:
  - url: "https://{operator_base_url}"
    variables:
      operator_base_url:
        default: wallet.example.com
        description: Operator wallet/API base URL configured in the platform.

components:
  parameters:
    XApiKey:
      name: X-Api-Key
      in: header
      required: true
      schema:
        type: string
      description: Platform API key.

    XClientId:
      name: X-Client-ID
      in: header
      required: true
      schema:
        type: string
      description: Signing client identifier.

    XClientTs:
      name: X-Client-TS
      in: header
      required: true
      schema:
        type: integer
      description: Unix timestamp in seconds (UTC).

    XClientSignature:
      name: X-Client-Signature
      in: header
      required: true
      schema:
        type: string
      description: HMAC-SHA256 signature (lowercase hex).

    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema:
        type: string
      description: Deterministic idempotency key (see key format table above).

  schemas:
    WalletBalanceResponse:
      type: object
      required: [wallet_id, player_id, balance, currency]
      properties:
        wallet_id:
          type: string
          description: Wallet identifier.
        player_id:
          type: string
          description: Player identifier.
        balance:
          type: integer
          description: Current wallet balance in integer minor units.
        currency:
          type: string
          description: ISO 4217 currency code.
          example: KES
        player_name:
          type: string
          description: Optional player display name.

    MoneyMutationResponse:
      type: object
      required: [transaction_id, new_balance, old_balance]
      properties:
        transaction_id:
          type: string
          description: Operator-assigned transaction identifier for this mutation.
        new_balance:
          type: integer
          description: Player wallet balance after the mutation.
        old_balance:
          type: integer
          description: Player wallet balance before the mutation.

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Machine-readable error code.

    RealityCheckResponse:
      type: object
      required: [code, message]
      description: |
        Returned by `/withdraw` with HTTP status `412` to interrupt a stake
        with a responsible-gaming reality check. The platform displays
        `message` in a modal with stop / continue / account-history buttons.
      properties:
        code:
          type: integer
          enum: [412]
          description: Echo of the HTTP status code for client convenience.
        message:
          type: string
          description: |
            Operator-supplied message shown to the player. Should describe
            why the check was triggered (e.g., elapsed session time, net loss
            threshold).
          example: You have been playing for 60 minutes

    NotifyPlayerActionRequest:
      type: object
      required: [session_token, game, user_id, notification, notification_data]
      properties:
        session_token:
          type: string
          description: Operator-issued session token provided at game launch.
        game:
          type: string
          description: Game identifier slug.
        user_id:
          type: string
          description: Player identifier in the operator system.
        notification:
          type: string
          enum: [reality_check]
        notification_data:
          type: object
          required: [action]
          properties:
            action:
              type: string
              enum: [STOP, CONTINUE]
              description: CONTINUE = player dismissed the warning. STOP = session ended due to inactivity.

    NotifyPlayerActionResponse:
      type: object
      required: [code, message]
      properties:
        code:
          type: integer
          enum: [200]
        message:
          type: string
          example: ok

paths:
  /auth:
    get:
      operationId: authSession
      summary: Authenticate player session and fetch wallet bootstrap data
      description: |
        Called once at session start. Verifies the operator `session_token`,
        returns the wallet identity and initial balance used to bootstrap the
        platform session.
      parameters:
        - $ref: "#/components/parameters/XApiKey"
        - $ref: "#/components/parameters/XClientId"
        - $ref: "#/components/parameters/XClientTs"
        - $ref: "#/components/parameters/XClientSignature"
        - name: session_token
          in: query
          required: true
          schema:
            type: string
          description: The operator launch/session token provided during game session launch.
        - name: currency
          in: query
          required: false
          schema:
            type: string
          description: Requested currency (if the session is not currency-bound).
      responses:
        "200":
          description: Session authenticated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WalletBalanceResponse"
        "401":
          description: Invalid or expired session token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /player_info:
    get:
      operationId: getPlayerInfo
      summary: Fetch current player wallet balance
      description: Called to refresh the player's balance mid-session.
      parameters:
        - $ref: "#/components/parameters/XApiKey"
        - $ref: "#/components/parameters/XClientId"
        - $ref: "#/components/parameters/XClientTs"
        - $ref: "#/components/parameters/XClientSignature"
        - name: session_token
          in: query
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Player wallet info.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WalletBalanceResponse"
        "401":
          description: Unauthorized.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /withdraw:
    post:
      operationId: withdrawPlayerFunds
      summary: Debit player wallet for a bet stake or player-funded rain
      description: |
        Debits the player wallet for the two current debit operations:

        1. Bet stake debit when a player places a bet.
        2. Player-funded rain debit when a player gives rain. In this case the
           amount is the full rain total (`amount_per_player × number_of_players`),
           debited once from the giver player's wallet. After this debit succeeds,
           Plexus Arena creates and manages the rain claim slots locally through
           `lib/plexus_arena/managed_rains`; the operator does not need to expose
           `/bonus-credit`, `/bonus-credit/{rain_id}/claim`, or `/free-bets`.

        Use the `Idempotency-Key` to deduplicate retries. Stake debits use
        `bet:{external_id}:stake_debit`; player-funded rain debits use
        `rain:{rain_id}:withdraw`.

        The `operation` request field explicitly distinguishes the debit type.
        Operators should not infer business meaning by parsing idempotency-key
        prefixes.

        Platform-managed free bets do not call `/withdraw`. They are consumed
        locally by Plexus Arena from the managed free-bet ledger because the
        stake is funded by platform/admin credit or by a prior player-funded
        rain debit.

        **Error semantics:**
        - `402` — insufficient balance; stake or rain funding is rejected, no retry.
        - `400` — invalid request, including an unknown `operation`.
        - `409` — already processed; return the original outcome as a no-op.
        - `412` — responsible-gaming reality check; the platform aborts the stake,
          surfaces the operator-supplied `message` in a modal to the player, and
          posts the player's `STOP`/`CONTINUE` decision back via
          `/playerNotificationCallback`. The platform does **not** retry — the
          player must take the next action. Body shape:
          `{"code": 412, "message": "You have been playing for 60 minutes"}`.
        - `408 / 429 / 5xx` — transient; the platform will retry with the same `Idempotency-Key`.
      parameters:
        - $ref: "#/components/parameters/XApiKey"
        - $ref: "#/components/parameters/XClientId"
        - $ref: "#/components/parameters/XClientTs"
        - $ref: "#/components/parameters/XClientSignature"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operation, wallet_id, player_id, amount, transaction_id]
              properties:
                operation:
                  type: string
                  enum: [stake_debit, player_rain_debit]
                  description: Explicit debit operation discriminator. `stake_debit` debits a normal bet stake. `player_rain_debit` debits the giver player's wallet once for the full player-funded rain total.
                wallet_id:
                  type: string
                player_id:
                  type: string
                amount:
                  type: integer
                  description: Stake amount or full player-funded rain total in integer minor units.
                round_id:
                  type: string
                  description: Platform round identifier for stake debits. Player-funded rain debits use the synthetic value `rain:{rain_id}` for traceability, but `operation` remains the authoritative discriminator.
                transaction_id:
                  type: string
                  description: Platform transaction identifier (matches Idempotency-Key root).
                client_seed:
                  type: [string, "null"]
                  description: Optional client seed for provably-fair rounds.
            examples:
              stake_debit:
                summary: Stake debit
                value:
                  wallet_id: wallet-123
                  player_id: player-123
                  operation: stake_debit
                  amount: 1000
                  round_id: "round-456"
                  transaction_id: "bet:bet-789:stake_debit"
                  client_seed: "client-seed"
              player_funded_rain_debit:
                summary: Player-funded rain debit
                value:
                  wallet_id: wallet-123
                  player_id: player-123
                  operation: player_rain_debit
                  amount: 50000
                  round_id: "rain:018f6d6b-7c27-7b2a-9c6a-111111111111"
                  transaction_id: "rain:018f6d6b-7c27-7b2a-9c6a-111111111111:withdraw"
                  client_seed: null
      responses:
        "200":
          description: Player funds debited.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MoneyMutationResponse"
        "400":
          description: Invalid request, including an unsupported operation discriminator.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "402":
          description: Insufficient balance.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Unauthorized.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Forbidden.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Player or wallet not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: Already processed — return original outcome.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MoneyMutationResponse"
        "412":
          description: |
            Responsible-gaming reality check. The stake is aborted, the platform
            shows the `message` to the player, and the player's
            `STOP`/`CONTINUE` choice is posted to `/playerNotificationCallback`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RealityCheckResponse"
              example:
                code: 412
                message: You have been playing for 60 minutes

  /deposit:
    post:
      operationId: depositCashout
      summary: Credit player wallet for a bet cashout (win)
      description: |
        Credits the player wallet when a round result is applied. Use the
        `Idempotency-Key` to deduplicate retries.

        `stake_funding_source` is required for accounting. It tells the operator
        whether the credited win came from a normal player-wallet stake or from
        a platform-managed free bet. For free-bet wins there may be no original
        operator `/withdraw` for this bet; `original_transaction_id` is then a
        platform bet reference rather than an operator debit transaction.
      parameters:
        - $ref: "#/components/parameters/XApiKey"
        - $ref: "#/components/parameters/XClientId"
        - $ref: "#/components/parameters/XClientTs"
        - $ref: "#/components/parameters/XClientSignature"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [wallet_id, player_id, amount, multiplier, round_id, transaction_id, original_transaction_id, stake_funding_source, is_free_bet]
              properties:
                wallet_id:
                  type: string
                player_id:
                  type: string
                amount:
                  type: integer
                  description: Cashout amount in integer minor units.
                multiplier:
                  type: number
                  format: double
                  description: Round multiplier applied to the stake.
                round_id:
                  type: string
                transaction_id:
                  type: string
                original_transaction_id:
                  type: string
                  description: Transaction id of the original stake debit for player-wallet stakes. For free-bet stakes, this is a platform bet reference because no operator `/withdraw` funded the stake.
                stake_funding_source:
                  type: string
                  enum: [player_wallet, managed_rain, player_rain, free_bet]
                  description: Accounting source for the stake that produced this cashout. `player_wallet` means a normal operator wallet debit funded the stake. `managed_rain` means platform/admin managed free bet. `player_rain` means a free bet funded by a prior player-funded rain debit. `free_bet` is a generic fallback for future managed free-bet sources.
                is_free_bet:
                  type: boolean
                  description: True when the credited win came from a free-bet stake. Operators should use this together with `stake_funding_source` for accounting.
            examples:
              cash_stake_win:
                summary: Cash stake win
                value:
                  wallet_id: wallet-123
                  player_id: player-123
                  amount: 2500
                  multiplier: 2.5
                  round_id: "round-456"
                  transaction_id: "bet-789:cashout_credit"
                  original_transaction_id: "bet-789"
                  stake_funding_source: player_wallet
                  is_free_bet: false
              managed_rain_free_bet_win:
                summary: Managed rain free-bet win
                value:
                  wallet_id: wallet-123
                  player_id: player-123
                  amount: 2500
                  multiplier: 2.5
                  round_id: "round-456"
                  transaction_id: "bet-790:cashout_credit"
                  original_transaction_id: "bet-790"
                  stake_funding_source: managed_rain
                  is_free_bet: true
      responses:
        "200":
          description: Cashout credited.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MoneyMutationResponse"
        "401":
          description: Unauthorized.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Player or wallet not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: Already processed — return original outcome.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MoneyMutationResponse"

  /rollback:
    post:
      operationId: rollbackTransaction
      summary: Reverse a prior stake debit or cashout credit
      description: |
        Reverses a previously successful stake debit (pre-start cancel or
        started-round void), player-funded rain debit, or cashout credit
        (cashout reversal). The `original_transaction_id` identifies the
        mutation to reverse.
      parameters:
        - $ref: "#/components/parameters/XApiKey"
        - $ref: "#/components/parameters/XClientId"
        - $ref: "#/components/parameters/XClientTs"
        - $ref: "#/components/parameters/XClientSignature"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [wallet_id, player_id, amount, transaction_id, original_transaction_id]
              properties:
                wallet_id:
                  type: string
                player_id:
                  type: string
                amount:
                  type: integer
                  description: Amount to return/reverse in integer minor units.
                transaction_id:
                  type: string
                  description: Platform transaction id for this rollback.
                original_transaction_id:
                  type: string
                  description: Transaction id of the debit or credit being reversed.
      responses:
        "200":
          description: Transaction reversed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MoneyMutationResponse"
        "401":
          description: Unauthorized.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Original transaction not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: Already processed — return original outcome.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MoneyMutationResponse"

  /playerNotificationCallback:
    post:
      operationId: notifyPlayerAction
      summary: Player reality-check notification (optional)
      description: |
        Called when a player interacts with the reality-check / idle-warning modal.
        Two sources can drive this callback:

        - **Reality check** — operator returned `412` from `/withdraw` with a
          message; the platform showed the modal; the player pressed
          `STOP` or `CONTINUE`.
        - **Idle timeout** — the player went idle past the configured timeout;
          the platform invalidates the session locally and posts `STOP` here
          for informational purposes.

        `CONTINUE` — player dismissed the warning and resumed play.
        `STOP` — player chose to stop, or the session was ended by idle timeout.

        This endpoint is optional. Operators that do not need to track player
        notification actions do not need to implement it. Declare the
        `player_notification` capability in your adapter to opt in.

        The platform treats any non-200 response as a non-fatal advisory failure and logs it.
      parameters:
        - $ref: "#/components/parameters/XApiKey"
        - $ref: "#/components/parameters/XClientId"
        - $ref: "#/components/parameters/XClientTs"
        - $ref: "#/components/parameters/XClientSignature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/NotifyPlayerActionRequest"
            examples:
              continue:
                summary: Player continues after reality check
                value:
                  session_token: eyJhbGciOiJIUzI1NiJ9...
                  game: danadana
                  user_id: "player-123"
                  notification: reality_check
                  notification_data:
                    action: CONTINUE
              stop:
                summary: Session ended due to inactivity
                value:
                  session_token: eyJhbGciOiJIUzI1NiJ9...
                  game: danadana
                  user_id: "player-123"
                  notification: reality_check
                  notification_data:
                    action: STOP
      responses:
        "200":
          description: Notification acknowledged.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotifyPlayerActionResponse"
              example:
                code: 200
                message: ok
