# TipPage Developer API - OpenAPI 3.1
#
# The same document that powers the reference at docs.tippage.com/api.
# Import it into Postman, Insomnia, or Bruno, or feed it to a client
# generator. Guides and webhook docs: https://docs.tippage.com/api
openapi: 3.1.0

info:
  title: TipPage Developer API
  version: "1.0"
  summary: Read tips, drive the TTS and media queues, and receive signed webhooks.
  description: |
    Build your own integrations on top of a TipPage: read tip history, watch and
    control the **TTS queue** and **media queue**, and receive **signed webhooks**
    the moment things happen. Every tip produces one TTS entry and, optionally,
    one media entry.

    ## Download the spec

    This entire reference is generated from one OpenAPI 3.1 document:
    **[openapi.yaml](https://docs.tippage.com/openapi.yaml)**. Import it into
    Postman, Insomnia, or Bruno, or feed it to a client generator.

    ## Authentication

    Every request needs an API key sent as a bearer token:

    ```
    Authorization: Bearer tp_live_...
    ```

    Keys are created by the streamer in **Dashboard → Settings → Developer**
    (owner and super admins only). The key identifies the streamer, so there is
    no account id anywhere in these paths. Each key carries **scopes** - the
    operations below name the scope they need. Keys are server-side
    credentials: never embed one in a browser page or show it on stream.

    ## Rate limits

    240 requests/min per IP and 120 requests/min per key. Limit state is
    returned in standard `RateLimit-*` headers; exceeding it returns `429`.

    ## Errors

    Errors are JSON: `{ "error": "<human message>", "code": "<machine_code>" }`.

    ## Webhooks

    Register endpoint URLs (here or in the dashboard) and TipPage POSTs a JSON
    event envelope to them. See the **Webhooks** section at the bottom for
    every event type and payload.

    **Verify every delivery before trusting it.** Your endpoint is a public
    URL - anyone who discovers it can POST a perfectly-shaped envelope. The
    `TipPage-Signature` header is the **only** proof a request came from
    TipPage; every other header and the envelope itself are plain text anyone
    can forge, and source-IP allowlisting doesn't work (delivery IPs are not
    stable). Each request carries:

    | Header | Value |
    |---|---|
    | `TipPage-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` - **the proof of origin** |
    | `TipPage-Event` | the event type (e.g. `tip.created`) - informational |
    | `TipPage-Event-Id` | the envelope id (`evt_...`) - informational |
    | `TipPage-Delivery` | the delivery id (`wd_...`, or `ping` for tests) - informational |
    | `TipPage-Attempt` | delivery attempt number (`1`-`8`); a value above 1 means this is a retry of the same event, byte-identical body - informational |

    The MAC is HMAC-SHA256 over `<t>.<raw request body>` with your endpoint's
    signing secret (`whsec_...`, shown once at creation). Verify against the
    **raw body bytes** (before any JSON parser touches the request), reject
    `t` more than ~5 minutes from your clock (blocks replays of captured
    deliveries), and compare MACs in constant time:

    ```js
    import crypto from "node:crypto";

    function verify(secret, header, rawBody, toleranceSec = 300) {
      const parts = Object.fromEntries(
        (header || "").split(",").map((p) => p.split("="))
      );
      if (!parts.t || !parts.v1) return false;
      if (!(Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSec)) return false;
      const mac = crypto.createHmac("sha256", secret)
        .update(`${parts.t}.${rawBody}`).digest("hex");
      const got = Buffer.from(parts.v1, "hex");
      const want = Buffer.from(mac, "hex");
      return got.length === want.length && crypto.timingSafeEqual(got, want);
    }
    ```

    On a failed check, respond `401` and do nothing else with the request.
    Retries re-sign the byte-identical body with a fresh `t`, so verify every
    request, not once per event id - and since delivery is at-least-once,
    dedupe verified events by the envelope `id`. The full verification guide
    (with an Express example and common pitfalls) is at
    [docs.tippage.com/developer-api](https://docs.tippage.com/developer-api).

    Delivery is **at-least-once** with no ordering guarantee. Non-2xx responses
    retry with backoff for up to ~44 hours (8 attempts); an endpoint that keeps
    failing is disabled automatically and the streamer is notified. Endpoint
    URLs must be public `https://` - private and internal addresses are
    rejected. Answer with any 2xx as fast as possible and do real work async.

servers:
  - url: https://api.tippage.com/v1

security:
  - bearerAuth: []

tags:
  - name: Identity
    description: Key introspection and API discovery.
  - name: TTS queue
    description: >
      The pending TTS queue and its played history. Reads need `tts:read`;
      controls need `tts:control`. The start/finish pair is the same
      protocol the stream overlay speaks - an external consumer can claim a
      TTS, play it its own way, and finish it into history. Full pattern:
      [claim, do your thing, finish](https://docs.tippage.com/developer-api/claim-and-finish).
  - name: Media queue
    description: >
      The media (video request) queue, its played history, live playback
      status, and direct video queueing. Reads need `media:read`; controls
      and queueing need `media:control`.
  - name: Tipping
    description: >
      Tips themselves: create manual tips (`tips:create`), the
      accept-new-tips switch (status readable by any valid key,
      opening/closing via `tipping:control`), and the supporter leaderboard
      (any valid key).
  - name: Viewers
    description: >
      Viewer management for the sub-rewards system. A viewer is someone who
      has signed in to the tip page with Twitch - list them with their
      first sign-in dates and credit balances (`viewers:read`), read one
      viewer's grant ledger and sub-reward usage, and grant or revoke
      credits (`viewers:manage`). Everywhere a `{user}` appears in a path
      you can pass either the Twitch user id or the viewer's name (login
      or display name, case-insensitive). Someone who has never signed in
      can't be looked up or granted credits - those requests answer
      `404 viewer_not_signed_in`.
  - name: AI voices
    description: >
      AI voice TTS (closed beta): the curated voice catalog with per-voice
      enabled flags, this month's character usage, and toggles for the
      whole feature or individual voices. Needs `ai_voices:manage`. Every
      endpoint answers `404` unless AI voices are enabled for your account -
      the feature is invite-only while in beta.
  - name: Channel points
    description: >
      Resolve channel point redemptions on Twitch. Needs
      `channel_points:manage`. Only works for TipPage-managed rewards -
      Twitch restricts redemption resolution to the app that created the
      reward. Pair with the `twitch.channel_point_redemption` webhook, whose
      `is_managed` flag says whether a redemption is resolvable.
  - name: Webhook endpoints
    description: >
      Manage where events get delivered. Needs `webhooks:manage`. Endpoints
      can also be managed in the dashboard.

paths:
  /me:
    get:
      operationId: getMe
      tags: [Identity]
      summary: Who am I
      description: >
        Returns the streamer this key belongs to and the key's own metadata.
        The first call to make when wiring up an integration.
      responses:
        "200":
          description: Key and tenant identity.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenant:
                    type: object
                    properties:
                      id: { type: string, examples: ["t_a7f3b9c2"] }
                      name: { type: string, examples: ["CallumFromTheCorner"] }
                  key:
                    type: object
                    properties:
                      id: { type: string, examples: ["ak_1f2e3d4c5b6a"] }
                      name: { type: string, examples: ["Discord bot"] }
                      scopes:
                        type: array
                        items: { type: string }
                        examples: [["tts:read", "tts:control"]]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /events:
    get:
      operationId: listEventTypes
      tags: [Identity]
      summary: Event and scope catalog
      description: Lists every webhook event type and every key scope, with descriptions.
      responses:
        "200":
          description: Catalogs keyed by name.
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: object
                    additionalProperties: { type: string }
                    description: "`event type -> description`"
                  scopes:
                    type: object
                    additionalProperties: { type: string }
                    description: "`scope -> description`"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tts/queue:
    get:
      operationId: getTtsQueue
      tags: [TTS queue]
      summary: Pending TTS queue
      description: >
        The queued TTS entries waiting to be played, oldest first, plus the playback
        state. `tts_url` is `null` until the pre-rendered audio is ready
        (subscribe to `tip.tts_ready` or re-poll). Requires `tts:read`.
      responses:
        "200":
          description: Queue state and items.
          content:
            application/json:
              schema:
                type: object
                properties:
                  is_paused: { type: boolean }
                  currently_playing: { type: boolean }
                  current_order_id:
                    type: [string, "null"]
                    description: order_id of the tip being played right now.
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/QueueTip" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tts/history:
    get:
      operationId: getTtsHistory
      tags: [TTS queue]
      summary: Played TTS history
      description: >
        TTS entries that finished playing, most recent first, cursor-paginated.
        Requires `tts:read`.
      parameters:
        - $ref: "#/components/parameters/HistoryLimit"
        - $ref: "#/components/parameters/HistoryBefore"
      responses:
        "200":
          description: One page of history.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tips:
                    type: array
                    items: { $ref: "#/components/schemas/HistoryTip" }
                  has_more: { type: boolean }
                  next_cursor:
                    type: [string, "null"]
                    description: Pass as `?before=` to fetch the next page.
        "400":
          description: Unknown cursor.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tts/now:
    get:
      operationId: getTtsNow
      tags: [TTS queue]
      summary: What's playing right now
      description: >
        The TTS currently claimed as playing, if any. TTS audio has no
        known duration, so there is no live position - `started_at` is
        when the claim was made, which is enough to spot a consumer that
        died mid-item. Requires `tts:read`.
      responses:
        "200":
          description: Current playback state.
          content:
            application/json:
              schema:
                type: object
                properties:
                  playing:
                    type: boolean
                    description: A TTS is claimed as playing right now.
                  is_paused: { type: boolean }
                  tip:
                    oneOf:
                      - $ref: "#/components/schemas/QueueTip"
                      - type: "null"
                  started_at:
                    type: [string, "null"]
                    format: date-time
                    description: When the current TTS was claimed.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tts/pause:
    post:
      operationId: pauseTts
      tags: [TTS queue]
      summary: Pause the TTS queue
      description: >
        Stops new TTS from starting; the queue keeps accumulating. Requires
        `tts:control`.
      responses:
        "200": { $ref: "#/components/responses/Success" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tts/resume:
    post:
      operationId: resumeTts
      tags: [TTS queue]
      summary: Resume the TTS queue
      description: Clears the paused state and playback pointer. Requires `tts:control`.
      responses:
        "200": { $ref: "#/components/responses/Success" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tts/skip:
    post:
      operationId: skipTts
      tags: [TTS queue]
      summary: Skip the currently-playing TTS
      description: Requires `tts:control`.
      responses:
        "200":
          description: Skipped (a no-op when nothing was playing).
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  skipped_order_id:
                    type: [string, "null"]
                    description: The tip that was playing, if any.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tts/clear:
    post:
      operationId: clearTts
      tags: [TTS queue]
      summary: Clear the whole TTS queue
      description: >
        Deletes every queued TTS and resets playback state (media playback
        state included, matching the dashboard's Clear button). Requires
        `tts:control`.
      responses:
        "200": { $ref: "#/components/responses/Success" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tts/{orderId}:
    delete:
      operationId: removeTtsItem
      tags: [TTS queue]
      summary: Remove one queued TTS
      description: >
        Removes the TTS half of a tip before it plays (a paired media item
        stays queued). The removal is audit-logged for the streamer's
        moderation review. Requires `tts:control`.
      parameters:
        - $ref: "#/components/parameters/OrderId"
      responses:
        "200": { $ref: "#/components/responses/Success" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tts/{orderId}/replay:
    post:
      operationId: replayTtsItem
      tags: [TTS queue]
      summary: Replay a TTS from history
      description: >
        Re-queues a played tip (as a new queue entry with a fresh `replay_...`
        order id; fires `tip.created` with `source: "replay"`). A short
        idempotency window returns `409` if the same tip was just replayed.
        Requires `tts:control`.
      parameters:
        - $ref: "#/components/parameters/OrderId"
      responses:
        "200": { $ref: "#/components/responses/Success" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Replayed too recently (idempotency window).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tts/{orderId}/start:
    post:
      operationId: startTtsItem
      tags: [TTS queue]
      summary: Claim a TTS as now playing
      description: >
        Marks a queued TTS as currently playing - the same call the overlay
        makes. Fires `queue.tts.started`. While claimed, other consumers get
        `409 already_playing`. If your effect then fails and the tip should
        not count as played, undo the claim with
        [release](/api/tts-queue/releasettsitem) instead of
        finishing. See the
        [claim, do your thing, finish](https://docs.tippage.com/developer-api/claim-and-finish)
        guide for the full pattern. Requires `tts:control`.
      parameters:
        - $ref: "#/components/parameters/OrderId"
      responses:
        "200":
          description: Claimed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  tip: { $ref: "#/components/schemas/QueueTip" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: >
            `queue_paused` (the queue is paused) or `already_playing` (another
            tip holds the slot; `current_order_id` names it).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Error"
                  - type: object
                    properties:
                      current_order_id: { type: string }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tts/{orderId}/finish:
    post:
      operationId: finishTtsItem
      tags: [TTS queue]
      summary: Finish a TTS
      description: >
        Promotes the TTS from queue to history and frees the now-playing slot
        (when it points at this one). Fires `queue.tts.finished`. Idempotent -
        finishing an already-gone TTS succeeds with `tip: null`. Finishing
        means "this played" - if it didn't (your effect failed), use
        [release](/api/tts-queue/releasettsitem) to put it back
        instead. Requires `tts:control`.
      parameters:
        - $ref: "#/components/parameters/OrderId"
      responses:
        "200":
          description: Finished.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  tip:
                    oneOf:
                      - $ref: "#/components/schemas/QueueTip"
                      - type: "null"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tts/{orderId}/release:
    post:
      operationId: releaseTtsItem
      tags: [TTS queue]
      summary: Release a claimed TTS back to the queue
      description: >
        The undo of [start](/api/tts-queue/startttsitem), for when your
        effect didn't go to plan - the audio device failed, your process is
        shutting down mid-item, the effect errored before anything played.
        Drops the now-playing claim WITHOUT finishing: the tip stays in the
        queue at its position, the slot frees, and the tip can be claimed
        again (by you after recovering, or by any other consumer). Fires
        `queue.tts.released`. Only drops a claim that actually points at
        this order id - it can never kick out a different tip's claim, and
        releasing something you don't hold is a safe no-op (`released:
        false`). If the tip DID play, use
        [finish](/api/tts-queue/finishttsitem) instead so it lands in
        history. Requires `tts:control`.
      parameters:
        - $ref: "#/components/parameters/OrderId"
      responses:
        "200":
          description: "Released (`released: false` when there was no claim to drop)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  released:
                    type: boolean
                    description: Whether a now-playing claim was actually dropped.
                  tip:
                    oneOf:
                      - $ref: "#/components/schemas/QueueTip"
                      - type: "null"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /media/queue:
    get:
      operationId: getMediaQueue
      tags: [Media queue]
      summary: Pending media queue
      description: >
        Video requests waiting to play, oldest first, plus playback state.
        Requires `media:read`.
      responses:
        "200":
          description: Queue state and items.
          content:
            application/json:
              schema:
                type: object
                properties:
                  is_paused: { type: boolean }
                  visible:
                    type: boolean
                    description: Whether the media player is shown on the overlay.
                  currently_playing: { type: boolean }
                  current_order_id: { type: [string, "null"] }
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/QueueMediaItem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /media/history:
    get:
      operationId: getMediaHistory
      tags: [Media queue]
      summary: Played media history
      description: >
        Media that finished playing, most recent first, cursor-paginated the
        same way as `/tts/history`. Requires `media:read`.
      parameters:
        - $ref: "#/components/parameters/HistoryLimit"
        - $ref: "#/components/parameters/HistoryBefore"
      responses:
        "200":
          description: One page of history.
          content:
            application/json:
              schema:
                type: object
                properties:
                  media:
                    type: array
                    items: { $ref: "#/components/schemas/HistoryMediaItem" }
                  has_more: { type: boolean }
                  next_cursor: { type: [string, "null"] }
        "400":
          description: Unknown cursor.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /media/now:
    get:
      operationId: getMediaNow
      tags: [Media queue]
      summary: What's playing right now
      description: >
        The media item currently on the overlay screen, with its live
        playback position. `position_seconds` is the position within the
        video (start offset + elapsed time), capped at the video's known
        duration; it is `null` while the queue is paused (pausing clears the
        timing anchor) or when no anchor exists. Requires `media:read`.
      responses:
        "200":
          description: Current playback state.
          content:
            application/json:
              schema:
                type: object
                properties:
                  playing:
                    type: boolean
                    description: A media item is on screen right now.
                  is_paused: { type: boolean }
                  visible:
                    type: boolean
                    description: Whether the media player is shown on the overlay.
                  media:
                    oneOf:
                      - $ref: "#/components/schemas/QueueMediaItem"
                      - type: "null"
                  started_at:
                    type: [string, "null"]
                    format: date-time
                    description: When playback of the current item began.
                  position_seconds:
                    type: [integer, "null"]
                    description: Live position within the video, in seconds.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /media:
    post:
      operationId: createMediaItem
      tags: [Media queue]
      summary: Queue a video
      description: >
        Put a video straight into the media queue, no tip attached - it
        plays on the overlay through the normal queue like any other
        request. The URL goes through the same validation as every entry
        route: platform parsing, playability checks, and the streamer's
        banned-videos list. Queued items carry `requested_via: "api"` and
        fire the `media.created` webhook. Requires `media:control`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url:
                  type: string
                  format: uri
                  description: The video URL (YouTube and other supported platforms).
                start_time:
                  type: integer
                  minimum: 0
                  default: 0
                  description: Start offset in seconds.
                name:
                  type: string
                  maxLength: 100
                  default: API
                  description: Display name shown in the queue ("requested by").
              required: [url]
      responses:
        "201":
          description: Queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  media: { $ref: "#/components/schemas/QueueMediaItem" }
        "400":
          description: >
            Invalid, unplayable, or banned video (`invalid_media`), or
            missing url (`bad_request`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /media/pause:
    post:
      operationId: pauseMedia
      tags: [Media queue]
      summary: Pause the media queue
      description: Requires `media:control`.
      responses:
        "200": { $ref: "#/components/responses/Success" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /media/resume:
    post:
      operationId: resumeMedia
      tags: [Media queue]
      summary: Resume the media queue
      description: Requires `media:control`.
      responses:
        "200": { $ref: "#/components/responses/Success" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /media/skip:
    post:
      operationId: skipMedia
      tags: [Media queue]
      summary: Skip the currently-playing media
      description: Requires `media:control`.
      responses:
        "200": { $ref: "#/components/responses/Success" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /media/{orderId}:
    delete:
      operationId: removeMediaItem
      tags: [Media queue]
      summary: Remove one queued media item
      description: >
        Removes the media half before it plays - a paired TTS stays queued.
        The removal is audit-logged for the streamer's moderation review,
        and `queue.media.removed` fires. Requires `media:control`.
      parameters:
        - $ref: "#/components/parameters/MediaOrderId"
      responses:
        "200": { $ref: "#/components/responses/Success" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /media/{orderId}/replay:
    post:
      operationId: replayMediaItem
      tags: [Media queue]
      summary: Replay a media item from history
      description: >
        Re-queues a played media item (as a new queue entry with a fresh
        `replay_...` order id; fires `media.created` with `is_replay`
        set). A short idempotency window returns `409` if the same item
        was just replayed. Requires `media:control`.
      parameters:
        - $ref: "#/components/parameters/MediaOrderId"
      responses:
        "200": { $ref: "#/components/responses/Success" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Replayed too recently (idempotency window).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /media/{orderId}/start:
    post:
      operationId: startMediaItem
      tags: [Media queue]
      summary: Claim a media item as now playing
      description: >
        Marks a queued media item as currently playing - the same call the
        overlay makes when it starts a video. Fires `queue.media.started`
        and sets the timing anchor that `/media/now` reads. Unlike the TTS
        claim, there is no pause or already-playing gate (mirroring the
        overlay protocol): pacing is the consumer's job. If your player
        then fails and the item should not count as played, undo the claim
        with [release](/api/media-queue/releasemediaitem) instead of
        finishing. See
        [claim, do your thing, finish](https://docs.tippage.com/developer-api/claim-and-finish).
        Requires `media:control`.
      parameters:
        - $ref: "#/components/parameters/MediaOrderId"
      responses:
        "200":
          description: Claimed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  media: { $ref: "#/components/schemas/QueueMediaItem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /media/{orderId}/finish:
    post:
      operationId: finishMediaItem
      tags: [Media queue]
      summary: Finish a media item
      description: >
        Promotes the media item from queue to history and clears the
        now-playing pointer when it points at this item. Fires
        `queue.media.finished`. Idempotent - finishing an already-gone item
        succeeds with `media: null`. Finishing means "this played" - if it
        didn't (your player failed), use
        [release](/api/media-queue/releasemediaitem) to put it back
        instead. Requires `media:control`.
      parameters:
        - $ref: "#/components/parameters/MediaOrderId"
      responses:
        "200":
          description: Finished.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  media:
                    oneOf:
                      - $ref: "#/components/schemas/QueueMediaItem"
                      - type: "null"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /media/{orderId}/release:
    post:
      operationId: releaseMediaItem
      tags: [Media queue]
      summary: Release a claimed media item back to the queue
      description: >
        The undo of [start](/api/media-queue/startmediaitem), for when
        playback didn't go to plan - the player errored, the video wouldn't
        load, your process is shutting down mid-item. Drops the now-playing
        claim WITHOUT finishing: the item stays in the queue at its
        position, and it can be claimed again. Fires `queue.media.released`.
        Only drops a claim that actually points at this order id - never a
        different item's claim - and releasing something you don't hold is
        a safe no-op (`released: false`). If the item DID play, use
        [finish](/api/media-queue/finishmediaitem) instead so it lands in
        history. Requires `media:control`.
      parameters:
        - $ref: "#/components/parameters/MediaOrderId"
      responses:
        "200":
          description: "Released (`released: false` when there was no claim to drop)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  released:
                    type: boolean
                    description: Whether a now-playing claim was actually dropped.
                  media:
                    oneOf:
                      - $ref: "#/components/schemas/QueueMediaItem"
                      - type: "null"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /media/show:
    post:
      operationId: showMediaPlayer
      tags: [Media queue]
      summary: Show the media player
      description: >
        Make the media player visible on the overlay - the same switch as
        the dashboard's Controls tab. Fires `queue.media.shown`. Requires
        `media:control`.
      responses:
        "200":
          description: Player shown.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  visible: { type: boolean, const: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /media/hide:
    post:
      operationId: hideMediaPlayer
      tags: [Media queue]
      summary: Hide the media player
      description: >
        Hide the media player on the overlay. Playback state is untouched -
        hiding doesn't pause; pair with `/media/pause` if you want silence
        too. Fires `queue.media.hidden`. Requires `media:control`.
      responses:
        "200":
          description: Player hidden.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  visible: { type: boolean, const: false }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tips:
    post:
      operationId: createManualTip
      tags: [Tipping]
      summary: Create a manual tip
      description: >
        Add a tip with no payment attached - the same thing as the
        dashboard's manual-tip form. It enters the TTS queue (and the media
        queue when `media_url` is given) and plays like any other tip, with
        TTS pre-rendered. Manual tips deliberately skip the word filter
        (the author is trusted) and don't touch the leaderboard or tip goal
        (no money moved). Fires `tip.created` with `source: "api"`.
        Requires `tips:create`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 22
                  description: The display name shown on stream.
                amount:
                  type: number
                  minimum: 0
                  maximum: 100000
                  description: Displayed amount, in the streamer's currency.
                message:
                  type: string
                  maxLength: 255
                media_url:
                  type: string
                  format: uri
                  description: Optional video to queue alongside (YouTube supported).
                media_start_time:
                  type: integer
                  minimum: 0
                  default: 0
                  description: Video start offset in seconds.
              required: [name, amount]
      responses:
        "201":
          description: Tip queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  order_id: { type: string, examples: ["api_1755115200000_x1y2z3"] }
                  media_queued:
                    type: boolean
                    description: Whether a media item was queued alongside.
        "400":
          description: Validation failed (name/amount/message/media URL).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tipping:
    get:
      operationId: getTippingStatus
      tags: [Tipping]
      summary: Is tipping open
      description: >
        Whether the tip page is accepting new tips right now. Any valid
        key - this is visible on the public tip page anyway.
      responses:
        "200":
          description: Tipping status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  open: { type: boolean }
                  closed_at:
                    type: [string, "null"]
                    format: date-time
                    description: When tipping was last closed (null while open).
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tipping/open:
    post:
      operationId: openTipping
      tags: [Tipping]
      summary: Open tipping
      description: >
        Start accepting new tips again. Fires the `tipping.opened` webhook.
        Requires `tipping:control`.
      responses:
        "200":
          description: Tipping is open.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  open: { type: boolean, const: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tipping/close:
    post:
      operationId: closeTipping
      tags: [Tipping]
      summary: Close tipping
      description: >
        Stop accepting new tips: the tip page shows its closed state and the
        tip endpoint rejects new checkouts server-side. A viewer already
        mid-payment when tipping closes still completes normally and their
        tip lands in the queue, and tips already queued are unaffected.
        Fires the `tipping.closed` webhook. Requires `tipping:control`.
      responses:
        "200":
          description: Tipping is closed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  open: { type: boolean, const: false }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /ai-voices:
    get:
      operationId: listAiVoices
      tags: [AI voices]
      summary: List AI voices
      description: >
        The feature's current state, the full curated voice catalog with
        per-voice enabled flags, and this month's character usage. Voice
        `id`s are stable slugs - use them with the per-voice toggles.
        `min_amount` is the tip amount that unlocks AI voices on the tip
        page. Requires `ai_voices:manage`. Closed beta: answers `404`
        unless AI voices are enabled for your account.
      responses:
        "200":
          description: Feature state, catalog, and usage.
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled:
                    type: boolean
                    description: The master switch - whether donors are offered AI voices.
                  mode:
                    type: string
                    enum: [optional, exclusive]
                    description: >
                      `optional` keeps the standard voice as the default;
                      `exclusive` makes every tip message use an AI voice.
                  min_amount:
                    type: number
                    description: Minimum tip amount that unlocks AI voices.
                  usage:
                    type: object
                    description: This month's character budget (AI generation spends characters).
                    properties:
                      month: { type: string, example: "2026-08" }
                      characters_used: { type: integer }
                      characters_limit: { type: integer }
                      characters_remaining: { type: integer }
                  voices:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, example: "leader" }
                        label: { type: string, example: "Barack Obama" }
                        hint: { type: string, example: "Smooth presidential orator" }
                        gender: { type: string, enum: [m, f, x] }
                        sample_url:
                          type: string
                          description: A short sample clip of the voice.
                        enabled:
                          type: boolean
                          description: Whether donors can currently pick this voice.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /ai-voices/enable:
    post:
      operationId: enableAiVoices
      tags: [AI voices]
      summary: Enable AI voices
      description: >
        Flip the master switch on - donors see the AI voices option on the
        tip page. Same setting as the dashboard toggle. Requires
        `ai_voices:manage`. Closed beta: answers `404` unless AI voices are
        enabled for your account.
      responses:
        "200":
          description: AI voices are on.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  enabled: { type: boolean, const: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /ai-voices/disable:
    post:
      operationId: disableAiVoices
      tags: [AI voices]
      summary: Disable AI voices
      description: >
        Flip the master switch off - the AI voices option disappears from
        the tip page (tips already queued keep their AI audio). Requires
        `ai_voices:manage`. Closed beta: answers `404` unless AI voices are
        enabled for your account.
      responses:
        "200":
          description: AI voices are off.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  enabled: { type: boolean, const: false }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /ai-voices/{voiceId}/enable:
    post:
      operationId: enableAiVoice
      tags: [AI voices]
      summary: Enable one voice
      description: >
        Add a single voice to the set donors can pick from. Requires
        `ai_voices:manage`. Closed beta: answers `404` unless AI voices are
        enabled for your account.
      parameters:
        - name: voiceId
          in: path
          required: true
          schema: { type: string }
          description: The voice's `id` slug from `GET /ai-voices`.
      responses:
        "200":
          description: The voice is enabled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  id: { type: string }
                  enabled: { type: boolean, const: true }
                  enabled_voices:
                    type: integer
                    description: How many voices are enabled after this change.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /ai-voices/{voiceId}/disable:
    post:
      operationId: disableAiVoice
      tags: [AI voices]
      summary: Disable one voice
      description: >
        Remove a single voice from the set donors can pick from. The last
        enabled voice can't be disabled - turn the whole feature off with
        `POST /ai-voices/disable` instead. Requires `ai_voices:manage`.
        Closed beta: answers `404` unless AI voices are enabled for your
        account.
      parameters:
        - name: voiceId
          in: path
          required: true
          schema: { type: string }
          description: The voice's `id` slug from `GET /ai-voices`.
      responses:
        "200":
          description: The voice is disabled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  id: { type: string }
                  enabled: { type: boolean, const: false }
                  enabled_voices:
                    type: integer
                    description: How many voices are enabled after this change.
        "400":
          description: This is the last enabled voice.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, const: last_voice }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /leaderboard:
    get:
      operationId: getLeaderboard
      tags: [Tipping]
      summary: Top supporters
      description: >
        The top 10 supporters by summed tip amount - the same data as the
        public leaderboard page. Any valid key.
      parameters:
        - name: days
          in: query
          schema:
            type: string
            enum: ["1", "7", "30", "all"]
          description: Time window. Omit (or pass `all`) for all-time.
      responses:
        "200":
          description: Ranked supporters, highest total first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  days:
                    type: [integer, "null"]
                    description: The applied window (null = all-time).
                  leaders:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string }
                        total: { type: number }
        "400":
          description: Invalid days value.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /viewers:
    get:
      operationId: listViewers
      tags: [Viewers]
      summary: List signed-in viewers
      description: >
        Every viewer who has signed in to the tip page with Twitch, most
        recently signed-in first, each with their sub-reward credit
        balance. Viewers who have never signed in don't appear - signing
        in is what creates a viewer. Requires `viewers:read`.
      parameters:
        - $ref: "#/components/parameters/HistoryLimit"
        - name: before
          in: query
          schema: { type: string }
          description: >
            Cursor - a `twitch_user_id` from a previous page
            (`next_cursor`). Returns viewers who signed in before them.
      responses:
        "200":
          description: One page of viewers, newest sign-up first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  viewers:
                    type: array
                    items:
                      allOf:
                        - $ref: "#/components/schemas/Viewer"
                        - type: object
                          properties:
                            credits: { $ref: "#/components/schemas/CreditBalance" }
                  has_more: { type: boolean }
                  next_cursor:
                    type: [string, "null"]
                    description: Pass as `before` to fetch the next page.
        "400":
          description: Unknown `before` cursor (`bad_cursor`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /viewers/{user}:
    get:
      operationId: getViewer
      tags: [Viewers]
      summary: One viewer in full
      description: >
        A single viewer: profile, when they first signed in, their credit
        balance (including gift-sub credits still pending because they
        arrived before the first sign-in), the full grant ledger, and
        sub-reward usage totals. Requires `viewers:read`.
      parameters:
        - $ref: "#/components/parameters/ViewerIdent"
      responses:
        "200":
          description: The viewer.
          content:
            application/json:
              schema:
                type: object
                properties:
                  viewer: { $ref: "#/components/schemas/Viewer" }
                  credits:
                    allOf:
                      - $ref: "#/components/schemas/CreditBalance"
                      - type: object
                        properties:
                          pending:
                            type: integer
                            description: >
                              Gift-sub credits waiting for their first
                              sign-in to convert (informational - already
                              part of neither `total` nor `available`).
                  grants:
                    type: array
                    description: The award ledger, newest first (up to 100 rows).
                    items:
                      type: object
                      properties:
                        source:
                          type: string
                          enum: [sub_tier1, sub_tier2, sub_tier3, gift_sub, manual]
                          description: >
                            What earned the credits. `manual` covers both
                            dashboard and API grants.
                        credits: { type: integer, description: Credits in this grant. }
                        credits_used: { type: integer, description: How many of them were spent. }
                        awarded_at: { type: string, format: date-time }
                  usage:
                    type: object
                    properties:
                      total_used: { type: integer, description: Credits spent, all time. }
                      queued: { type: integer, description: Sub-reward messages waiting in the TTS queue right now. }
                      played: { type: integer, description: Sub-reward messages that played on stream. }
                      last_used_at: { type: [string, "null"], format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/ViewerNotSignedIn" }
        "409": { $ref: "#/components/responses/AmbiguousViewer" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /viewers/{user}/rewards:
    get:
      operationId: listViewerRewards
      tags: [Viewers]
      summary: A viewer's sub-reward history
      description: >
        The viewer's played sub-reward messages, most recent first - the
        same shape as `/tts/history`, filtered to this viewer's sub
        rewards. Replays by the streamer are excluded; a sub reward still
        waiting to play shows up in `/tts/queue` like any other tip.
        Requires `viewers:read`.
      parameters:
        - $ref: "#/components/parameters/ViewerIdent"
        - $ref: "#/components/parameters/HistoryLimit"
        - $ref: "#/components/parameters/HistoryBefore"
      responses:
        "200":
          description: One page of played sub rewards.
          content:
            application/json:
              schema:
                type: object
                properties:
                  viewer: { $ref: "#/components/schemas/Viewer" }
                  rewards:
                    type: array
                    items: { $ref: "#/components/schemas/HistoryTip" }
                  has_more: { type: boolean }
                  next_cursor: { type: [string, "null"] }
        "400":
          description: Unknown `before` cursor (`bad_cursor`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/ViewerNotSignedIn" }
        "409": { $ref: "#/components/responses/AmbiguousViewer" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /viewers/{user}/credits/grant:
    post:
      operationId: grantViewerCredits
      tags: [Viewers]
      summary: Grant credits
      description: >
        Grants sub-reward credits to a signed-in viewer - the same
        operation as the dashboard's grant form; the credits appear on the
        viewer's tip page immediately. Credits can only be granted to
        viewers who have signed in at least once: an unknown name or id
        answers `404 viewer_not_signed_in`, and nothing is granted.
        Requires `viewers:manage`.
      parameters:
        - $ref: "#/components/parameters/ViewerIdent"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                amount:
                  type: integer
                  minimum: 1
                  maximum: 100
                  description: Credits to grant.
              required: [amount]
      responses:
        "200":
          description: Granted; the fresh balance is returned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, const: true }
                  granted: { type: integer }
                  viewer: { $ref: "#/components/schemas/Viewer" }
                  credits: { $ref: "#/components/schemas/CreditBalance" }
        "400":
          description: "`amount` isn't an integer from 1 to 100 (`bad_request`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/ViewerNotSignedIn" }
        "409": { $ref: "#/components/responses/AmbiguousViewer" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /viewers/{user}/credits/revoke:
    post:
      operationId: revokeViewerCredits
      tags: [Viewers]
      summary: Revoke credits
      description: >
        Takes unused sub-reward credits away from a viewer. Only unused
        credits are revocable - credits already spent on a message are
        gone. Asking for more than the viewer has available revokes
        nothing and answers `400 insufficient_credits` with the actual
        available count. Requires `viewers:manage`.
      parameters:
        - $ref: "#/components/parameters/ViewerIdent"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                amount:
                  type: integer
                  minimum: 1
                  description: Credits to revoke.
              required: [amount]
      responses:
        "200":
          description: Revoked; the fresh balance is returned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, const: true }
                  revoked: { type: integer }
                  viewer: { $ref: "#/components/schemas/Viewer" }
                  credits: { $ref: "#/components/schemas/CreditBalance" }
        "400":
          description: >
            Bad `amount` (`bad_request`), or more than the viewer's unused
            balance (`insufficient_credits` - the response includes
            `available`).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Error"
                  - type: object
                    properties:
                      available:
                        type: integer
                        description: Unused credits actually available (on `insufficient_credits`).
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/ViewerNotSignedIn" }
        "409": { $ref: "#/components/responses/AmbiguousViewer" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /channel-points/redemptions/{redemptionId}/fulfill:
    post:
      operationId: fulfillRedemption
      tags: [Channel points]
      summary: Mark a redemption fulfilled
      description: >
        Resolves the redemption as FULFILLED on Twitch - the viewer's points
        stay spent and the redemption leaves the Twitch rewards queue. Call
        this when your automation completed the redeemed action. Requires
        `channel_points:manage` and a TipPage-managed reward. Idempotent:
        repeating the call returns `already_resolved: true`.
      parameters:
        - $ref: "#/components/parameters/RedemptionId"
      responses:
        "200": { $ref: "#/components/responses/RedemptionResolved" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: Unknown redemption id for this channel. }
        "409":
          description: >
            Not resolvable - `code` says why: `reward_not_managed` (reward
            wasn't created by TipPage), `already_resolved` (resolved the
            other way), `song_request_active` (the song-request flow owns
            it), or `twitch_rejected` (Twitch refused the update).
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { description: Twitch was unreachable or errored - retry shortly. }

  /channel-points/redemptions/{redemptionId}/cancel:
    post:
      operationId: cancelRedemption
      tags: [Channel points]
      summary: Cancel a redemption (refund the points)
      description: >
        Resolves the redemption as CANCELED on Twitch - the viewer's channel
        points are refunded. Call this when your automation couldn't complete
        the redeemed action. Requires `channel_points:manage` and a
        TipPage-managed reward. Idempotent: repeating the call returns
        `already_resolved: true`.
      parameters:
        - $ref: "#/components/parameters/RedemptionId"
      responses:
        "200": { $ref: "#/components/responses/RedemptionResolved" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: Unknown redemption id for this channel. }
        "409":
          description: >
            Not resolvable - `code` says why: `reward_not_managed`,
            `already_resolved`, `song_request_active`, or `twitch_rejected`.
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { description: Twitch was unreachable or errored - retry shortly. }

  /webhook-endpoints:
    get:
      operationId: listWebhookEndpoints
      tags: [Webhook endpoints]
      summary: List endpoints
      description: Requires `webhooks:manage`. Signing secrets are never listed.
      responses:
        "200":
          description: All endpoints for this streamer.
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoints:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createWebhookEndpoint
      tags: [Webhook endpoints]
      summary: Add an endpoint
      description: >
        Registers a delivery URL. The response contains the signing `secret`
        **exactly once** - store it immediately. URLs must be public
        `https://`; private/internal addresses are rejected. Max 10 endpoints.
        Requires `webhooks:manage`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointCreate" }
      responses:
        "201":
          description: Created. `secret` is shown only here (and on rotation).
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
                  secret:
                    type: string
                    examples: ["whsec_9f8e7d..."]
        "400":
          description: Invalid URL, events, or endpoint limit reached.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhook-endpoints/{endpointId}:
    get:
      operationId: getWebhookEndpoint
      tags: [Webhook endpoints]
      summary: Get one endpoint
      description: Requires `webhooks:manage`.
      parameters:
        - $ref: "#/components/parameters/EndpointId"
      responses:
        "200":
          description: The endpoint.
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      operationId: updateWebhookEndpoint
      tags: [Webhook endpoints]
      summary: Update an endpoint
      description: >
        Any subset of url / description / events / is_active. Re-enabling a
        disabled endpoint clears its failure state. Requires `webhooks:manage`.
      parameters:
        - $ref: "#/components/parameters/EndpointId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointUpdate" }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
        "400":
          description: Invalid URL or events.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: deleteWebhookEndpoint
      tags: [Webhook endpoints]
      summary: Delete an endpoint
      description: Deliveries stop immediately; history is removed. Requires `webhooks:manage`.
      parameters:
        - $ref: "#/components/parameters/EndpointId"
      responses:
        "200": { $ref: "#/components/responses/Success" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhook-endpoints/{endpointId}/rotate-secret:
    post:
      operationId: rotateWebhookSecret
      tags: [Webhook endpoints]
      summary: Rotate the signing secret
      description: >
        Mints a new `whsec_` and returns it once. The old secret stops
        validating immediately. Requires `webhooks:manage`.
      parameters:
        - $ref: "#/components/parameters/EndpointId"
      responses:
        "200":
          description: The new secret - shown only here.
          content:
            application/json:
              schema:
                type: object
                properties:
                  secret: { type: string, examples: ["whsec_1a2b3c..."] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhook-endpoints/{endpointId}/ping:
    post:
      operationId: pingWebhookEndpoint
      tags: [Webhook endpoints]
      summary: Send a test event
      description: >
        Sends a signed `ping` envelope straight to the endpoint (no retries,
        not recorded in deliveries) and reports what happened. Requires
        `webhooks:manage`.
      parameters:
        - $ref: "#/components/parameters/EndpointId"
      responses:
        "200":
          description: Attempt result (a failing endpoint still returns 200 here).
          content:
            application/json:
              schema:
                type: object
                properties:
                  delivered: { type: boolean }
                  http_status:
                    type: [integer, "null"]
                    description: The endpoint's HTTP status, when it answered.
                  error: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhook-endpoints/{endpointId}/deliveries:
    get:
      operationId: listWebhookDeliveries
      tags: [Webhook endpoints]
      summary: Recent deliveries
      description: Most recent first. Requires `webhooks:manage`.
      parameters:
        - $ref: "#/components/parameters/EndpointId"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        "200":
          description: Delivery attempts for this endpoint.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deliveries:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDelivery" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhook-deliveries/{deliveryId}/redeliver:
    post:
      operationId: redeliverWebhookDelivery
      tags: [Webhook endpoints]
      summary: Redeliver a delivery
      description: >
        Re-queues a delivered or failed delivery for immediate retry with the
        **same event id and byte-identical payload**. Returns `409
        attempt_in_progress` while an automatic attempt for the row is
        mid-flight. Requires `webhooks:manage`.
      parameters:
        - name: deliveryId
          in: path
          required: true
          schema: { type: string }
          description: Delivery id (`wd_...`) from the deliveries list.
      responses:
        "200": { $ref: "#/components/responses/Success" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: >
            An automatic delivery attempt for this row is mid-flight
            (`attempt_in_progress`) - check its result in a moment.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

# ── Webhook events ────────────────────────────────────────────────────────
# Every payload is the same envelope: { id, type, created, data }. See the
# top-of-file description for signing and retry semantics.
webhooks:
  tip.created:
    post:
      tags: [Webhook events]
      summary: "tip.created"
      description: >
        A tip entered the TTS queue, from any source - `data.source` says
        which (a webhook-testing tip says `test`, a replay says `replay`).
        `tts_url` is always `null` here; the audio renders asynchronously and
        arrives via `tip.tts_ready`.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "tip.created" }
                    data: { $ref: "#/components/schemas/TipCreatedData" }
      responses:
        "200": { description: Return any 2xx quickly; do real work asynchronously. }

  tip.tts_ready:
    post:
      tags: [Webhook events]
      summary: "tip.tts_ready"
      description: Pre-rendered TTS audio became available for a queued tip.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "tip.tts_ready" }
                    data:
                      type: object
                      properties:
                        order_id: { type: string }
                        tts_url: { type: string, format: uri }
      responses:
        "200": { description: Return any 2xx quickly. }

  tip.filtered:
    post:
      tags: [Webhook events]
      summary: "tip.filtered"
      description: >
        A tip went through, but the word filter (or the AI filter) replaced
        words in its name or message before it reached the queue. Unusually
        for this API, the payload includes the **pre-filter originals** -
        seeing what was caught is the point of the event. `reasoning` is the
        AI filter's explanation, when the AI filter made the call.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "tip.filtered" }
                    data:
                      type: object
                      properties:
                        order_id: { type: [string, "null"] }
                        name: { type: [string, "null"], description: The filtered name (what shows on stream). }
                        original_name: { type: [string, "null"], description: The name as the donor typed it. }
                        amount: { type: [number, "null"] }
                        currency: { type: [string, "null"] }
                        message: { type: [string, "null"], description: The filtered message. }
                        original_message: { type: [string, "null"], description: The message as the donor typed it. }
                        matched_words:
                          type: array
                          items: { type: string }
                        reasoning: { type: [string, "null"] }
      responses:
        "200": { description: Return any 2xx quickly. }

  tip.blocked:
    post:
      tags: [Webhook events]
      summary: "tip.blocked"
      description: >
        A tip was rejected outright by the filter (a blocked word, or the AI
        filter's block verdict) and never reached the queue. The payload
        carries the original content for moderation review. Note the donor
        is still charged on the Stripe path - blocking happens after
        capture; the streamer decides on refunds.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "tip.blocked" }
                    data:
                      type: object
                      properties:
                        order_id: { type: [string, "null"] }
                        name: { type: [string, "null"], description: The name as the donor typed it. }
                        amount: { type: [number, "null"] }
                        currency: { type: [string, "null"] }
                        message: { type: [string, "null"], description: The message as the donor typed it. }
                        blocked_words:
                          type: array
                          items: { type: string }
                        reasoning: { type: [string, "null"] }
      responses:
        "200": { description: Return any 2xx quickly. }

  tipping.opened:
    post:
      tags: [Webhook events]
      summary: "tipping.opened / tipping.closed"
      description: >
        The accept-new-tips switch flipped (from the dashboard or the API),
        with an empty `data` object. Closing stops new checkouts from
        starting; a viewer already mid-payment when tipping closes still
        completes normally and their tip lands in the queue.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type:
                      type: string
                      enum: [tipping.opened, tipping.closed]
                    data: { type: object }
      responses:
        "200": { description: Return any 2xx quickly. }

  queue.tts.started:
    post:
      tags: [Webhook events]
      summary: "queue.tts.started"
      description: A queued TTS started playing (overlay or API consumer claimed it).
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "queue.tts.started" }
                    data:
                      type: object
                      properties:
                        tip: { $ref: "#/components/schemas/EventTip" }
      responses:
        "200": { description: Return any 2xx quickly. }

  queue.tts.finished:
    post:
      tags: [Webhook events]
      summary: "queue.tts.finished"
      description: A tip finished playing and moved to history.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "queue.tts.finished" }
                    data:
                      type: object
                      properties:
                        tip: { $ref: "#/components/schemas/EventTip" }
      responses:
        "200": { description: Return any 2xx quickly. }

  queue.tts.released:
    post:
      tags: [Webhook events]
      summary: "queue.tts.released"
      description: >
        A now-playing claim was released without finishing (the consumer's
        effect failed or it shut down mid-item) - the tip stays in the
        queue and can be claimed again. `tip` is null in the rare case the
        released claim pointed at an already-removed row.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "queue.tts.released" }
                    data:
                      type: object
                      properties:
                        order_id: { type: string }
                        tip:
                          oneOf:
                            - $ref: "#/components/schemas/EventTip"
                            - type: "null"
      responses:
        "200": { description: Return any 2xx quickly. }

  queue.tts.skipped:
    post:
      tags: [Webhook events]
      summary: "queue.tts.skipped"
      description: The currently-playing TTS was skipped.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "queue.tts.skipped" }
                    data:
                      type: object
                      properties:
                        order_id:
                          type: [string, "null"]
                          description: The skipped tip, when one was playing.
      responses:
        "200": { description: Return any 2xx quickly. }

  queue.tts.removed:
    post:
      tags: [Webhook events]
      summary: "queue.tts.removed"
      description: A queued TTS was removed before playing (moderation).
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "queue.tts.removed" }
                    data:
                      type: object
                      properties:
                        tip: { $ref: "#/components/schemas/EventTip" }
      responses:
        "200": { description: Return any 2xx quickly. }

  queue.tts.paused:
    post:
      tags: [Webhook events]
      summary: "queue.tts.paused / resumed / cleared"
      description: >
        TTS queue lifecycle events with an empty `data` object. Types:
        `queue.tts.paused`, `queue.tts.resumed`, `queue.tts.cleared`.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type:
                      type: string
                      enum: [queue.tts.paused, queue.tts.resumed, queue.tts.cleared]
                    data: { type: object }
      responses:
        "200": { description: Return any 2xx quickly. }

  media.created:
    post:
      tags: [Webhook events]
      summary: "media.created"
      description: >
        A media item entered the media queue - via a tip, a `!sr` chat
        request, channel points, or a replay (`data.requested_via`
        discriminates). `order_id` links back to the tip when there is one.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "media.created" }
                    data: { $ref: "#/components/schemas/MediaCreatedData" }
      responses:
        "200": { description: Return any 2xx quickly. }

  queue.media.started:
    post:
      tags: [Webhook events]
      summary: "queue.media.started / finished / removed"
      description: >
        A media item started (`queue.media.started`) or finished
        (`queue.media.finished`) playing, or was removed from the queue
        before playing (`queue.media.removed`).
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type:
                      type: string
                      enum: [queue.media.started, queue.media.finished, queue.media.removed]
                    data:
                      type: object
                      properties:
                        media: { $ref: "#/components/schemas/EventMedia" }
      responses:
        "200": { description: Return any 2xx quickly. }

  queue.media.released:
    post:
      tags: [Webhook events]
      summary: "queue.media.released"
      description: >
        A now-playing claim on a media item was released without finishing
        (the consumer's player failed or it shut down mid-item) - the item
        stays in the queue and can be claimed again. `media` is null in the
        rare case the released claim pointed at an already-removed row.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "queue.media.released" }
                    data:
                      type: object
                      properties:
                        order_id: { type: string }
                        media:
                          oneOf:
                            - $ref: "#/components/schemas/EventMedia"
                            - type: "null"
      responses:
        "200": { description: Return any 2xx quickly. }

  queue.media.paused:
    post:
      tags: [Webhook events]
      summary: "queue.media.paused / resumed / skipped / shown / hidden"
      description: >
        Media queue lifecycle events with an empty `data` object. Types:
        `queue.media.paused`, `queue.media.resumed`, `queue.media.skipped`,
        `queue.media.shown`, `queue.media.hidden` (player visibility on the
        overlay; hiding doesn't pause playback).
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type:
                      type: string
                      enum: [queue.media.paused, queue.media.resumed, queue.media.skipped, queue.media.shown, queue.media.hidden]
                    data: { type: object }
      responses:
        "200": { description: Return any 2xx quickly. }

  twitch.follow:
    post:
      tags: [Webhook events]
      summary: "twitch.* (follow, sub, resub, gift_sub, cheer, raid)"
      description: >
        Twitch alerts, forwarded after the streamer's alert settings and
        AI-filter checks (a suppressed alert never emits). Types:
        `twitch.follow`, `twitch.sub`, `twitch.resub`, `twitch.gift_sub`,
        `twitch.cheer`, `twitch.raid`. Fields beyond the user identity are
        present only where they make sense (tier on subs, bits on cheers,
        viewers on raids, ...).
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type:
                      type: string
                      enum: [twitch.follow, twitch.sub, twitch.resub, twitch.gift_sub, twitch.cheer, twitch.raid]
                    data: { $ref: "#/components/schemas/TwitchEventData" }
      responses:
        "200": { description: Return any 2xx quickly. }

  twitch.channel_point_redemption:
    post:
      tags: [Webhook events]
      summary: "twitch.channel_point_redemption"
      description: >
        A viewer redeemed a channel point reward. Fires for EVERY reward on
        the channel - including rewards TipPage has no actions mapped to -
        so you can build your own redemption automations. The redeemer's
        name and text input pass the streamer's word/AI filter first, and a
        filter-blocked redemption never emits. Delivery is at-least-once:
        dedupe on `data.redemption_id` (stable across Twitch redeliveries,
        unlike the envelope id). When `is_managed` is true, close the loop
        with the fulfill/cancel endpoints once your automation has run.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "twitch.channel_point_redemption" }
                    data: { $ref: "#/components/schemas/ChannelPointRedemptionData" }
      responses:
        "200": { description: Return any 2xx quickly. }

  chat.command:
    post:
      tags: [Webhook events]
      summary: "chat.command"
      description: >
        A viewer executed one of the streamer's custom chat commands - the
        hook for turning commands into real-world triggers (`!explode` firing
        actual hardware). Emits only after the command's permission and
        cooldown checks pass, and only for custom commands - built-ins like
        `!queue` never fire it. `command` is the canonical command name;
        `invoked_as` is what the viewer typed (differs when an alias was
        used). Delivery is at-least-once: dedupe on `data.message_id`.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "chat.command" }
                    data: { $ref: "#/components/schemas/ChatCommandData" }
      responses:
        "200": { description: Return any 2xx quickly. }

  chat.moderated:
    post:
      tags: [Webhook events]
      summary: "chat.moderated"
      description: >
        Chat moderation acted on a message: a warn, delete, timeout, or ban
        from the banned-phrase list or a protection (links, caps, emotes,
        symbols). `feature` names the rule family, `matched_term` the entry
        that matched. `enforced: false` means the Twitch-side action failed
        (bot not modded in the channel, or missing scopes) - the attempt is
        still reported so nothing slips by silently. Message text is public
        chat content.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "chat.moderated" }
                    data:
                      type: object
                      properties:
                        action:
                          type: string
                          enum: [warn, delete, timeout, ban]
                        enforced: { type: boolean }
                        duration_seconds:
                          type: [integer, "null"]
                          description: Timeout length; null for other actions.
                        target_user_id: { type: [string, "null"] }
                        target_login: { type: [string, "null"] }
                        target_name: { type: [string, "null"] }
                        feature:
                          type: [string, "null"]
                          description: Which rule family fired (e.g. terms, links, caps).
                        matched_term: { type: [string, "null"] }
                        message: { type: [string, "null"] }
                        reason: { type: [string, "null"] }
      responses:
        "200": { description: Return any 2xx quickly. }

  ping:
    post:
      tags: [Webhook events]
      summary: "ping"
      description: >
        Sent by the dashboard's "Test" button and the ping endpoint. Not
        subscribable - every endpoint can receive it.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "ping" }
                    data:
                      type: object
                      properties:
                        message: { type: string }
      responses:
        "200": { description: Return any 2xx quickly. }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "tp_live_..."
      description: >
        A TipPage API key, created in Dashboard → Settings → Developer.

  parameters:
    OrderId:
      name: orderId
      in: path
      required: true
      schema: { type: string }
      description: >
        The tip's order id (e.g. `tip_1755100000000_ab12cd`) - the only
        external row reference.
    MediaOrderId:
      name: orderId
      in: path
      required: true
      schema: { type: string }
      description: The media item's order id - the only external row reference.
    EndpointId:
      name: endpointId
      in: path
      required: true
      schema: { type: string }
      description: Webhook endpoint id (`we_...`).
    RedemptionId:
      name: redemptionId
      in: path
      required: true
      schema: { type: string, maxLength: 64 }
      description: >
        The Twitch redemption id (UUID) - `data.redemption_id` on the
        `twitch.channel_point_redemption` webhook.
    ViewerIdent:
      name: user
      in: path
      required: true
      schema: { type: string }
      description: >
        The viewer's Twitch user id, login, or display name (names are
        case-insensitive; URL-encode names with special characters).
        All-digit input is tried as a user id first. Prefer the user id in
        automation - it survives renames and can never be ambiguous.
    HistoryLimit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    HistoryBefore:
      name: before
      in: query
      schema: { type: string }
      description: >
        Cursor - an `order_id` from a previous page (`next_cursor`). Returns
        rows strictly older than it.

  responses:
    Success:
      description: Done.
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean, const: true }
    RedemptionResolved:
      description: The redemption was resolved (or already was).
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean, const: true }
              status: { type: string, enum: [fulfilled, canceled] }
              already_resolved:
                type: boolean
                description: This redemption had already been resolved to this status.
    Unauthorized:
      description: >
        Missing or invalid API key (`missing_api_key` / `invalid_api_key`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: The key lacks the required scope (`missing_scope`).
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Error"
              - type: object
                properties:
                  required_scope: { type: string }
    NotFound:
      description: No such resource.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ViewerNotSignedIn:
      description: >
        No viewer with that name or Twitch user id has ever signed in to
        this tip page (`viewer_not_signed_in`). Signing in with Twitch is
        what creates a viewer - until then they can't be looked up or
        granted credits.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    AmbiguousViewer:
      description: >
        More than one signed-in viewer matches that name
        (`ambiguous_viewer`) - retry with the Twitch user id.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Rate limit exceeded - check the `RateLimit-*` headers.
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }

  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
          description: Human-readable message.
        code:
          type: string
          description: Machine-readable code (e.g. `missing_scope`, `not_in_queue`).
      required: [error]

    QueueTip:
      type: object
      description: A tip in the pending TTS queue.
      properties:
        order_id: { type: string, examples: ["tip_1755100000000_ab12cd"] }
        name: { type: string, description: Display name (post-filter). }
        amount: { type: [number, "null"], examples: [5.00] }
        message:
          type: [string, "null"]
          description: The tip message (post-filter - what shows on stream).
        tts_url:
          type: [string, "null"]
          format: uri
          description: Pre-rendered audio; `null` until rendering completes.
        is_replay: { type: boolean }
        is_sub_reward: { type: boolean }
        twitch_user_id:
          type: [string, "null"]
          description: Present when the donor tipped signed in with Twitch.
        name_was_filtered: { type: boolean }
        message_was_filtered: { type: boolean }
        queued_at: { type: string, format: date-time }

    HistoryTip:
      type: object
      description: A tip that finished playing.
      properties:
        order_id: { type: string }
        name: { type: string }
        amount: { type: [number, "null"] }
        message: { type: [string, "null"] }
        tts_url: { type: [string, "null"], format: uri }
        is_replay: { type: boolean }
        is_sub_reward: { type: boolean }
        twitch_user_id: { type: [string, "null"] }
        name_was_filtered: { type: boolean }
        message_was_filtered: { type: boolean }
        queued_at:
          type: string
          format: date-time
          description: When the tip entered the queue.
        played_at:
          type: string
          format: date-time
          description: When it finished playing on stream.

    EventTip:
      type: object
      description: >
        Tip object carried inside queue.tts.* event payloads. Same fields as
        the REST queue shape, except the queue timestamp is named
        `created_at`.
      properties:
        order_id: { type: string }
        name: { type: string }
        amount: { type: [number, "null"] }
        message: { type: [string, "null"] }
        tts_url: { type: [string, "null"], format: uri }
        is_replay: { type: boolean }
        is_sub_reward: { type: boolean }
        twitch_user_id: { type: [string, "null"] }
        name_was_filtered: { type: boolean }
        message_was_filtered: { type: boolean }
        created_at: { type: string, format: date-time }

    TipCreatedData:
      type: object
      properties:
        order_id: { type: string }
        name: { type: string }
        amount: { type: [number, "null"] }
        message: { type: [string, "null"] }
        source:
          type: string
          enum: [stripe, paypal, manual, api, replay, sub_reward, ayupcc, test, unknown]
          description: >
            Where the tip came from. `manual` is the dashboard's manual-tip
            form; `api` is a POST /tips manual tip.
        is_replay: { type: boolean }
        is_sub_reward: { type: boolean }
        twitch_user_id: { type: [string, "null"] }
        name_was_filtered: { type: boolean }
        message_was_filtered: { type: boolean }
        tts_url:
          type: "null"
          description: Always null here - listen for `tip.tts_ready`.

    QueueMediaItem:
      type: object
      description: A media item in the pending queue.
      properties:
        order_id: { type: string }
        donor_name: { type: string }
        media_url: { type: string, format: uri }
        media_start_time: { type: integer, description: Start offset in seconds. }
        video_title: { type: [string, "null"] }
        video_thumbnail: { type: [string, "null"], format: uri }
        video_duration: { type: integer, description: Duration in seconds (0 = unknown). }
        platform: { type: string, examples: [youtube] }
        requested_via:
          type: string
          description: tip | chat | admin | channel_point | ...
        is_replay: { type: boolean }
        queued_at: { type: string, format: date-time }

    HistoryMediaItem:
      type: object
      description: A media item that finished playing.
      properties:
        order_id: { type: string }
        donor_name: { type: string }
        media_url: { type: string, format: uri }
        media_start_time: { type: integer }
        video_title: { type: [string, "null"] }
        video_thumbnail: { type: [string, "null"], format: uri }
        video_duration: { type: integer }
        platform: { type: string }
        requested_via: { type: string }
        is_replay: { type: boolean }
        queued_at: { type: string, format: date-time }
        played_at: { type: string, format: date-time }

    EventMedia:
      type: object
      description: >
        Media object carried inside queue.media.* event payloads (queue
        timestamp named `created_at`).
      properties:
        order_id: { type: string }
        donor_name: { type: string }
        media_url: { type: string, format: uri }
        media_start_time: { type: integer }
        video_title: { type: [string, "null"] }
        video_thumbnail: { type: [string, "null"], format: uri }
        video_duration: { type: integer }
        platform: { type: string }
        requested_via: { type: string }
        is_replay: { type: boolean }
        created_at: { type: string, format: date-time }

    MediaCreatedData:
      type: object
      properties:
        order_id:
          type: [string, "null"]
          description: Links to the tip's order id when the media came from a tip.
        donor_name: { type: string }
        media_url: { type: string, format: uri }
        media_start_time: { type: integer }
        video_title: { type: [string, "null"] }
        video_duration: { type: integer }
        platform: { type: string }
        requested_via: { type: string }
        is_replay: { type: boolean }

    TwitchEventData:
      type: object
      description: >
        Twitch alert payload. `user_name`/`user_login` always present; the
        rest depend on the event type.
      properties:
        user_name: { type: [string, "null"], description: Display name. }
        user_login: { type: [string, "null"] }
        message:
          type: [string, "null"]
          description: Human-readable alert line ("X subscribed at Tier 2!").
        tier: { type: string, description: "Sub tier: 1 / 2 / 3 (subs, resubs, gifts)." }
        months: { type: integer, description: Cumulative months (resubs). }
        sub_message: { type: string, description: The viewer's resub message. }
        total: { type: integer, description: Gift count (gift subs). }
        is_anonymous: { type: boolean, description: Anonymous gifter (gift subs). }
        gift_recipients:
          type: array
          items: { type: string }
          description: Recipient names attributed to a gift bomb.
        bits: { type: integer, description: Bits cheered (cheers). }
        viewers: { type: integer, description: Raid party size (raids). }

    ChannelPointRedemptionData:
      type: object
      description: Channel point redemption payload.
      properties:
        user_name: { type: [string, "null"], description: Redeemer display name (post-filter). }
        user_login: { type: [string, "null"] }
        reward_id: { type: string, description: Twitch reward id. }
        reward_title: { type: [string, "null"] }
        reward_cost: { type: [integer, "null"], description: Cost in channel points. }
        user_input:
          type: [string, "null"]
          description: The viewer's text input (post-filter), when the reward asks for one.
        redemption_id:
          type: string
          description: Twitch redemption id - dedupe on this.
        status:
          type: string
          description: Redemption status at event time (normally `unfulfilled`).
        redeemed_at: { type: [string, "null"], format: date-time }
        is_managed:
          type: boolean
          description: >
            The reward was created by TipPage - which is what makes the
            redemption resolvable via the fulfill/cancel endpoints (Twitch
            restricts resolution to the creating app).

    ChatCommandData:
      type: object
      description: Custom chat command execution payload.
      properties:
        command: { type: string, description: Canonical command name, without the `!`. }
        invoked_as:
          type: string
          description: The name the viewer actually typed (canonical name or an alias).
        args:
          type: [string, "null"]
          description: The rest of the chat line after the command, original case.
        user_id: { type: [string, "null"], description: Twitch user id of the chatter. }
        user_login: { type: [string, "null"] }
        user_name: { type: [string, "null"], description: Display name. }
        is_mod:
          type: boolean
          description: Chatter is a moderator or the broadcaster.
        message_id:
          type: [string, "null"]
          description: Twitch chat message id - dedupe on this.

    EventEnvelope:
      type: object
      description: Every webhook body is this envelope.
      properties:
        id:
          type: string
          description: Unique event id.
          examples: ["evt_9f1e2d3c4b5a6978"]
        type: { type: string, examples: ["tip.created"] }
        created: { type: string, format: date-time }
        data: { type: object }
      required: [id, type, created, data]

    Viewer:
      type: object
      description: A viewer who has signed in to the tip page with Twitch.
      properties:
        twitch_user_id: { type: string, examples: ["123456789"] }
        login: { type: string, examples: ["gigachad42"] }
        display_name: { type: string, examples: ["GigaChad42"] }
        profile_image_url: { type: [string, "null"], format: uri }
        subscriber_tier:
          type: integer
          enum: [0, 1, 2, 3]
          description: Their sub tier as last seen (0 = not subscribed).
        first_signed_in_at:
          type: string
          format: date-time
          description: When they first signed in to this tip page.
        last_active_at:
          type: string
          format: date-time
          description: Last account activity seen (profile refreshes included).

    CreditBalance:
      type: object
      description: A viewer's sub-reward credit balance.
      properties:
        available: { type: integer, description: Credits spendable right now. }
        total: { type: integer, description: Credits ever granted. }
        used: { type: integer, description: Credits already spent. }

    WebhookEndpoint:
      type: object
      properties:
        id: { type: string, examples: ["we_1a2b3c4d5e6f"] }
        url: { type: string, format: uri }
        description: { type: [string, "null"] }
        events:
          type: array
          items: { type: string }
          description: Subscribed event types, or `["*"]` for everything.
        is_active: { type: boolean }
        disabled_reason:
          type: [string, "null"]
          description: Set when the endpoint was auto-disabled after repeated failures.
        consecutive_failures:
          type: integer
          description: Deliveries that exhausted every retry since the last success.
        last_success_at: { type: [string, "null"], format: date-time }
        last_failure_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    WebhookEndpointCreate:
      type: object
      properties:
        url:
          type: string
          format: uri
          description: Public https:// URL.
        description: { type: string, maxLength: 200 }
        events:
          type: array
          items: { type: string }
          description: Event types to receive, or `["*"]` for everything.
      required: [url, events]

    WebhookEndpointUpdate:
      type: object
      description: Any subset of fields.
      properties:
        url: { type: string, format: uri }
        description: { type: [string, "null"], maxLength: 200 }
        events:
          type: array
          items: { type: string }
        is_active: { type: boolean }

    WebhookDelivery:
      type: object
      properties:
        id: { type: string, examples: ["wd_a1b2c3d4e5f6"] }
        event_id: { type: string, examples: ["evt_9f1e2d3c4b5a6978"] }
        event_type: { type: string, examples: ["tip.created"] }
        status:
          type: string
          enum: [delivered, failed, retrying, pending]
        attempts: { type: integer }
        next_attempt_at:
          type: [string, "null"]
          format: date-time
          description: Next retry, when still pending/retrying.
        last_status:
          type: [integer, "null"]
          description: Last HTTP status from the endpoint.
        last_error: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        delivered_at: { type: [string, "null"], format: date-time }
