openapi: 3.1.0
info:
  title: Wagernode API
  version: 1.0.0
  description: |
    Per-tenant, API-key-authenticated HTTP API for external tools to manage V-Points balances.

    ## Authentication
    Send your API key in the `X-Api-Key` header on every request.

    ## Idempotency
    On mutating endpoints, include the `Idempotency-Key` header to safely retry. Same key + same body = cached response. Same key + different body = `409 IDEMPOTENCY_KEY_CONFLICT`.

    ## Rate limits
    Requests are limited per-tenant via a token bucket (default 100 capacity, 10/sec refill). On 429, honor the `Retry-After` header.
servers:
  - url: https://wagernode.gg/api/external/v1
    description: Production
security:
  - ApiKeyAuth: []
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string, example: INSUFFICIENT_BALANCE }
            message: { type: string }
            details: { type: object }
    MutationResponse:
      type: object
      properties:
        transaction_id: { type: string, format: uuid }
        kick_id: { type: string }
        tenant_viewer_id: { type: string, format: uuid }
        balance_before: { type: number }
        balance_after: { type: number }
        amount: { type: number, description: "Signed delta. Positive for credit, negative for debit, signed delta for set." }
        operation: { type: string, enum: [credit, debit, set] }
  responses:
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: Key valid but missing the required scope for this endpoint
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Per-tenant token bucket empty. Honor the Retry-After header.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds to wait before retrying.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    IdempotencyConflict:
      description: Idempotency-Key was previously seen with a different request body
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
paths:
  /users/by-kick/{kickId}:
    get:
      summary: Look up a user by Kick ID
      parameters:
        - in: path
          name: kickId
          required: true
          schema: { type: string }
      responses:
        '200':
          description: User found
          content:
            application/json:
              schema:
                type: object
                properties:
                  kick_id: { type: string }
                  tenant_viewer_id: { type: string, format: uuid }
                  discord_id: { type: string }
                  linked_platforms:
                    type: array
                    items: { type: string }
        '404':
          description: Not found
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
  /users/by-kick/{kickId}/v-points/balance:
    get:
      summary: Get current V-Points balance
      parameters:
        - in: path
          name: kickId
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Balance
          content:
            application/json:
              schema:
                type: object
                properties:
                  kick_id: { type: string }
                  tenant_viewer_id: { type: string, format: uuid }
                  balance: { type: number }
  /users/by-kick/{kickId}/v-points/credit:
    post:
      summary: Credit V-Points
      parameters:
        - in: path
          name: kickId
          required: true
          schema: { type: string }
        - in: header
          name: Idempotency-Key
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount: { type: number, minimum: 0.01 }
                reason: { type: string }
                external_ref: { type: string }
      responses:
        '200':
          description: Credited
          content: { application/json: { schema: { $ref: '#/components/schemas/MutationResponse' } } }
        '422':
          description: Invalid amount
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
  /users/by-kick/{kickId}/v-points/debit:
    post:
      summary: Debit V-Points
      parameters:
        - in: path
          name: kickId
          required: true
          schema: { type: string }
        - in: header
          name: Idempotency-Key
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount: { type: number, minimum: 0.01 }
                reason: { type: string }
                external_ref: { type: string }
      responses:
        '200':
          description: Debited
          content: { application/json: { schema: { $ref: '#/components/schemas/MutationResponse' } } }
        '409':
          description: Insufficient balance
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
  /users/by-kick/{kickId}/v-points/set:
    post:
      summary: Overwrite V-Points balance to an exact value
      parameters:
        - in: path
          name: kickId
          required: true
          schema: { type: string }
        - in: header
          name: Idempotency-Key
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [balance]
              properties:
                balance: { type: number, minimum: 0 }
                reason: { type: string }
                external_ref: { type: string }
      responses:
        '200':
          description: Set
          content: { application/json: { schema: { $ref: '#/components/schemas/MutationResponse' } } }
  /users/by-kick/{kickId}/v-points/transactions:
    get:
      summary: Paginated transaction history
      parameters:
        - in: path
          name: kickId
          required: true
          schema: { type: string }
        - in: query
          name: limit
          schema: { type: integer, default: 50, maximum: 200 }
        - in: query
          name: cursor
          schema: { type: string }
      responses:
        '200':
          description: Page
          content:
            application/json:
              schema:
                type: object
                properties:
                  transactions:
                    type: array
                    items: { type: object }
                  next_cursor: { type: [string, 'null'] }
  /v-points/bulk-credit:
    post:
      summary: Credit multiple users in one atomic batch (max 500)
      parameters:
        - in: header
          name: Idempotency-Key
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [credits]
              properties:
                credits:
                  type: array
                  maxItems: 500
                  items:
                    type: object
                    required: [kick_id, amount]
                    properties:
                      kick_id: { type: string }
                      amount: { type: number, minimum: 0.01 }
                      reason: { type: string }
                      external_ref: { type: string }
      responses:
        '200':
          description: Bulk result
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  count: { type: integer }
                  total_credited: { type: number }
                  results: { type: array, items: { type: object } }
  /giveaways:
    post:
      summary: Submit a completed external giveaway
      description: |
        Push a completed provably-fair giveaway from an external system into Wagernode's record.
        Server validates `sha256(server_seed) === server_seed_hash` at insert. The verify endpoint can
        re-run the algorithm using the stored range/HMAC metadata to confirm winner positions.
        Custom RNG ranges (e.g. `[0.0001, 100.0000]`) are supported via the `range_*` fields.
      parameters:
        - in: header
          name: Idempotency-Key
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [external_id, title, server_seed, server_seed_hash, public_seed, range_format, winners_count, drawn_at, winners, participants]
              properties:
                external_id: { type: string, description: "Caller's identifier; unique per tenant." }
                title: { type: string }
                description: { type: string }
                prize_description: { type: string }
                image_url: { type: string, format: uri }
                server_seed: { type: string }
                server_seed_hash: { type: string, description: "Hex sha256(server_seed). Validated at insert." }
                public_seed: { type: string }
                range_format:
                  type: string
                  enum: [float-uniform, integer-modulo, integer-rejection, pool-index, custom]
                range_min: { type: number }
                range_max: { type: number }
                range_min_inclusive: { type: boolean, default: true }
                range_max_inclusive: { type: boolean, default: false }
                bytes_used: { type: integer, enum: [4, 6, 8], default: 8 }
                decimal_precision: { type: integer, minimum: -1, maximum: 8, default: -1, description: "-1 for no rounding." }
                hmac_key_source: { type: string, enum: [server_seed, public_seed], default: server_seed }
                hmac_msg_template: { type: string, default: "{public_seed}:{nonce}" }
                winners_count: { type: integer, minimum: 1, maximum: 100 }
                drawn_at: { type: string, format: date-time }
                winners:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type: object
                    required: [external_user_id, nonce, verification_hash, winning_position]
                    properties:
                      external_user_id: { type: string }
                      external_user_platform: { type: string, description: "kick / discord / twitch / etc." }
                      username: { type: string }
                      prize_label: { type: string }
                      nonce: { type: integer, minimum: 0 }
                      verification_hash: { type: string, description: "64-char hex (HMAC output)." }
                      winning_position: { type: number, description: "Roll value as recorded by the caller, in their range." }
                participants:
                  type: array
                  minItems: 1
                  maxItems: 10000
                  description: |
                    Complete entries pool the draw was rolled against. Required: external giveaways are
                    submitted post-draw with the participants list AND winners in one atomic POST. For
                    `range_format: pool-index` the verify endpoint additionally confirms that
                    `participants[winning_position] === winner.external_user_id`. For other range formats
                    the list is stored as audit only.
                  items:
                    type: object
                    required: [external_user_id]
                    properties:
                      external_user_id: { type: string }
                      external_user_platform: { type: string, description: "kick / discord / twitch / etc." }
                      username: { type: string }
                      tickets_count: { type: integer, minimum: 1, default: 1 }
                      position: { type: integer, minimum: 0, description: "0-indexed slot in the pool. Defaults to array index if omitted." }
      responses:
        '200':
          description: Created
          content:
            application/json:
              schema:
                type: object
                description: |
                  The persisted external giveaway, with `winners` and `participants` arrays included
                  (participants ordered by `position`).
        '409':
          description: external_id already exists for this tenant
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '422':
          description: Invalid seed hash, invalid amount, or batch too large
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
  /giveaways/{id}:
    get:
      summary: Fetch an external giveaway
      description: |
        `{id}` may be either Wagernode's internal UUID or the caller's `external_id` (auto-detected
        by UUID format). Cross-tenant lookups return 404 to avoid existence leak.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Found
          content:
            application/json:
              schema:
                type: object
                description: |
                  The stored external giveaway with `winners` and `participants` arrays
                  (participants ordered by `position`).
        '404':
          description: Not found (or not visible to this tenant)
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
  /giveaways/{id}/verify:
    get:
      summary: Verify the provably-fair proof of an external giveaway
      description: |
        Re-runs the stored algorithm (HMAC + range mapping) for each recorded winner and compares
        the recomputed `winning_position` to what was submitted. Returns `{verified, winners[]}`.
        For `range_format: custom` the response is `{verified: false, reason: 'custom_range_format_not_verifiable'}`
        — we record the data but make no mathematical claim.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Verification result
          content:
            application/json:
              schema:
                type: object
                properties:
                  verified: { type: boolean }
                  giveaway_id: { type: string, format: uuid }
                  range_format: { type: string }
                  reason: { type: string, description: "Present when verified=false." }
                  winners:
                    type: array
                    items:
                      type: object
                      properties:
                        nonce: { type: integer }
                        recorded_position: { type: number }
                        recomputed_position: { type: number }
                        recorded_hash: { type: string }
                        recomputed_hash: { type: string }
                        match: { type: boolean }
                        pool_resolution:
                          type: object
                          nullable: true
                          description: |
                            Present only for `range_format: pool-index`. Confirms that the participant
                            stored at `winning_position` matches the recorded winner.
                          properties:
                            match: { type: boolean }
                            recorded_winner: { type: string, description: "external_user_id of the recorded winner." }
                            pool_winner_at_position: { type: string, nullable: true, description: "external_user_id of the participant at winning_position." }
        '404':
          description: Not found (or not visible to this tenant)
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
  /leaderboards:
    get:
      summary: List leaderboards visible to this tenant (live + archived)
      responses:
        '200':
          description: List
          content:
            application/json:
              schema:
                type: object
                properties:
                  leaderboards:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        deal_id: { type: string, format: uuid }
                        platform_name: { type: string }
                        start_date: { type: string, format: date-time }
                        end_date: { type: string, format: date-time }
                        is_active: { type: boolean }
                        archived_at: { type: [string, 'null'], format: date-time }
                        source: { type: string, enum: [live, archive] }
  /leaderboards/latest:
    get:
      summary: Most recent finished leaderboard for this tenant (with entries + prizes)
      description: |
        Returns the most recent finished leaderboard for the tenant's deals along with
        its inline entries (top players sorted by amount, with the prize for each place
        already resolved from the leaderboard's `prize_split`). Optionally filter by
        `?platform=<name>` (case-insensitive). Prefers archived rollovers over inactive
        live leaderboards.

        For paginated access to entries with cursors, use `/leaderboards/{id}/entries`.
      parameters:
        - in: query
          name: platform
          schema: { type: string }
        - in: query
          name: limit
          description: Max entries to inline. Default 200, capped at 200.
          schema: { type: integer, default: 200, maximum: 200 }
      responses:
        '200':
          description: The latest leaderboard with inline entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  deal_id: { type: string, format: uuid }
                  platform_name: { type: string }
                  start_date: { type: string, format: date-time }
                  end_date: { type: string, format: date-time }
                  is_active: { type: boolean }
                  archived_at: { type: [string, 'null'], format: date-time }
                  source: { type: string, enum: [live, archive] }
                  prize: { type: [string, 'null'], description: "Free-text or structured prize description as configured." }
                  prize_split:
                    type: array
                    description: "Per-place prize entries; index 0 = first place. Shape may be a number, a string, or an object like { cash, bonus_buy, daily_wheel } depending on configuration."
                    items: {}
                  entries:
                    type: array
                    items:
                      type: object
                      properties:
                        rank: { type: integer }
                        place: { type: integer, description: "Alias for rank." }
                        player_uid: { type: string }
                        username: { type: string }
                        amount: { type: number }
                        prize: { description: "Resolved from prize_split[rank-1]; null if no prize for this place.", oneOf: [ { type: 'null' }, { type: number }, { type: string }, { type: object } ] }
        '404':
          description: No finished leaderboard for this tenant (or platform filter)
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
  /leaderboards/{id}/entries:
    get:
      summary: Paginated leaderboard entries (unmasked usernames, with prize per place)
      description: |
        Returns ranked entries for a leaderboard. Usernames are returned UNMASKED.
        The `amount` field is the same value rendered on the public leaderboard.
        Each entry's `prize` is resolved from the leaderboard's `prize_split` for its
        rank. Excluded users are filtered out by default.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: limit
          schema: { type: integer, default: 50, maximum: 200 }
        - in: query
          name: cursor
          schema: { type: string }
      responses:
        '200':
          description: Page of entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  entries:
                    type: array
                    items:
                      type: object
                      properties:
                        rank: { type: integer }
                        place: { type: integer, description: "Alias for rank." }
                        player_uid: { type: string }
                        username: { type: string }
                        amount: { type: number }
                        prize: { description: "Resolved from prize_split[rank-1]; null if no prize for this place.", oneOf: [ { type: 'null' }, { type: number }, { type: string }, { type: object } ] }
                  next_cursor: { type: [string, 'null'] }
        '404':
          description: Leaderboard not visible to this tenant
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
