openapi: 3.0.3
info:
  title: PLLAY Partner API
  version: "1.0.0"
  description: |
    The v1 surface of PLLAY's B2B Partner API.
    This spec covers ONLY the versioned /v1/* routes. Older unversioned routes
    predate scopes and versioning, are deprecated, and are not documented here.

    Published copy: https://pllay.io/artifacts/partner-api/partner-api-v1.yaml

    ## Webhooks (resolution and dispute events)
    Resolution and dispute events are delivered to an HTTPS endpoint that
    PLLAY registers and subscribes for you (send PLLAY your URL; the signing
    secret is shared privately). `PATCH /webhooks` below does NOT configure
    this endpoint: it sets the legacy endpoint for `prediction.*` events only.

    Event types: `resolution.completed`, `resolution.voided`,
    `resolution.failed`, `dispute.opened`, `dispute.decided`,
    `resolution.corrected`. Your endpoint receives only the types it is
    subscribed to, so ask PLLAY to include the three dispute events.

    Headers on every delivery:
      - `X-Webhook-Event`: the event name
      - `X-Webhook-Signature`: `sha256=<hex>` of the raw body
      - `X-Webhook-Timestamp`: unix seconds
      - `X-Pllay-Signature`: `t=<unix_seconds>,v1=<hex>`, where `v1` is the
        HMAC-SHA256 of `"<t>.<raw body>"` with your endpoint secret. Recompute
        and compare it, and reject a `t` more than 300 seconds from your clock.

    Body (envelope):
    `{ "schema_version", "event_id", "event_type", "environment", "occurred_at", "data" }`.
    The envelope `event_id` is the webhook event id: it is the same on every
    retry, so de-duplicate on it. `data.event_id` is the result (event) id.

    `data` keys:
      - `resolution.completed` / `resolution.voided` / `resolution.failed`:
        `event_id`, `external_market_id`, `state`, `tier`,
        `outcome_option_index`, `confidence`, `resolved_at`, `latency_ms`
        (the same values `GET /events/{id}` returns).
      - `dispute.opened`: `dispute_id`, `event_id`, `external_market_id`,
        `reason_code`, `status` (`open`), `opened_at`, `ack_due_at`,
        `decide_due_at` (target times).
      - `dispute.decided`: `dispute_id`, `event_id`, `status` (`upheld` or
        `overturned`), `decision_reason`, `decided_at`. `decision_reason` is
        the reviewer's written rationale and is sent to you as written.
      - `resolution.corrected`: `event_id`, `external_market_id`,
        `dispute_id`, `correction_id`, `kind` (`corrected` or `void`), `tier`,
        `outcome_option_index` (null for void), `original` (`tier`,
        `outcome_option_index`), `decision_reason`, `corrected_at`.
  contact:
    url: https://pllay.io/developers/partner-api

servers:
  - url: "{partner_api_base}/v1"
    description: PLLAY issues your partner API base URL together with your key.
    variables:
      partner_api_base:
        default: https://partner-api.invalid
        description: Placeholder. Use the base URL PLLAY sends you with your sandbox or production key.

security:
  - PartnerKey: []

paths:
  /predictions/launch:
    post:
      operationId: launchPrediction
      summary: Launch a new partner-attributed prediction
      description: Requires the `interactions:write` scope.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/CorrelationId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [creator_external_id, question, options]
              properties:
                creator_external_id:
                  type: string
                  description: Must already be linked to this partner as an active creator.
                question:
                  type: string
                  maxLength: 200
                options:
                  type: array
                  items: { type: string }
                  minItems: 2
                  maxItems: 4
                timer_seconds:
                  type: integer
                  minimum: 15
                  maximum: 600
                  default: 60
      responses:
        '201':
          description: Prediction launched
          content:
            application/json:
              schema:
                type: object
                properties:
                  prediction_id: { type: string, format: uuid }
                  prediction_url: { type: string, format: uri }
                  embed_url: { type: string, format: uri }
                  embed_token:
                    type: string
                    nullable: true
                    description: Single-interaction public token (pllay_et_…). Null when minting failed. Not a partner secret.
                  embed_token_expires_at:
                    type: string
                    format: date-time
                    nullable: true
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: creator_external_id has no active link to this partner
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /predictions:
    get:
      operationId: listPredictions
      summary: List this partner's own predictions, cursor-paginated
      description: Requires the `interactions:read` scope. Never returns another partner's predictions.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - name: cursor
          in: query
          description: Opaque token from a prior response's pagination.next_cursor
          schema: { type: string }
      responses:
        '200':
          description: A page of predictions
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/PredictionSummary' }
                  pagination:
                    type: object
                    properties:
                      next_cursor: { type: string, nullable: true }
                      has_more: { type: boolean }
        '400':
          description: cursor is not a valid opaque token
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /predictions/{id}/state:
    get:
      operationId: getPredictionState
      summary: Read live state and odds for one of this partner's predictions
      description: |
        Requires the `interactions:read` scope. Anti-enumeration: a prediction that
        exists but belongs to a different partner returns the SAME 404 as a
        prediction that does not exist at all.
      parameters:
        - $ref: '#/components/parameters/PredictionId'
      responses:
        '200':
          description: Prediction state
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PredictionState' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /predictions/{id}/close:
    patch:
      operationId: closePrediction
      summary: Lock a prediction — stop accepting new stake, prior to outcome submission
      description: Requires the `interactions:write` scope. Only valid from OPEN or COUNTDOWN.
      parameters:
        - $ref: '#/components/parameters/PredictionId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Prediction locked
          content:
            application/json:
              schema:
                type: object
                properties:
                  prediction_id: { type: string, format: uuid }
                  status: { type: string, enum: [LOCKED] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: prediction is not in a closable state
        '429': { $ref: '#/components/responses/RateLimited' }

  /predictions/{id}/participation:
    get:
      operationId: getPartnerPlayerParticipationSummary
      summary: Read choice counts for the partner's own prediction
      description: |
        Requires the `interactions:read` scope. Counts rows in
        partner_player_participations for this prediction. Does not return
        player ids, does not count PLLAY accounts, and does not include
        rewards or exposure. Readable after the prediction is locked.
      parameters:
        - $ref: '#/components/parameters/PredictionId'
      responses:
        '200':
          description: Choice counts by option
          content:
            application/json:
              schema:
                type: object
                properties:
                  prediction_id: { type: string, format: uuid }
                  total: { type: integer }
                  by_option:
                    type: array
                    items:
                      type: object
                      properties:
                        option_index: { type: integer }
                        selected_option: { type: string }
                        count: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: prediction is not a core partner prediction
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      operationId: recordPartnerPlayerParticipation
      summary: Record a choice under the platform's own player id
      description: |
        Requires the `interactions:write` scope. Call this from the partner's
        server, with the player id that server already authenticated. Does not
        create a PLLAY account. Only valid while the prediction is OPEN or COUNTDOWN.
        The same player and the same option is idempotent. A different option for
        that player is a conflict.
      parameters:
        - $ref: '#/components/parameters/PredictionId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [external_player_id, selected_option]
              properties:
                external_player_id:
                  type: string
                  maxLength: 128
                  description: The platform's own player id. Not a PLLAY user id and not a PLLAY credential.
                selected_option:
                  type: string
                  description: Must match one of the prediction's option labels.
      responses:
        '200':
          description: Participation recorded, or the same choice was already recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  prediction_id: { type: string, format: uuid }
                  external_player_id: { type: string }
                  selected_option: { type: string }
                  option_index: { type: integer }
                  submitted_at: { type: string, format: date-time }
                  created: { type: boolean }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: prediction is not a core partner prediction
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: participation is closed, or this player already chose a different option
        '429': { $ref: '#/components/responses/RateLimited' }

  /predictions/{id}/outcome:
    post:
      operationId: submitOutcome
      summary: Submit the winning outcome — settles immediately and authoritatively
      description: |
        Requires the `outcomes:write` scope. This does NOT have a separate
        propose/confirm split — unlike Pulse's sponsor-outcome workflow, System A's
        settlement is immediate the moment this call succeeds.
      parameters:
        - $ref: '#/components/parameters/PredictionId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                winning_outcome:
                  type: string
                  description: Must match one of the prediction's option labels (case-insensitive).
                winning_option_index:
                  type: integer
                  description: Alternative to winning_outcome — the 0-based option index.
      responses:
        '200':
          description: Prediction settled
          content:
            application/json:
              schema:
                type: object
                properties:
                  prediction_id: { type: string, format: uuid }
                  winning_option_index: { type: integer }
                  winner: { type: string }
                  service_fee_cents: { type: integer }
                  partner_revenue_cents: { type: integer }
                  pllay_revenue_cents: { type: integer }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /predictions/{id}/resolution:
    get:
      operationId: getResolution
      summary: Read the final resolution of a settled or refunded prediction
      description: Requires the `outcomes:read` scope. Returns 409 if the prediction has not yet resolved.
      parameters:
        - $ref: '#/components/parameters/PredictionId'
      responses:
        '200':
          description: Resolution record
          content:
            application/json:
              schema:
                type: object
                properties:
                  prediction_id: { type: string, format: uuid }
                  status: { type: string, enum: [SETTLED, REFUNDED] }
                  winning_option_index: { type: integer, nullable: true }
                  winner: { type: string, nullable: true }
                  resolution_source: { type: string, nullable: true }
                  resolved_by: { type: string, nullable: true }
                  resolved_at: { type: string, format: date-time, nullable: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: prediction has not yet resolved
        '429': { $ref: '#/components/responses/RateLimited' }

  /campaign-config:
    get:
      operationId: getCampaignConfig
      summary: Read this partner's own commercial configuration
      description: |
        Requires the `campaigns:read` scope. Partner predictions never use
        sponsor_campaigns — this returns the partner's fixed-share commercial terms
        (take_rate, partner_take, brand_config), not a Pulse-style campaign object.
      responses:
        '200':
          description: Commercial configuration
          content:
            application/json:
              schema:
                type: object
                properties:
                  partner_id: { type: string, format: uuid }
                  take_rate: { type: number }
                  partner_take: { type: number }
                  brand_config: { type: object }
                  model: { type: string, enum: [fixed_share] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /analytics:
    get:
      operationId: getAnalytics
      summary: Read this partner's revenue summary
      description: Requires the `analytics:read` scope.
      responses:
        '200':
          description: Revenue summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  partner_id: { type: string, format: uuid }
                  this_month_cents: { type: integer }
                  last_month_cents: { type: integer }
                  total_cents: { type: integer }
                  pending_payout_cents: { type: integer }
                  prediction_count: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /intelligence/summary:
    get:
      operationId: getIntelligenceSummary
      summary: Read a Platform Intelligence participation summary for this partner's own content
      description: |
        Requires the `intelligence:read` scope. Only available to a partner whose
        `product_domain` is `intelligence` and whose `tenant_id` is set — a
        predictions-hosting partner is refused with `intelligence_not_enabled` even
        if the scope were granted to its key. Every number is a direct aggregate of
        real rows (`data_labeling: actual_only_no_simulation`); nothing here is
        modeled or forecast. This is the first intelligence route, not the full product.
      parameters:
        - name: window_days
          in: query
          description: Rolling window in days, ending now.
          schema: { type: integer, minimum: 1, maximum: 90, default: 28 }
      responses:
        '200':
          description: Participation summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  partner_id: { type: string, format: uuid }
                  data_labeling: { type: string, enum: [actual_only_no_simulation] }
                  window_days: { type: integer }
                  content_sources_in_scope: { type: integer }
                  k_floor: { type: integer, description: "The k-anonymity floor applied below. Matches public.graph_k_floor()." }
                  suppressed: { type: boolean, description: "true when participants is below k_floor — every field below is NULL in that case, not a raw small number." }
                  participants: { type: integer, nullable: true }
                  repeat_participants: { type: integer, nullable: true }
                  interactions_per_participant: { type: number, nullable: true }
                  session_retention_pct: { type: number, nullable: true }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: partner is not configured for Platform Intelligence (wrong product_domain or no tenant_id)
        '402':
          description: partner has no active commercial plan (`commercial_plan_required`) — set via `admin_set_partner_commercial_plan`
        '429': { $ref: '#/components/responses/RateLimited' }

  /intelligence/moments:
    get:
      operationId: getIntelligenceMoments
      summary: Read the partner's own content moments ranked by return rate
      description: |
        Requires the `intelligence:read` scope. Same partner gating as
        `GET /intelligence/summary` — `product_domain` must be `intelligence`
        and `tenant_id` must be set, checked independently of the scope.
        Scoped to the partner's own tenant. Ranked by
        `return_rate` descending, then `revenue_cents` descending. Does not
        expose any internal creator identity. Every number is `data_labeling:
        actual_only_no_simulation`.
      parameters:
        - name: window_days
          in: query
          description: Rolling window in days, ending now.
          schema: { type: integer, minimum: 1, maximum: 90, default: 28 }
        - name: limit
          in: query
          description: Maximum number of moments to return, ranked by return_rate.
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
      responses:
        '200':
          description: Moments ranked by return rate
          content:
            application/json:
              schema:
                type: object
                properties:
                  partner_id: { type: string, format: uuid }
                  data_labeling: { type: string, enum: [actual_only_no_simulation] }
                  window_days: { type: integer }
                  moments_in_scope: { type: integer }
                  k_floor: { type: integer, description: "The k-anonymity floor applied below. Matches public.graph_k_floor()." }
                  moments:
                    type: array
                    items:
                      type: object
                      properties:
                        event_id: { type: string, format: uuid }
                        event_type: { type: string }
                        suppressed: { type: boolean, description: "true when this moment's participants is below k_floor — every field below is NULL in that case, not a raw small number." }
                        participants: { type: integer, nullable: true }
                        returned_participants: { type: integer, nullable: true }
                        return_rate: { type: number, nullable: true }
                        revenue_cents: { type: integer, nullable: true }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: partner is not configured for Platform Intelligence (wrong product_domain or no tenant_id)
        '402':
          description: partner has no active commercial plan (`commercial_plan_required`) — set via `admin_set_partner_commercial_plan`
        '429': { $ref: '#/components/responses/RateLimited' }

  /intelligence/audience:
    get:
      operationId: getIntelligenceAudience
      summary: Read the partner's own audience composition, k-anonymized by content category
      description: |
        Requires the `intelligence:read` scope. Same partner gating as
        `GET /intelligence/summary` — `product_domain` must be `intelligence`
        and `tenant_id` must be set, checked independently of the scope.
        cohort_size/mean_accuracy/mean_events_per_fan are null below the
        k-anonymity floor, and results are limited to the partner's own
        tenant. No `window_days` — this is a lifetime
        cohort aggregate per (campaign, content_category), not a rolling
        window. Every number is `data_labeling: actual_only_no_simulation`.
      parameters:
        - name: limit
          in: query
          description: Maximum number of cohorts to return, ranked non-suppressed-first then by cohort_size.
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
      responses:
        '200':
          description: Audience cohorts, k-anonymized
          content:
            application/json:
              schema:
                type: object
                properties:
                  partner_id: { type: string, format: uuid }
                  data_labeling: { type: string, enum: [actual_only_no_simulation] }
                  k_floor: { type: integer, description: "The k-anonymity floor applied below. Matches public.graph_k_floor()." }
                  cohorts_in_scope: { type: integer }
                  audience:
                    type: array
                    items:
                      type: object
                      properties:
                        campaign_id: { type: string, format: uuid }
                        content_category: { type: string }
                        suppressed: { type: boolean, description: "true when this cohort's size is below k_floor — every field below is NULL in that case, not a raw small number." }
                        cohort_size: { type: integer, nullable: true }
                        mean_accuracy: { type: number, nullable: true }
                        mean_events_per_fan: { type: number, nullable: true }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: partner is not configured for Platform Intelligence (wrong product_domain or no tenant_id)
        '402':
          description: partner has no active commercial plan (`commercial_plan_required`) — set via `admin_set_partner_commercial_plan`
        '429': { $ref: '#/components/responses/RateLimited' }

  /intelligence/exports:
    post:
      operationId: createIntelligenceExport
      summary: Enqueue a background export of summary/moments/audience
      description: |
        Requires the `intelligence:read` scope — no separate export scope.
        Returns `202` with the new job immediately; the export itself runs
        asynchronously (minutes, not seconds — poll
        `GET /intelligence/exports/{id}`). Reuses the exact same
        partner_intelligence_summary/_moments/_audience RPC the
        corresponding sync `GET` route calls, so k-anonymity suppression,
        tenant scoping, and sandbox/production isolation are identical to
        the synchronous response — the export just serializes it to a
        downloadable file instead of returning it inline. `window_days`/
        `limit` are the same fields and bounds as the target route's own
        query parameters. Idempotency-Key supported.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [route]
              properties:
                route: { type: string, enum: [summary, moments, audience] }
                window_days: { type: integer, minimum: 1, maximum: 90, default: 28, description: "Only used when route is summary or moments." }
                limit: { type: integer, minimum: 1, maximum: 100, default: 25, description: "Only used when route is moments or audience." }
                format: { type: string, enum: [json, csv], default: json }
      responses:
        '202':
          description: Export enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  export_id: { type: string, format: uuid }
                  status: { type: string, enum: [queued] }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /intelligence/exports/{id}:
    get:
      operationId: getIntelligenceExport
      summary: Poll an export job's status; download it once succeeded
      description: |
        Requires the `intelligence:read` scope. An export belonging to a
        different partner returns the same generic `404` as one that
        doesn't exist (anti-enumeration, same posture as the `/predictions`
        routes). Once `status` is `succeeded`, the response carries a
        `download_url` — a fresh, 15-minute signed URL minted on every call,
        never a stored/reusable one. `failed` responses never include the
        underlying database error, only a redacted `error_message`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Export status (and download URL once succeeded)
          content:
            application/json:
              schema:
                type: object
                properties:
                  export_id: { type: string, format: uuid }
                  status: { type: string, enum: [queued, running, succeeded, failed, expired] }
                  route: { type: string, enum: [summary, moments, audience] }
                  format: { type: string, enum: [json, csv] }
                  row_count: { type: integer, nullable: true }
                  requested_at: { type: string, format: date-time }
                  completed_at: { type: string, format: date-time, nullable: true }
                  error_message: { type: string, nullable: true, description: "A redacted failure reason. Non-null only when status is failed." }
                  download_url: { type: string, description: "Present only when status is succeeded." }
                  download_url_expires_in_seconds: { type: integer, description: "Present only when status is succeeded." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "export not found (or belongs to a different partner)" }
        '429': { $ref: '#/components/responses/RateLimited' }

  /vision/verify:
    post:
      operationId: createVisionVerification
      summary: Submit a recorded video for PLLAY Vision outcome verification
      description: |
        Requires the `vision:verify` scope. Only available to a partner
        whose `product_domain` is `vision`, gated by the
        `vision_recorded_enabled` capability flag -- a predictions-hosting
        partner is refused with `vision_not_enabled` even if the scope were
        granted to its key. Recorded video only; there is no live-stream
        submission path. Returns `202` immediately -- verification runs
        asynchronously; poll `GET /vision/verify/{id}`. Structurally
        separate from PLLAY's own internal settlement path: this queue has
        no foreign key into PLLAY's predictions and no code path to real
        settlement.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [video_url, adapter, market]
              properties:
                video_url: { type: string, format: uri, description: "Must be an https:// URL to a public host -- loopback, private-network, link-local, and known metadata hosts are rejected." }
                adapter: { type: string, description: "Adapter filename, e.g. cs2_matchresult.json. Must match ^[a-z0-9_]+\\.json$ and name an adapter PLLAY supports (e.g. fortnite_result.json)." }
                game_slug: { type: string, nullable: true }
                market:
                  type: object
                  required: [kind, options]
                  description: "Market descriptor: {kind, options: [{index, label}, ...], ...}. Plain strings are accepted and numbered in order. For Fortnite use kind pov with options WIN and LOSE (see the Fortnite guide at https://pllay.io/developers#fortnite). For Dota 2 also send pov_side: home if the POV player is Radiant, away if Dire (https://pllay.io/developers#dota2)."
                  properties:
                    pov_side: { type: string, enum: [home, away], default: home, description: "Which side the POV player is on, for adapters that read the winning SIDE rather than the POV outcome. Required for Dota 2 (adapter dota2_victory.json): home if the POV player is Radiant, away if Dire. The victory screen names the winning side, and pov_side maps it to the POV player's WIN or LOSE. A Dota 2 event (game_slug dota2 or adapter dota2_victory.json, market kind pov or side) without pov_side set to home or away is rejected with 400 pov_side_required (field market.pov_side, documentation_url https://pllay.io/developers#dota2) before anything is queued. Ignored by adapters that read the POV outcome directly (Fortnite, Valorant). See https://pllay.io/developers#dota2." }
            examples:
              fortnite:
                summary: "Fortnite: did the POV player win (Victory Royale)?"
                value:
                  video_url: https://example.com/your-fortnite-clip.mp4
                  adapter: fortnite_result.json
                  game_slug: fortnite
                  market: { kind: pov, options: [WIN, LOSE] }
              league:
                summary: "League of Legends: did the POV player win?"
                value:
                  video_url: https://example.com/your-league-clip.mp4
                  adapter: lol_result.json
                  game_slug: league-of-legends
                  market: { kind: pov, options: [WIN, LOSE] }
              dota2:
                summary: "Dota 2: did the POV player's side win? (POV player on Dire, so pov_side away)"
                value:
                  video_url: https://example.com/your-dota2-clip.mp4
                  adapter: dota2_victory.json
                  game_slug: dota2
                  market: { kind: pov, options: [WIN, LOSE], pov_side: away }
      responses:
        '202':
          description: Verification job enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  verification_id: { type: string, format: uuid }
                  status: { type: string, enum: [queued] }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: partner is not configured for PLLAY Vision (wrong product_domain or capability disabled)
        '402':
          description: |
            Not accepted, nothing queued or billed (`retryable: false`):
            `commercial_plan_required` (no active commercial plan — set via `admin_set_partner_commercial_plan`);
            `spend_cap_reached` (pay-per-settle monthly spend cap reached; production only, resumes next UTC month or when the cap is raised);
            `billing_suspended` (pay-per-settle invoice unpaid past its grace period; production only, sandbox keys keep working).
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503':
          description: '`billing_check_failed` — the pay-per-settle billing check could not complete; nothing was queued (`retryable: true`).'

  /vision/verify/{id}:
    get:
      operationId: getVisionVerification
      summary: Poll a verification job's status; read its result once available
      description: |
        Requires the `vision:verify` scope. A verification belonging to a
        different partner returns the same generic `404` as one that
        doesn't exist (anti-enumeration, same posture as
        `/intelligence/exports/{id}`). Once `status` is `succeeded`, the
        response carries an `evidence_url` -- a fresh, 15-minute signed URL
        minted on every call, never a stored/reusable one.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Verification status (and evidence URL once succeeded)
          content:
            application/json:
              schema:
                type: object
                properties:
                  verification_id: { type: string, format: uuid }
                  status: { type: string, enum: [queued, processing, succeeded, failed, canceled] }
                  result: { type: object, nullable: true, description: "{tier, winner_index|winner_label, confidence, reason}. Non-null only when status is succeeded." }
                  error_message: { type: string, nullable: true, description: "A redacted failure reason. Non-null only when status is failed." }
                  requested_at: { type: string, format: date-time }
                  completed_at: { type: string, format: date-time, nullable: true }
                  evidence_url: { type: string, description: "Present only when status is succeeded." }
                  evidence_url_expires_in_seconds: { type: integer, description: "Present only when status is succeeded." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "verification not found (or belongs to a different partner)" }
        '429': { $ref: '#/components/responses/RateLimited' }

  /events:
    post:
      operationId: createResolutionEvent
      summary: Submit a resolution event (title/market validated synchronously, then queued for PLLAY Vision)
      description: |
        Requires the `resolution:write` scope. Resolution API v1 Task 3:
        validates `video_url`, `adapter` (must be a supported title per
        the accuracy-ledger-derived adapter list), and `market` shape
        SYNCHRONOUSLY, before anything is queued -- a `422 validation_error`
        never reaches the queue. Once validation passes, this route
        delegates to the exact same insert `POST /vision/verify` uses
        (same `partner_vision_verification_jobs` queue, same product-domain
        and commercial-plan gates), so the same `403`/`402` outcomes it
        documents apply here too. Accepts `external_market_id` to let a
        partner correlate the resulting job back to its own market record.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [video_url, adapter, market]
              properties:
                video_url: { type: string, format: uri, description: "Must be an https:// URL." }
                adapter: { type: string, description: "Must be a supported adapter filename, e.g. cs2_matchresult.json." }
                game_slug: { type: string, nullable: true }
                market:
                  type: object
                  required: [kind, options]
                  properties:
                    kind: { type: string, enum: [pov, side, team], description: "Restricted to the kinds PLLAY Vision can map to an option index -- an unsupported kind, e.g. the older 'match_winner' placeholder, is a 422 field error at submit time rather than a VOID result after PLLAY Vision runs. `pov` (used by Fortnite, Valorant, League of Legends and Dota 2; adapters fortnite_result.json, valorant_result.json, lol_result.json, dota2_victory.json) answers 'did the POV player win?': the options must contain exactly one label equal to WIN and one equal to LOSE (case-insensitive); anything else cannot be mapped and resolves as VOID. See https://pllay.io/developers#fortnite. Dota 2 (adapter dota2_victory.json) also uses `pov`, plus market.pov_side (home = Radiant, away = Dire); see https://pllay.io/developers#dota2." }
                    options: { type: array, items: { type: string }, minItems: 2 }
                    pov_side: { type: string, enum: [home, away], default: home, description: "Which side the POV player is on, for adapters that read the winning SIDE rather than the POV outcome. Required for Dota 2 (adapter dota2_victory.json): home if the POV player is Radiant, away if Dire. The victory screen names the winning side, and pov_side maps it to the POV player's WIN or LOSE. A Dota 2 event (game_slug dota2 or adapter dota2_victory.json, market kind pov or side) without pov_side set to home or away is rejected with 400 pov_side_required (field market.pov_side, documentation_url https://pllay.io/developers#dota2) before anything is queued. Ignored by adapters that read the POV outcome directly (Fortnite, Valorant). See https://pllay.io/developers#dota2." }
                external_market_id: { type: string, maxLength: 200, nullable: true }
            examples:
              fortnite:
                summary: "Fortnite: did the POV player win (Victory Royale)?"
                value:
                  video_url: https://example.com/your-fortnite-clip.mp4
                  adapter: fortnite_result.json
                  game_slug: fortnite
                  market: { kind: pov, options: [WIN, LOSE] }
                  external_market_id: your-market-123
              league:
                summary: "League of Legends: did the POV player win?"
                value:
                  video_url: https://example.com/your-league-clip.mp4
                  adapter: lol_result.json
                  game_slug: league-of-legends
                  market: { kind: pov, options: [WIN, LOSE] }
                  external_market_id: your-market-123
              dota2:
                summary: "Dota 2: did the POV player's side win? (POV player on Dire, so pov_side away)"
                value:
                  video_url: https://example.com/your-dota2-clip.mp4
                  adapter: dota2_victory.json
                  game_slug: dota2
                  market: { kind: pov, options: [WIN, LOSE], pov_side: away }
                  external_market_id: your-market-123
      responses:
        '202':
          description: Event validated and enqueued for verification
          content:
            application/json:
              schema:
                type: object
                properties:
                  verification_id: { type: string, format: uuid }
                  event_id: { type: string, format: uuid, description: "Same value as verification_id." }
                  status: { type: string, enum: [queued] }
        '400':
          description: "pov_side_required: a Dota 2 event (game_slug dota2 or adapter dota2_victory.json, market kind pov or side) without market.pov_side set to home (POV player on Radiant) or away (Dire). Error envelope with code pov_side_required, field_errors [{field: market.pov_side}] and documentation_url https://pllay.io/developers#dota2. Also invalid JSON."
        '422': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: partner is not configured for PLLAY Vision (wrong product_domain or capability disabled)
        '402':
          description: |
            Not accepted, nothing queued or billed (`retryable: false`): `commercial_plan_required`,
            `spend_cap_reached` or `billing_suspended` (see `POST /vision/verify`).
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503':
          description: '`billing_check_failed` — the pay-per-settle billing check could not complete; nothing was queued (`retryable: true`).'

  /events/{id}:
    get:
      operationId: getResolutionEvent
      summary: Read a resolution event's tiered outcome
      description: |
        Requires the `resolution:read` scope. Same anti-enumeration posture
        as `GET /vision/verify/{id}`: an event belonging to a different
        partner, or the wrong environment (sandbox vs. production), returns
        the same generic `404` as one that doesn't exist. `status` collapses
        the underlying job's `queued`/`processing`/`succeeded`/`failed`/
        `canceled` states into `queued`/`processing`/`resolved`/`failed`
        (`canceled` reads as `failed`, never `queued`). `resolution` is
        non-null only once `status` is `resolved`. Within `resolution`,
        `tier` is `AUTO`, `CONFIRM`, or `VOID` -- `VOID` never carries an
        `outcome_option_index`/`outcome_label` even if one was recorded, and
        `settleable` is `true` only for `AUTO` (a `CONFIRM` result becomes
        settleable only after ops confirms it).

        `state` restates `status` and `resolution.tier` in the vocabulary
        of the settlement contract; it adds no new judgement, and `status`,
        `tier` and `settleable` are unchanged. `settled` means tier `AUTO`
        today. `reversed` and staff confirmation of a `CONFIRM` result are
        not produced yet. `open` is not used: an event is `locked` from the
        moment it is submitted.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Resolution status (and tiered outcome once resolved)
          content:
            application/json:
              schema:
                type: object
                required: [event_id, status, state]
                properties:
                  event_id: { type: string, format: uuid }
                  status: { type: string, enum: [queued, processing, resolved, failed] }
                  state:
                    type: string
                    enum: [locked, held, settled, reversed, voided]
                    description: >-
                      The event in the vocabulary of the settlement contract.
                      locked: submitted, not yet resolved. settled: resolved at
                      tier AUTO. held: needs a person — a CONFIRM result
                      (confirmation by PLLAY staff is not available yet, so a
                      CONFIRM result stays held), or a run that failed. voided:
                      no outcome — a VOID result, or a canceled event. reversed:
                      not produced on this route. An overturn keeps this
                      event's original values and issues a NEW corrected or
                      void result, delivered as the resolution.corrected
                      webhook and as correction on GET /v1/disputes/{id}. Anything the API cannot classify reads held,
                      never settled. A clear read on recorded video settles
                      automatically for Fortnite, League of Legends, Dota 2,
                      Rocket League and PUBG: BATTLEGROUNDS, which are available and
                      live today. VALORANT requires human confirmation while automatic settlement is under review.
                      “Available and live today” means offered now for
                      recorded-video settlement, not live-stream settlement
                      (https://pllay.io/coverage#live). Game names are trademarks
                      of their owners, and a listing is not a partnership,
                      sponsorship or endorsement
                      (https://pllay.io/coverage#titles). When the read is not clear, the result is
                      held for review. A production result on any other title
                      is held with reason title_not_certified. Sandbox: an
                      external_market_id starting with sandbox-held always
                      resolves CONFIRM (held).
                  external_market_id: { type: string, nullable: true }
                  resolution:
                    type: object
                    nullable: true
                    description: "Non-null only when status is resolved."
                    properties:
                      outcome_option_index: { type: integer, nullable: true }
                      outcome_label: { type: string, nullable: true }
                      tier: { type: string, enum: [AUTO, CONFIRM, VOID] }
                      settleable: { type: boolean }
                      confidence: { type: number, nullable: true, description: "Null until the Vision engine produces a measured score; tier is the signal today." }
                      model_version: { type: string, nullable: true }
                      adapter_version: { type: string, nullable: true }
                      resolved_at: { type: string, format: date-time }
                      latency_ms: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "event not found (or belongs to a different partner/environment)" }
        '429': { $ref: '#/components/responses/RateLimited' }

  /events/{id}/disputes:
    post:
      operationId: openResolutionDispute
      summary: Dispute a resolved event
      description: |
        Requires the `resolution:dispute` scope.
        Only a resolved event (any tier, including `VOID` -- a void can be
        wrong too) can be disputed, within 72 hours after the result was
        issued (`resolution.resolved_at`). Each result can be disputed once:
        a second filing is refused while the first is open
        (`dispute_already_open`) and after it is decided
        (`dispute_already_decided`). An event belonging to a different partner
        or environment returns the same generic `404` as one that doesn't
        exist. Opening a dispute emits a `dispute.opened` webhook to
        subscribed endpoints. Supports `Idempotency-Key`.

        PLLAY reviews the evidence and decides upheld or overturned, with a
        written reason (`dispute.decided` webhook). PLLAY aims to acknowledge
        a dispute within 4 hours and decide it within 48 hours of filing;
        these are target times, not a contractual guarantee. An overturned
        result is never edited: PLLAY issues a corrected or void result as a
        new record that references the original and sends
        `resolution.corrected`. PLLAY never moves money; you adjust your own
        users' positions from that webhook. Dispute webhooks reach you only
        on the endpoint PLLAY registers for you, subscribed to the dispute
        events (see Webhooks above).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason_code, reason_text]
              properties:
                reason_code: { type: string, enum: [wrong_outcome, wrong_market, evidence_unclear, other] }
                reason_text: { type: string, minLength: 1, maxLength: 2000, description: "The explanation (up to 2,000 characters)." }
                evidence_url: { type: string, format: uri, maxLength: 2048, nullable: true, description: "Optional supporting evidence. Must be an https:// URL with a host." }
      responses:
        '201':
          description: Dispute opened
          content:
            application/json:
              schema:
                type: object
                required: [dispute_id, status]
                properties:
                  dispute_id: { type: string, format: uuid }
                  status: { type: string, enum: [open] }
        '422':
          description: "`validation_error` (bad body), or `dispute_window_closed` (more than 72 hours since the result was issued)"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "event not found (or belongs to a different partner/environment)" }
        '409':
          description: "`event_not_resolved` (event has not resolved yet), `dispute_already_open`, `dispute_already_decided` (each result can be disputed once), or an idempotency conflict"
        '429': { $ref: '#/components/responses/RateLimited' }

  /disputes/{id}:
    get:
      operationId: getResolutionDispute
      summary: Read a dispute
      description: |
        Requires the `resolution:read` scope. A dispute belonging to a
        different partner returns the same generic `404` as one that doesn't
        exist. `status` is `open` until PLLAY decides it (`upheld` or
        `overturned`), at which point `decision_reason` and `decided_at` are set.
        The lifecycle fields (`lifecycle_status`, `acknowledged_at`,
        `ack_due_at`, `decide_due_at`) and `correction` are additive and null
        for a dispute filed before they existed. `ack_due_at` and
        `decide_due_at` are target times, not a contractual guarantee.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The dispute
          content:
            application/json:
              schema:
                type: object
                required: [dispute_id, event_id, reason_code, reason_text, evidence_url, status, decision_reason, opened_at, decided_at]
                properties:
                  dispute_id: { type: string, format: uuid }
                  event_id: { type: string, format: uuid }
                  reason_code: { type: string, enum: [wrong_outcome, wrong_market, evidence_unclear, other] }
                  reason_text: { type: string }
                  evidence_url: { type: string, nullable: true }
                  status: { type: string, enum: [open, upheld, overturned] }
                  decision_reason: { type: string, nullable: true }
                  opened_at: { type: string, format: date-time }
                  decided_at: { type: string, format: date-time, nullable: true }
                  lifecycle_status:
                    type: string
                    nullable: true
                    enum: [filed, acknowledged, under_review, decided_upheld, decided_overturned, reversed]
                    description: "The review step. `reversed` means the overturn's corrected or void result has been issued."
                  acknowledged_at: { type: string, format: date-time, nullable: true }
                  ack_due_at: { type: string, format: date-time, nullable: true, description: "Target time to acknowledge the dispute." }
                  decide_due_at: { type: string, format: date-time, nullable: true, description: "Target time to decide the dispute." }
                  correction:
                    type: object
                    nullable: true
                    description: "Set once an overturn has issued its new result. The original event is unchanged."
                    properties:
                      correction_id: { type: string, format: uuid }
                      kind: { type: string, enum: [corrected, void] }
                      outcome_option_index: { type: integer, nullable: true, description: "The corrected outcome; null for void." }
                      created_at: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "dispute not found (or belongs to a different partner, or its event is in a different environment than the key -- sandbox vs. production)" }
        '429': { $ref: '#/components/responses/RateLimited' }

  /events/{id}/evidence:
    get:
      operationId: getResolutionEventEvidence
      summary: Read the evidence bundle behind a resolved event
      description: |
        Requires the `resolution:read` scope. Same partner/environment
        filter and anti-enumeration `404` as `GET /events/{id}`. Returns
        `409 not_resolved` until the event has resolved. A resolved event
        with no stored evidence record still returns `200` with
        `signals: {}` and `audit_hash: null` -- PLLAY never invents a hash.
        `media.url` is a fresh signed URL valid for `expires_in_seconds`
        (900); if signing fails, `media` is `null` rather than an error.
        `audit_hash` is HMAC-SHA256 under a PLLAY-held key, so verify it
        with `POST /evidence/verify`, not locally.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Evidence bundle
          content:
            application/json:
              schema:
                type: object
                properties:
                  event_id: { type: string, format: uuid }
                  contract_version: { type: string, enum: ["1"] }
                  model_version: { type: string, nullable: true }
                  adapter_version: { type: string, nullable: true }
                  confidence: { type: number, nullable: true, description: "Nullable by design; tier is the signal." }
                  vision_confidence:
                    type: object
                    nullable: true
                    description: |
                      Informational only. The vision worker's raw confidence score and, when a
                      per-title calibration map exists, its calibrated probability. Copied from
                      `signals.vision_confidence` (which stays in `signals` and is covered by
                      `audit_hash`). It never changes the tier or outcome and is not `confidence`.
                      `null` for evidence recorded before it existed or when the block is malformed.
                    properties:
                      confidence_formula_version: { type: string, example: "raw-conf-1" }
                      status: { type: string, description: "`scored`, or why there is no score (e.g. `no_candidate`)." }
                      raw_confidence: { type: number, nullable: true, minimum: 0, maximum: 1 }
                      calibrated_confidence: { type: number, nullable: true, minimum: 0, maximum: 1 }
                      calibration_map_version: { type: string, nullable: true }
                      calibration_method: { type: string, nullable: true, description: "`identity`, `platt` or `isotonic`." }
                      calibration_status: { type: string, nullable: true, description: "`applied`, `not_fitted`, `formula_mismatch`, `invalid_map`, `no_raw_score` or `not_applied`." }
                      components: { type: object, nullable: true, additionalProperties: true }
                  signals: { type: object, additionalProperties: true }
                  audit_hash: { type: string, nullable: true }
                  media:
                    type: object
                    nullable: true
                    properties:
                      url: { type: string, format: uri }
                      expires_in_seconds: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "event not found (or belongs to a different partner/environment)" }
        '409': { description: "not_resolved -- the event has not resolved yet" }
        '429': { $ref: '#/components/responses/RateLimited' }

  /evidence/verify:
    post:
      operationId: verifyEvidence
      summary: Verify an evidence bundle's audit_hash server-side
      description: |
        Requires the `resolution:read` scope. PLLAY recomputes the HMAC over
        the stored evidence record (moment, model/adapter version,
        confidence) and the submitted `signals` (default: the stored
        signals), then compares it with `audit_hash` in constant time.
        Returns only `{valid}` -- never the signing key or the expected
        hash. `valid` is `false` when no signed record exists. Same
        partner/environment filter as `GET /events/{id}` (`404`), and
        `409 not_resolved` for an unresolved event.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event_id, audit_hash]
              properties:
                event_id: { type: string, format: uuid }
                audit_hash: { type: string }
                signals: { type: object, additionalProperties: true, description: "The bundle's signals as you received them. Omit to check the hash against PLLAY's stored signals." }
      responses:
        '200':
          description: Verification result
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "event not found (or belongs to a different partner/environment)" }
        '409': { description: "not_resolved -- the event has not resolved yet" }
        '422': { description: "validation_error" }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks:
    get:
      operationId: getWebhook
      summary: Read this partner's current webhook configuration
      description: |
        Requires the `webhooks:manage` scope. Never returns webhook_secret —
        it is write-once/return-once, minted and shown exactly once by
        `PATCH /webhooks`. `configured` tells you whether a secret exists
        without re-exposing it.
      responses:
        '200':
          description: Current webhook configuration
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook_url: { type: string, format: uri, nullable: true }
                  configured: { type: boolean, description: "Whether a signing secret has been minted." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      operationId: updateWebhook
      summary: Set or rotate this partner's webhook endpoint
      description: |
        Requires the `webhooks:manage` scope. Rotates the HMAC signing secret on
        every URL change — the new secret is returned exactly once, here.
        Configures the legacy endpoint for `prediction.*` events only.
        Resolution and dispute events (`resolution.*`, `dispute.*`) go to an
        endpoint PLLAY registers for you; send PLLAY your HTTPS URL.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [webhook_url]
              properties:
                webhook_url: { type: string, format: uri, pattern: '^https://' }
      responses:
        '200':
          description: Webhook updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook_url: { type: string, format: uri }
                  webhook_secret: { type: string, description: "Returned exactly once — store it now." }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /embed/state:
    get:
      operationId: getEmbedState
      summary: Read the public, non-partner-authenticated state of one interaction
      description: |
        PUBLIC. Backs the PLLAY embeddable participation web component — not
        part of the partner-authenticated surface above, and NOT covered by
        the `X-Partner-Key` security scheme or any scope. Auth is a
        single-interaction, origin-locked embed token (`pllay_et_...`, minted
        automatically by `interactions.create()`), passed as `X-Embed-Token`
        or a `?token=` query param. Never carries fan identity or win/loss
        personalization — `rewards_distributed` is an interaction-level
        aggregate (did anyone win anything), never "did the current viewer."
      security: []
      parameters:
        - name: X-Embed-Token
          in: header
          required: false
          schema: { type: string }
          description: Required unless supplied as the `token` query param instead.
        - name: token
          in: query
          required: false
          schema: { type: string }
      responses:
        '200':
          description: Interaction state
          content:
            application/json:
              schema:
                type: object
                properties:
                  interaction_id: { type: string, format: uuid }
                  widget_state: { type: string }
                  title: { type: string }
                  options: { type: array, items: { type: string } }
                  pool_total_cents: { type: integer }
                  time_remaining_seconds: { type: integer, nullable: true }
                  winning_option_index: { type: integer, nullable: true }
                  winner: { type: string, nullable: true }
                  resolution_source: { type: string, nullable: true }
                  resolved_at: { type: string, format: date-time, nullable: true }
                  rewards_distributed: { type: boolean, nullable: true, description: "Interaction-level aggregate, not per-viewer." }
                  updated_at: { type: string, format: date-time }
        '401':
          description: Missing, malformed, expired, revoked, or wrong-origin embed token
        '404':
          description: Interaction not found

components:
  securitySchemes:
    PartnerKey:
      type: apiKey
      in: header
      name: X-Partner-Key
      description: |
        pllay_sk_live_... or pllay_sk_sandbox_... — issue via the admin-only
        issue_partner_api_key RPC. Legacy pllay_pk_... keys also work on every
        route here (treated as carrying all scopes) for backward compatibility.

  parameters:
    PredictionId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string }
      description: Recommended for every mutating request. The same key and the same body within 24 hours returns the original 200 response.
    CorrelationId:
      name: X-Correlation-Id
      in: header
      required: false
      schema: { type: string }
      description: Echoed back on the response and recorded in partner_api_request_log for support correlation.

  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          required: [code, message, request_id, documentation_url, retryable]
          properties:
            code: { type: string }
            message: { type: string }
            request_id: { type: string }
            documentation_url: { type: string, format: uri }
            retryable: { type: boolean }
            field_errors:
              type: array
              items:
                type: object
                properties:
                  field: { type: string }
                  message: { type: string }
    PredictionSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        state: { type: string }
        options: { type: array, items: { type: string } }
        winning_option_index: { type: integer, nullable: true }
        total_pool: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    PredictionState:
      type: object
      properties:
        prediction_id: { type: string, format: uuid }
        status: { type: string }
        pool_total_cents: { type: integer }
        options: { type: array, items: { type: string } }
        odds: { type: array, items: { type: object } }
        time_remaining_seconds: { type: integer, nullable: true }

  responses:
    Unauthorized:
      description: Missing, malformed, invalid, revoked, expired, or under-scoped key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Prediction does not exist, or exists but belongs to a different partner (identical response, by design)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ValidationError:
      description: Request failed field-level validation
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    IdempotencyConflict:
      description: Idempotency-Key was reused with a different request body
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    PayloadTooLarge:
      description: Request body exceeds 100,000 bytes
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Per-partner, per-route rate limit exceeded
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
