# 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.

    > **Changed 2026-09-06 - viewers are platform-neutral.** A viewer can sign
    > in to the tip page with Twitch or Kick and link the other account, so
    > every tip, viewer and credit object that carried `twitch_user_id` now
    > carries `platform` (`twitch` | `kick`) and `platform_user_id` instead -
    > there is no alias. The viewer object gained `linked_accounts`, and the
    > `{user}` path segment accepts `<platform>:<id>` (`kick:123`) alongside a
    > bare id or a name.

    ## 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. Chat sends
    (`POST /chat/messages`) have their own budget on top: 20 messages per
    30 seconds per streamer, shared across all keys. 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/api](https://docs.tippage.com/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: Realtime
    description: >
      The webhook event stream, live over a WebSocket at
      wss://ws.tippage.com - same events, same payloads, same event
      ids as the webhooks, delivered while you're connected. No SDK
      needed: open a plain WebSocket, send one JSON line -
      `{"data": {"apiKey": "tp_live_..."}}` for connections you control,
      or `{"data": {"token": "..."}}` with a short-lived token from
      POST /realtime/token to let clients that shouldn't hold your key
      (browsers, third parties) connect. The server streams the events
      your key's scopes allow. Full guide, wire format, channel table,
      and lifecycle: https://docs.tippage.com/api/realtime
  - 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/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 or Kick - and every
      viewer object names that account as `platform` + `platform_user_id`,
      with the person's other linked account(s) in `linked_accounts`. 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`). Balances, grants and
      reward history are read across a person's linked accounts; a grant or
      revoke lands on the exact account you name. Everywhere a `{user}`
      appears in a path you can pass `<platform>:<id>` (`kick:123`), a bare
      numeric id, or the viewer's name (login or display name,
      case-insensitive, on either platform). 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 (TipPage+): 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 available to your
      account.
  - 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.
  - name: Chat timers
    description: >
      Scheduled chat-bot messages - the bot posts them on a fixed or
      randomized interval, optionally only while the streamer is live or
      after enough chat activity. Needs `timers:manage`. The same timers
      as the dashboard's Chat bot page; max 20 per streamer. Editing a
      timer's interval restarts its schedule from now.
  - name: Counters
    description: >
      Named per-streamer numbers the chat bot bumps with `{count}` /
      `{count:name}` command tokens and overlay Data labels can display
      live. Addressed by name - a lowercase slug (`a-z 0-9 _ -`, max 64
      chars) is the key on every surface. Needs `counters:manage`. Every
      change made here behaves exactly like a chat bump: overlays update
      live and the `counter.updated` webhook fires. Max 100 per streamer.
  - name: Chat bot
    description: >
      The streamer's chat bot: its status and send identity (any valid
      key), speaking in chat as the bot (`chat:write`), and the custom
      `!commands` it answers (`commands:manage`, addressed by command name
      - the trigger without the `!`). Pair with the `chat.command` webhook
      event to react when a custom command runs.
  - name: Overlays
    description: >
      The account's overlays: list them, inspect one (name, resolution,
      widget types - never overlay keys), delete one, reload a connected
      one, fire the Event celebration widget on demand (all of them or
      one specific instance), and read, update, or add to each overlay's
      tip goal. All of it needs `overlays:manage`.

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"] }
                      currency:
                        type: string
                        description: ISO 4217 code of the streamer's tip currency.
                        examples: ["GBP"]
                      currency_symbol:
                        type: string
                        description: >
                          Display symbol for that currency, as shown on the tip page.
                          Use it when rendering `amount` fields anywhere in this API.
                        examples: ["£"]
                  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" }

  /realtime/token:
    post:
      operationId: mintRealtimeToken
      tags: [Realtime]
      summary: Mint a realtime handoff token
      description: >
        A short-lived credential for opening the realtime WebSocket from a
        context that shouldn't hold the API key. Send
        `{"data": {"token": "..."}}` as the first WebSocket message before
        it expires; the connection then behaves exactly like an API-key
        connection (channels come from the key's scopes at connect time,
        and the connection outlives the token). Daemons that hold the key
        can skip this endpoint and connect with
        `{"data": {"apiKey": "..."}}` directly. Requires a key with at
        least one realtime-capable scope. Guide:
        https://docs.tippage.com/api/realtime
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                expires_in:
                  type: integer
                  minimum: 60
                  maximum: 3600
                  default: 900
                  description: Token lifetime in seconds (how long it can be used to connect).
                groups:
                  type: array
                  items:
                    type: string
                    enum: [tts, media, twitch, chat, counters, overlays]
                  example: ["tts", "twitch"]
                  description: >
                    Narrow the token to a subset of the key's groups - so a
                    broad key can hand out a token that only sees some of
                    them. Every name must be a group the key's own scopes
                    already grant (otherwise 403 `group_not_granted`); it
                    can only narrow, never widen. Omit to grant everything
                    the key allows.
      responses:
        "200":
          description: Connection details.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url:
                    type: string
                    example: "wss://ws.tippage.com"
                    description: The raw WebSocket endpoint - no client library needed.
                  token: { type: string, description: "Send as `{\"data\": {\"token\": \"...\"}}` in the first WebSocket message." }
                  expires_at: { type: string, format: date-time }
                  channels:
                    type: array
                    items: { type: string }
                    example: ["dev:t_abc123:tts", "dev:t_abc123:twitch"]
                    description: The channels this token will actually grant (the key's scopes, narrowed by `groups` if given).
        "400":
          description: "`groups` was malformed or contained an unknown group name (`bad_groups`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: The key has no realtime-capable scopes (`no_realtime_scopes`), or `groups` asked for one the key doesn't grant (`group_not_granted`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "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). 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/reference/tts-queue/releasettsitem) instead of
        finishing. See the
        [claim, do your thing, finish](https://docs.tippage.com/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/reference/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/reference/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/reference/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/reference/media-queue/releasemediaitem) instead of
        finishing. See
        [claim, do your thing, finish](https://docs.tippage.com/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/reference/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/reference/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/reference/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 tip
      description: >
        Add a tip that TipPage didn't take payment for. It enters the TTS
        queue (and the media queue when `media_url` is given) and plays like
        any other tip, with TTS pre-rendered. These tips deliberately skip
        the word filter (the author is trusted). Fires `tip.created` with
        `source: "api"`.


        Two independent fields decide how the tip is counted:

        * **`source`** labels where the tip came from - a stable lower-case
          slug per platform (`kofi`, `youtube-superchat`, `ayupcc`). Reports
          list one row per distinct source. On its own it makes a **free**
          tip: shown and read out on stream, never counted as money.
        * **`paid: true`** says real money was processed on that platform
          (a Ko-fi payment, a Super Chat, a legacy tipping service you're
          bridging). The tip then counts as revenue on the streamer's
          overview and in their PDF reports under that source, climbs the
          leaderboard and moves the tip goal. Requires `source`.
          `reference` is your own transaction id for that payment, shown to
          the streamer for reconciliation.

        Neither field - a **manual tip**, exactly like the dashboard's
        manual-tip form: visible on stream, never in the money totals,
        itemised separately in reports.

        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.
                source:
                  type: string
                  pattern: "^[a-z0-9][a-z0-9._-]{1,47}$"
                  description: >
                    Where the tip came from, as a lower-case slug (`kofi`,
                    `youtube-superchat`, `ayupcc`). A label only - pair it
                    with `paid: true` when money really moved there.
                  examples: ["kofi"]
                paid:
                  type: boolean
                  default: false
                  description: >
                    Real money was processed on `source`. Makes the tip count
                    as revenue (overview, reports, leaderboard, tip goal).
                    Requires `source`.
                reference:
                  type: string
                  maxLength: 128
                  description: >
                    Your own reference for the payment on that platform (a
                    transaction or order id). Requires `source`.
                  examples: ["kofi_txn_8f3a2c"]
              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.
                  paid:
                    type: boolean
                    description: "Whether the tip counts as revenue (you sent `paid: true` with a source)."
                  source:
                    type: [string, "null"]
                    description: The normalised `source` you sent, or null for a manual tip.
                  reference:
                    type: [string, "null"]
        "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, every voice this account can offer
        (TipPage's own, the ones this streamer cloned, and voices other
        streamers published) with per-voice enabled flags, and this month's
        usage. Voice `id`s are stable (`voi_...`) - 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 generation, for information only - there is
                      no monthly allowance to spend down.
                    properties:
                      month: { type: string, example: "2026-09" }
                      characters_spoken: { type: integer }
                      seconds_generated: { type: number }
                      generations: { type: integer }
                  voices:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, example: "voi_9c1f0a2b7d4e5f60" }
                        label: { type: string, example: "Narrator" }
                        hint: { type: string, example: "Warm documentary read" }
                        gender: { type: string, enum: [m, f, x] }
                        sample_url:
                          type: string
                          description: A short sample clip of the voice.
                        reference_seconds:
                          type: integer
                          description: >
                            Seconds of reference audio behind the voice. A
                            message is spoken in one generation and every
                            voice in it shares that generation's budget, so
                            these add up.
                        source:
                          type: string
                          enum: [platform, own, shared]
                          description: >
                            `platform` is a TipPage voice, `own` one this
                            streamer cloned, `shared` one another streamer
                            published.
                        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`. TipPage+: 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`. TipPage+: 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`. TipPage+: 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` 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`.
        TipPage+: 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` 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 account that has signed in to the tip page (Twitch or Kick),
        most recently signed-in first, each with the person's sub-reward
        credit balance (summed across their linked accounts, so a viewer
        who signed in here with both shows two rows with the same 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 - the `next_cursor` from a previous page. Opaque
            (base64url of the sign-in time + platform + id the page ended
            on); a malformed one answers `400 bad_cursor`. Returns viewers
            who signed in before that point.
      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:
                        platform:
                          type: string
                          enum: [twitch, kick]
                          description: Which of the person's accounts this grant landed on.
                        platform_user_id: { type: string }
                        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" }

  /timers:
    get:
      operationId: listChatTimers
      tags: [Chat timers]
      summary: List timers
      description: All timers, enabled or not. Requires `timers:manage`.
      responses:
        "200":
          description: All timers for this streamer.
          content:
            application/json:
              schema:
                type: object
                properties:
                  timers:
                    type: array
                    items: { $ref: "#/components/schemas/ChatTimer" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createChatTimer
      tags: [Chat timers]
      summary: Create a timer
      description: >
        A new timer posts for the first time one interval from creation, not
        immediately. Messages can't start with `/` or `!`, and support the
        same template variables as custom commands minus the argument ones
        (`{channel}`, `{pick:a|b|c}`, `{random:1-100}`, `{list:name}`,
        `{uptime}`, `{ai:...}`, ... - see
        https://docs.tippage.com/chat-bot/variables). Max 20 timers.
        Requires `timers:manage`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ChatTimerCreate" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  timer: { $ref: "#/components/schemas/ChatTimer" }
        "400":
          description: Invalid fields (`invalid_timer`) or timer limit reached (`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" }

  /timers/{timerId}:
    get:
      operationId: getChatTimer
      tags: [Chat timers]
      summary: Get one timer
      description: Requires `timers:manage`.
      parameters:
        - $ref: "#/components/parameters/TimerId"
      responses:
        "200":
          description: The timer.
          content:
            application/json:
              schema:
                type: object
                properties:
                  timer: { $ref: "#/components/schemas/ChatTimer" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      operationId: updateChatTimer
      tags: [Chat timers]
      summary: Update a timer
      description: >
        Any subset of fields - `{"enabled": false}` benches a timer without
        deleting it. Changing `interval_minutes` / `interval_max_minutes`
        restarts the timer's schedule from now. Requires `timers:manage`.
      parameters:
        - $ref: "#/components/parameters/TimerId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ChatTimerUpdate" }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  timer: { $ref: "#/components/schemas/ChatTimer" }
        "400":
          description: Invalid fields.
          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: deleteChatTimer
      tags: [Chat timers]
      summary: Delete a timer
      description: The bot stops posting the message immediately. Requires `timers:manage`.
      parameters:
        - $ref: "#/components/parameters/TimerId"
      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" }

  /counters:
    get:
      operationId: listCounters
      tags: [Counters]
      summary: List counters
      description: All counters, name-ordered. Requires `counters:manage`.
      responses:
        "200":
          description: All counters for this streamer.
          content:
            application/json:
              schema:
                type: object
                properties:
                  counters:
                    type: array
                    items: { $ref: "#/components/schemas/Counter" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /counters/{name}:
    parameters:
      - name: name
        in: path
        required: true
        schema: { type: string, pattern: "^[a-z0-9_-]{1,64}$" }
        description: The counter's name.
    get:
      operationId: getCounter
      tags: [Counters]
      summary: Read a counter
      description: Requires `counters:manage`.
      responses:
        "200":
          description: The counter.
          content:
            application/json:
              schema:
                type: object
                properties:
                  counter: { $ref: "#/components/schemas/Counter" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      operationId: setCounter
      tags: [Counters]
      summary: Set a counter
      description: >
        Sets an absolute value, creating the counter if it doesn't exist
        yet. Overlay labels bound to it update live and `counter.updated`
        fires. Requires `counters:manage`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [value]
              properties:
                value: { type: integer, description: "New absolute value." }
      responses:
        "200":
          description: The counter after the write.
          content:
            application/json:
              schema:
                type: object
                properties:
                  counter: { $ref: "#/components/schemas/Counter" }
        "400":
          description: Invalid name or value.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: deleteCounter
      tags: [Counters]
      summary: Delete a counter
      description: >
        The value is lost; `{count}` in a command recreates it from zero
        on next use. Requires `counters:manage`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /counters/{name}/increment:
    post:
      operationId: incrementCounter
      tags: [Counters]
      summary: Increment a counter
      description: >
        Adds `by` (default 1, may be negative), creating the counter at
        `by` on first use - the same semantics as `{count}` in a chat
        command. Requires `counters:manage`.
      parameters:
        - name: name
          in: path
          required: true
          schema: { type: string, pattern: "^[a-z0-9_-]{1,64}$" }
          description: The counter's name.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                by:
                  type: integer
                  default: 1
                  description: Non-zero; negative decrements.
      responses:
        "200":
          description: The counter after the increment.
          content:
            application/json:
              schema:
                type: object
                properties:
                  counter: { $ref: "#/components/schemas/Counter" }
        "400":
          description: Invalid name or value.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /bot:
    get:
      operationId: getBotStatus
      tags: [Chat bot]
      summary: Bot status
      description: >
        Whether the chat bot is switched on, the channels it's in (Twitch
        and/or Kick), and the identity it speaks as on Twitch - the shared
        TipPageBot or the streamer's linked custom bot. On Kick the bot is
        always the TipPage app account. Any valid key.
      responses:
        "200":
          description: Bot status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  bot:
                    type: object
                    properties:
                      enabled:
                        type: boolean
                        description: The bot is switched on for at least one platform (see `platforms` for each).
                      channel:
                        type: [string, "null"]
                        description: The streamer's Twitch login (`null` without a Twitch channel).
                        examples: [somestreamer]
                      channels:
                        type: object
                        description: Every chat the bot is in, by platform - `null` = not connected.
                        properties:
                          twitch: { type: [string, "null"], examples: [somestreamer] }
                          kick: { type: [string, "null"], examples: [somestreamer] }
                      platforms:
                        type: object
                        description: >
                          Per platform: the bot's on/off switch there, the channel,
                          and the identity it speaks as. On Kick the identity is
                          always the TipPage app's bot account.
                        properties:
                          twitch:
                            type: object
                            properties:
                              enabled: { type: boolean }
                              channel: { type: [string, "null"] }
                              identity: { type: object }
                          kick:
                            type: object
                            properties:
                              enabled: { type: boolean }
                              channel: { type: [string, "null"] }
                              identity: { type: object }
                      identity:
                        type: object
                        description: Who the bot's messages appear from.
                        properties:
                          type: { type: string, enum: [default, custom] }
                          login: { type: string, examples: [tippagebot] }
                          display_name: { type: string, examples: [TipPageBot] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /chat/messages:
    post:
      operationId: sendChatMessage
      tags: [Chat bot]
      summary: Send a chat message
      description: >
        The bot says your message in the streamer's chat - every connected
        chat by default, or just Twitch or Kick with `platform` - spoken
        by the same identity as every other bot line (TipPageBot or the
        linked custom bot on Twitch; the TipPage account on Kick). Sent
        verbatim - no template variables - and it can't start with `/` or
        `!`, so the bot never runs slash commands or triggers other bots.
        Own rate limit on top of the global ones: 20 messages per 30
        seconds per streamer, shared across all keys. Requires `chat:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                message:
                  type: string
                  maxLength: 500
                  description: What the bot says. Can't start with `/` or `!`.
                platform:
                  type: string
                  enum: [twitch, kick, all]
                  default: all
                  description: Which chat to speak in. `all` = every chat the streamer has connected.
              required: [message]
      responses:
        "200": { $ref: "#/components/responses/Success" }
        "400":
          description: >
            Missing message (`bad_request`), over 500 characters
            (`message_too_long`), or disallowed content
            (`message_disallowed`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: >
            The bot can't speak right now: switched off in the dashboard
            (`bot_disabled`), no connected channel on the requested
            platform, or not connected to the channel yet
            (`bot_unavailable` - transient when the channel exists, retry shortly).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: The chat gateway is unreachable (`gateway_unavailable`) - retry shortly.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /commands:
    get:
      operationId: listCustomCommands
      tags: [Chat bot]
      summary: List custom commands
      description: All custom commands, enabled or not. Requires `commands:manage`.
      responses:
        "200":
          description: The streamer's custom commands.
          content:
            application/json:
              schema:
                type: object
                properties:
                  commands:
                    type: array
                    items: { $ref: "#/components/schemas/CustomCommand" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createCustomCommand
      tags: [Chat bot]
      summary: Create a command
      description: >
        `name` is the trigger without the `!` prefix; the bot's built-in
        triggers are reserved. Responses support the same template
        variables as the dashboard's Chat bot page - `{user}`, `{touser}`,
        `{args}`, `{arg:N}`, `{pick:a|b|c}`, `{random:1-100}`, `{list:name}`,
        `{if:...}`, `{repeat:N|...}`, `{uptime}`, `{ai:...}` and more, and
        variables nest (full reference:
        https://docs.tippage.com/chat-bot/variables). Response lists
        themselves are managed in the dashboard. Requires `commands:manage`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomCommandCreate" }
      responses:
        "201":
          description: Created. Active in chat within a few seconds.
          content:
            application/json:
              schema:
                type: object
                properties:
                  command: { $ref: "#/components/schemas/CustomCommand" }
        "400":
          description: Invalid fields (`invalid_command`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: >
            Name already taken (`command_exists`), or an alias collides
            with another trigger (`alias_in_use`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /commands/{commandName}:
    get:
      operationId: getCustomCommand
      tags: [Chat bot]
      summary: Get one command
      description: Requires `commands:manage`.
      parameters:
        - $ref: "#/components/parameters/CommandName"
      responses:
        "200":
          description: The command.
          content:
            application/json:
              schema:
                type: object
                properties:
                  command: { $ref: "#/components/schemas/CustomCommand" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      operationId: updateCustomCommand
      tags: [Chat bot]
      summary: Update a command
      description: >
        Any subset of fields - `{"enabled": false}` benches a command
        without deleting it, `name` renames it (the old name stops
        triggering immediately). Requires `commands:manage`.
      parameters:
        - $ref: "#/components/parameters/CommandName"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomCommandUpdate" }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  command: { $ref: "#/components/schemas/CustomCommand" }
        "400":
          description: Invalid fields (`invalid_command`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: >
            The new name is already taken (`command_exists`), or an alias
            collides with another trigger (`alias_in_use`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: deleteCustomCommand
      tags: [Chat bot]
      summary: Delete a command
      description: The bot stops answering it immediately. Requires `commands:manage`.
      parameters:
        - $ref: "#/components/parameters/CommandName"
      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" }

  /overlays:
    get:
      operationId: listOverlays
      tags: [Overlays]
      summary: List overlays
      description: >
        Every overlay on the account. Overlay keys are deliberately never
        returned. Requires `overlays:manage`.
      responses:
        "200":
          description: The account's overlays.
          content:
            application/json:
              schema:
                type: object
                properties:
                  overlays:
                    type: array
                    items: { $ref: "#/components/schemas/Overlay" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /overlays/celebrate:
    post:
      operationId: celebrateAllOverlays
      tags: [Overlays]
      summary: Fire a celebration everywhere
      description: >
        Fires the Event celebration widget on every connected overlay that
        carries one - confetti and/or channel emotes, using each widget's
        own configured look. Overlays without the widget ignore it.
        Requires `overlays:manage`.
      responses:
        "200":
          description: Broadcast sent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /overlays/{overlayId}:
    get:
      operationId: getOverlay
      tags: [Overlays]
      summary: Get one overlay
      description: >
        One overlay, plus the widget types currently on its layout.
        Requires `overlays:manage`.
      parameters:
        - $ref: "#/components/parameters/OverlayId"
      responses:
        "200":
          description: The overlay.
          content:
            application/json:
              schema:
                type: object
                properties:
                  overlay:
                    allOf:
                      - $ref: "#/components/schemas/Overlay"
                      - type: object
                        properties:
                          widgets:
                            type: array
                            description: Widget types on the layout.
                            items: { type: string }
                            example: ["alert", "chat", "celebration"]
                          celebrations:
                            type: array
                            description: >
                              Placed Event celebration widget instances -
                              their ids target POST .../celebrate at one
                              specific widget.
                            items:
                              type: object
                              properties:
                                id: { type: string, example: celebration-mt9x2ab }
                                name: { type: string, nullable: true, example: "Goal zone" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: deleteOverlay
      tags: [Overlays]
      summary: Delete an overlay
      description: >
        Permanently deletes the overlay and disconnects its browser source.
        The last remaining overlay can't be deleted (`last_overlay`, 409).
        Requires `overlays:manage`.
      parameters:
        - $ref: "#/components/parameters/OverlayId"
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: It's the account's only overlay (`last_overlay`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /overlays/{overlayId}/reload:
    post:
      operationId: reloadOverlay
      tags: [Overlays]
      summary: Reload an overlay
      description: >
        Asks a connected overlay's browser source to refresh itself and
        pick up its latest configuration - handy when a source looks
        stuck without touching OBS. A disconnected overlay ignores the
        request, so this is always safe to call. Requires
        `overlays:manage`.
      parameters:
        - $ref: "#/components/parameters/OverlayId"
      responses:
        "200":
          description: Broadcast sent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /overlays/{overlayId}/celebrate:
    post:
      operationId: celebrateOverlay
      tags: [Overlays]
      summary: Fire a celebration on one overlay
      description: >
        Fires the Event celebration widget on this overlay only. The
        widget's own configuration decides the look (confetti / emotes,
        style, duration); overlays without the widget ignore the trigger.
        Pass `widget_id` (from the overlay's `celebrations` list) to fire
        just ONE of several placed celebration widgets. Requires
        `overlays:manage`.
      parameters:
        - $ref: "#/components/parameters/OverlayId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                widget_id:
                  type: string
                  description: A celebration widget instance id from GET /overlays/{overlayId}. Omit to fire every celebration widget on the overlay.
                  example: celebration-mt9x2ab
      responses:
        "200":
          description: Broadcast sent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /overlays/{overlayId}/tip-goal:
    get:
      operationId: getOverlayTipGoal
      tags: [Overlays]
      summary: Get an overlay's tip goal
      description: >
        The overlay's tip goal campaign. `current` = tips received since
        the campaign start, plus any manual adjustments made through the
        add endpoint. Requires `overlays:manage`.
      parameters:
        - $ref: "#/components/parameters/OverlayId"
      responses:
        "200":
          description: The tip goal.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tip_goal: { $ref: "#/components/schemas/TipGoal" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      operationId: updateOverlayTipGoal
      tags: [Overlays]
      summary: Update an overlay's tip goal
      description: >
        Any subset of campaign fields. Changing them reloads the overlay's
        browser source so the widget picks them up. Requires
        `overlays:manage`.
      parameters:
        - $ref: "#/components/parameters/OverlayId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                enabled: { type: boolean }
                title: { type: string, maxLength: 80, example: "New PC fund" }
                amount: { type: number, minimum: 0, maximum: 1000000, example: 500 }
                starts_at: { type: string, format: date-time, nullable: true, description: "Count tips from this moment. Empty/null clears it." }
                ends_at: { type: string, format: date-time, nullable: true, description: "Campaign end. Empty/null clears it." }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
        "400":
          description: Invalid amount or date.
          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" }

  /overlays/{overlayId}/tip-goal/add:
    post:
      operationId: addToOverlayTipGoal
      tags: [Overlays]
      summary: Add to an overlay's tip goal
      description: >
        Adjusts the goal's progress by `amount` (negative subtracts)
        WITHOUT creating a tip - it uses a separate manual-adjustment
        offset that the dashboard's reset-progress clears. The on-screen
        goal updates live, and a crossing into the target fires any
        goal-hit celebration. Requires `overlays:manage`.
      parameters:
        - $ref: "#/components/parameters/OverlayId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount:
                  type: number
                  description: Non-zero; negative to subtract. Up to 1,000,000 either way.
                  example: 25
      responses:
        "200":
          description: Adjusted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  added: { type: number, example: 25 }
        "400":
          description: amount missing, zero, or out of range (`invalid_amount`).
          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" }

# ── 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. }

  tip.held:
    post:
      tags: [Webhook events]
      summary: "tip.held"
      description: >
        A tip was parked in the manual review queue instead of entering the
        TTS / media queues. `reason` says why (`manual` = the streamer
        reviews everything, `filter` = the word/AI filter matched and the
        streamer chose review over silent replace/block, `ai_unavailable`
        = the AI filter could not be reached). `payment_state` says where
        the money is meanwhile: `captured` (charged), `held` (Stripe auth
        only - approval captures, rejection releases) or `none` (nothing
        was paid, e.g. a sub-reward message).
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "tip.held" }
                    data:
                      type: object
                      properties:
                        order_id: { type: string }
                        name: { type: [string, "null"] }
                        amount: { type: [number, "null"] }
                        currency: { type: [string, "null"] }
                        message: { type: [string, "null"], description: The message as the donor typed it. }
                        has_media: { type: boolean }
                        reason: { type: string, enum: [manual, filter, ai_unavailable] }
                        payment_state: { type: string, enum: [captured, held, none] }
                        source: { type: string }
      responses:
        "200": { description: Return any 2xx quickly. }

  tip.reviewed:
    post:
      tags: [Webhook events]
      summary: "tip.reviewed"
      description: >
        A moderator decided a held tip, or its Stripe payment hold expired
        before anyone did. `decision` is `approved`, `rejected` or
        `expired`; `allow_tts` / `allow_media` say which halves were let
        through. An approval is followed by the normal `tip.created` and/or
        `media.created` as the tip enters the queues. `payment_released`
        is true when a held authorisation was cancelled - the donor was
        never charged.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "tip.reviewed" }
                    data:
                      type: object
                      properties:
                        order_id: { type: string }
                        decision: { type: string, enum: [approved, rejected, expired] }
                        allow_tts: { type: boolean }
                        allow_media: { type: boolean }
                        reason: { type: string, enum: [manual, filter, ai_unavailable] }
                        payment_state: { type: string, enum: [captured, held, none] }
                        payment_released: { type: boolean }
                        name: { type: [string, "null"] }
                        amount: { type: [number, "null"] }
                        currency: { 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. }

  kick.follow:
    post:
      tags: [Webhook events]
      summary: "kick.* (follow, sub, resub, gift_sub, kicks)"
      description: >
        Kick alerts - the same shape as the `twitch.*` events with
        `platform: "kick"`, forwarded after the streamer's alert settings and
        AI-filter checks. Types: `kick.follow`, `kick.sub`, `kick.resub`,
        `kick.gift_sub`, `kick.kicks`. Kick subscriptions carry no tier
        (`tier` is always `"1"`). `kick.kicks` is Kick's paid gift - the
        counterpart of a cheer - with `kicks` (the amount), `gift_name` and
        the viewer's `kicks_message`.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type:
                      type: string
                      enum: [kick.follow, kick.sub, kick.resub, kick.gift_sub, kick.kicks]
                    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. `platform` is always `twitch` for now - Kick chat
        moderation lands later.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "chat.moderated" }
                    data:
                      type: object
                      properties:
                        platform: { type: string, enum: [twitch, kick] }
                        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. }

  warning:
    post:
      tags: [Webhook events]
      summary: "warning"
      description: >
        An operational warning about your integration - delivered to
        **every** active endpoint regardless of its event subscriptions,
        and cannot be turned off. `code` is the stable machine-readable
        identifier (current codes: `multiple_tts_consumers`,
        `multiple_media_consumers` - an overlay and an API consumer both
        claimed items from the same queue within a short window, so items
        are likely playing twice); `message` is human-readable prose.
        At minimum log `message` somewhere a human reads, or forward it to
        a Discord channel. New codes are added over time - treat an
        unknown `code` as a log line, never an error. The same warning
        fires at most once per ~6 hours per streamer.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: "warning" }
                    data:
                      type: object
                      required: [code, message]
                      properties:
                        code:
                          type: string
                          description: Stable identifier; branch on this, never on message text.
                        message:
                          type: string
                          description: Human-readable explanation, safe to pipe straight into a log or Discord.
                      additionalProperties:
                        description: Per-code extras (e.g. queue, consumers).
      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_... or tpat_..."
      description: >
        A TipPage API key (`tp_live_...`, created in Dashboard → Settings → Developer)
        or an OAuth 2.0 access token (`tpat_...`, see the OAuth guide). Both carry
        the same scopes and are accepted on every endpoint.
    oauth2:
      type: oauth2
      description: >
        For apps other streamers connect. Authorization code + PKCE (S256); refresh
        tokens rotate on use. Scopes are the same vocabulary as API keys. Guide: /api/oauth.
      flows:
        authorizationCode:
          authorizationUrl: https://api.tippage.com/oauth/authorize
          tokenUrl: https://api.tippage.com/oauth/token
          refreshUrl: https://api.tippage.com/oauth/token
          scopes:
            tts:read: Read the TTS queue and history
            tips:create: Create tips
            tts:control: Control the TTS queue
            media:read: Read the media queue
            media:control: Control the media queue
            tipping:control: Open/close tipping
            viewers:read: Read viewers
            viewers:manage: Grant/revoke reward credits
            webhooks:manage: Manage webhook endpoints
            counters:manage: Manage counters
            timers:manage: Manage chat timers
            chat:write: Send chat messages
            commands:manage: Manage chat commands
            overlays:manage: Manage overlays
            channel_points:manage: Resolve channel point redemptions
            ai_voices:manage: Manage AI voices (TipPage+)

  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_...`).
    TimerId:
      name: timerId
      in: path
      required: true
      schema: { type: string }
      description: Chat timer id (`tmr_...`).
    CommandName:
      name: commandName
      in: path
      required: true
      schema: { type: string }
      description: The command's name - its trigger without the `!` prefix.
    OverlayId:
      name: overlayId
      in: path
      required: true
      description: The overlay's id (`o_` + 8 hex chars).
      schema: { type: string, example: o_1a2b3c4d }
    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: >
        Which viewer: `<platform>:<id>` (`twitch:123456789`, `kick:123`),
        a bare numeric id, or a login / display name (case-insensitive,
        matched on both platforms; URL-encode names with special
        characters). A bare id is tried on Twitch then Kick - if an account
        with that id has signed in on both, the answer is
        `409 ambiguous_viewer`; a name shared by two different people is
        409 too, while a name that matches both of ONE person's linked
        accounts resolves to the one they signed in with most recently.
        Prefer `platform: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 credential (`missing_api_key` / `invalid_api_key`, or `invalid_token` for an expired/revoked OAuth access token).
      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 id has ever signed in to this tip page
        (`viewer_not_signed_in`). Signing in on the tip page (Twitch or
        Kick) 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 (`ambiguous_viewer`) - two
        people share that name, or a bare id exists on both platforms.
        Retry with `<platform>:<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 }
        source:
          type: string
          description: >
            Where the tip came from: `stripe`, `paypal`, `manual` (dashboard
            manual tip), `api` (POST /tips), `sub_reward`, `replay`, `test`,
            `ayupcc`, or `unknown` for tips older than source tracking.
        external_source:
          type: [string, "null"]
          description: >
            Developer-API tips only - the `source` label the caller sent
            (`kofi`, `ayupcc`); `null` for every other source. Says where
            the tip came from, not whether money moved - see `is_paid`.
        external_ref:
          type: [string, "null"]
          description: Developer-API tips only - the caller's own payment reference.
        is_paid:
          type: boolean
          description: >
            Real money changed hands (Stripe, PayPal, or a developer-API tip
            sent with `paid: true`). `false` for manual tips, free
            integration tips and sub rewards - those never count toward the
            streamer's totals.
        platform:
          type: [string, "null"]
          enum: [twitch, kick, null]
          description: >
            The platform of the account the donor was signed in with when
            they tipped; `null` for an unattributed tip.
        platform_user_id:
          type: [string, "null"]
          description: The donor's id on that platform, when signed in.
        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 }
        source:
          type: string
          description: >
            Where the tip came from: `stripe`, `paypal`, `manual` (dashboard
            manual tip), `api` (POST /tips), `sub_reward`, `replay`, `test`,
            `ayupcc`, or `unknown` for tips older than source tracking.
        external_source:
          type: [string, "null"]
          description: >
            Developer-API tips only - the `source` label the caller sent
            (`kofi`, `ayupcc`); `null` for every other source. Says where
            the tip came from, not whether money moved - see `is_paid`.
        external_ref:
          type: [string, "null"]
          description: Developer-API tips only - the caller's own payment reference.
        is_paid:
          type: boolean
          description: >
            Real money changed hands (Stripe, PayPal, or a developer-API tip
            sent with `paid: true`). `false` for manual tips, free
            integration tips and sub rewards - those never count toward the
            streamer's totals.
        platform: { type: [string, "null"], enum: [twitch, kick, null], description: Platform of the signed-in donor, or `null`. }
        platform_user_id: { type: [string, "null"], description: The donor's id on that platform, when signed in. }
        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 }
        source:
          type: string
          description: >
            Where the tip came from: `stripe`, `paypal`, `manual` (dashboard
            manual tip), `api` (POST /tips), `sub_reward`, `replay`, `test`,
            `ayupcc`, or `unknown` for tips older than source tracking.
        external_source:
          type: [string, "null"]
          description: >
            Developer-API tips only - the `source` label the caller sent
            (`kofi`, `ayupcc`); `null` for every other source. Says where
            the tip came from, not whether money moved - see `is_paid`.
        external_ref:
          type: [string, "null"]
          description: Developer-API tips only - the caller's own payment reference.
        is_paid:
          type: boolean
          description: >
            Real money changed hands (Stripe, PayPal, or a developer-API tip
            sent with `paid: true`). `false` for manual tips, free
            integration tips and sub rewards - those never count toward the
            streamer's totals.
        platform: { type: [string, "null"], enum: [twitch, kick, null], description: Platform of the signed-in donor, or `null`. }
        platform_user_id: { type: [string, "null"], description: The donor's id on that platform, when signed in. }
        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, dev, unknown]
          description: >
            Where the tip came from. `manual` is the dashboard's manual-tip
            form; `api` is a POST /tips tip - check `external_source` /
            `is_paid` to tell a paid-elsewhere tip from a free or manual one.
        external_source:
          type: [string, "null"]
          description: >
            Developer-API tips only - the `source` label sent to POST /tips
            (where the tip came from). `null` otherwise.
        external_ref:
          type: [string, "null"]
          description: Developer-API tips only - the caller's `reference`.
        is_paid:
          type: boolean
          description: >
            Real money changed hands. `true` for Stripe, PayPal and
            developer-API tips sent with `paid: true`; `false` for manual
            tips, sub rewards and free integration tips.
        is_replay: { type: boolean }
        is_sub_reward: { type: boolean }
        platform: { type: [string, "null"], enum: [twitch, kick, null], description: Platform of the signed-in donor, or `null`. }
        platform_user_id: { type: [string, "null"], description: The donor's id on that platform, when signed in. }
        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: >
        Platform alert payload (`twitch.*` and `kick.*` events). `platform`,
        `user_name` and `user_login` are always present; the rest depend on
        the event type.
      properties:
        event_id: { type: string, description: "The alert's permanent id (`ev_` + 16 hex) - the same id the activity feed uses." }
        platform: { type: string, enum: [twitch, kick], description: Which platform the viewer is on. }
        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). }
        kicks: { type: integer, description: Kicks gifted (kick.kicks). }
        gift_name: { type: [string, "null"], description: The Kicks gift's name (kick.kicks). }
        kicks_message: { type: [string, "null"], description: The viewer's note on a Kicks gift (kick.kicks). }

    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:
        platform: { type: string, enum: [twitch, kick], description: Which chat the command was typed in. }
        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: The chatter's user id on that platform. }
        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 }
        actor:
          description: |
            Who caused the event. `null` for system-originated events (a
            payment landing, an overlay heartbeat lapsing). Otherwise one of
            `user` (a team member in the dashboard), `chat` (a moderator's
            chat command via the bot), `api_key` (a developer API key), `oauth_app` (an OAuth-connected app),
            `overlay` (the overlay reporting start/finish) or `bot`.
          oneOf:
            - type: "null"
            - type: object
              properties:
                type: { type: string, enum: [user, chat, api_key, oauth_app, overlay, bot] }
                user_id: { type: [string, "null"], description: "TipPage user id (`u_...`). Always set for `user` actors; set for `chat` actors when the chatter's account is linked to a TipPage user." }
                platform: { type: [string, "null"], enum: [twitch, kick, null], description: "user / chat actors - the platform account behind the actor" }
                platform_id: { type: [string, "null"], description: "user / chat actors - the account's id on that platform" }
                login: { type: [string, "null"], description: "user / chat actors" }
                display_name: { type: [string, "null"], description: "user / chat actors" }
                id: { type: [string, "null"], description: "api_key (`ak_...`) / oauth_app (`oc_...`, the client id) / overlay (`ovl_...`) actors" }
                name: { type: [string, "null"], description: "api_key / oauth_app / overlay actors" }
                authorization_id: { type: [string, "null"], description: "oauth_app actors only - the authorization (`oa_...`) the app acts under" }
              required: [type]
          examples:
            - { type: chat, user_id: null, platform: twitch, platform_id: "123456789", login: modname, display_name: ModName }
            - { type: user, user_id: u_9f3c1a7e42b08d61, platform: twitch, platform_id: "44322889", login: streamer, display_name: Streamer }
        data: { type: object }
      required: [id, type, created, actor, data]

    Viewer:
      type: object
      description: >
        A viewer who has signed in to the tip page. `platform` +
        `platform_user_id` name the account they signed in with here;
        `linked_accounts` lists the person's other platform account(s), if
        they linked any (may be empty). Credits and reward history are
        read across the whole set.
      properties:
        platform: { type: string, enum: [twitch, kick], examples: [twitch] }
        platform_user_id: { type: string, examples: ["123456789"] }
        login: { type: [string, "null"], examples: ["gigachad42"] }
        display_name: { type: [string, "null"], 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).
        linked_accounts:
          type: array
          description: The person's OTHER platform accounts - never the one above. Empty when nothing is linked.
          items:
            type: object
            properties:
              platform: { type: string, enum: [twitch, kick] }
              platform_user_id: { type: string }
              login: { type: [string, "null"] }
              display_name: { type: [string, "null"] }
      required: [platform, platform_user_id, linked_accounts]

    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 }

    TipGoal:
      type: object
      properties:
        enabled: { type: boolean }
        title: { type: string, example: "New PC fund" }
        amount: { type: number, description: The target., example: 500 }
        current: { type: number, description: Tips since starts_at plus manual adjustments., example: 173.5 }
        starts_at: { type: string, format: date-time, nullable: true }
        ends_at: { type: string, format: date-time, nullable: true }
    Overlay:
      type: object
      properties:
        id: { type: string, example: o_1a2b3c4d }
        name: { type: string, example: "Main overlay" }
        resolution:
          type: object
          description: The overlay's fixed canvas resolution in pixels.
          properties:
            width: { type: integer, example: 1920 }
            height: { type: integer, example: 1080 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Counter:
      type: object
      properties:
        name:
          type: string
          description: The counter's name - its key on every surface (chat tokens, labels, this API).
          examples: ["deaths"]
        value: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ChatTimer:
      type: object
      properties:
        id: { type: string, examples: ["tmr_1a2b3c4d5e6f7a8b"] }
        name:
          type: string
          description: Label shown in the dashboard - viewers never see it.
        message:
          type: string
          description: What the bot posts. Same template variables as custom commands minus the argument ones.
        interval_minutes:
          type: integer
          minimum: 1
          maximum: 1440
        interval_max_minutes:
          type: [integer, "null"]
          minimum: 1
          maximum: 1440
          description: >
            `null` = fixed cadence. Set = a fresh random interval in
            [`interval_minutes`, `interval_max_minutes`] is rolled after
            every post.
        min_chat_lines:
          type: integer
          minimum: 0
          maximum: 1000
          description: >
            The timer waits until this many chat messages have been sent
            since its last post. 0 = no requirement. A due timer that
            hasn't met the bar posts as soon as chat catches up - it never
            skips a cycle.
        live_only:
          type: boolean
          description: >
            Only post while the streamer is live - the Twitch channel's
            status when one is connected, else the Kick channel's.
            `min_chat_lines` counts Twitch chat only.
        platforms:
          type: array
          items: { type: string, enum: [twitch, kick] }
          description: >
            Which chat(s) the timer posts in. Default both. One schedule -
            when it fires, it posts once per connected platform in the set.
        enabled: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ChatTimerCreate:
      type: object
      properties:
        name: { type: string, maxLength: 64 }
        message:
          type: string
          maxLength: 2500
          description: The template. Can't start with `/` or `!`; the rendered chat line is cut to 500.
        interval_minutes: { type: integer, minimum: 1, maximum: 1440 }
        interval_max_minutes:
          type: [integer, "null"]
          minimum: 1
          maximum: 1440
          description: Omit or `null` for a fixed cadence.
        min_chat_lines: { type: integer, minimum: 0, maximum: 1000, default: 0 }
        live_only: { type: boolean, default: true }
        platforms:
          type: array
          items: { type: string, enum: [twitch, kick] }
          default: [twitch, kick]
          description: Which chat(s) the timer posts in. At least one.
        enabled: { type: boolean, default: true }
      required: [name, message, interval_minutes]

    ChatTimerUpdate:
      type: object
      description: Any subset of fields.
      properties:
        name: { type: string, maxLength: 64 }
        message: { type: string, maxLength: 2500 }
        interval_minutes: { type: integer, minimum: 1, maximum: 1440 }
        interval_max_minutes: { type: [integer, "null"], minimum: 1, maximum: 1440 }
        min_chat_lines: { type: integer, minimum: 0, maximum: 1000 }
        live_only: { type: boolean }
        platforms:
          type: array
          items: { type: string, enum: [twitch, kick] }
          description: At least one.
        enabled: { type: boolean }

    CustomCommand:
      type: object
      properties:
        name:
          type: string
          description: The trigger, without the `!` prefix.
          examples: [discord]
        response:
          type: string
          description: What the bot answers. Template variables supported.
        description:
          type: [string, "null"]
          maxLength: 200
          description: >
            Optional viewer-facing blurb shown on the tip page's command
            list instead of the response.
            `null` = the page shows a preview of the response with
            variables rendered as labels (`{urlfetch:}` addresses and
            `{ai:}` prompts are never shown).
        listed:
          type: boolean
          description: Whether the command appears on the tip page's command list. It works in chat either way.
        ai_memory:
          type: boolean
          description: >
            `{ai:}` conversation memory - when true the character remembers
            this command's last 15 exchanges (with timestamps, kept until
            the command is deleted) and reacts to repeats and returning
            viewers. Only meaningful for responses containing `{ai:}`.
            Memory is per platform unless `ai_memory_shared`.
        ai_memory_shared:
          type: boolean
          description: One memory for Twitch and Kick instead of one per platform. Only meaningful with `ai_memory`.
        platforms:
          type: array
          items: { type: string, enum: [twitch, kick] }
          description: >
            Which chat(s) it runs in. Default both. A platform the streamer
            hasn't connected is simply skipped; the set can't be empty
            (use `enabled: false` to bench it).
        permission:
          type: string
          enum: [everyone, subscriber, moderator]
          description: >
            Who can trigger it. `subscriber` includes moderators;
            `moderator` includes the streamer.
        cooldown_seconds:
          type: integer
          minimum: 0
          maximum: 3600
        cooldown_type:
          type: string
          enum: [global, per_user]
          description: >
            `global` = one shared cooldown for the whole channel;
            `per_user` = each viewer gets their own.
        aliases:
          type: array
          items: { type: string }
          description: Extra triggers for the same command, also without the `!`.
        enabled: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CustomCommandCreate:
      type: object
      properties:
        name:
          type: string
          maxLength: 50
          pattern: "^[a-z0-9_-]+$"
          description: >
            The trigger without the `!` prefix - lowercase letters,
            numbers, hyphens, and underscores. The bot's built-in
            triggers are reserved.
        response:
          type: string
          maxLength: 2500
          description: The template. Can't start with `/` or `!`; the rendered chat line is cut to 500.
        description:
          type: [string, "null"]
          maxLength: 200
          description: Viewer-facing blurb for the tip page's command list. Plain text.
        listed: { type: boolean, default: true }
        ai_memory: { type: boolean, default: false }
        ai_memory_shared: { type: boolean, default: false }
        platforms:
          type: array
          items: { type: string, enum: [twitch, kick] }
          default: [twitch, kick]
          description: Which chat(s) the command answers in. At least one.
        permission:
          type: string
          enum: [everyone, subscriber, moderator]
          default: everyone
        cooldown_seconds: { type: integer, minimum: 0, maximum: 3600, default: 0 }
        cooldown_type: { type: string, enum: [global, per_user], default: global }
        aliases:
          type: array
          maxItems: 10
          items: { type: string }
          description: Same lexical rules as `name`; max 10.
        enabled: { type: boolean, default: true }
      required: [name, response]

    CustomCommandUpdate:
      type: object
      description: >
        Any subset of fields. `name` renames the command - the old name
        stops triggering.
      properties:
        name: { type: string, maxLength: 50, pattern: "^[a-z0-9_-]+$" }
        response: { type: string, maxLength: 2500 }
        description: { type: [string, "null"], maxLength: 200 }
        listed: { type: boolean }
        ai_memory: { type: boolean }
        ai_memory_shared: { type: boolean }
        platforms:
          type: array
          items: { type: string, enum: [twitch, kick] }
          description: At least one.
        permission: { type: string, enum: [everyone, subscriber, moderator] }
        cooldown_seconds: { type: integer, minimum: 0, maximum: 3600 }
        cooldown_type: { type: string, enum: [global, per_user] }
        aliases:
          type: array
          maxItems: 10
          items: { type: string }
        enabled: { 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 }
