openapi: 3.1.0
info:
  title: TwinTone API
  description: |
    Programmatic access to AI-hosted live streams — start streams, track them in
    real time, and pull analytics. Built for iGaming operators and live-commerce
    brands.

    API streams draw on the same plan minutes as dashboard streams. API keys
    are issued in the dashboard under DEVELOPERS → API Keys. Live keys require
    the Growth plan or above; test keys are free.
  version: 1.0.0
  license:
    name: Proprietary
    url: https://docs.twintone.ai/
  contact:
    name: TwinTone Support
    url: https://live.twintone.ai/dashboard/billing
servers:
  - url: https://live.twintone.ai/api/v1
    description: Production API (v1)
security:
  - bearerAuth: []
tags:
  - name: Creators
    description: Discover the stock creators (avatars) available to host streams.
  - name: Streams
    description: Start, list, get, and end AI-hosted live streams.
  - name: Analytics
    description: Aggregate usage and performance across your brand's streams.
  - name: Webhooks
    description: Register endpoints to receive stream lifecycle events.
paths:
  /:
    get:
      summary: API root — discovery document
      description: >
        Returns a small discovery document (name, version, status, docs link,
        and an index of endpoints with their required scopes). Public — no API
        key required. Useful as a liveness check when pasting the base URL
        into a browser.
      security: []
      operationId: getApiRoot
      responses:
        "200":
          description: Discovery document.
          content:
            application/json:
              schema:
                type: object
                required: [name, version, status, docs, endpoints]
                properties:
                  name:
                    type: string
                    example: TwinTone API
                  version:
                    type: string
                    example: v1
                  status:
                    type: string
                    example: operational
                  docs:
                    type: string
                    format: uri
                    example: "https://docs.twintone.ai"
                  openapi:
                    type: string
                    format: uri
                    description: Where this spec is published.
                  status_page:
                    type: string
                    format: uri
                    example: "https://status.twintone.ai"
                  authentication:
                    type: string
                    description: Human-readable auth instructions.
                  endpoints:
                    type: object
                    description: Endpoint index — path → description + scope.
                    additionalProperties:
                      type: string
  /creators:
    get:
      tags: [Creators]
      summary: List creators
      description: >
        Lists the stock creators available to host streams, sorted by name.
        Public — no API key required. This is the discovery surface for
        `creator_id`: pass an entry's `id` (the stable `avt_` + 12-char public
        id) as `creator_id` to `POST /streams`. Responses are cacheable for 60
        seconds (`Cache-Control: public, max-age=60`).
      security: []
      operationId: listCreators
      responses:
        "200":
          description: List of stock creators.
          content:
            application/json:
              schema:
                type: object
                required: [creators]
                properties:
                  creators:
                    type: array
                    items:
                      $ref: "#/components/schemas/CreatorSummary"
        "500":
          $ref: "#/components/responses/InternalError"
  /streams:
    post:
      tags: [Streams]
      summary: Start a stream
      description: >
        Creates a new stream and dispatches an AI host to the room. The stream
        transitions `preparing → live` (billing starts at `live`), or to `error`.
      security:
        - bearerAuth: [streams:write]
      operationId: startStream
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StartStreamRequest"
      responses:
        "201":
          description: Stream created and preparing.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StreamCreated"
        "400":
          description: >
            Validation failure. `missing_fields` — body isn't valid JSON, a
            required field is absent, or `duration_minutes` is missing on a
            live-key request. `invalid_fields` — a constraint was violated:
            `creator_id` ≤ 64 chars, `product_name` ≤ 200, `platform` ≤ 32 and
            one of the allowed values, `script` ≤ 50,000, `duration_minutes` a
            whole number 1–480. `invalid_creator` — `creator_id` doesn't match
            any creator from `GET /creators`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                missing_fields:
                  value:
                    error:
                      code: missing_fields
                      message: "Required: creator_id, product_name, platform (all strings)"
                invalid_fields:
                  value:
                    error:
                      code: invalid_fields
                      message: "duration_minutes must not exceed 480 (8 hours)."
                invalid_creator:
                  value:
                    error:
                      code: invalid_creator
                      message: "creator_id does not exist. List available creators via GET /api/v1/creators."
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: >
            Live API access requires the Growth plan or above
            (`plan_required`). Test keys are exempt.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                plan_required:
                  value:
                    error:
                      code: plan_required
                      message: "API access requires the Growth plan or above. Manage your plan in the TwinTone dashboard."
        "403":
          description: >
            Two distinct failures share this status. `insufficient_scope` —
            the key lacks `streams:write`. `vertical_mismatch` — the
            `vertical` in the request doesn't match your brand's vertical.
            Distinguish by the `code` field, never by status alone.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                insufficient_scope:
                  value:
                    error:
                      code: insufficient_scope
                      message: "Your API key does not have the required scope for this endpoint."
                vertical_mismatch:
                  value:
                    error:
                      code: vertical_mismatch
                      message: "Brand is live-commerce, cannot create igaming stream"
        "429":
          description: >
            Two distinct limits share this status. `rate_limited` — over the
            100 req/min request limit; back off and retry.
            `concurrency_limit` — your brand already has 3 active
            (`preparing` + `live`) streams. **Retrying will not clear
            `concurrency_limit`** — end a stream (`DELETE /streams/{id}`) or
            wait for one to finish before starting another.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                rate_limited:
                  value:
                    error:
                      code: rate_limited
                      message: "Too many requests. Limit: 100/min."
                concurrency_limit:
                  value:
                    error:
                      code: concurrency_limit
                      message: "Brand already has 3 active streams. End one before starting another."
        "500":
          $ref: "#/components/responses/InternalError"
        "502":
          description: >
            Room creation or agent dispatch failed after the stream row was
            created (`launch_failed`). **The stream still exists** in `error`
            status — inspect it via `GET /streams/{id}`. Usually transient;
            retry creates a new stream.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error:
                  code: launch_failed
                  message: "Stream created but the agent could not be dispatched. Check stream status or contact support."
    get:
      tags: [Streams]
      summary: List streams
      description: >
        Lists your brand's streams, newest first. Results are scoped to your
        brand.
      security:
        - bearerAuth: [streams:read]
      operationId: listStreams
      parameters:
        - name: status
          in: query
          description: Filter by status.
          required: false
          schema:
            type: string
            enum: [preparing, live, ended, error]
        - name: limit
          in: query
          description: Number of results to return (max 100).
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          description: Offset for pagination.
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        "200":
          description: >
            List of streams. Note: `status`, `limit`, and `offset` are never
            rejected — out-of-range `limit`/`offset` values are clamped
            (1–100 / ≥ 0) and an unrecognized `status` simply matches nothing.
            This operation does not return 400.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StreamList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PlanRequired"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /streams/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Stream ID (UUID).
        schema:
          type: string
          format: uuid
          example: a1b2c3d4-e5f6-7890-abcd-ef1234567890
    get:
      tags: [Streams]
      summary: Get a stream
      description: >
        Returns full detail for a single stream, including its event history.
        404 if the stream doesn't exist or belongs to another brand.
      security:
        - bearerAuth: [streams:read]
      operationId: getStream
      responses:
        "200":
          description: Stream detail.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StreamDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PlanRequired"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          description: >
            Stream doesn't exist, belongs to another brand, or the lookup
            failed. This operation reports lookup errors as 404, not 500.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error:
                  code: not_found
                  message: "Stream not found or belongs to another brand."
        "429":
          $ref: "#/components/responses/RateLimited"
    delete:
      tags: [Streams]
      summary: End a stream
      description: >
        Ends a `live` stream immediately and stops billing. 404 if the stream
        isn't found or isn't live.
      security:
        - bearerAuth: [streams:write]
      operationId: endStream
      responses:
        "200":
          description: Stream ended.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StreamEnded"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PlanRequired"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          description: >
            Stream doesn't exist, belongs to another brand, or is not
            currently `live`. Ending an already-ended stream returns 404, not
            200.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error:
                  code: not_found
                  message: "Stream not found or is not live."
        "429":
          $ref: "#/components/responses/RateLimited"
  /analytics:
    get:
      tags: [Analytics]
      summary: Get analytics
      description: >
        Aggregate usage and performance across your brand's streams for a
        period. `total_minutes` sums completed streams (`ended_at -
        started_at`); a live stream contributes its minutes only after it ends.
      security:
        - bearerAuth: [analytics:read]
      operationId: getAnalytics
      parameters:
        - name: period
          in: query
          description: Reporting period.
          required: false
          schema:
            type: string
            enum: ["24h", "7d", "30d"]
            default: "7d"
        - name: stream_id
          in: query
          description: Restrict analytics to a single stream.
          required: false
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Analytics summary.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Analytics"
        "400":
          description: "`period` is not one of `24h`, `7d`, `30d`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error:
                  code: invalid_fields
                  message: "period must be one of: 24h, 7d, 30d"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PlanRequired"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /webhooks:
    post:
      tags: [Webhooks]
      summary: Register a webhook
      description: >
        Registers a webhook endpoint. The signing `secret` is returned once,
        at registration — store it with the same care as an API key.
        `GET /webhooks` never returns it again.
      security:
        - bearerAuth: [webhooks:write]
      operationId: registerWebhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RegisterWebhookRequest"
      responses:
        "201":
          description: Webhook registered.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Webhook"
        "400":
          description: >
            Validation failure. `missing_fields` — body isn't valid JSON or
            `url`/`events` are absent. `invalid_url` — `url` must be a valid
            HTTPS URL on a public host (localhost, private/reserved IPs, and
            IPv6 literals are rejected). `invalid_events` — `events` is empty
            or contains an unknown event type.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                missing_fields:
                  value:
                    error:
                      code: missing_fields
                      message: "Required: url, events[]"
                invalid_url:
                  value:
                    error:
                      code: invalid_url
                      message: "webhook url must use https"
                invalid_events:
                  value:
                    error:
                      code: invalid_events
                      message: "events must be a non-empty subset of: stream.started, stream.ended, stream.error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PlanRequired"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
    get:
      tags: [Webhooks]
      summary: List webhooks
      description: Lists registered webhooks. Secrets are never returned.
      security:
        - bearerAuth: [webhooks:write]
      operationId: listWebhooks
      responses:
        "200":
          description: List of webhooks (no secrets).
          content:
            application/json:
              schema:
                type: object
                required: [webhooks]
                properties:
                  webhooks:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookSummary"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PlanRequired"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      tags: [Webhooks]
      summary: Delete a webhook
      description: >
        Deletes a webhook by ID. **Idempotent**: deleting an ID that doesn't
        exist (or belongs to another brand) still returns
        `200 {deleted: true}` — this operation never returns 404. Treat 200
        as "the webhook is gone", not "the webhook existed".
      security:
        - bearerAuth: [webhooks:write]
      operationId: deleteWebhook
      parameters:
        - name: id
          in: query
          required: true
          description: Webhook ID (as returned in `webhook_id`).
          schema:
            type: string
            format: uuid
            example: b7f1c2ae-4c1d-4f36-9b3a-2f8d1a6e5c90
      responses:
        "200":
          description: >
            Webhook deleted (or already absent — see the idempotency note
            above).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookDeleted"
        "400":
          $ref: "#/components/responses/MissingFields"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PlanRequired"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        API keys issued in the dashboard (DEVELOPERS → API Keys). Live keys start
        with `tt_live_`, test keys with `tt_test_`. Send as
        `Authorization: Bearer <key>`. Test keys simulate the full lifecycle
        with no agent dispatch and no billing.
  schemas:
    StartStreamRequest:
      type: object
      required: [creator_id, product_name, platform]
      properties:
        duration_minutes:
          type: integer
          minimum: 1
          maximum: 480
          description: >
            Stream duration in minutes — a whole number, 1–480 (8 hours max).
            **Required for `tt_live_` keys**: the agent worker reads it from
            room metadata as a cost guard and fails closed — a live request
            without it is rejected with `400 missing_fields` before any room
            is created. Optional for `tt_test_` keys (validated if sent, but
            test streams simulate the lifecycle without dispatch).
          example: 30
        creator_id:
          type: string
          description: >
            Creator to host. Must be the stable `avt_` + 12-char public ID
            from `GET /api/v1/creators`. Anything else returns `400 invalid_creator`.
            (Derived slugs like `mila` are no longer accepted.)
          example: "avt_9ww075quzedr"
        product_name:
          type: string
          description: Product/game being featured.
          example: "Radiance Serum"
        platform:
          type: string
          enum: [youtube, tiktok, twitch, kick, facebook, instagram]
          description: Target streaming platform.
        script:
          type: string
          description: Talking points / script seed for the host.
          example: "Welcome in! Today we are talking about the Radiance Serum..."
        vertical:
          type: string
          enum: [live-commerce, igaming]
          description: >
            Must match your brand's vertical. iGaming brands are restricted to
            the kick, youtube and twitch platforms.
          example: live-commerce
    CreatorSummary:
      type: object
      required: [id, name, status, languages, style, personality, vibe, consent, photo]
      properties:
        id:
          type: string
          description: >
            Stable public creator id (`avt_` + 12 chars). Pass this as
            `creator_id` to `POST /streams`.
          example: "avt_9ww075quzedr"
        name:
          type: string
          example: "Mila"
        status:
          type: [string, "null"]
          example: "active"
        languages:
          type: array
          items:
            type: string
          example: ["en"]
        style:
          type: [string, "null"]
          example: "Friendly, warm, relatable"
        personality:
          type: [string, "null"]
          example: "Enthusiastic beauty expert who makes every product feel personal"
        vibe:
          type: [string, "null"]
          example: "Your best friend who happens to know everything about skincare"
        consent:
          type: [boolean, "null"]
          example: true
        photo:
          type: [string, "null"]
          format: uri
          description: Avatar image URL.
    StreamCreated:
      type: object
      required: [stream_id, status, room_name, ws_url, viewer_token, created_at]
      properties:
        stream_id:
          type: string
          format: uuid
          description: Stream ID.
          example: a1b2c3d4-e5f6-7890-abcd-ef1234567890
        status:
          type: string
          enum: [preparing, live]
          description: >
            `preparing` for live keys (the agent promotes the stream to `live`
            once it's running). Test keys return `live` immediately — the
            lifecycle is simulated with no agent dispatch.
        room_name:
          type: string
          description: Real-time room name for the stream.
          example: stream-a1b2c3d4
        ws_url:
          type: [string, "null"]
          format: uri
          description: >
            WebSocket URL for connecting to the real-time room. `null` on
            test-key streams (no real room is created).
          example: "wss://room.example.com"
        viewer_token:
          type: [string, "null"]
          description: Token for viewer access, if applicable. `null` on test-key streams.
          example: null
        test:
          type: boolean
          description: >
            `true` on test-key streams. **Absent** (not `false`) on live-key
            streams.
        agent_dispatched:
          type: boolean
          description: >
            Live keys only: whether the AI host agent was successfully
            dispatched to the room. Absent on test-key streams.
        dispatch_method:
          type: [string, "null"]
          description: >
            Live keys only: how the agent was dispatched. Absent on test-key
            streams.
        created_at:
          type: string
          format: date-time
          example: "2026-08-07T10:00:00.000Z"
    StreamList:
      type: object
      required: [streams, total, limit, offset]
      properties:
        streams:
          type: array
          description: Streams, newest first, scoped to your brand.
          items:
            $ref: "#/components/schemas/StreamSummary"
        total:
          type: integer
          description: Total number of streams matching the filter.
          example: 47
        limit:
          type: integer
          description: Requested limit.
          example: 20
        offset:
          type: integer
          description: Requested offset.
          example: 0
    StreamSummary:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: Stream ID.
        status:
          type: string
          enum: [preparing, live, ended, error]
        platform:
          type: string
        vertical:
          type: string
          enum: [live-commerce, igaming]
        creator_id:
          type: [string, "null"]
          description: >
            The creator's stable `avt_…` public id (see `GET /creators`).
            `null` if the creator can no longer be resolved.
          example: "avt_9ww075quzedr"
        product_name:
          type: string
        source:
          type: string
          description: >
            How the stream was started — `api` for streams created via this
            API; dashboard-created streams appear here too with their own
            source value.
          example: api
        room_name:
          type: [string, "null"]
          description: Real-time room name for the stream.
        started_at:
          type: [string, "null"]
          format: date-time
        ended_at:
          type: [string, "null"]
          format: date-time
        created_at:
          type: string
          format: date-time
        viewer_count:
          type: integer
          description: Live-updating viewer count.
    StreamDetail:
      type: object
      required:
        - stream_id
        - status
        - creator_id
        - product_name
        - platform
        - vertical
        - viewer_count
        - started_at
        - ended_at
        - room_name
        - events
      properties:
        stream_id:
          type: string
          format: uuid
          example: a1b2c3d4-e5f6-7890-abcd-ef1234567890
        status:
          type: string
          enum: [preparing, live, ended, error]
          example: live
        creator_id:
          type: [string, "null"]
          description: >
            The creator's stable `avt_…` public id (see `GET /creators`).
            `null` if the creator can no longer be resolved.
          example: "avt_9ww075quzedr"
        product_name:
          type: string
          example: "Radiance Serum"
        platform:
          type: string
          example: youtube
        vertical:
          type: string
          enum: [live-commerce, igaming]
          example: live-commerce
        viewer_count:
          type: integer
          description: Live-updating viewer count.
          example: 312
        started_at:
          type: [string, "null"]
          format: date-time
          example: "2026-08-07T10:00:14.000Z"
        ended_at:
          type: [string, "null"]
          format: date-time
          example: null
        room_name:
          type: string
          example: stream-a1b2c3d4
        events:
          type: array
          description: >
            **Always `[]` in the current release.** Inline event history is
            not populated — subscribe to webhooks (`POST /webhooks`) for
            `stream.started` / `stream.ended` / `stream.error` events instead.
            The field is reserved and will remain an array.
          items:
            $ref: "#/components/schemas/StreamEvent"
    StreamEvent:
      type: object
      description: >
        Reserved. Not currently returned inline — event delivery is
        webhook-only (see `POST /webhooks`).
      required: [type, created_at]
      properties:
        type:
          type: string
          enum: [stream.started, stream.ended, stream.error]
          example: stream.started
        created_at:
          type: string
          format: date-time
    StreamEnded:
      type: object
      required: [stream_id, status, ended_at]
      properties:
        stream_id:
          type: string
          format: uuid
        status:
          type: string
          enum: [ended]
          example: ended
        ended_at:
          type: string
          format: date-time
          example: "2026-08-07T11:30:00.000Z"
    Analytics:
      type: object
      required:
        - period
        - brand_id
        - total_streams
        - total_viewers
        - total_minutes
        - live_now
        - avg_viewers_per_stream
        - by_platform
        - streams
      properties:
        period:
          type: string
          enum: ["24h", "7d", "30d"]
          example: "30d"
        brand_id:
          type: string
          description: Your brand ID.
        total_streams:
          type: integer
          example: 47
        total_viewers:
          type: integer
          example: 12840
        total_minutes:
          type: integer
          description: >
            Sum of completed stream minutes in the period. A live stream
            contributes its minutes only after it ends.
          example: 2130
        live_now:
          type: integer
          example: 1
        avg_viewers_per_stream:
          type: number
          example: 273
        by_platform:
          type: object
          description: >
            Number of **streams** per platform in the period (not viewers —
            use `total_viewers` / per-stream `viewer_count` for audience
            numbers). Streams with no platform recorded appear under the key
            `unknown`.
          additionalProperties:
            type: integer
          example: { youtube: 3, tiktok: 2 }
        streams:
          type: array
          items:
            $ref: "#/components/schemas/AnalyticsStream"
    AnalyticsStream:
      type: object
      properties:
        id:
          type: string
          format: uuid
        status:
          type: string
          enum: [preparing, live, ended, error]
        viewer_count:
          type: integer
        started_at:
          type: [string, "null"]
          format: date-time
        ended_at:
          type: [string, "null"]
          format: date-time
        platform:
          type: string
        vertical:
          type: string
          enum: [live-commerce, igaming]
    RegisterWebhookRequest:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          description: HTTPS endpoint that will receive deliveries.
          example: "https://your-server.com/twintone/webhook"
        events:
          type: array
          minItems: 1
          items:
            type: string
            enum: [stream.started, stream.ended, stream.error]
          example: [stream.started, stream.ended, stream.error]
    Webhook:
      type: object
      required: [webhook_id, url, events, secret, active]
      properties:
        webhook_id:
          type: string
          description: Webhook ID.
          example: b7f1c2ae-4c1d-4f36-9b3a-2f8d1a6e5c90
        url:
          type: string
          format: uri
        events:
          type: array
          items:
            type: string
            enum: [stream.started, stream.ended, stream.error]
        secret:
          type: string
          description: >
            Signing secret, returned only at registration (`whsec_…`). Never
            returned by GET /webhooks.
          example: "whsec_…"
        active:
          type: boolean
          example: true
    WebhookSummary:
      type: object
      required: [webhook_id, url, events, active, created_at]
      properties:
        webhook_id:
          type: string
          description: Webhook ID.
          example: b7f1c2ae-4c1d-4f36-9b3a-2f8d1a6e5c90
        url:
          type: string
          format: uri
        events:
          type: array
          items:
            type: string
            enum: [stream.started, stream.ended, stream.error]
        active:
          type: boolean
          example: true
        created_at:
          type: string
          format: date-time
    WebhookDeleted:
      type: object
      required: [webhook_id, deleted]
      properties:
        webhook_id:
          type: string
        deleted:
          type: boolean
          example: true
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - missing_fields
                - unauthorized
                - insufficient_scope
                - vertical_mismatch
                - not_found
                - rate_limited
                - plan_required
                - db_error
                - invalid_fields
                - invalid_creator
                - concurrency_limit
                - launch_failed
                - invalid_url
                - invalid_events
            message:
              type: string
  responses:
    MissingFields:
      description: Required body or query parameters are absent.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            error:
              code: missing_fields
              message: "Missing required fields: creator_id"
    Unauthorized:
      description: >
        Missing, malformed, revoked, or expired API key. The response body is
        identical in all four cases.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            error:
              code: unauthorized
              message: "Invalid or revoked API key"
    InsufficientScope:
      description: >
        The API key is valid but does not carry the scope this operation
        requires (`insufficient_scope`). Create a key with the needed scope in
        the dashboard — scopes cannot be added to an existing key.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            error:
              code: insufficient_scope
              message: "Your API key does not have the required scope for this endpoint."
    PlanRequired:
      description: >-
        Live API access (stream and webhook management) requires the Growth
        plan or above (tt_test_ sandbox keys are exempt).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            error:
              code: plan_required
              message: "API access requires the Growth plan or above. Manage your plan in the TwinTone dashboard."
    InternalError:
      description: >
        Database error on our side (`db_error`). Safe to retry with backoff;
        contact support if persistent.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            error:
              code: db_error
              message: "Something went wrong. Try again."
    RateLimited:
      description: Over the per-minute request limit.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            error:
              code: rate_limited
              message: "Too many requests. Limit: 100/min."
