openapi: 3.1.0
info:
  title: Apogee Studios API
  version: "1.0.0"
  description: |
    Apogee Studios provably-fair crash + slot games API.

    All endpoints live under `/v1/` and are subject to our
    [versioning policy](./VERSIONING.md). Every request and response
    uses `application/json` unless noted.

    **Authentication:** operator-facing endpoints use HMAC-SHA256
    signing — see [INTEGRATION.md §4](./INTEGRATION.md#4-authentication--hmac-sha256-signing).
    Admin endpoints use the `X-Apogee-Admin-Token` header. Public
    endpoints (games catalog, fair verification, rules) are unauthed.

    **Rate limits:** see [API.md §Rate limits](./API.md#rate-limits).
  contact:
    name: Apogee Studios
    email: partners@apogeetech.net
    url: https://apogeetech.net
  license:
    name: Proprietary
servers:
  - url: https://api.apogeetech.net
    description: Production
  - url: https://sandbox-api.apogeetech.net
    description: Sandbox (state wiped monthly, see docs/SANDBOX.md)

tags:
  - name: Public
    description: Unauthenticated endpoints — game catalog, fair verification, rules, launch
  - name: Sessions
    description: Game session lifecycle (HMAC-signed)
  - name: Wallet
    description: Player wallet operations (session-token auth)
  - name: Rounds
    description: Round telemetry and history
  - name: Merchants
    description: Merchant CRUD (admin only)
  - name: Admin
    description: Admin-only endpoints
  - name: Operator
    description: Endpoints YOU host — Apogee calls these with HMAC-signed POSTs

paths:
  /:
    get:
      tags: [Public]
      summary: Service health
      responses:
        "200":
          description: Service metadata
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }

  /v1/games:
    get:
      tags: [Public]
      summary: Game catalog
      description: |
        Canonical source for every game Apogee hosts. Each entry includes
        `launchPath` — always take iframe URLs from this endpoint rather
        than hardcoding paths.
      responses:
        "200":
          description: Catalog
          content:
            application/json:
              schema:
                type: object
                properties:
                  games:
                    type: array
                    items: { $ref: "#/components/schemas/Game" }

  /v1/games/{gameId}:
    get:
      tags: [Public]
      summary: Single game detail
      parameters:
        - name: gameId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Game entry
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Game" }
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/launch:
    get:
      tags: [Public]
      summary: Demo launch / cross-game redirect
      description: |
        Unauthenticated launch. Supports TWO modes:

        1. **JSON** (default or `Accept: application/json`): returns
           `{launchUrl, sessionToken, ...}` for programmatic consumers.
        2. **302 redirect** (`Accept: text/html` or `?redirect=1`):
           returns an HTTP 302 straight to the correct HTML bundle.

        Mints a Firestore-backed demo session with the supplied balance
        so the game gets a real wallet with SSE updates.
      parameters:
        - name: gameId
          in: query
          required: true
          schema: { type: string, example: "skyward" }
        - name: merchant
          in: query
          schema: { type: string, example: "mrc_demo" }
        - name: player
          in: query
          schema: { type: string }
        - name: currency
          in: query
          schema: { type: string, example: "EUR" }
        - name: balance
          in: query
          description: Starting balance in minor units
          schema: { type: integer, minimum: 0, example: 10000 }
        - name: lang
          in: query
          schema: { type: string, example: "en" }
        - name: returnUrl
          in: query
          schema: { type: string, format: uri }
        - name: redirect
          in: query
          description: Force 302 redirect even with `Accept: application/json`
          schema: { type: string, enum: ["1", "true"] }
      responses:
        "200":
          description: Launch payload (JSON mode)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LaunchResponse" }
        "302":
          description: Redirect to game bundle (HTML mode)
          headers:
            Location:
              schema: { type: string, format: uri }
            X-Apogee-Game:
              schema: { type: string }
        "404":
          $ref: "#/components/responses/NotFound"
        "423":
          description: Game disabled

  /v1/sessions:
    post:
      tags: [Sessions]
      summary: Create a player session (HMAC-signed)
      description: |
        Called by the operator's backend when a player opens a game.
        Returns a `sessionToken` + `launchUrl` that can be iframed.
        Session persists for 30 minutes with auto-extend on every wallet call.
      security:
        - ApogeeHmac: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SessionCreate" }
      responses:
        "201":
          description: Session created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SessionResponse" }
        "401":
          $ref: "#/components/responses/AuthError"
        "403":
          description: Key lacks `sessions.create` permission
        "404":
          description: Game not in catalog
        "423":
          description: Game disabled

  /v1/sessions/{token}:
    get:
      tags: [Sessions]
      summary: Session lookup (HMAC-signed)
      security:
        - ApogeeHmac: []
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string, pattern: "^sess_[a-z0-9]+$" }
      responses:
        "200":
          description: Session state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SessionState" }
        "401": { $ref: "#/components/responses/AuthError" }
        "403":
          description: Session belongs to another merchant
        "404":
          description: Session not found

  /v1/wallet/balance:
    get:
      tags: [Wallet]
      summary: Get current balance for a session
      description: |
        Called by the game client. If the merchant has a `walletUrl`,
        this proxies to the operator's `/balance` endpoint and returns
        the authoritative balance. Otherwise returns the shadow balance
        from the Firestore session doc.

        All amounts are **integer minor units**.
      parameters:
        - name: session
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Balance
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WalletBalance" }
        "404":
          description: Session not found
        "502":
          description: Operator wallet rejected / unreachable

  /v1/wallet/debit:
    post:
      tags: [Wallet]
      summary: Debit (place bet)
      description: |
        Called by the game client when a bet is placed. Idempotent on
        `txId` — retries return the cached response without double-charging.
        Server-side caps: `maxStake`, rate limit, integer-only amount.

        **Forwards to operator** if merchant has `walletUrl` set.
        `roundId` is always coerced to a JSON string on the outbound call
        to match strict-typed operator unmarshalling (see PITFALLS §3b).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DebitRequest" }
      responses:
        "200":
          description: Debit committed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DebitResponse" }
        "400":
          description: Bad amount / missing fields / bad session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "402":
          description: Insufficient balance
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: Session not found
        "409":
          description: Session busy (another debit in flight)
        "429":
          description: Rate limited
        "502":
          description: Operator wallet rejected

  /v1/wallet/credit:
    post:
      tags: [Wallet]
      summary: Credit (cashout / refund)
      description: |
        Called by the game client on cashout OR refund. Must carry
        `refTxId` pointing at the original debit so the operator ledger
        can pair debit↔credit rows. Idempotent on `txId`.

        `reason` values: `cashout`, `bet-cancelled`, `bet-cancelled-pending`,
        `bet-cancelled-in-flight`, `bet-window-closed`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreditRequest" }
      responses:
        "200":
          description: Credit settled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreditResponse" }
        "400":
          description: Bad amount
        "404":
          description: Session not found
        "429":
          description: Rate limited
        "502":
          description: Operator wallet rejected

  /v1/wallet/stream:
    get:
      tags: [Wallet]
      summary: Realtime balance stream (Server-Sent Events)
      description: |
        EventSource endpoint. Emits a `balance` event every time the
        session's balance changes, backed by Firestore onSnapshot. Used
        by the game client to keep the topbar in sync across tabs or
        when the operator pushes a balance update directly.
      parameters:
        - name: session
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: SSE stream (event-stream content type)
          content:
            text/event-stream:
              schema:
                type: string
                example: |
                  event: balance
                  data: {"session":"sess_...","balance":1000000,"currency":"ETB","version":3}

  /v1/fair/verify:
    get:
      tags: [Public]
      summary: Provably-fair round verification
      description: |
        Recomputes the crash point from a revealed server seed + client
        seed + nonce. Also returns the SHA-256 commitment so verifiers
        can prove it matches the pre-round publication.
      parameters:
        - name: serverSeed
          in: query
          required: true
          schema: { type: string, pattern: "^[0-9a-f]{8,128}$" }
        - name: clientSeed
          in: query
          schema: { type: string, default: "apogee" }
        - name: nonce
          in: query
          schema: { type: string }
        - name: instantDivisor
          in: query
          schema: { type: integer, minimum: 2, default: 33 }
        - name: maxCrash
          in: query
          schema: { type: number, default: 10000 }
      responses:
        "200":
          description: Verification result
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FairVerifyResponse" }

  /v1/merchants/{merchantId}/rules:
    get:
      tags: [Public]
      summary: Merchant bet-preflight rules
      description: |
        Returns the consolidated ruleset for a merchant + currency + game.
        Games call this at boot and cache the response, then enforce the
        values client-side as part of preflight. See docs/BET-PREFLIGHT.md.

        All amounts in minor units.
      parameters:
        - name: merchantId
          in: path
          required: true
          schema: { type: string, pattern: "^mrc_[a-z0-9_]+$" }
        - name: currency
          in: query
          schema: { type: string }
        - name: gameId
          in: query
          schema: { type: string }
      responses:
        "200":
          description: Rules
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MerchantRules" }

  /v1/merchants/{merchantId}/rtp:
    get:
      tags: [Public]
      summary: Merchant effective RTP (per currency + game)
      parameters:
        - name: merchantId
          in: path
          required: true
          schema: { type: string }
        - name: currency
          in: query
          schema: { type: string }
        - name: gameId
          in: query
          schema: { type: string }
      responses:
        "200":
          description: Effective RTP
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MerchantRtp" }

  /v1/rounds:
    get:
      tags: [Rounds]
      summary: Round history (admin / billing)
      description: |
        Returns round telemetry filtered by merchant. For player-facing
        dispute tools, use the per-round endpoint or filter by `playerId`.
      parameters:
        - name: merchantId
          in: query
          schema: { type: string }
        - name: playerId
          in: query
          schema: { type: string }
        - name: gameId
          in: query
          schema: { type: string }
        - name: from
          in: query
          schema: { type: string, format: date-time }
        - name: to
          in: query
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - name: cursor
          in: query
          schema: { type: string }
      responses:
        "200":
          description: Rounds page
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RoundsPage" }
    post:
      tags: [Rounds]
      summary: Submit round telemetry (game client → API)
      description: |
        Game clients push round outcomes here for billing. No HMAC
        required — entry is tagged with the session's merchantId and
        rate-limited per merchant.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RoundTelemetry" }
      responses:
        "201":
          description: Round recorded

  /v1/players/{playerId}/terminate:
    post:
      tags: [Sessions]
      summary: Terminate all active sessions for a player (responsible gambling)
      description: |
        Called by the operator when a player self-excludes, hits a
        responsible-gambling limit, or is flagged by compliance. Every
        active session for that `playerId` under the calling merchant is
        expired immediately. Any in-flight bets are rolled back via the
        operator's `/wallet/rollback` endpoint.

        See docs/RESPONSIBLE-GAMBLING.md for the full flow.
      security:
        - ApogeeHmac: []
      parameters:
        - name: playerId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, enum: [self_excluded, limit_reached, compliance_flag, other] }
                until:  { type: string, format: date-time, description: "Optional reactivation time" }
      responses:
        "200":
          description: Sessions terminated
          content:
            application/json:
              schema:
                type: object
                properties:
                  terminated: { type: integer, description: "Number of sessions killed" }
                  rolledBack: { type: integer, description: "Number of in-flight bets rolled back" }
        "401":
          $ref: "#/components/responses/AuthError"

components:
  securitySchemes:
    ApogeeHmac:
      type: apiKey
      in: header
      name: X-Apogee-Signature
      description: |
        HMAC-SHA256 signature of `METHOD\nPATH\nTIMESTAMP\nNONCE\nSHA256(BODY)`
        using your merchant's secret key. Requires four headers:
        `X-Apogee-Key`, `X-Apogee-Timestamp`, `X-Apogee-Nonce`, `X-Apogee-Signature`.
    ApogeeAdminToken:
      type: apiKey
      in: header
      name: X-Apogee-Admin-Token

  schemas:
    Health:
      type: object
      properties:
        service: { type: string, example: "apogee-api" }
        version: { type: string, example: "v18" }
        env:     { type: string, example: "sandbox" }
        time:    { type: string, format: date-time }

    Game:
      type: object
      required: [id, name, status, launchPath]
      properties:
        id:            { type: string, example: "skyward" }
        name:          { type: string, example: "Skyward" }
        type:          { type: string, enum: [crash, "crash-multiplayer", slot, instant] }
        status:        { type: string, enum: [live, beta, planned, disabled] }
        rtp:           { type: number, example: 97 }
        maxMultiplier: { type: integer, example: 10000 }
        launchPath:    { type: string, example: "/play.html?gameId=skyward" }
        minBetMinor:   { type: integer, example: 10 }
        maxBetMinor:   { type: integer, example: 10000000 }
        currencies:
          type: array
          items: { type: string }

    LaunchResponse:
      type: object
      required: [launchUrl, gameId, sessionToken]
      properties:
        launchUrl:    { type: string, format: uri }
        gameId:       { type: string }
        merchantId:   { type: string }
        sessionToken: { type: string, pattern: "^sess_[a-z0-9]+$" }
        balance:      { type: integer, description: "minor units" }
        currency:     { type: string }
        env:          { type: string }
        mode:         { type: string, example: "iframe" }

    SessionCreate:
      type: object
      required: [gameId, playerId, currency, balance]
      properties:
        gameId:    { type: string }
        playerId:  { type: string }
        playerName: { type: string }
        currency:  { type: string }
        balance:   { type: integer, description: "minor units" }
        language:  { type: string, default: "en" }
        returnUrl: { type: string, format: uri }
        mode:      { type: string, enum: [real, demo], default: real }
        country:   { type: string }
        ipAddress: { type: string }

    SessionResponse:
      type: object
      properties:
        sessionToken: { type: string }
        launchUrl:    { type: string, format: uri }
        expiresAt:    { type: string, format: date-time }
        env:          { type: string }
        gameId:       { type: string }

    SessionState:
      type: object
      properties:
        token:      { type: string }
        merchantId: { type: string }
        gameId:     { type: string }
        playerId:   { type: string }
        currency:   { type: string }
        balance:    { type: integer }
        version:    { type: integer }
        createdAt:  { type: integer, description: "Epoch ms" }
        updatedAt:  { type: integer }
        expiresAt:  { type: integer }

    WalletBalance:
      type: object
      properties:
        session:    { type: string }
        balance:    { type: integer }
        currency:   { type: string }
        playerId:   { type: string }
        merchantId: { type: string }
        version:    { type: integer }

    DebitRequest:
      type: object
      required: [session, amount]
      properties:
        session:  { type: string, pattern: "^sess_[a-z0-9]+$" }
        amount:   { type: integer, minimum: 1, description: "minor units, integer only" }
        reason:   { type: string, enum: [bet] }
        txId:     { type: string, description: "stable across retries for idempotency" }
        roundId:  { type: string, description: "always serialised as string on the wire" }

    DebitResponse:
      type: object
      properties:
        session: { type: string }
        balance: { type: integer }
        delta:   { type: integer, description: "negative (debit)" }
        txId:    { type: string }

    CreditRequest:
      type: object
      required: [session, amount]
      properties:
        session:  { type: string }
        amount:   { type: integer, minimum: 1 }
        reason:
          type: string
          enum:
            - cashout
            - bet-cancelled
            - bet-cancelled-pending
            - bet-cancelled-in-flight
            - bet-window-closed
        txId:     { type: string }
        refTxId:  { type: string, description: "Original debit this credit refunds or settles" }
        roundId:  { type: string }

    CreditResponse:
      type: object
      properties:
        session: { type: string }
        balance: { type: integer }
        delta:   { type: integer, description: "positive (credit)" }
        txId:    { type: string }

    MerchantRules:
      type: object
      properties:
        merchantId:  { type: string }
        currency:    { type: string }
        gameId:      { type: string }
        gameAllowed: { type: boolean }
        gameStatus:  { type: string }
        rules:
          type: object
          properties:
            minStake:           { type: integer }
            maxStake:           { type: integer }
            maxWin:             { type: integer }
            maxRoundExposure:   { type: integer }
            minAutoTarget:      { type: number }
            maxAutoTarget:      { type: number }
            rateBurstPerSec:    { type: integer }
            rateSustainedPerSec: { type: integer }

    MerchantRtp:
      type: object
      properties:
        merchantId:      { type: string }
        currency:        { type: string }
        gameId:          { type: string }
        effectiveRtp:    { type: number }
        houseEdge:       { type: number }
        instantDivisor:  { type: integer }
        source:          { type: string, enum: [game_default, merchant_default, currency_override] }

    FairVerifyResponse:
      type: object
      properties:
        inputs:
          type: object
          properties:
            serverSeed:     { type: string }
            clientSeed:     { type: string }
            nonce:          { type: string }
            instantDivisor: { type: integer }
            maxCrash:       { type: number }
        serverSeedHash: { type: string }
        hmacHex:        { type: string }
        windows:
          type: object
          properties:
            instantBustWindow:  { type: string }
            crashFormulaWindow: { type: string }
        derivation:
          type: object
          properties:
            hInsta:      { type: string }
            hCrash:      { type: string }
            instantBust: { type: boolean }
            formula:     { type: string }
        crashPoint: { type: number }

    RoundTelemetry:
      type: object
      required: [gameId, merchantId, crash]
      properties:
        gameId:         { type: string }
        merchantId:     { type: string }
        playerId:       { type: string }
        roundId:        { type: string }
        crash:          { type: number }
        wagered:        { type: integer }
        returned:       { type: integer }
        serverSeedHash: { type: string }

    RoundsPage:
      type: object
      properties:
        rounds:
          type: array
          items: { $ref: "#/components/schemas/RoundTelemetry" }
        count:     { type: integer }
        nextCursor: { type: string, nullable: true }

    ErrorResponse:
      type: object
      required: [code]
      properties:
        code:      { type: string, example: "insufficient_balance" }
        message:   { type: string }
        requestId: { type: string, example: "req_8f2a..." }

  responses:
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    AuthError:
      description: Signature invalid, expired timestamp, or replayed nonce
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }

security:
  - ApogeeHmac: []
