openapi: 3.0.3
info:
  title: Nulx API
  version: 1.0.0
  description: |
    The Nulx v1 API lets you upload source video, track transcoding jobs,
    issue playback credentials, and manage billing for a tenant-scoped video
    platform.

    **Quickstart**: see `/docs/api` for a live walkthrough, or follow the
    3-step flow below.

    1. `POST /api/v1/uploads/initiate` — create a tenant-scoped upload session
       and receive a presigned PUT URL.
    2. `PUT` the raw video bytes to the returned `upload_url`, then
       `POST /api/v1/uploads/{id}/complete` to enqueue transcoding.
    3. `GET /api/v1/playback/{id}` (numeric id or `external_id`) once the
       asset's `playback_status` is `ready` to retrieve the HLS/DASH URLs
       and a signed playback token.

    ### Authentication

    Most endpoints require one of:
    - `X-API-Key` — a tenant-scoped API key (see Api Keys endpoints). Each key
      carries a list of OAuth-like scopes (`assets:read`, `assets:write`,
      `playback:read`, `billing:write`, `api_keys:read`, `api_keys:write`,
      `webhooks:read`, `webhooks:write`) that gate write operations.
    - `X-SSTREAM-ADMIN-TOKEN` — a shared admin/bootstrap token with access to
      every tenant and every scope.

    Worker-only endpoints under `/api/v1/transcoding_jobs` instead require
    `X-SSTREAM-WORKER-TOKEN`. Lease-scoped callbacks also require the per-job
    `X-SSTREAM-JOB-LEASE-TOKEN` issued by local `POST /next` or Cloud v2
    `POST /claim`; attempt-fenced Cloud v2 operations additionally require
    `X-SSTREAM-JOB-ATTEMPT-ID`. Expired or mismatched Cloud callbacks return
    `409` without mutating the job, callback receipt, or terminal state.

    ### Idempotency

    `POST /api/v1/billing/checkout` accepts an `Idempotency-Key` header so
    retried requests do not create duplicate PayPal orders.

    ### Webhooks

    Register an HTTPS endpoint via `POST /api/v1/webhooks` to receive
    lifecycle events as soon as they happen, instead of polling
    `GET /api/v1/playback/{id}`.

    Event catalog (pass any subset as `event_types` when creating the
    endpoint; omit it, or pass an empty array, to receive every event):

    | Event | Fires when |
    | --- | --- |
    | `video.upload.completed` | An upload session finishes and the asset's source is confirmed |
    | `video.job.queued` | A transcoding job enters the queue (including re-queues after a stale-lease recovery) |
    | `video.job.started` | A worker claims a queued job and it starts running |
    | `video.job.failed` | A transcoding job fails (job-level; see also `video.asset.errored`) |
    | `video.asset.ready` | A video asset's playback becomes ready |
    | `video.asset.errored` | A video asset's transcoding fails (asset-level) |
    | `video.asset.deleted` | A video asset is destroyed |
    | `video.subtitle.ready` | A subtitle/caption track is uploaded, translated, or synced and becomes ready |
    | `video.track.ready` | An asset track (chapter/scene/marker/index — see `/api/v1/assets/{video_asset_id}/tracks`) becomes ready |
    | `tenant.traffic.anomaly` | Playback traffic deviates from baseline for a tenant |

    Each delivery is a `POST` of the event payload as JSON, with:
    - `Nulx-Event`: the event type (e.g. `video.asset.ready`).
    - `Nulx-Signature`: `t=<unix_timestamp>,v1=<hex hmac-sha256>`, where the
      signature is computed over the string `"<t>.<raw request body>"` using
      the endpoint's signing secret (returned once, at creation time, as
      `secret` in the `POST /api/v1/webhooks` response).

    To verify a delivery: recompute the HMAC the same way with your stored
    secret and compare it (constant-time) to the `v1` value for the `t` in
    the header. Reject deliveries where `t` is too far in the past to guard
    against replay.

    Example payload:
    ```json
    {
      "event": "video.asset.ready",
      "created_at": "2026-07-05T12:00:00Z",
      "data": {
        "asset_id": "asset-001",
        "title": "Spring Keynote",
        "playback_status": "ready",
        "playback_url": "https://cdn.nulx.dev/hls/asset-001/master.m3u8"
      }
    }
    ```

    Delivery endpoints are re-resolved and checked at send time to block
    requests to private/internal network addresses (SSRF protection);
    deliveries to such addresses are recorded as failed and not retried.
    Transient failures (timeouts, connection errors) are retried up to 5
    times with exponential backoff.
  contact:
    name: Nulx API Support
    email: dev.lasneo@gmail.com
servers:
  - url: https://{host}
    description: Production / staging (templated host)
    variables:
      host:
        default: api.nulx.dev
  - url: http://localhost:3000
    description: Local development

tags:
  - name: Tenants
  - name: Video Assets
  - name: Collections
  - name: Subtitles
  - name: Tracks
  - name: Uploads
  - name: API Keys
  - name: Device Tokens
  - name: Billing
  - name: Usage
  - name: Playback
  - name: Ingest Batches
  - name: Transcoding Jobs
  - name: Cloud Release
  - name: Webhooks
  - name: Live Gateway

security:
  - ApiKeyAuth: []
  - AdminToken: []

paths:
  /api/v1/live/auth:
    post:
      operationId: authorizeLiveGateway
      tags: [Live Gateway]
      summary: Authorize a BYO MediaMTX publish or read
      description: Internal callback for the customer-owned live gateway. Do not call it from browsers.
      security:
        - LiveGatewayToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action, path]
              properties:
                action: { type: string, enum: [publish, read, playback] }
                path: { type: string }
                user: { type: string }
                password: { type: string, format: password }
                protocol: { type: string }
      responses:
        "204":
          description: Gateway action authorized
        "401":
          description: Gateway token rejected
        "403":
          description: Channel, publisher, or stream key rejected
        "429":
          description: Gateway callback rate limited

  /api/v1/tenants:
    get:
      operationId: listTenants
      tags: [Tenants]
      summary: List tenants visible to the caller
      description: |
        Returns the tenant scoped to the current API key, or every tenant
        when authenticated with `X-SSTREAM-ADMIN-TOKEN`. No scope beyond a
        valid API key or admin token is required.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      responses:
        "200":
          description: List of tenants
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Tenant"
              examples:
                default:
                  value:
                    - id: 1
                      name: Launchpad Media
                      slug: launchpad
                      status: active
                      plan_name: growth
                      storage_bytes: 536870912
                      egress_bytes: 1073741824
                      transcoding_minutes: 42
        "401":
          $ref: "#/components/responses/Unauthorized"
      x-codeSamples:
        - lang: curl
          label: curl
          source: |
            curl -s https://api.nulx.dev/api/v1/tenants \
              -H "X-API-Key: $SSTREAM_API_KEY"

  /api/v1/assets:
    get:
      operationId: listVideoAssets
      tags: [Video Assets]
      summary: List video assets for the caller's tenant
      description: |
        Requires a valid API key or admin token. Results are scoped to the
        tenant that owns the API key (admin tokens see every tenant's
        assets is not implied — this endpoint still scopes by `tenant_scope`
        which is the caller's own tenant list).
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      responses:
        "200":
          description: List of video assets, newest first
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/VideoAsset"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createVideoAsset
      tags: [Video Assets]
      summary: Create a video asset directly (out-of-band ingestion)
      description: |
        Creates a `VideoAsset` record for a tenant and immediately enqueues a
        `TranscodingJob`, returning the worker contract that a transcoder
        would receive from `POST /api/v1/transcoding_jobs/next`. Use this when
        the source file already exists in storage; for browser/direct
        uploads, prefer `POST /api/v1/uploads/initiate` instead.

        Requires scope: **assets:write** (or admin token).
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, video_asset]
              properties:
                tenant_id:
                  type: integer
                  description: Tenant that will own the asset. Must belong to the caller unless using the admin token.
                video_asset:
                  type: object
                  required: [title, input_status, playback_status, source_bytes, storage_bytes, duration_seconds, source_key]
                  properties:
                    title:
                      type: string
                    input_status:
                      type: string
                      enum: [pending, uploaded, failed]
                    playback_status:
                      type: string
                      enum: [draft, processing, ready, errored]
                    source_bytes:
                      type: integer
                      minimum: 0
                    storage_bytes:
                      type: integer
                      minimum: 0
                    duration_seconds:
                      type: number
                      minimum: 0
                    source_key:
                      type: string
                      description: Storage key/path of the already-uploaded source file.
                    manifest_path:
                      type: string
                      nullable: true
            example:
              tenant_id: 1
              video_asset:
                title: New Launch
                input_status: uploaded
                playback_status: processing
                source_bytes: 1234
                storage_bytes: 5678
                duration_seconds: 90
                source_key: uploads/new-launch.mp4
                manifest_path: https://player.sstream.local/hls/new-launch/master.m3u8
      responses:
        "201":
          description: Asset and transcoding job created
          content:
            application/json:
              schema:
                type: object
                properties:
                  asset:
                    $ref: "#/components/schemas/VideoAsset"
                  transcoding_job:
                    $ref: "#/components/schemas/TranscodingJob"
                  worker_contract:
                    $ref: "#/components/schemas/WorkerContract"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          description: tenant_id not found or not owned by the caller
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
      x-codeSamples:
        - lang: curl
          label: curl
          source: |
            curl -s -X POST https://api.nulx.dev/api/v1/assets \
              -H "X-API-Key: $SSTREAM_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{
                "tenant_id": 1,
                "video_asset": {
                  "title": "New Launch",
                  "input_status": "uploaded",
                  "playback_status": "processing",
                  "source_bytes": 1234,
                  "storage_bytes": 5678,
                  "duration_seconds": 90,
                  "source_key": "uploads/new-launch.mp4"
                }
              }'
        - lang: javascript
          label: JavaScript (fetch)
          source: |
            const res = await fetch("https://api.nulx.dev/api/v1/assets", {
              method: "POST",
              headers: {
                "X-API-Key": process.env.SSTREAM_API_KEY,
                "Content-Type": "application/json"
              },
              body: JSON.stringify({
                tenant_id: 1,
                video_asset: {
                  title: "New Launch",
                  input_status: "uploaded",
                  playback_status: "processing",
                  source_bytes: 1234,
                  storage_bytes: 5678,
                  duration_seconds: 90,
                  source_key: "uploads/new-launch.mp4"
                }
              })
            });
            const data = await res.json();
            console.log(data);
        - lang: ruby
          label: Ruby (Net::HTTP)
          source: |
            require "net/http"
            require "json"

            uri = URI("https://api.nulx.dev/api/v1/assets")
            req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "X-API-Key" => ENV["SSTREAM_API_KEY"])
            req.body = {
              tenant_id: 1,
              video_asset: {
                title: "New Launch",
                input_status: "uploaded",
                playback_status: "processing",
                source_bytes: 1234,
                storage_bytes: 5678,
                duration_seconds: 90,
                source_key: "uploads/new-launch.mp4"
              }
            }.to_json

            res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
            puts res.body

  /api/v1/assets/export:
    get:
      operationId: exportVideoAssetMetadata
      tags: [Video Assets]
      summary: Export asset metadata (CSV or JSON)
      description: Requires scope `assets:read` (or admin token). Exports title/description/tags/original_filename for the tenant's assets, symmetric with the CSV import columns. CSV is returned by default; pass `format=json` for JSON.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - in: query
          name: format
          schema:
            type: string
            enum: [csv, json]
          description: Optional response format. Defaults to CSV.
      responses:
        "200":
          description: Exported asset metadata
          content:
            text/csv:
              schema:
                type: string
            application/json:
              schema:
                type: array
                items:
                  type: object
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"

  /api/v1/assets/graph_export:
    get:
      operationId: exportAssetGraph
      tags: [Video Assets]
      summary: No-lock-in asset graph export (JSON)
      description: >-
        Requires scope `assets:read` (or admin token). Exports the complete
        playable graph for every asset (ownership/provider, source key,
        HLS/DASH manifest + rendition ladder, thumbnails, subtitles, and
        delivery/auth requirements) so the tenant can locate and play their
        media without Nulx. For a public BYO (external_serve) asset it also
        returns a direct customer-CDN manifest_url with no Nulx token. Never
        includes storage credentials or ephemeral signed URLs.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      responses:
        "200":
          description: The tenant's complete asset graph
          content:
            application/json:
              schema:
                type: object
                properties:
                  schema_version: { type: integer }
                  exported_at: { type: string, format: date-time }
                  tenant: { type: string }
                  assets:
                    type: array
                    items:
                      type: object
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
  /api/v1/assets/import:
    post:
      operationId: importVideoAssetMetadata
      tags: [Video Assets]
      summary: Bulk import asset metadata from CSV
      description: Requires scope `assets:write` (or admin token). Matches CSV rows to assets by original_filename or folder relative path and applies title/description/tags; returns applied and unmatched row results.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
          text/csv:
            schema:
              type: string
      responses:
        "200":
          description: Import result with updated assets and unmatched rows
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"

  /api/v1/assets/{id}:
    get:
      operationId: getVideoAsset
      tags: [Video Assets]
      summary: Fetch a single video asset
      description: |
        Requires a valid API key or admin token. Scoped to the tenant that
        owns the API key.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetId"
      responses:
        "200":
          description: Video asset
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VideoAsset"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateVideoAsset
      tags: [Video Assets]
      summary: Update a video asset's title, description, or collection
      description: |
        Requires scope: **assets:write** (or admin token). Only `title`,
        `description`, and `collection_id` may be updated here (other
        fields such as `playback_status` are managed by the transcoding
        pipeline). `collection_id` is re-scoped to the asset's own tenant —
        assigning a collection owned by another tenant is silently
        rejected (cleared to `null`) rather than erroring.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [video_asset]
              properties:
                video_asset:
                  type: object
                  properties:
                    title:
                      type: string
                    description:
                      type: string
                      nullable: true
                    collection_id:
                      type: integer
                      nullable: true
            example:
              video_asset:
                title: Updated Title
                description: Revised description
                collection_id: 1
      responses:
        "200":
          description: Video asset updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VideoAsset"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
    put:
      operationId: replaceVideoAsset
      tags: [Video Assets]
      summary: Update a video asset's title, description, or collection (PUT alias)
      description: |
        Identical behavior to `PATCH /api/v1/assets/{id}` — Rails routes
        both verbs to the same resourceful update action.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [video_asset]
              properties:
                video_asset:
                  type: object
                  properties:
                    title:
                      type: string
                    description:
                      type: string
                      nullable: true
                    collection_id:
                      type: integer
                      nullable: true
            example:
              video_asset:
                title: Updated Title
                description: Revised description
                collection_id: 1
      responses:
        "200":
          description: Video asset updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VideoAsset"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v1/assets/{id}/tags:
    patch:
      operationId: updateVideoAssetTags
      tags: [Video Assets]
      summary: Add or update schemaless tags on an asset
      description: Requires a valid API key or admin token. Merges key/value tags into the asset's metadata (searchable via the metadata GIN index).
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Updated asset tags
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/assets/{id}/tags/{key}:
    delete:
      operationId: deleteVideoAssetTag
      tags: [Video Assets]
      summary: Remove a tag from an asset
      description: Requires a valid API key or admin token.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: key
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Tag removed
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/assets/{id}/download:
    get:
      operationId: downloadOriginalVideoAsset
      tags: [Video Assets]
      summary: Get a short-lived signed download URL for the original source
      description: |
        Operator-only endpoint. Requires scope: **assets:write** (or admin
        token). Returns a 5-minute signed GET URL for the asset's original
        `source_key` using the asset's resolved StorageProvider.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetId"
      responses:
        "200":
          description: Signed original download URL
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VideoAssetDownload"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/assets/{id}/deovr_manifest.json:
    get:
      operationId: getDeovrVideoAssetManifest
      tags: [Video Assets, Playback]
      summary: Get a DeoVR/HereSphere deep-link manifest for a ready asset
      description: |
        Returns a small JSON manifest for VR players. `encodings[0]["video-sources"][0].url`
        points at the resolved HLS/MP4 playback URL. Signed playback assets include
        a short-lived playback token and append it to the source URL as `token`.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetId"
      responses:
        "200":
          description: DeoVR/HereSphere manifest
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeovrManifest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/assets/{id}/analytics:
    get:
      operationId: getVideoAssetAnalytics
      tags: [Video Assets]
      summary: Per-asset viewer analytics summary
      description: |
        Requires a valid API key or admin token. Aggregates this asset's
        `PlaybackEvent` rows (default range: trailing 30 days) into play
        counts, unique viewers, watch time, and completion rate — the
        same aggregation used by the tenant-wide analytics dashboard,
        scoped to a single asset.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetId"
      responses:
        "200":
          description: Analytics summary for the asset
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AnalyticsSummary"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/assets/{video_asset_id}/subtitles:
    get:
      operationId: listSubtitleTracks
      tags: [Subtitles]
      summary: List subtitle/caption tracks for a video asset
      description: Requires a valid API key or admin token, scoped to the caller's tenant.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetIdParent"
      responses:
        "200":
          description: List of subtitle tracks, ordered by position then language
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/SubtitleTrack"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      operationId: createSubtitleTrack
      tags: [Subtitles]
      summary: Upload a subtitle/caption track for a video asset
      description: |
        Requires scope: **assets:write** (or admin token). Accepts
        `multipart/form-data` with a `file` (`.vtt` or `.srt` — SRT is
        converted to WebVTT server-side), `language` (BCP-47-ish tag, e.g.
        `en`, `pt-BR`), and `label`. An asset may have at most 20 subtitle
        tracks, and `language` must be unique per asset.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetIdParent"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, language, label]
              properties:
                file:
                  type: string
                  format: binary
                  description: Subtitle file, .vtt or .srt.
                language:
                  type: string
                  example: en
                label:
                  type: string
                  example: English
      responses:
        "201":
          description: Subtitle track created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubtitleTrack"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: Invalid file, or the asset already has 20 subtitle tracks
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: R2 storage is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/assets/{video_asset_id}/subtitles/{id}:
    patch:
      operationId: updateSubtitleTrack
      tags: [Subtitles]
      summary: Update a subtitle/caption track
      description: |
        Requires scope **assets:write** (or admin token). Updates `label`
        and/or `language` (must stay unique per asset), and optionally
        replaces the cue content with a full WebVTT body in `content`
        (parsed and validated server-side; the `WEBVTT` header is added if
        missing, maximum 2MB, UTF-8). The stored object and the asset's
        subtitle metadata are kept in sync.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetIdParent"
        - $ref: "#/components/parameters/SubtitleTrackId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  example: English
                language:
                  type: string
                  example: en
                content:
                  type: string
                  description: Full WebVTT body replacing the track's cues.
      responses:
        "200":
          description: Subtitle track updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubtitleTrack"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: Invalid WebVTT content, or the language is already used by another track on this asset
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: R2 storage is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    put:
      operationId: replaceSubtitleTrack
      tags: [Subtitles]
      summary: Update a subtitle/caption track (PUT alias)
      description: |
        Identical behavior to `PATCH /api/v1/assets/{video_asset_id}/subtitles/{id}`
        — Rails routes both verbs to the same resourceful update action.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetIdParent"
        - $ref: "#/components/parameters/SubtitleTrackId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  example: English
                language:
                  type: string
                  example: en
                content:
                  type: string
                  description: Full WebVTT body replacing the track's cues.
      responses:
        "200":
          description: Subtitle track updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubtitleTrack"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: Invalid WebVTT content, or the language is already used by another track on this asset
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: R2 storage is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      operationId: deleteSubtitleTrack
      tags: [Subtitles]
      summary: Remove a subtitle/caption track
      description: Requires scope **assets:write** (or admin token).
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetIdParent"
        - $ref: "#/components/parameters/SubtitleTrackId"
      responses:
        "204":
          description: Subtitle track removed
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: R2 storage is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/assets/{video_asset_id}/subtitles/{id}/translate:
    post:
      operationId: translateSubtitleTrack
      tags: [Subtitles]
      summary: Auto-translate a subtitle/caption track into a new language
      description: |
        Requires scope **assets:write** (or admin token). Translates the
        track's cue text via the deployment's LibreTranslate-compatible
        service (SSTREAM_TRANSLATION_URL) while preserving cue timings, and
        creates (or replaces, when the target language already exists) a
        subtitle track labeled "<label> (auto translated)". Rate limited.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetIdParent"
        - $ref: "#/components/parameters/SubtitleTrackId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_language]
              properties:
                target_language:
                  type: string
                  example: ko
      responses:
        "201":
          description: Translated subtitle track created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubtitleTrack"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: Invalid target language, or the asset already has 20 subtitle tracks
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Translation rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "502":
          description: The translation service failed to translate the track
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Translation or R2 storage is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/assets/{video_asset_id}/subtitles/{id}/sync:
    post:
      operationId: syncSubtitleTrack
      tags: [Subtitles]
      summary: Re-time a subtitle/caption track against a reference track
      description: |
        Requires scope **assets:write** (or admin token). Aligns the track's
        cue timings to another subtitle track on the same asset (typically a
        worker-generated transcript) by matching normalized cue text: matched
        pairs become time anchors for piecewise-linear remapping, falling
        back to a single global offset when anchors are scarce. The adjusted
        WebVTT replaces the track in place. When `reference_track_id` is
        omitted, the most recent auto-generated (or default-language) track
        is used. Rate limited.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetIdParent"
        - $ref: "#/components/parameters/SubtitleTrackId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reference_track_id:
                  type: integer
                  description: Subtitle track on the same asset to align against.
      responses:
        "200":
          description: Subtitle track re-timed, with sync diagnostics
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SubtitleTrack"
                  - type: object
                    properties:
                      sync:
                        type: object
                        properties:
                          reference_track_id:
                            type: integer
                          strategy:
                            type: string
                            enum: [piecewise, global_offset]
                          match_ratio:
                            type: number
                          anchor_count:
                            type: integer
                          min_offset_seconds:
                            type: number
                          max_offset_seconds:
                            type: number
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: No usable reference track, or the cue texts do not overlap enough to align
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Sync rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: R2 storage is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/assets/{video_asset_id}/tracks:
    get:
      operationId: listAssetTracks
      tags: [Tracks]
      summary: List timecode-anchored tracks for a video asset
      description: |
        Requires a valid API key or admin token, scoped to the caller's
        tenant. This is the extension surface for external services that
        react to `video.asset.ready`, compute something with timecodes
        (chapters, scene markers, a face/embedding index) against the
        asset's source in the customer's own bucket, and write results back
        here. Ordered by `position`, then `name`.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetIdParent"
      responses:
        "200":
          description: List of asset tracks
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/AssetTrack"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      operationId: upsertAssetTrack
      tags: [Tracks]
      summary: Create or replace an asset track (idempotent upsert)
      description: |
        Requires scope **assets:write** (or admin token). Both `POST` and
        `PUT` on this collection path route here and behave identically:
        the row is looked up by `(kind, name)` on this asset, and either
        created or replaced in place. This lets a producer (e.g. a face
        indexing job) safely re-run and re-submit the same result without
        creating duplicate rows.

        Include at least one of `cues` (inline) or `storage_key` (pointer
        into the customer's own bucket) - both may be present together
        (e.g. a small inline summary alongside a pointer to the full
        dataset). Choose inline for human-scale results (chapters, markers
        - typically tens of entries); choose `storage_key` for
        machine-scale results (frame indexes, embeddings - thousands of
        entries) so Nulx never becomes the custodian of that data. Inline
        `cues` are capped at 500 entries and ~200KB serialized - exceeding
        either returns `422` with guidance to use `storage_key` instead.

        When the track's `status` becomes `"ready"` (on this call or via
        the update endpoint below), the `video.track.ready` webhook fires.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetIdParent"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AssetTrackWrite"
            examples:
              chapters:
                summary: Small, human-read chapter list (inline)
                value:
                  kind: chapter
                  name: chapters-en
                  status: ready
                  cues:
                    - start_ms: 0
                      payload: { title: Intro }
                    - start_ms: 65000
                      payload: { title: "Act 1" }
              face_index:
                summary: Machine-scale index (pointer only)
                value:
                  kind: index
                  name: face-index-v2
                  status: ready
                  source: api
                  producer: face-index-v2
                  storage_key: customer-bucket/face-index/asset-001.jsonl
      responses:
        "200":
          description: Existing track (same kind/name) replaced
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssetTrack"
        "201":
          description: Track created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssetTrack"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: Missing both cues and storage_key, malformed cues, or inline cues over the size ceiling
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    put:
      operationId: upsertAssetTrackPut
      tags: [Tracks]
      summary: Create or replace an asset track (idempotent upsert, PUT alias)
      description: |
        Identical behavior to `POST /api/v1/assets/{video_asset_id}/tracks`
        — both verbs route to the same idempotent-upsert action. See that
        operation for the full request/response contract.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetIdParent"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AssetTrackWrite"
      responses:
        "200":
          description: Existing track (same kind/name) replaced
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssetTrack"
        "201":
          description: Track created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssetTrack"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: Missing both cues and storage_key, malformed cues, or inline cues over the size ceiling
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/assets/{video_asset_id}/tracks/{id}:
    get:
      operationId: showAssetTrack
      tags: [Tracks]
      summary: Show a single asset track
      description: Requires a valid API key or admin token, scoped to the caller's tenant.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetIdParent"
        - $ref: "#/components/parameters/AssetTrackId"
      responses:
        "200":
          description: Asset track
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssetTrack"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateAssetTrack
      tags: [Tracks]
      summary: Update an asset track by id
      description: |
        Requires scope **assets:write** (or admin token). Updates any of
        `kind`, `name`, `position`, `status`, `cues`, `storage_key`,
        `source`, `producer`. The same size limits as creation apply to
        `cues`. Transitioning `status` to `"ready"` fires
        `video.track.ready` (only once, on the transition into `ready`).
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetIdParent"
        - $ref: "#/components/parameters/AssetTrackId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AssetTrackUpdate"
      responses:
        "200":
          description: Asset track updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssetTrack"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: Missing both cues and storage_key, malformed cues, or inline cues over the size ceiling
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    put:
      operationId: replaceAssetTrack
      tags: [Tracks]
      summary: Update an asset track by id (PUT alias)
      description: |
        Identical behavior to `PATCH /api/v1/assets/{video_asset_id}/tracks/{id}`
        — Rails routes both verbs to the same resourceful update action.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetIdParent"
        - $ref: "#/components/parameters/AssetTrackId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AssetTrackUpdate"
      responses:
        "200":
          description: Asset track updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssetTrack"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: Missing both cues and storage_key, malformed cues, or inline cues over the size ceiling
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      operationId: deleteAssetTrack
      tags: [Tracks]
      summary: Remove an asset track
      description: Requires scope **assets:write** (or admin token).
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/VideoAssetIdParent"
        - $ref: "#/components/parameters/AssetTrackId"
      responses:
        "204":
          description: Asset track removed
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/collections:
    get:
      operationId: listCollections
      tags: [Collections]
      summary: List collections (folders) for the caller's tenant
      description: |
        Requires scope: **assets:read** or **assets:write** (or admin
        token). Ordered by `position`, then `created_at`.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      responses:
        "200":
          description: List of collections
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Collection"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
    post:
      operationId: createCollection
      tags: [Collections]
      summary: Create a collection (folder) for organizing assets
      description: Requires scope **assets:write** (or admin token).
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, collection]
              properties:
                tenant_id:
                  type: integer
                  description: Tenant that will own the collection. Must belong to the caller unless using the admin token.
                collection:
                  type: object
                  required: [name, slug]
                  properties:
                    name:
                      type: string
                    slug:
                      type: string
                    description:
                      type: string
                      nullable: true
                    position:
                      type: integer
            example:
              tenant_id: 1
              collection:
                name: Product Launches
                slug: product-launches
                description: All launch-related videos
      responses:
        "201":
          description: Collection created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Collection"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v1/collections/tree:
    get:
      operationId: getCollectionTree
      tags: [Collections]
      summary: Nested collection tree for the tenant
      description: Requires a valid API key or admin token. Returns the tenant's collections as a nested tree (parent_id hierarchy).
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      responses:
        "200":
          description: Nested collection tree
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/collections/{id}:
    patch:
      operationId: updateCollection
      tags: [Collections]
      summary: Update a collection
      description: Requires scope **assets:write** (or admin token).
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/CollectionId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [collection]
              properties:
                collection:
                  type: object
                  properties:
                    name:
                      type: string
                    slug:
                      type: string
                    description:
                      type: string
                      nullable: true
                    position:
                      type: integer
            example:
              collection:
                name: Renamed Collection
      responses:
        "200":
          description: Collection updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Collection"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
    put:
      operationId: replaceCollection
      tags: [Collections]
      summary: Update a collection (PUT alias)
      description: |
        Identical behavior to `PATCH /api/v1/collections/{id}` — Rails
        routes both verbs to the same resourceful update action.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/CollectionId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [collection]
              properties:
                collection:
                  type: object
                  properties:
                    name:
                      type: string
                    slug:
                      type: string
                    description:
                      type: string
                      nullable: true
                    position:
                      type: integer
            example:
              collection:
                name: Renamed Collection
      responses:
        "200":
          description: Collection updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Collection"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
    delete:
      operationId: deleteCollection
      tags: [Collections]
      summary: Delete a collection
      description: |
        Requires scope: **assets:write** (or admin token). Assets in the
        collection are not deleted — they are ungrouped
        (`collection_id` set to `null`).
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/CollectionId"
      responses:
        "204":
          description: Collection deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/api_keys:
    get:
      operationId: listApiKeys
      tags: [API Keys]
      summary: List API keys for the caller's tenant
      description: |
        Requires scope: **api_keys:read** or **api_keys:write** (or admin
        token). Never returns the plaintext token, only `last_four`.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      responses:
        "200":
          description: List of API keys
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ApiKey"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
    post:
      operationId: createApiKey
      tags: [API Keys]
      summary: Issue a new API key for a tenant
      description: |
        Requires scope: **api_keys:write** (or admin token). The plaintext
        `token` is only ever returned in this response — store it securely,
        it cannot be retrieved again.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id]
              properties:
                tenant_id:
                  type: integer
                name:
                  type: string
                  default: SDK key
                scopes:
                  type: array
                  items:
                    type: string
                    enum: ["assets:read", "assets:write", "playback:read", "billing:write", "api_keys:read", "api_keys:write", "webhooks:read", "webhooks:write"]
                  default: ["assets:read"]
            example:
              tenant_id: 1
              name: Fresh key
              scopes: ["assets:write"]
      responses:
        "201":
          description: API key created (plaintext token included once)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ApiKey"
                  - type: object
                    properties:
                      token:
                        type: string
                        description: Plaintext token. Shown only in this response.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          description: tenant_id not found or not owned by the caller

  /api/v1/device_tokens:
    post:
      operationId: createDeviceToken
      tags: [Device Tokens]
      summary: Sign in a desktop uploader and issue a device token
      description: |
        Authenticates an account without an existing API key and returns a
        tenant-scoped token once. Store the plaintext token securely; it cannot
        be retrieved again.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email_address, password]
              properties:
                email_address:
                  type: string
                  format: email
                password:
                  type: string
                  format: password
                device_name:
                  type: string
                  maxLength: 100
            example:
              email_address: uploader@example.com
              password: correct-horse-battery-staple
              device_name: Mina's Mac
      responses:
        "201":
          description: Desktop uploader token issued (plaintext token included once)
          content:
            application/json:
              schema:
                type: object
                required: [token, key_id, name, scopes, tenant, user]
                properties:
                  token:
                    type: string
                    pattern: "^[0-9a-f]{40}$"
                    description: Plaintext token. Shown only in this response.
                  key_id:
                    type: integer
                  name:
                    type: string
                  scopes:
                    type: array
                    minItems: 2
                    maxItems: 2
                    uniqueItems: true
                    items:
                      type: string
                      enum: ["assets:read", "assets:write"]
                    example: ["assets:read", "assets:write"]
                  tenant:
                    type: object
                    required: [slug, name]
                    properties:
                      slug: { type: string }
                      name: { type: string }
                  user:
                    type: object
                    required: [email_address]
                    properties:
                      email_address: { type: string, format: email }
        "401":
          description: Invalid email address or password
          content:
            application/json:
              schema:
                type: object
                required: [error, message]
                properties:
                  error:
                    type: string
                    enum: [invalid_credentials]
                  message:
                    type: string
        "403":
          description: Email address is unverified or tenant is inactive
          content:
            application/json:
              schema:
                type: object
                required: [error, message]
                properties:
                  error:
                    type: string
                    enum: [email_unverified, tenant_inactive]
                  message:
                    type: string
        "422":
          description: Device name is invalid
          content:
            application/json:
              schema:
                type: object
                required: [error, message]
                properties:
                  error:
                    type: string
                    enum: [invalid_device_name]
                  message:
                    type: string
        "429":
          description: Too many login attempts
          content:
            application/json:
              schema:
                type: object
                required: [error, message]
                properties:
                  error:
                    type: string
                    enum: [rate_limited]
                  message:
                    type: string
  /api/v1/device_tokens/current:
    delete:
      operationId: revokeCurrentDeviceToken
      tags: [Device Tokens]
      summary: Revoke the API key used for this request
      description: Revokes only the key supplied in the `X-API-Key` header.
      security:
        - ApiKeyAuth: []
      responses:
        "204":
          description: Current device token revoked
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/webhooks:
    get:
      operationId: listWebhookEndpoints
      tags: [Webhooks]
      summary: List webhook endpoints for the caller's tenant(s)
      description: |
        Requires scope: **webhooks:read** or **webhooks:write** (or admin
        token). Never returns the signing secret, only `last_four`.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      responses:
        "200":
          description: List of webhook endpoints
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/WebhookEndpoint"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
    post:
      operationId: createWebhookEndpoint
      tags: [Webhooks]
      summary: Register a new webhook endpoint for a tenant
      description: |
        Requires scope: **webhooks:write** (or admin token). The endpoint
        `url` must be `https://` — see the top-level Webhooks section for
        the delivery payload shape and `Nulx-Signature` verification scheme.
        The plaintext `secret` is only ever returned in this response — store
        it securely, it cannot be retrieved again. A tenant may have at most
        5 webhook endpoints.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, url]
              properties:
                tenant_id:
                  type: integer
                url:
                  type: string
                  format: uri
                  description: Must be an https:// URL.
                event_types:
                  type: array
                  items:
                    type: string
                    enum:
                      - video.upload.completed
                      - video.job.queued
                      - video.job.started
                      - video.job.failed
                      - video.asset.ready
                      - video.asset.errored
                      - video.asset.deleted
                      - video.subtitle.ready
                      - video.track.ready
                      - tenant.traffic.anomaly
                  description: >-
                    Event types this endpoint should receive. Omit, or pass an
                    empty array, to receive every event (the default).
            example:
              tenant_id: 1
              url: https://example.com/webhooks/nulx
              event_types: [video.asset.ready, video.asset.errored]
      responses:
        "201":
          description: Webhook endpoint created (plaintext secret included once)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/WebhookEndpoint"
                  - type: object
                    properties:
                      secret:
                        type: string
                        description: Plaintext signing secret. Shown only in this response.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "422":
          description: Validation failed (e.g. non-https URL, or tenant already at the 5 endpoint limit)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/webhooks/{id}:
    delete:
      operationId: deleteWebhookEndpoint
      tags: [Webhooks]
      summary: Delete a webhook endpoint
      description: |
        Requires scope: **webhooks:write** (or admin token). Deliveries to
        this endpoint stop immediately.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/WebhookId"
      responses:
        "204":
          description: Webhook endpoint deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/webhooks/{id}/active:
    patch:
      operationId: setWebhookEndpointActive
      tags: [Webhooks]
      summary: Activate or deactivate a webhook endpoint
      description: |
        Requires scope: **webhooks:write** (or admin token). Deactivated
        endpoints are skipped when dispatching events.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/WebhookId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [active]
              properties:
                active:
                  type: boolean
            example:
              active: false
      responses:
        "200":
          description: Webhook endpoint updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpoint"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/billing/estimate:
    get:
      operationId: getBillingEstimate
      tags: [Billing]
      summary: Estimate the current billing period charges
      description: Requires a valid API key or admin token. No write scope required.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      responses:
        "200":
          description: Billing estimate for the current tenant/period
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BillingEstimate"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/billing/checkout:
    post:
      operationId: createBillingCheckout
      tags: [Billing]
      summary: Create a PayPal checkout order for the current billing statement
      description: |
        Requires scope: **billing:write** (or admin token). Rate limited to
        20 requests / 10 minutes per caller identity (`billing_write` bucket,
        shared with capture/refund). Enterprise/custom-pricing tenants use
        invoice billing; checkout is rejected with
        `billing.enterprise_invoice_required`.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "201":
          description: Checkout order created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckoutResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "422":
          description: Enterprise/custom-pricing tenant requires invoice billing
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: PayPal client is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
      x-codeSamples:
        - lang: curl
          label: curl
          source: |
            curl -s -X POST https://api.nulx.dev/api/v1/billing/checkout \
              -H "X-API-Key: $SSTREAM_API_KEY" \
              -H "Idempotency-Key: $(uuidgen)"
        - lang: javascript
          label: JavaScript (fetch)
          source: |
            const res = await fetch("https://api.nulx.dev/api/v1/billing/checkout", {
              method: "POST",
              headers: {
                "X-API-Key": process.env.SSTREAM_API_KEY,
                "Idempotency-Key": crypto.randomUUID()
              }
            });
            const data = await res.json();
            console.log(data.approval_url);
        - lang: ruby
          label: Ruby (Net::HTTP)
          source: |
            require "net/http"
            require "securerandom"

            uri = URI("https://api.nulx.dev/api/v1/billing/checkout")
            req = Net::HTTP::Post.new(uri,
              "X-API-Key" => ENV["SSTREAM_API_KEY"],
              "Idempotency-Key" => SecureRandom.uuid)

            res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
            puts res.body

  /api/v1/billing/creem/checkout:
    post:
      operationId: createCreemBillingCheckout
      tags: [Billing]
      summary: Create a Creem USD checkout session for the current billing statement
      description: |
        Requires scope: **billing:write** (or admin token). Creates a USD-only
        Creem hosted checkout for the current tenant billing statement. Rate
        limited with the shared `billing_write` bucket (20 / 10 minutes).
        Enterprise/custom-pricing tenants use invoice billing; checkout is
        rejected with `billing.enterprise_invoice_required`.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "201":
          description: Creem checkout session created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreemCheckoutResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "409":
          description: Statement has already been settled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: Statement currency is not USD, or tenant requires invoice billing
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Creem client is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/billing/capture:
    post:
      operationId: captureBillingCheckout
      tags: [Billing]
      summary: Capture a previously created PayPal order
      description: |
        Requires scope: **billing:write** (or admin token). Rate limited
        (shared `billing_write` bucket, 20 / 10 minutes).
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [statement_id, order_id]
              properties:
                statement_id:
                  type: integer
                order_id:
                  type: string
      responses:
        "200":
          description: Capture result
          content:
            application/json:
              schema:
                type: object
                properties:
                  statement_id:
                    type: integer
                  status:
                    type: string
                  capture:
                    type: object
                    description: Raw PayPal capture response
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "422":
          description: Order id mismatch or PayPal request error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: PayPal client is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/billing/refund:
    post:
      operationId: refundBillingStatement
      tags: [Billing]
      summary: Refund a paid billing statement
      description: |
        Requires scope: **billing:write** (or admin token). Rate limited
        (shared `billing_write` bucket, 20 / 10 minutes).
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [statement_id]
              properties:
                statement_id:
                  type: integer
                amount_cents:
                  type: integer
                  nullable: true
                  description: Partial refund amount; omit for a full refund.
                note:
                  type: string
                  nullable: true
            example:
              statement_id: 42
              amount_cents: 500
      responses:
        "200":
          description: Refund processed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RefundResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "422":
          description: Statement is not paid, so it cannot be refunded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                not_paid:
                  value: { error: "Billing statement is not paid" }
        "409":
          description: Statement has already been refunded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                already_refunded:
                  value: { error: "Billing statement has already been refunded" }
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          description: PayPal rejected or failed to process the refund
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                paypal_failed:
                  value: { error: "PayPal refund failed: 500" }
        "503":
          description: PayPal client is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
      x-codeSamples:
        - lang: curl
          label: curl
          source: |
            curl -s -X POST https://api.nulx.dev/api/v1/billing/refund \
              -H "X-API-Key: $SSTREAM_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"statement_id": 42, "amount_cents": 500, "note": "Customer requested partial refund"}'

  /api/v1/billing/webhook:
    post:
      operationId: handleBillingWebhook
      tags: [Billing]
      summary: PayPal webhook receiver
      description: |
        Public endpoint (no `X-API-Key`/admin token required — PayPal calls
        this directly). Verifies PayPal transmission headers in production;
        `X-SSTREAM-PAYPAL-SIMULATED` is accepted only in non-production
        environments to simulate events locally. Rate limited to 120
        requests / 10 minutes per source IP (`webhook` bucket, independent
        from the `billing_write` bucket). Idempotent: replaying the same
        event `id` returns `deduplicated: true` without reprocessing.
      security: []
      parameters:
        - name: PAYPAL-AUTH-ALGO
          in: header
          required: false
          schema: { type: string }
        - name: PAYPAL-CERT-URL
          in: header
          required: false
          schema: { type: string }
        - name: PAYPAL-TRANSMISSION-ID
          in: header
          required: false
          schema: { type: string }
        - name: PAYPAL-TRANSMISSION-SIG
          in: header
          required: false
          schema: { type: string }
        - name: PAYPAL-TRANSMISSION-TIME
          in: header
          required: false
          schema: { type: string }
        - name: X-SSTREAM-PAYPAL-SIMULATED
          in: header
          required: false
          schema: { type: string }
          description: Non-production only. Bypasses live signature verification.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, event_type, resource]
              properties:
                id:
                  type: string
                event_type:
                  type: string
                  example: PAYMENT.CAPTURE.COMPLETED
                resource:
                  type: object
      responses:
        "200":
          description: Webhook processed (or deduplicated)
          content:
            application/json:
              schema:
                type: object
                properties:
                  handled:
                    type: boolean
                  event_id:
                    type: string
                  deduplicated:
                    type: boolean
        "400":
          description: Request body was not valid JSON
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: Payload missing required keys or verification headers
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Live webhook signature verification is unavailable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/billing/creem/webhook:
    post:
      operationId: handleCreemBillingWebhook
      tags: [Billing]
      summary: Creem webhook receiver
      description: |
        Public endpoint (no `X-API-Key`/admin token required). Verifies
        Creem's `creem-signature` header using HMAC-SHA256 over the raw request
        body with `CREEM_WEBHOOK_SECRET`. Rate limited to 120 requests / 10
        minutes per source IP (`webhook` bucket). Idempotent: replaying the
        same event `id` returns `deduplicated: true` without reprocessing.
      security: []
      parameters:
        - name: creem-signature
          in: header
          required: true
          schema: { type: string }
          description: Hex HMAC-SHA256 signature of the raw request body.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, eventType, object]
              properties:
                id:
                  type: string
                eventType:
                  type: string
                  example: checkout.completed
                object:
                  type: object
      responses:
        "200":
          description: Webhook processed (or deduplicated)
          content:
            application/json:
              schema:
                type: object
                properties:
                  handled:
                    type: boolean
                  deduplicated:
                    type: boolean
                  statement_id:
                    type: integer
                    nullable: true
        "400":
          description: Request body was not valid JSON
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: Payload or signature verification failed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Creem webhook secret is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/billing/creem/subscription_webhook:
    post:
      operationId: handleCreemSubscriptionWebhook
      tags: [Billing]
      summary: Creem subscription lifecycle webhook receiver
      description: |
        Public endpoint (no `X-API-Key`/admin token required) for the recurring
        platform-fee subscription. Separate from `/billing/creem/webhook`
        because that handler resolves a billing statement from the payload and
        a subscription event has none.

        Verifies Creem's `creem-signature` header using HMAC-SHA256 over the raw
        request body with `CREEM_WEBHOOK_SECRET`. Rate limited to 120 requests /
        10 minutes per source IP (`webhook` bucket).

        Handled `eventType` values: `subscription.active`, `subscription.paid`,
        `subscription.update`, `subscription.trialing`, `subscription.past_due`,
        `subscription.paused`, `subscription.scheduled_cancel`,
        `subscription.canceled`, `subscription.expired`. Anything else returns
        `handled: false` with `reason: ignored_event`.
      security: []
      parameters:
        - name: creem-signature
          in: header
          required: true
          schema: { type: string }
          description: Hex HMAC-SHA256 signature of the raw request body.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [eventType, object]
              properties:
                eventType:
                  type: string
                  example: subscription.active
                object:
                  type: object
                  description: Creem SubscriptionEntity.
      responses:
        "200":
          description: Event applied, or ignored because it is out of scope
          content:
            application/json:
              schema:
                type: object
                properties:
                  handled:
                    type: boolean
                  reason:
                    type: string
                    nullable: true
                  event_type:
                    type: string
                  tenant_id:
                    type: integer
                    nullable: true
                  subscription_status:
                    type: string
                    nullable: true
                  tenant_status:
                    type: string
                    nullable: true
        "400":
          description: Request body was not valid JSON
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Signature verification failed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Creem subscription billing is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/uploads/initiate:
    post:
      operationId: initiateUpload
      tags: [Uploads]
      summary: Create a tenant-scoped direct upload session
      description: |
        Creates a `VideoAsset` (draft) plus an `UploadSession` and returns a
        presigned R2 `PUT` URL. Requires scope: **assets:write** (or admin
        token). Rate limited to 60 requests / 10 minutes per caller identity
        (shared with `complete`).
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, filename]
              properties:
                tenant_id:
                  type: integer
                filename:
                  type: string
                title:
                  type: string
                  description: Defaults to a titleized version of the filename.
                bytes_expected:
                  type: integer
                  minimum: 0
                  default: 0
                content_type:
                  type: string
                  default: video/mp4
                source_width:
                  type: integer
                  default: 1920
                source_height:
                  type: integer
                  default: 1080
            example:
              tenant_id: 1
              filename: demo.mp4
              title: New Video
              bytes_expected: 1024
              source_height: 720
              content_type: video/mp4
      responses:
        "201":
          description: Upload session created with a presigned upload URL
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UploadSession"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "422":
          description: Upload rejected by the abuse guard (e.g. disallowed content type)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                rejected:
                  value: { error: "upload_rejected", reason: "disallowed_content_type", details: {} }
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: R2 storage is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
      x-codeSamples:
        - lang: curl
          label: "curl (1/3: initiate)"
          source: |
            curl -s -X POST https://api.nulx.dev/api/v1/uploads/initiate \
              -H "X-API-Key: $SSTREAM_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{
                "tenant_id": 1,
                "filename": "demo.mp4",
                "bytes_expected": 1024,
                "source_height": 720,
                "content_type": "video/mp4"
              }'
        - lang: javascript
          label: "JavaScript (fetch, 1/3: initiate)"
          source: |
            const res = await fetch("https://api.nulx.dev/api/v1/uploads/initiate", {
              method: "POST",
              headers: {
                "X-API-Key": process.env.SSTREAM_API_KEY,
                "Content-Type": "application/json"
              },
              body: JSON.stringify({
                tenant_id: 1,
                filename: "demo.mp4",
                bytes_expected: 1024,
                source_height: 720,
                content_type: "video/mp4"
              })
            });
            const { upload_id, asset_id, upload_url, upload_headers } = await res.json();
        - lang: ruby
          label: "Ruby (Net::HTTP, 1/3: initiate)"
          source: |
            require "net/http"
            require "json"

            uri = URI("https://api.nulx.dev/api/v1/uploads/initiate")
            req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "X-API-Key" => ENV["SSTREAM_API_KEY"])
            req.body = {
              tenant_id: 1,
              filename: "demo.mp4",
              bytes_expected: 1024,
              source_height: 720,
              content_type: "video/mp4"
            }.to_json

            res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
            upload = JSON.parse(res.body)

  /api/v1/uploads/initiate_multipart:
    post:
      operationId: initiateMultipartUpload
      tags: [Uploads]
      summary: Initiate a resumable multipart source upload
      description: Requires a valid API key or admin token (assets:write) and a unique `Idempotency-Key` header. Persists a durable local intent before provider I/O, then starts an S3 multipart upload or Bunny Stream TUS session. Replays return the same upload without creating another provider resource.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 255
      responses:
        "201":
          description: New multipart upload intent initiated
        "200":
          description: Existing multipart upload intent replayed
        "400":
          description: Missing or invalid Idempotency-Key
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/uploads/{id}/sign_part:
    post:
      operationId: signMultipartUploadPart
      tags: [Uploads]
      summary: Presign a single S3 multipart part PUT
      description: Requires a valid API key or admin token (assets:write). Returns a presigned PUT URL for the given part number. Every part requires a signed positive `content_length`; non-final parts must be at least 5 MiB, parts are capped at 5 GiB, and the declared cumulative size must equal the reserved upload size before completion.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Presigned part URL
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/uploads/{id}/complete_multipart:
    post:
      operationId: completeMultipartUpload
      tags: [Uploads]
      summary: Finalize a multipart/resumable upload
      description: Requires a valid API key or admin token (assets:write). Reconciles signed part sizes and etags, persists the finalizing state, completes or recovers the provider upload, verifies provider-observed bytes, then runs the shared UploadCompleter path.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Multipart upload completed
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/uploads/{id}/abort_multipart:
    post:
      operationId: abortMultipartUpload
      tags: [Uploads]
      summary: Abort a multipart/resumable upload
      description: Requires a valid API key or admin token (assets:write). Aborts the provider multipart upload and frees any accumulated parts.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Multipart upload aborted
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/uploads/{id}:
    get:
      operationId: getUpload
      tags: [Uploads]
      summary: Fetch an upload session's status
      description: Requires a valid API key or admin token, scoped to the caller's tenant.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/UploadId"
      responses:
        "200":
          description: Upload session
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UploadSession"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/uploads/{id}/complete:
    post:
      operationId: completeUpload
      tags: [Uploads]
      summary: Mark an upload complete and enqueue transcoding
      description: |
        Call this after the client has finished `PUT`-ing bytes to the
        presigned `upload_url`. Requires scope: **assets:write** (or admin
        token). Rate limited (shared 60 / 10 minutes bucket with
        `initiate`).
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/UploadId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                bytes_received:
                  type: integer
                  minimum: 0
                  description: Defaults to bytes_expected when omitted.
            example:
              bytes_received: 536870912
      responses:
        "200":
          description: Upload marked complete; a transcoding job is queued (idempotent)
          content:
            application/json:
              schema:
                type: object
                properties:
                  upload_id:
                    type: integer
                  asset_id:
                    type: integer
                  job_status:
                    type: string
                    enum: [queued, running, completed, failed]
                  upload_status:
                    type: string
                    enum: [initiated, uploaded, processing, completed, failed]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: bytes_received exceeds bytes_expected
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                exceeded:
                  value: { error: "upload_rejected", reason: "bytes_exceed_expected" }
        "429":
          $ref: "#/components/responses/RateLimited"
      x-codeSamples:
        - lang: curl
          label: "curl (2/3: PUT + complete)"
          source: |
            # PUT the file bytes to the upload_url returned by initiate
            curl -s -X PUT "$UPLOAD_URL" \
              -H "Content-Type: video/mp4" \
              --upload-file demo.mp4

            # Then mark it complete
            curl -s -X POST https://api.nulx.dev/api/v1/uploads/$UPLOAD_ID/complete \
              -H "X-API-Key: $SSTREAM_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"bytes_received": 536870912}'
        - lang: javascript
          label: "JavaScript (fetch, 2/3: PUT + complete)"
          source: |
            await fetch(uploadUrl, {
              method: "PUT",
              headers: { "Content-Type": "video/mp4" },
              body: fileBlob
            });

            const res = await fetch(`https://api.nulx.dev/api/v1/uploads/${uploadId}/complete`, {
              method: "POST",
              headers: {
                "X-API-Key": process.env.SSTREAM_API_KEY,
                "Content-Type": "application/json"
              },
              body: JSON.stringify({ bytes_received: fileBlob.size })
            });
            const data = await res.json();
        - lang: ruby
          label: "Ruby (Net::HTTP, 2/3: PUT + complete)"
          source: |
            require "net/http"
            require "json"

            put_uri = URI(upload_url)
            Net::HTTP.start(put_uri.host, put_uri.port, use_ssl: true) do |http|
              req = Net::HTTP::Put.new(put_uri, "Content-Type" => "video/mp4")
              req.body = File.binread("demo.mp4")
              http.request(req)
            end

            complete_uri = URI("https://api.nulx.dev/api/v1/uploads/#{upload_id}/complete")
            req = Net::HTTP::Post.new(complete_uri, "Content-Type" => "application/json", "X-API-Key" => ENV["SSTREAM_API_KEY"])
            req.body = { bytes_received: 536_870_912 }.to_json
            res = Net::HTTP.start(complete_uri.host, complete_uri.port, use_ssl: true) { |http| http.request(req) }
            puts res.body

  /api/v1/ingest_batches/desktop_storage:
    get:
      operationId: getDesktopStorage
      tags: [Ingest Batches]
      summary: Resolve the server-managed desktop upload storage
      description: |
        Returns the active tenant read/write storage connection selected by the
        server for desktop uploads. Call this preflight before enabling Start;
        no provider credentials or provider configuration are returned. Requires
        scope **assets:write** (or admin token).
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      responses:
        "200":
          description: Managed desktop storage is available
          content:
            application/json:
              schema:
                type: object
                required: [storage_connection]
                properties:
                  storage_connection:
                    $ref: "#/components/schemas/DesktopStorageConnection"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v1/ingest_batches:
    post:
      operationId: createIngestBatch
      tags: [Ingest Batches]
      summary: Create a storage ingest batch lease
      description: |
        Creates a tenant-scoped batch/prefix lease for BYO storage ingestion.
        The client encoder uses this before committing a manifest. Requires
        scope: **assets:write** (or admin token).
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/IngestBatchCreateRequest"
            example:
              storage_connection_id: 9
              allowed_prefix: streams/
              batch_id: batch_8f0a1b2c
      responses:
        "200":
          description: Existing desktop batch identity replay
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestBatch"
        "201":
          description: Ingest batch created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestBatch"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v1/ingest_batches/{id}:
    get:
      operationId: getIngestBatch
      tags: [Ingest Batches]
      summary: Fetch ingest batch status
      description: Requires scope **assets:read** (or admin token). `id` may be the server record ID or the exact 32-lowercase-hex desktop `batch_uid`; both are resolved only within the authenticated tenant.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/IngestBatchId"
      responses:
        "200":
          description: Ingest batch status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestBatch"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/ingest_batches/{id}/manifest/preview:
    post:
      operationId: previewIngestManifest
      tags: [Ingest Batches]
      summary: Validate an ingest manifest without mutation
      description: |
        Validates that one or more manifest documents are version `v1`, match
        the batch id when provided, stay inside the batch prefix, and resolve
        through the configured storage provider. No assets are created.
        Requires scope **assets:write** (or admin token).
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/CanonicalIngestBatchId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/IngestManifestCommitRequest"
      responses:
        "200":
          description: Manifest preview
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestBatch"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v1/ingest_batches/{id}/manifest/commit:
    post:
      operationId: commitIngestManifest
      tags: [Ingest Batches]
      summary: Commit a client encoder manifest to assets
      description: |
        Accepts the Nulx encoder payload `{ "manifest": [ ... ] }`, verifies
        storage objects through the tenant storage connection, and starts a
        durable finalizer. It returns `202` with the current `IngestBatch`
        response (normally status `commit_pending`) while finalization remains
        pending, including an exact idempotent replay. Reusing that key with a
        mutated pending manifest returns `409` with
        `ingest_batch_idempotency_conflict`; finalizer enqueue failure returns
        `503` with `ingest_commit_finalizer_unavailable`. Requires scope:
        **assets:write** (or admin token). Signed/private BYO manifests require
        the storage connection owner consent or credentials to declare
        `delivery_auth: provider_edge` (or an equivalent provider-edge/signed-url
        flag). Without that marker, the asset is committed but remains not-ready
        and playback returns `delivery_auth.unsatisfied`. Bunny traffic
        collection must be separately consented with the `traffic_read` purpose.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/CanonicalIngestBatchId"
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/IngestManifestCommitRequest"
            example:
              manifest:
                - version: v1
                  batch_id: batch_8f0a1b2c
                  relative_path: media/trailer.mp4
                  path_hash: 8a6d5f
                  master_playlist_key: streams/8a6d5f/trailer/master.m3u8
                  ready: true
                  total_bytes: 4096
                  hls_artifacts:
                    - relative_path: master.m3u8
                      key: streams/8a6d5f/trailer/master.m3u8
                      bytes: 1024
                      checksum_sha256: abc123
      responses:
        "200":
          description: Batch committed or idempotent replay
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestBatch"
        "202":
          description: Commit finalization is pending, including exact idempotent replay.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestBatch"
        "400":
          description: Missing Idempotency-Key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "409":
          description: Batch is canceled or an idempotency key conflicts with a mutated pending manifest.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                idempotency_conflict:
                  value: { error: ingest_batch_idempotency_conflict }
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "503":
          description: The durable ingest commit finalizer could not be enqueued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                finalizer_unavailable:
                  value: { error: ingest_commit_finalizer_unavailable }

  /api/v1/ingest_batches/{id}/cancel:
    post:
      operationId: cancelIngestBatch
      tags: [Ingest Batches]
      summary: Cancel an uncommitted ingest batch
      description: |
        `id` may be the server record ID or the exact 32-lowercase-hex desktop
        `batch_uid`; both are resolved only within the authenticated tenant. A
        missing valid desktop UID returns the explicit `200` recovery receipt
        `{ "status": "canceled", "batch_uid": "<uid>" }` so a pre-create
        client marker can be cleared. Arbitrary missing IDs remain `404`.
        Runs synchronous cleanup for every active desktop upload, then returns
        `200` only when cleanup completed and the payload status is `canceled`
        (including replay). Returns `202` with payload status `canceling` when
        cleanup remains pending; a durable `ManagedIngestCleanupJob` is enqueued
        for retry. No provider credentials are returned.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - $ref: "#/components/parameters/IngestBatchId"
      responses:
        "200":
          description: Cleanup completed, an idempotent replay, or a missing valid desktop UID recovery receipt with status `canceled`.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/IngestBatch"
                  - $ref: "#/components/schemas/IngestBatchCancellationReceipt"
        "202":
          description: Cleanup remains pending; payload status is `canceling` and a durable cleanup job has been enqueued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestBatch"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "409":
          description: Batch is already committed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/ingest_batches/{id}/uploads:
    post:
      operationId: createDesktopUpload
      tags: [Ingest Batches]
      summary: Create a server-managed desktop object upload
      description: |
        Creates an immutable upload intent inside a `desktop_upload` batch. The
        server selects the tenant storage connection; caller-supplied
        `storage_connection_id` is rejected. R2 always returns a resumable
        multipart flow, including single-part files; Bunny returns a
        cancellation-aware server-proxy flow. `checksum_sha256` cryptographically
        binds the intent to the resulting object metadata. Long-lived provider
        credentials are never returned. Requires scope **assets:write**.
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/CanonicalIngestBatchId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DesktopUploadCreateRequest"
      responses:
        "200":
          description: Existing immutable upload intent
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DesktopUpload"
        "201":
          description: Upload intent created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DesktopUpload"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v1/ingest_batches/{id}/uploads/{upload_id}/proxy:
    put:
      operationId: proxyDesktopUpload
      tags: [Ingest Batches]
      summary: Stream an object through the control plane to Bunny
      description: Requires an exact `Content-Length` and scope **assets:write**. This provider path is not resumable; a cancellation racing an active stream leaves the batch `canceling` until the proxy deletes the object and finalizes cancellation.
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/CanonicalIngestBatchId"
        - $ref: "#/components/parameters/DesktopUploadId"
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        "200":
          description: Upload completed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DesktopUpload"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v1/ingest_batches/{id}/uploads/{upload_id}/parts:
    post:
      operationId: signDesktopUploadPart
      tags: [Ingest Batches]
      summary: Sign one exact R2 multipart part
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/CanonicalIngestBatchId"
        - $ref: "#/components/parameters/DesktopUploadId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [part_number, content_length]
              properties:
                part_number: { type: integer, minimum: 1, maximum: 10000 }
                content_length: { type: integer, minimum: 1 }
      responses:
        "200":
          description: Short-lived signed part upload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DesktopUploadPart"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v1/ingest_batches/{id}/uploads/{upload_id}/complete:
    post:
      operationId: completeDesktopUpload
      tags: [Ingest Batches]
      summary: Complete an R2 multipart upload
      description: Completion durably records `completing` and the normalized multipart intent before calling the provider. A retry reconciles that same intent; it never returns the upload to `multipart`. The response remains pending until asynchronous integrity verification confirms the immutable object tuple.
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/CanonicalIngestBatchId"
        - $ref: "#/components/parameters/DesktopUploadId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                parts:
                  type: array
                  items:
                    type: object
                    required: [part_number, etag]
                    properties:
                      part_number: { type: integer }
                      etag: { type: string }
      responses:
        "200":
          description: Upload was already verified.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DesktopUpload"
        "202":
          description: Provider completion is recorded and integrity verification is pending.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DesktopUploadVerificationPending"
        "409":
          description: Upload cannot be completed in its current state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "422":
          description: Multipart input is invalid or integrity verification previously failed.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/Error"
                  - $ref: "#/components/schemas/DesktopUploadVerificationFailed"
        "503":
          description: Provider completion could not be confirmed, or durable verification could not be enqueued.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/Error"
                  - $ref: "#/components/schemas/DesktopUploadVerificationEnqueuePending"

  /api/v1/ingest_batches/{id}/uploads/{upload_id}:
    delete:
      operationId: abortDesktopUpload
      tags: [Ingest Batches]
      summary: Abort an active R2 multipart upload
      description: Calls the provider abort synchronously. Returns `200` only after the provider confirms abort; then the terminal payload status is `aborted` and the provider multipart ID is omitted. Provider failure leaves the upload non-terminal.
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/CanonicalIngestBatchId"
        - $ref: "#/components/parameters/DesktopUploadId"
      responses:
        "200":
          description: Provider confirmed the multipart upload was aborted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DesktopUpload"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "503":
          description: Provider abort could not be confirmed; the upload remains non-terminal. The response uses the safe `storage_provider_abort_failed` Error envelope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/usage/summary:
    get:
      operationId: getUsageSummary
      tags: [Usage]
      summary: Current-month usage and billing estimate per tenant
      description: Requires a valid API key or admin token. Admin token returns every tenant; an API key returns only its own tenant.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      responses:
        "200":
          description: Usage summaries
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/UsageSummary"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/encoding_spec:
    get:
      operationId: getEncodingSpec
      tags: [Encoding]
      summary: Canonical transcoding encoding specification
      description: Requires a valid API key or admin token. Returns the shared encoding ladder, GOP/keyint, segment duration, and video/audio codec spec that the server transcoder and the client encoder both follow.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      responses:
        "200":
          description: Encoding specification
          content:
            application/json:
              schema:
                type: object
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/playback/{id}:
    get:
      operationId: getPlayback
      tags: [Playback]
      summary: Get playback URLs and a signed playback token for an asset
      description: |
        Accepts either the numeric database `id` or the asset's
        `external_id` in the `{id}` path segment. Only assets with
        `playback_status: ready` are resolvable — others 404. Requires a
        valid API key or admin token; scope **playback:read** is expected
        on API keys used purely for playback lookups (admin token bypasses
        scope checks).

        A token is always issued regardless of the asset's
        `playback_policy`. When `playback_policy` is `signed`, the `/watch`
        and `/embed` pages for this asset reject requests without a valid
        token, and this response also includes ready-to-use `watch_url`
        and `embed_url` values with the token pre-filled.
      security:
        - ApiKeyAuth: []
        - AdminToken: []
      parameters:
        - name: id
          in: path
          required: true
          description: Numeric VideoAsset id or its external_id (UUID).
          schema:
            type: string
          example: 4f7b6e2a-3c1d-4e9a-9b0a-2f6c1a7d8e11
      responses:
        "200":
          description: Playback credentials
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlaybackInfo"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Asset not found, not owned by the caller, or not yet ready for playback
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
      x-codeSamples:
        - lang: curl
          label: "curl (3/3: get playback)"
          source: |
            curl -s https://api.nulx.dev/api/v1/playback/$ASSET_EXTERNAL_ID \
              -H "X-API-Key: $SSTREAM_API_KEY"
        - lang: javascript
          label: "JavaScript (fetch, 3/3: get playback)"
          source: |
            const res = await fetch(`https://api.nulx.dev/api/v1/playback/${assetExternalId}`, {
              headers: { "X-API-Key": process.env.SSTREAM_API_KEY }
            });
            const { hls_url, token } = await res.json();
        - lang: ruby
          label: "Ruby (Net::HTTP, 3/3: get playback)"
          source: |
            require "net/http"
            require "json"

            uri = URI("https://api.nulx.dev/api/v1/playback/#{asset_external_id}")
            req = Net::HTTP::Get.new(uri, "X-API-Key" => ENV["SSTREAM_API_KEY"])
            res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
            playback = JSON.parse(res.body)
            puts playback["hls_url"]

  /api/v1/playback_events:
    post:
      operationId: createPlaybackEvent
      tags: [Playback]
      summary: Ingest a viewer playback beacon (public, unauthenticated)
      description: |
        Public beacon endpoint the video player posts to on play/progress/
        ended/error. Viewers are anonymous, so this endpoint requires **no**
        `X-API-Key` or admin token — instead it is rate limited to 300
        requests / minute per caller IP, validates the asset by
        `asset_id` (its `external_id`), and derives the tenant from that
        asset (never trusted from the request body).

        `position_seconds` and `watched_seconds` are clamped to
        `0..86400` server-side. `viewer_country` is derived at ingest
        from an edge/proxy header (e.g. Cloudflare's `CF-IPCountry`) —
        never from a stored raw IP address, and no GeoIP lookup is
        performed.

        To avoid a failed CORS preflight (no `OPTIONS` route exists for
        this path), send this beacon as a "simple request": either
        `navigator.sendBeacon` or a `fetch` with
        `Content-Type: text/plain`, not `application/json`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [asset_id, viewer_session, event_type]
              properties:
                asset_id:
                  type: string
                  description: The VideoAsset's external_id.
                viewer_session:
                  type: string
                  description: Opaque per-viewer-session identifier generated by the player.
                event_type:
                  type: string
                  enum: [play, progress, ended, error]
                position_seconds:
                  type: integer
                  minimum: 0
                  default: 0
                watched_seconds:
                  type: integer
                  minimum: 0
                  default: 0
            example:
              asset_id: 4f7b6e2a-3c1d-4e9a-9b0a-2f6c1a7d8e11
              viewer_session: 9c3e2f0a-8b1d-4a6e-9f2a-1d7c6b5e4a3f
              event_type: progress
              position_seconds: 42
              watched_seconds: 42
      responses:
        "202":
          description: Beacon accepted
        "404":
          description: No VideoAsset found for the given asset_id
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: Invalid event (e.g. unknown event_type, missing viewer_session)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/transcoding_jobs/next:
    post:
      operationId: leaseNextTranscodingJob
      tags: [Transcoding Jobs]
      summary: Lease the next queued transcoding job for a worker
      description: |
        Worker-only endpoint. Requires `X-SSTREAM-WORKER-TOKEN` (not an API
        key or admin token). Before leasing, requeues any `running` jobs
        whose lease has expired. Returns `204 No Content` when no job is
        queued. Jobs with a previously published Cloud output are deliberately
        excluded: publication alone is not terminal completion and recovery
        must resume through the Cloud v2 claim/lease fence. The returned
        `lease_token` must be echoed back via `X-SSTREAM-JOB-LEASE-TOKEN` on
        heartbeat/complete/fail calls.
      parameters:
        - $ref: "#/components/parameters/RequiredIdempotencyKey"
      security:
        - WorkerToken: []
      responses:
        "200":
          description: Leased job worker contract
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WorkerContract"
        "204":
          description: No queued job available
        "403":
          description: Missing or invalid worker token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                forbidden:
                  value: { error: "worker token required" }

  /api/v1/transcoding_jobs/claim:
    post:
      operationId: claimCloudTranscodingJob
      tags: [Transcoding Jobs]
      summary: Claim the next Cloud v2 R2 transcoding job
      description: |
        Worker-only Cloud v2 lease endpoint. Requires `X-SSTREAM-WORKER-TOKEN`
        and a non-empty `Idempotency-Key` no longer than 255 bytes. The key is
        bound to the canonical JSON request body: retrying with the same key
        and a different body fails closed with `409`. A replay returns the
        existing running, unexpired lease only when its lease token and attempt
        and persisted source-read grant (including authority and expiry) remain
        valid. A stale replay returns `409` and
        never reissues retired lease or source credentials. Queued jobs backed
        by an active write-capable R2 authority are eligible, including stale
        published-output recovery jobs; read-only R2 sources remain available to
        local `POST /next` workers. `204` means no eligible job is queued.

        The returned `attempt_id` and `lease_token`, authority binding, and source
        manifest fence Cloud v2 relinquish, capability, publication, and terminal
        callback operations. `lease_id`, when returned, is only a compatibility
        mirror and is not a canonical lease identity. Do not derive storage
        credentials or output paths outside this contract.
      security:
        - WorkerToken: []
      parameters:
        - $ref: "#/components/parameters/RequiredIdempotencyKey"
      requestBody:
        description: Optional caller metadata. Its complete JSON value is part of the idempotency replay identity.
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CloudClaimRequest"
      responses:
        "200":
          description: A Cloud v2 job lease with a source-read capability and output publication contract
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CloudWorkerContract"
        "204":
          description: No eligible Cloud R2 job available
        "403":
          description: Missing or invalid worker token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          $ref: "#/components/responses/InvalidTranscodingContract"
        "422":
          $ref: "#/components/responses/InvalidTranscodingContract"
        "503":
          $ref: "#/components/responses/InvalidTranscodingContract"

  /api/v1/transcoding_jobs/{id}/relinquish:
    post:
      operationId: relinquishCloudTranscodingJob
      tags: [Transcoding Jobs]
      summary: Relinquish an active Cloud v2 transcoding lease
      description: |
        Releases this Cloud v2 running lease back to the queue.
        `X-SSTREAM-JOB-LEASE-TOKEN`, `X-SSTREAM-JOB-ATTEMPT-ID`, and
        `Idempotency-Key` are required and must match the current lease. The key
        must be non-empty and no longer than 255 bytes. An exact retry with the
        same key, lease token, attempt ID, and request body returns the original
        queued receipt with `idempotent: true`, even though the lease has already
        been released. Reusing a key with any changed request identity fails
        closed with `409`.

        `lease_id` is an optional compatibility input, not a canonical lease
        identity: when supplied, it must equal
        `X-SSTREAM-JOB-LEASE-TOKEN` or the request fails with `409`.
      security:
        - WorkerToken: []
      parameters:
        - $ref: "#/components/parameters/JobId"
        - $ref: "#/components/parameters/JobLeaseToken"
        - $ref: "#/components/parameters/JobAttemptId"
        - $ref: "#/components/parameters/RequiredIdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RelinquishLeaseRequest"
      responses:
        "200":
          description: Lease relinquished, or the exact idempotent relinquish receipt
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TranscodingLeaseStatus"
        "403":
          description: Missing or invalid worker token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/InvalidTranscodingContract"
        "422":
          $ref: "#/components/responses/InvalidTranscodingContract"
  /api/v1/transcoding_jobs/{id}/source_capability:
    post:
      operationId: refreshCloudTranscodingSourceCapability
      tags: [Transcoding Jobs]
      summary: Refresh a Cloud v2 source-read capability for the current lease attempt
      description: |
        Requires the current worker token, lease token, UUID attempt ID, and a
        non-empty idempotency key no longer than 255 bytes. `source.grant_id`
        must equal the source-read grant issued with this lease; `source.etag`
        must be the canonical strong quoted ETag from the immutable,
        provider-full-verified source evidence.

        This is a stateless capability refresh for the same source and grant.
        The server revalidates the active Cloud v2 lease and attempt, job
        ownership, authority, persisted source grant, and provider-full source
        evidence before issuing a fresh presigned GET. The response retains the
        same grant ID and binds `If-Match` to the verified ETag. Its expiry is
        bounded by both the current lease and ten minutes. Identical requests
        may renew URL timestamps but never mutate job, claim, evidence, or
        publication state; stale, mutated, or weakly verified claims fail
        closed and never fall back to a public source URL.
      security:
        - WorkerToken: []
      parameters:
        - $ref: "#/components/parameters/JobId"
        - $ref: "#/components/parameters/JobLeaseToken"
        - $ref: "#/components/parameters/JobAttemptId"
        - $ref: "#/components/parameters/RequiredIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CloudSourceCapabilityRequest"
      responses:
        "200":
          description: Fresh short-lived source-read capability for the same grant and verified source
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CloudSourceCapabilityResponse"
        "403":
          description: Missing or invalid worker token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/InvalidTranscodingContract"
        "422":
          $ref: "#/components/responses/InvalidTranscodingContract"
        "503":
          $ref: "#/components/responses/InvalidTranscodingContract"

  /api/v1/transcoding_jobs/{id}/output_capability:
    post:
      operationId: issueCloudTranscodingOutputCapability
      tags: [Transcoding Jobs]
      summary: Issue R2 PUT capabilities for a Cloud v2 attempt's staged output
      description: |
        Requires the current worker token, lease token, UUID attempt ID, and
        idempotency key. Each object is bound to the claimed authority,
        attempt-specific staged prefix, content length, content type, and
        SHA-256. The response grants only short-lived signed PUT URLs and
        required headers; it never returns storage credentials.

        Reuse of an idempotency key is allowed only for the exact same
        authority, attempt, manifest digest, and object list. Published output
        cannot receive a new or refreshed capability. Lease, attempt,
        authority, digest, or replay mismatches fail closed.
      security:
        - WorkerToken: []
      parameters:
        - $ref: "#/components/parameters/JobId"
        - $ref: "#/components/parameters/JobLeaseToken"
        - $ref: "#/components/parameters/JobAttemptId"
        - $ref: "#/components/parameters/RequiredIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CloudOutputCapabilityRequest"
      responses:
        "200":
          description: Attempt- and authority-bound output grant
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CloudOutputCapabilityResponse"
        "403":
          description: Missing or invalid worker token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/InvalidTranscodingContract"
        "422":
          $ref: "#/components/responses/InvalidTranscodingContract"
        "503":
          $ref: "#/components/responses/InvalidTranscodingContract"

  /api/v1/transcoding_jobs/{id}/publish:
    post:
      operationId: publishCloudTranscodingOutput
      tags: [Transcoding Jobs]
      summary: Verify and atomically publish a Cloud v2 attempt's staged output
      description: |
        Requires the current worker token, lease token, UUID attempt ID, and
        the same idempotency key used to issue the output grant.
        `publish.expected_version` must equal the output contract's
        `expected_version`. Every supplied object must include the canonical
        strong quoted ETag returned by the provider for that exact staged
        object. Publication records verification evidence and returns `202`
        while asynchronous verification is pending; this request never reads
        staged object bodies. An exact pending replay re-enqueues the
        CAS-deduplicated evidence. Once verified, the same request returns
        `200` with `published`; any changed grant, authority, attempt, digest,
        object list, idempotency key, ETag, or expected version fails closed.
        Publication is not a terminal transition: Cloud v2 completion still
        requires a current lease, matching attempt, and a validated completion
        result. A recovered published job must obtain a new Cloud v2 lease
        before it can continue.
      security:
        - WorkerToken: []
      parameters:
        - $ref: "#/components/parameters/JobId"
        - $ref: "#/components/parameters/JobLeaseToken"
        - $ref: "#/components/parameters/JobAttemptId"
        - $ref: "#/components/parameters/RequiredIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CloudOutputPublishRequest"
      responses:
        "200":
          description: Output published, or an exact idempotent publication replay
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CloudOutputPublishResponse"
        "202":
          description: Output verification is pending
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CloudOutputPublishResponse"
        "403":
          description: Missing or invalid worker token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/InvalidTranscodingContract"
        "422":
          $ref: "#/components/responses/InvalidTranscodingContract"
        "503":
          $ref: "#/components/responses/InvalidTranscodingContract"

  /api/v1/transcoding_jobs/{id}/heartbeat:
    post:
      operationId: heartbeatTranscodingJob
      tags: [Transcoding Jobs]
      summary: Renew a job's lease and report progress
      description: |
        Worker-only endpoint. Requires `X-SSTREAM-WORKER-TOKEN` plus the
        job's current `X-SSTREAM-JOB-LEASE-TOKEN`. Cloud v2 jobs additionally
        require the UUID `X-SSTREAM-JOB-ATTEMPT-ID`; legacy/local jobs omit it.
        Returns 409 when the lease token is stale or the job is no longer running.
      security:
        - WorkerToken: []
      parameters:
        - $ref: "#/components/parameters/JobId"
        - $ref: "#/components/parameters/JobLeaseToken"
        - $ref: "#/components/parameters/OptionalJobAttemptId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                heartbeat:
                  type: object
                  properties:
                    progress:
                      type: integer
                      minimum: 0
                      maximum: 100
                    worker_id:
                      type: string
                    stage:
                      type: string
            example:
              heartbeat:
                progress: 64
                worker_id: worker-a
                stage: packaging
      responses:
        "200":
          description: Lease renewed
          content:
            application/json:
              schema:
                type: object
                properties:
                  job_id:
                    type: integer
                  status:
                    type: string
                  progress:
                    type: integer
                  lease_expires_at:
                    type: string
                    format: date-time
        "403":
          description: Missing or invalid worker token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          $ref: "#/components/responses/StaleLease"
        "422":
          $ref: "#/components/responses/InvalidTranscodingContract"

  /api/v1/transcoding_jobs/{id}/complete:
    post:
      operationId: completeTranscodingJob
      tags: [Transcoding Jobs]
      summary: Publish finished playback artifacts for a job
      description: |
        Worker-only endpoint. Requires `X-SSTREAM-WORKER-TOKEN` plus the
        job's current, unexpired `X-SSTREAM-JOB-LEASE-TOKEN`. Cloud v2 jobs also
        require the matching `X-SSTREAM-JOB-ATTEMPT-ID` and a successfully
        published output manifest before completion. Publication never extends
        or bypasses the lease fence and never terminally finalizes a job by
        itself. Supplying `Idempotency-Key` binds retries to the same job,
        lease, attempt, action, and payload; an exact terminal receipt replay
        returns `idempotent: true` once completed.
        The server validates every artifact the job's ffmpeg profile requested
        (manifest, DASH manifest when dash packaging was requested, thumbnail
        when enabled, and one subtitle path per requested caption track).
      security:
        - WorkerToken: []
      parameters:
        - $ref: "#/components/parameters/JobId"
        - $ref: "#/components/parameters/JobLeaseToken"
        - $ref: "#/components/parameters/OptionalJobAttemptId"
        - $ref: "#/components/parameters/OptionalWorkerIdempotencyKey"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                result:
                  type: object
                  properties:
                    manifest_path:
                      type: string
                    dash_manifest_path:
                      type: string
                    drm_manifest_path:
                      type: string
                    alternative_manifest_paths:
                      type: object
                      properties:
                        hevc: { type: string }
                        av1: { type: string }
                      additionalProperties: false
                    thumbnail_path:
                      type: string
                    source_duration_seconds:
                      type: number
                      exclusiveMinimum: 0
                      maximum: 2592000
                      description: Probed source duration used for source-minute option billing.
                    artifact_bytes_total:
                      type: integer
                      minimum: 0
                      maximum: 1000000000000000
                      description: Total completed artifact bytes; omitted when storage usage is not reported.
                    thumbnail_candidates:
                      type: array
                      items:
                        type: string
                    packaging:
                      type: array
                      items:
                        type: string
                        enum: [hls, dash]
                    subtitle_paths:
                      type: array
                      items:
                        type: string
                    subtitle_tracks:
                      type: array
                      items:
                        type: object
                        required: [path, language, label]
                        additionalProperties: false
                        properties:
                          path: { type: string }
                          language: { type: string }
                          label: { type: string }
                    failed_renditions:
                      type: array
                      items:
                        type: string
                      description: Non-blank failed rendition names mark the completed asset as partially failed.
                    plan:
                      type: object
                      properties:
                        master_playlist:
                          type: string
                        renditions:
                          type: array
                          items:
                            type: object
                            properties:
                              name: { type: string }
                              bandwidth: { type: integer }
                              width: { type: integer }
                              height: { type: integer }
                              codecs: { type: string }
                              playlist: { type: string }
                              video_bitrate: { type: integer }
                              audio_bitrate: { type: integer }
            example:
              result:
                plan:
                  master_playlist: master.m3u8
                  renditions:
                    - name: 1080p
                      bandwidth: 4800000
                      video_bitrate: 4800000
                      audio_bitrate: 192000
                manifest_path: /streams/output-prefix/master.m3u8
                dash_manifest_path: /streams/output-prefix/manifest.mpd
                thumbnail_path: /streams/output-prefix/thumbnail.jpg
                subtitle_paths: ["/streams/output-prefix/subtitles-en.vtt"]
                packaging: ["hls", "dash"]
      responses:
        "200":
          description: Job marked completed (or already-completed idempotent replay)
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [job_id, status]
                properties:
                  job_id: { type: integer }
                  status: { type: string, enum: [completed] }
                  idempotent:
                    type: boolean
                    description: Present and true only on a duplicate completion callback.
        "403":
          description: Missing or invalid worker token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Stale lease, missing or mismatched Cloud attempt fence, unpublished Cloud output, or an opposite terminal state
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: Completion payload or Cloud v2 callback contract is invalid.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/TranscodingResultError"
                  - $ref: "#/components/schemas/TranscodingContractError"

  /api/v1/transcoding_jobs/{id}/fail:
    post:
      operationId: failTranscodingJob
      tags: [Transcoding Jobs]
      summary: Mark a job failed
      description: |
        Worker-only endpoint. Requires `X-SSTREAM-WORKER-TOKEN` plus the
        job's current `X-SSTREAM-JOB-LEASE-TOKEN`. Cloud v2 jobs also require
        the matching `X-SSTREAM-JOB-ATTEMPT-ID`. Progress is clamped to
        `0..99`. Supplying `Idempotency-Key` binds retries to the same job,
        lease, attempt, action, and payload; an exact terminal receipt replay
        returns `idempotent: true` once failed.
      security:
        - WorkerToken: []
      parameters:
        - $ref: "#/components/parameters/JobId"
        - $ref: "#/components/parameters/JobLeaseToken"
        - $ref: "#/components/parameters/OptionalJobAttemptId"
        - $ref: "#/components/parameters/OptionalWorkerIdempotencyKey"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                result:
                  type: object
                  properties:
                    error:
                      type: string
                      default: worker_failed
                    error_code:
                      type: string
                      enum: [all_renditions_failed]
                      description: When `all_renditions_failed`, records the asset failure stage as `all_failed`; omit for the default `errored` stage.
                    progress:
                      type: integer
                      minimum: 0
                      maximum: 99
            example:
              result:
                error: ffmpeg exploded
                error_code: all_renditions_failed
                progress: 42
      responses:
        "200":
          description: Job marked failed (or already-failed idempotent replay)
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [job_id, status, error]
                properties:
                  job_id: { type: integer }
                  status: { type: string, enum: [failed] }
                  error: { type: string }
                  idempotent:
                    type: boolean
                    description: Present and true only on a duplicate failure callback.
        "403":
          description: Missing or invalid worker token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Stale lease, missing or mismatched Cloud attempt fence, or an opposite terminal state
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          $ref: "#/components/responses/InvalidTranscodingContract"

  /api/v1/cloud_release_roles/preflight:
    post:
      operationId: preflightCloudReleaseRoles
      tags: [Cloud Release]
      summary: Reject unfinished legacy release controls before infrastructure mutation
      security:
        - CloudDeployerToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [release_id]
              properties:
                release_id:
                  type: string
                  minLength: 1
                  maxLength: 255
      responses:
        "200":
          description: Environment release controls are eligible for the requested release
          content:
            application/json:
              schema:
                type: object
                required: [release_id, status]
                properties:
                  release_id:
                    type: string
                  status:
                    type: string
                    enum: [ok]
        "401":
          $ref: "#/components/responses/DeployerBearerUnauthorized"
        "409":
          $ref: "#/components/responses/CloudReleaseConflict"
        "422":
          $ref: "#/components/responses/InvalidCloudReleaseRequest"

  /api/v1/cloud_release_roles/{role}/pause:
    post:
      operationId: pauseCloudReleaseRole
      tags: [Cloud Release]
      summary: Pause a cloud release role
      security:
        - CloudDeployerToken: []
      parameters:
        - $ref: "#/components/parameters/CloudReleaseRole"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CloudReleaseControlRequest"
            examples:
              queue:
                value:
                  release_id: release-2026-07-22
                  correlation_id: release-2026-07-22:queue
              scheduler:
                value:
                  release_id: release-2026-07-22
                  correlation_id: release-2026-07-22:scheduler
      responses:
        "200":
          description: Role pause state and drain evidence
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CloudReleaseRoleControl"
        "401":
          $ref: "#/components/responses/DeployerBearerUnauthorized"
        "404":
          description: Unknown release role
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          $ref: "#/components/responses/CloudReleaseConflict"
        "422":
          $ref: "#/components/responses/InvalidCloudReleaseRequest"
        "503":
          $ref: "#/components/responses/QueueControlUnavailable"

  /api/v1/cloud_release_roles/{role}/status:
    get:
      operationId: getCloudReleaseRoleStatus
      tags: [Cloud Release]
      summary: Get cloud release role drain status
      security:
        - CloudDeployerToken: []
      parameters:
        - $ref: "#/components/parameters/CloudReleaseRole"
        - $ref: "#/components/parameters/CloudReleaseId"
      responses:
        "200":
          description: Role gate state and current drain evidence
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CloudReleaseRoleControl"
        "401":
          $ref: "#/components/responses/DeployerBearerUnauthorized"
        "404":
          description: Unknown role or no control record for the role
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          $ref: "#/components/responses/CloudReleaseConflict"
        "422":
          $ref: "#/components/responses/InvalidCloudReleaseRequest"
        "503":
          $ref: "#/components/responses/QueueControlUnavailable"

  /api/v1/cloud_release_roles/{role}/verify:
    post:
      operationId: verifyCloudReleaseRoleRollout
      tags: [Cloud Release]
      summary: Bind observed worker rollout evidence to a drained release role
      security:
        - CloudDeployerToken: []
      parameters:
        - $ref: "#/components/parameters/CloudReleaseRole"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [release_id, correlation_id, rollout_evidence]
              properties:
                release_id:
                  type: string
                  minLength: 1
                  maxLength: 255
                correlation_id:
                  type: string
                  minLength: 1
                  maxLength: 255
                  pattern: "^.+:(queue|scheduler)$"
                  description: Must exactly equal "#{release_id}:#{role}"; the path role and release_id body value determine the required canonical correlation.
                rollout_evidence:
                  $ref: "#/components/schemas/RolloutVerificationEvidence"
            examples:
              queue:
                value:
                  release_id: release-2026-07-22
                  correlation_id: release-2026-07-22:queue
                  rollout_evidence:
                    etag: etag-queue
                    generation: queue-v2
                    latest_percent: 100
                    old_percent: 0
              scheduler:
                value:
                  release_id: release-2026-07-22
                  correlation_id: release-2026-07-22:scheduler
                  rollout_evidence:
                    etag: etag-scheduler
                    generation: scheduler-v2
                    latest_percent: 100
                    old_percent: 0
      responses:
        "200":
          description: Role rollout evidence is bound and the role reached split_verified
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CloudReleaseRoleControl"
        "401":
          $ref: "#/components/responses/DeployerBearerUnauthorized"
        "404":
          description: Unknown release role
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          $ref: "#/components/responses/CloudReleaseConflict"
        "422":
          $ref: "#/components/responses/InvalidCloudReleaseRequest"
        "503":
          $ref: "#/components/responses/QueueControlUnavailable"
  /api/v1/cloud_release_roles/{role}/resume:
    post:
      operationId: resumeCloudReleaseRole
      tags: [Cloud Release]
      summary: Resume a cloud release role after release invariants pass
      security:
        - CloudDeployerToken: []
      parameters:
        - $ref: "#/components/parameters/CloudReleaseRole"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CloudReleaseControlRequest"
            examples:
              queue:
                value:
                  release_id: release-2026-07-22
                  correlation_id: release-2026-07-22:queue
              scheduler:
                value:
                  release_id: release-2026-07-22
                  correlation_id: release-2026-07-22:scheduler
      responses:
        "200":
          description: Role resumed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CloudReleaseRoleControl"
        "401":
          $ref: "#/components/responses/DeployerBearerUnauthorized"
        "404":
          description: Unknown release role
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          $ref: "#/components/responses/CloudReleaseConflict"
        "422":
          $ref: "#/components/responses/InvalidCloudReleaseRequest"
        "503":
          $ref: "#/components/responses/QueueControlUnavailable"
  /api/v1/cloud_release_roles/{role}/complete:
    post:
      operationId: completeCloudReleaseRole
      tags: [Cloud Release]
      summary: Complete a resumed cloud release role
      description: Requires deployer authentication. Repeating completion for the same authenticated release and correlation context is idempotent.
      security:
        - CloudDeployerToken: []
      parameters:
        - $ref: "#/components/parameters/CloudReleaseRole"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CloudReleaseControlRequest"
            examples:
              queue:
                value:
                  release_id: release-2026-07-22
                  correlation_id: release-2026-07-22:queue
              scheduler:
                value:
                  release_id: release-2026-07-22
                  correlation_id: release-2026-07-22:scheduler
      responses:
        "200":
          description: Role completed (or an idempotent completion replay)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CloudReleaseRoleControl"
        "401":
          $ref: "#/components/responses/DeployerBearerUnauthorized"
        "404":
          description: Unknown release role
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          $ref: "#/components/responses/CloudReleaseConflict"
        "422":
          $ref: "#/components/responses/InvalidCloudReleaseRequest"


  /api/v1/cloud_release_migrations/status:
    get:
      operationId: getCloudReleaseMigrationStatus
      tags: [Cloud Release]
      summary: Get durable cloud release migration evidence
      security:
        - CloudDeployerToken: []
      parameters:
        - $ref: "#/components/parameters/CloudReleaseMigrationReleaseId"
        - $ref: "#/components/parameters/CloudReleaseMigrationDigest"
      responses:
        "200":
          description: Migration status for the exact release and migration digest
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CloudReleaseMigrationStatus"
        "401":
          $ref: "#/components/responses/DeployerBearerUnauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/InvalidCloudReleaseRequest"

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Tenant-scoped API key. Scopes gate write operations.
    AdminToken:
      type: apiKey
      in: header
      name: X-SSTREAM-ADMIN-TOKEN
      description: Shared admin/bootstrap token with unrestricted tenant and scope access.
    LiveGatewayToken:
      type: apiKey
      in: query
      name: auth_token
      description: Fixed high-entropy token shared only with the customer-owned MediaMTX gateway.
    WorkerToken:
      type: apiKey
      in: header
      name: X-SSTREAM-WORKER-TOKEN
      description: Trusted transcoding-worker token. Required for all /transcoding_jobs endpoints instead of ApiKeyAuth/AdminToken.
    CloudDeployerToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >-
        Requires `Authorization: Bearer <Google-signed OIDC JWT>` from the configured release-deployer identity. The token must have Google issuer, RS256 signature, verified email in `SSTREAM_DEPLOY_OIDC_EMAILS`, and exact `SSTREAM_DEPLOY_OIDC_AUDIENCE`.

  parameters:
    CloudReleaseRole:
      name: role
      in: path
      required: true
      schema:
        type: string
        enum: [queue, scheduler]
    CloudReleaseId:
      name: release_id
      in: query
      required: true
      schema:
        type: string
        minLength: 1
        maxLength: 255
    CloudReleaseMigrationReleaseId:
      name: release_id
      in: query
      required: true
      schema:
        type: string
        minLength: 1
        maxLength: 128
        pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
    CloudReleaseMigrationDigest:
      name: migration_digest
      in: query
      required: true
      schema:
        type: string
        pattern: "^sha256:[0-9a-f]{64}$"
    UploadId:
      name: id
      in: path
      required: true
      schema:
        type: integer
    JobId:
      name: id
      in: path
      required: true
      schema:
        type: integer
    JobLeaseToken:
      name: X-SSTREAM-JOB-LEASE-TOKEN
      in: header
      required: true
      schema:
        type: string
      description: Lease token returned by local POST /api/v1/transcoding_jobs/next or Cloud v2 POST /api/v1/transcoding_jobs/claim.
    JobAttemptId:
      name: X-SSTREAM-JOB-ATTEMPT-ID
      in: header
      required: true
      description: Cloud v2 UUID attempt fence returned by POST /api/v1/transcoding_jobs/claim.
      schema:
        type: string
        format: uuid
    OptionalJobAttemptId:
      name: X-SSTREAM-JOB-ATTEMPT-ID
      in: header
      required: false
      description: Cloud v2 UUID attempt fence returned by POST /api/v1/transcoding_jobs/claim; required for Cloud v2 jobs and omitted for legacy jobs.
      schema:
        type: string
        format: uuid
    RequiredIdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: Non-empty key (maximum 255 bytes) bound to the canonical request contract; retries must reuse an identical request.
      schema:
        type: string
        minLength: 1
        maxLength: 255
    OptionalWorkerIdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Optional for complete and fail callbacks. When present, it is a non-empty key no longer than 255 bytes bound to the job, lease token, attempt, action, and canonical request body. Exact retries replay the recorded receipt; a changed request with the same key conflicts.
      schema:
        type: string
        minLength: 1
        maxLength: 255
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema:
        type: string
      description: Client-supplied key to safely retry checkout creation without double-charging.
    WebhookId:
      name: id
      in: path
      required: true
      schema:
        type: integer
    VideoAssetId:
      name: id
      in: path
      required: true
      schema:
        type: integer
    VideoAssetIdParent:
      name: video_asset_id
      in: path
      required: true
      schema:
        type: integer
    SubtitleTrackId:
      name: id
      in: path
      required: true
      schema:
        type: integer
    AssetTrackId:
      name: id
      in: path
      required: true
      schema:
        type: integer
    CollectionId:
      name: id
      in: path
      required: true
      schema:
        type: integer
    IngestBatchId:
      name: id
      in: path
      required: true
      description: Canonical ingest batch record ID (`^[1-9][0-9]{0,18}$`) or an exact 32-lowercase-hex desktop batch UID (`^[0-9a-f]{32}$`).
      schema:
        oneOf:
          - type: string
            pattern: "^[1-9][0-9]{0,18}$"
          - type: string
            pattern: "^[0-9a-f]{32}$"
    CanonicalIngestBatchId:
      name: id
      in: path
      required: true
      description: Canonical positive ingest batch record ID.
      schema:
        type: string
        pattern: "^[1-9][0-9]{0,18}$"
    DesktopUploadId:
      name: upload_id
      in: path
      required: true
      schema:
        type: string

  responses:
    DeployerBearerUnauthorized:
      description: Missing or invalid release-deployer Bearer OIDC JWT
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            default:
              value: { error: "unauthorized", code: "unauthorized" }
    Unauthorized:
      description: Missing or invalid X-API-Key / admin token
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            default:
              value: { error: "unauthorized" }
    InsufficientScope:
      description: API key lacks a required scope
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            default:
              value: { error: "insufficient_scope", required_scopes: ["assets:write"] }
    InvalidCloudReleaseRequest:
      description: Cloud release request is missing, malformed, or violates a bounded identifier contract
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    CloudReleaseConflict:
      description: Release control state, release identity, correlation identity, or migration state conflicts
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    QueueControlUnavailable:
      description: Queue pause, resume, or claimed-execution visibility control is unavailable
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: Record not found or not owned by the caller
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    UnprocessableEntity:
      description: Validation failed
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Conflict:
      description: Request conflicts with immutable or terminal resource state
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    RateLimited:
      description: Too many requests — retry after the rate limit window resets
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            default:
              value: { error: "rate_limited" }
    StaleLease:
      description: Lease token is stale/invalid, or the job is no longer running
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            default:
              value: { error: "stale or invalid lease token", job_id: 1, status: "queued" }
    InvalidTranscodingResult:
      description: Completion payload is missing required artifacts for the job's requested packaging/thumbnail/captions
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/TranscodingResultError"
          examples:
            default:
              value:
                error: invalid_transcoding_result
                job_id: 1
                status: running
                details: ["manifest_path required", "dash_manifest_path required for dash packaging"]
    InvalidTranscodingContract:
      description: The Cloud v2 worker contract is invalid, stale, expired, or conflicts with an immutable grant/publication binding.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/TranscodingContractError"
          examples:
            conflict:
              value:
                error: invalid_transcoding_contract
                details: ["stale or invalid lease"]

  schemas:
    Error:
      type: object
      description: |
        Standard error envelope returned by every 4xx/5xx JSON response.
        `error` is a human-readable message kept for backward compatibility;
        `code` is a stable, machine-readable identifier (e.g. `not_found`,
        `unauthorized`, `forbidden_scope`, `rate_limited`,
        `parameter_missing`, `internal_server_error`) safe to branch on.
      properties:
        error:
          type: string
        code:
          type: string
          description: Stable machine-readable error identifier.
        reason:
          type: string
        details:
          description: Additional structured detail; shape varies by endpoint.
        required_scopes:
          type: array
          items:
            type: string
        job_id:
          type: integer
        status:
          type: string
      required: [error]

    CloudReleaseControlRequest:
      type: object
      additionalProperties: false
      required: [release_id, correlation_id]
      properties:
        release_id:
          type: string
          minLength: 1
          maxLength: 255
        correlation_id:
          type: string
          minLength: 1
          maxLength: 255
          pattern: "^.+:(queue|scheduler)$"
          description: Must exactly equal "#{release_id}:#{role}"; the path role and release_id body value determine the required canonical correlation.
    CloudReleaseRoleControl:
      type: object
      additionalProperties: false
      required: [role, release_id, correlation_id, gate_state, active_count, updated_at, rollout_evidence]
      properties:
        role:
          type: string
          enum: [queue, scheduler]
        release_id:
          type: string
        correlation_id:
          type: string
        gate_state:
          type: string
          enum: [pause_requested, paused, drain_requested, drained, replace_requested, generation_observed, split_verified, resume_requested, resumed, role_completed]
        active_count:
          type: integer
          minimum: 0
        updated_at:
          type: string
          format: date-time
        rollout_evidence:
          $ref: "#/components/schemas/RolloutEvidence"
    RolloutEvidence:
      description: Empty until rollout verification, then the exact immutable verification evidence.
      oneOf:
        - type: object
          additionalProperties: false
          maxProperties: 0
        - $ref: "#/components/schemas/RolloutVerificationEvidence"
    RolloutVerificationEvidence:
      type: object
      additionalProperties: false
      required: [etag, generation, latest_percent, old_percent]
      properties:
        etag:
          type: string
          minLength: 1
          maxLength: 255
        generation:
          type: string
          minLength: 1
          maxLength: 255
        latest_percent:
          type: integer
          enum: [100]
        old_percent:
          type: integer
          enum: [0]
    CloudReleaseMigrationStatus:
      type: object
      additionalProperties: false
      required: [release_id, migration_digest, before_schema_sha256, after_schema_sha256, status, started_at, completed_at, failed_at]
      properties:
        release_id:
          type: string
          pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
        migration_digest:
          type: string
          pattern: "^sha256:[0-9a-f]{64}$"
        before_schema_sha256:
          type: string
          pattern: "^[0-9a-f]{64}$"
        after_schema_sha256:
          type: string
          nullable: true
          pattern: "^[0-9a-f]{64}$"
        status:
          type: string
          enum: [running, completed, failed]
        started_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          nullable: true
        failed_at:
          type: string
          format: date-time
          nullable: true
    IngestBatch:
      type: object
      required: [id]
      properties:
        id: { type: string }
        tenant_id: { type: string }
        storage_connection_id: { type: string }
        storage_connection:
          $ref: "#/components/schemas/DesktopStorageConnection"
        upload_capabilities:
          $ref: "#/components/schemas/DesktopUploadCapabilities"
        batch_uid: { type: string }
        status:
          type: string
          enum: [created, canceling, commit_pending, committed, canceled, failed]
        manifest_version: { type: integer, example: 1 }
        provider: { type: string, example: bunny }
        source_prefix: { type: string, example: streams/ }
        purpose: { type: string, example: ingest }
        asset_count: { type: integer }
        ready_count: { type: integer }
        total: { type: integer }
        total_bytes: { type: integer }
        committed_at: { type: string, format: date-time, nullable: true }
        canceled_at: { type: string, format: date-time, nullable: true }
        error_details:
          type: array
          nullable: true
          description: Stable asynchronous finalization failure codes when status is `failed`.
          items:
            type: object
            additionalProperties: false
            required: [code]
            properties:
              code:
                type: string
                enum: [desktop_hls_verification_failed, ingest_commit_storage_provider_unavailable]
        assets:
          type: array
          items:
            $ref: "#/components/schemas/IngestBatchAsset"

    IngestBatchCancellationReceipt:
      type: object
      description: Safe cancellation recovery receipt for a valid desktop batch UID that has no tenant record.
      additionalProperties: false
      properties:
        status:
          type: string
          enum: [canceled]
        batch_uid:
          type: string
          pattern: "^[a-f0-9]{32}$"
      required: [status, batch_uid]
    IngestBatchCreateRequest:
      type: object
      oneOf:
        - required: [storage_connection_id]
          anyOf:
            - required: [allowed_prefix]
            - required: [source_prefix]
        - required: [purpose, batch_uid]
          properties:
            purpose:
              const: desktop_upload
      properties:
        tenant_id:
          type: integer
          description: Required only when using an admin token; API keys infer their tenant.
        storage_connection_id:
          type: integer
          description: Omit for desktop upload so the server selects the tenant connection.
        allowed_prefix:
          type: string
          description: Storage key prefix the manifest must stay under.
        source_prefix:
          type: string
          deprecated: true
          description: Legacy alias for `allowed_prefix`; new clients should send `allowed_prefix`.
        purpose:
          type: string
          enum: [ingest, desktop_upload]
          default: ingest
        batch_id:
          type: string
          description: Optional legacy client batch ID for non-desktop ingest; defaults to a generated UUID.
        batch_uid:
          type: string
          pattern: "^[a-f0-9]{32}$"
          description: Required client-generated desktop batch UID.

    IngestBatchResponse:
      allOf:
        - $ref: "#/components/schemas/IngestBatch"

    ManifestPreviewResponse:
      allOf:
        - $ref: "#/components/schemas/IngestBatch"


    IngestBatchAsset:
      type: object
      properties:
        id: { type: string }
        relative_path: { type: string }
        object_key: { type: string }
        path_hash: { type: string }
        byte_size: { type: integer }
        checksum: { type: string, nullable: true }
        content_type: { type: string, nullable: true }
        status:
          type: string
          enum: [pending, verified, committed, failed]
        video_asset_id: { type: string, nullable: true }

    DesktopStorageConnection:
      type: object
      required: [id, label, provider, status]
      properties:
        id: { type: string }
        label: { type: string }
        provider: { type: string, enum: [r2, bunny] }
        status: { type: string }

    DesktopUploadCapabilities:
      type: object
      required: [mode, multipart, resumable]
      properties:
        mode: { type: string, enum: [presigned, server_proxy] }
        multipart: { type: boolean }
        resumable: { type: boolean }
        multipart_part_size: { type: integer, nullable: true }

    DesktopUploadCreateRequest:
      type: object
      required: [relative_key, content_type, content_length, checksum_sha256]
      properties:
        relative_key:
          type: string
          description: Relative object key confined to the server-issued batch prefix.
        content_type: { type: string }
        content_length: { type: integer, minimum: 1 }
        checksum_sha256:
          type: string
          pattern: "^[a-f0-9]{64}$"
          description: Lowercase hexadecimal SHA-256 digest of the complete object.

    DesktopUpload:
      type: object
      required: [id, object_key, content_type, content_length, checksum_sha256, status]
      properties:
        id: { type: string }
        object_key: { type: string }
        content_type: { type: string }
        content_length: { type: integer }
        checksum_sha256:
          type: string
          pattern: "^[a-f0-9]{64}$"
        status: { type: string, enum: [pending, proxying, direct_pending, multipart, completing, verifying, verification_failed, uploaded, cleanup_pending, aborted] }
        mode: { type: string, enum: [multipart, proxy], nullable: true }
        part_size: { type: integer, nullable: true }
        resumable: { type: boolean, nullable: true }
    DesktopUploadVerificationPending:
      type: object
      required: [error, code, upload]
      properties:
        error: { type: string, enum: [upload_verification_pending] }
        code: { type: string, enum: [upload_verification_pending] }
        upload: { $ref: "#/components/schemas/DesktopUpload" }

    DesktopUploadVerificationEnqueuePending:
      type: object
      required: [error, code, upload]
      properties:
        error: { type: string, enum: [upload_verification_enqueue_pending] }
        code: { type: string, enum: [upload_verification_enqueue_pending] }
        upload: { $ref: "#/components/schemas/DesktopUpload" }

    DesktopUploadVerificationFailed:
      type: object
      required: [error, code, upload]
      properties:
        error: { type: string, enum: [upload_verification_failed] }
        code: { type: string, enum: [upload_verification_failed] }
        upload: { $ref: "#/components/schemas/DesktopUpload" }

    DesktopUploadPart:
      type: object
      required: [part_number, upload_url, headers]
      properties:
        part_number: { type: integer }
        upload_url: { type: string, format: uri }
        headers:
          type: object
          additionalProperties: { type: string }

    IngestManifestCommitRequest:
      type: object
      required: [manifest]
      properties:
        manifest:
          oneOf:
            - $ref: "#/components/schemas/IngestManifestDocument"
            - type: array
              items:
                $ref: "#/components/schemas/IngestManifestDocument"

    ClientIngestManifestV1:
      allOf:
        - $ref: "#/components/schemas/IngestManifestDocument"

    IngestManifestDocument:
      type: object
      required: [version, batch_id, relative_path, path_hash]
      properties:
        version:
          oneOf:
            - type: string
              enum: [v1]
            - type: integer
              enum: [1]
        batch_id: { type: string }
        provider: { type: string, nullable: true }
        relative_path: { type: string }
        path_hash: { type: string }
        master_playlist_key:
          type: string
          description: HLS master playlist object key produced by nulx-encoder.
        total_bytes: { type: integer }
        ready: { type: boolean }
        original:
          type: object
          properties:
            relative_path: { type: string }
            key: { type: string }
            bytes: { type: integer }
            checksum_sha256: { type: string }
        hls_artifacts:
          type: array
          items:
            type: object
            properties:
              relative_path: { type: string }
              key: { type: string }
              bytes: { type: integer }
              checksum_sha256: { type: string }
        files:
          type: array
          description: Legacy explicit file-entry form also accepted by the API.
          items:
            type: object
            required: [relative_path]
            properties:
              relative_path: { type: string }
              object_key: { type: string }
              path_hash: { type: string }
              byte_size: { type: integer }
              size: { type: integer }
              checksum: { type: string }
              content_type: { type: string }

    Tenant:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        slug: { type: string }
        status: { type: string, enum: [active, suspended] }
        plan_name: { type: string }
        storage_bytes: { type: integer }
        egress_bytes: { type: integer }
        transcoding_minutes: { type: integer }

    VideoAsset:
      type: object
      properties:
        id: { type: integer }
        external_id: { type: string, format: uuid }
        title: { type: string }
        description: { type: string, nullable: true }
        collection_id: { type: integer, nullable: true }
        input_status: { type: string, enum: [pending, uploaded, failed] }
        playback_status: { type: string, enum: [draft, processing, ready, errored] }
        source_bytes: { type: integer }
        storage_bytes: { type: integer }
        duration_seconds: { type: number }
        manifest_path: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        tenant:
          type: object
          properties:
            id: { type: integer }
            name: { type: string }
            slug: { type: string }

    PlaybackTarget:
      type: object
      properties:
        asset_id: { type: string }
        playback_url: { type: string, nullable: true }
        hls_url: { type: string, nullable: true }
        dash_url: { type: string, nullable: true }
        thumbnail_url: { type: string, nullable: true }
        token: { type: string }
        token_ttl_seconds:
          type: integer
          nullable: true
          description: Lifetime of the returned token in seconds; the caller's backend should re-mint (call this endpoint again with its API key) before it elapses. There is no public browser refresh. Null when no token is issued.
        playback_policy: { type: string, enum: [public, signed] }
        status: { type: string }

    Collection:
      type: object
      description: A folder used to organize a tenant's video assets.
      properties:
        id: { type: integer }
        tenant_id: { type: integer }
        name: { type: string }
        slug: { type: string }
        description: { type: string, nullable: true }
        position: { type: integer }
        created_at: { type: string, format: date-time }

    SubtitleTrack:
      type: object
      properties:
        id: { type: integer }
        language: { type: string, example: en }
        label: { type: string, example: English }
        format: { type: string, enum: [vtt, srt] }
        status: { type: string, enum: [pending, ready, failed] }
        position: { type: integer }
        created_at: { type: string, format: date-time }

    AssetTrackCue:
      type: object
      required: [start_ms]
      properties:
        start_ms:
          type: integer
          minimum: 0
          description: Cue start time, in milliseconds from the start of the asset.
        end_ms:
          type: integer
          nullable: true
          description: Optional cue end time in milliseconds; must be >= start_ms.
        payload:
          type: object
          description: "Free-form JSON payload for this cue, e.g. `{title: \"Intro\"}` for a chapter."
          additionalProperties: true

    AssetTrackFields:
      type: object
      description: |
        A track must end up with at least one of `cues` or `storage_key`
        (enforced server-side, not expressible as a plain JSON Schema
        constraint here). Inline `cues` are capped at 500 entries and
        ~200KB serialized.
      properties:
        kind:
          type: string
          enum: [chapter, scene, marker, index]
        name:
          type: string
          example: chapters-en
          description: Label, unique per (asset, kind). Also the upsert key on POST/PUT.
        position:
          type: integer
        status:
          type: string
          enum: [pending, ready, failed]
        cues:
          type: array
          items:
            $ref: "#/components/schemas/AssetTrackCue"
        storage_key:
          type: string
          nullable: true
          description: Pointer to the full payload in the customer's own bucket, for machine-scale results.
        source:
          type: string
          enum: [api, worker, nulx]
          description: Who produced this track.
        producer:
          type: string
          nullable: true
          example: face-index-v2
          description: Free-text identifier of the external producer.

    AssetTrackWrite:
      description: Body for the upsert-by-(kind, name) create endpoint - both are required since they're the upsert key.
      allOf:
        - $ref: "#/components/schemas/AssetTrackFields"
        - type: object
          required: [kind, name]

    AssetTrackUpdate:
      description: Body for updating a track by id - all fields optional, only the ones present are changed.
      allOf:
        - $ref: "#/components/schemas/AssetTrackFields"

    AssetTrack:
      allOf:
        - $ref: "#/components/schemas/AssetTrackFields"
        - type: object
          properties:
            id: { type: integer }
            cue_count: { type: integer, description: "cues.size, regardless of storage_mode." }
            storage_mode:
              type: string
              enum: [inline, external]
              description: "\"external\" when storage_key is set, otherwise \"inline\"."
            created_at: { type: string, format: date-time }
            updated_at: { type: string, format: date-time }

    AnalyticsSummary:
      type: object
      description: |
        Aggregated viewer analytics for a tenant or (when scoped to a
        single asset) one video asset, over a date range (default:
        trailing 30 days).
      properties:
        total_plays: { type: integer }
        unique_viewers: { type: integer }
        total_watch_seconds: { type: integer }
        avg_watch_seconds_per_play: { type: number }
        completion_rate:
          type: number
          description: Percentage (0-100) of plays that reached an "ended" event.
        daily_series:
          type: array
          items:
            type: object
            properties:
              date: { type: string, format: date }
              plays: { type: integer }
              watch_minutes: { type: number }
        top_videos:
          type: array
          items:
            type: object
            properties:
              video_asset:
                $ref: "#/components/schemas/VideoAsset"
              plays: { type: integer }
        geography:
          type: array
          items:
            type: object
            properties:
              country: { type: string, nullable: true, description: ISO 3166-1 alpha-2 country code, or null if unknown. }
              plays: { type: integer }

    UploadSession:
      type: object
      properties:
        upload_id: { type: integer }
        id: { type: integer }
        asset_id: { type: integer }
        filename: { type: string }
        status: { type: string, enum: [initiated, uploaded, processing, completed, failed] }
        upload_url: { type: string, format: uri }
        upload_method: { type: string, example: PUT }
        upload_headers:
          type: object
          additionalProperties:
            type: string
        public_url: { type: string, format: uri, nullable: true }
        storage_key: { type: string }
        bytes_expected: { type: integer }
        bytes_received: { type: integer, nullable: true }
        expires_in: { type: integer, nullable: true }
        completed_at: { type: string, format: date-time, nullable: true }
        video_asset:
          type: object
          properties:
            id: { type: integer }
            external_id: { type: string }
            title: { type: string }
            input_status: { type: string }
            playback_status: { type: string }

    ApiKey:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        last_four: { type: string }
        scopes:
          type: array
          items:
            type: string
        last_used_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        tenant:
          type: object
          properties:
            id: { type: integer }
            name: { type: string }
            slug: { type: string }

    WebhookEndpoint:
      type: object
      properties:
        id: { type: integer }
        url: { type: string, format: uri }
        active: { type: boolean }
        last_four: { type: string }
        event_types:
          type: array
          items: { type: string }
          description: >-
            Event types this endpoint receives. An empty array means every
            event (see the top-level Webhooks section for the full catalog).
        created_at: { type: string, format: date-time }
        tenant:
          type: object
          properties:
            id: { type: integer }
            name: { type: string }
            slug: { type: string }

    WebhookEventPayload:
      type: object
      description: |
        The JSON body of every outbound webhook delivery. Sent as `POST` to
        the endpoint's `url` with a `Nulx-Event` header (the event type) and
        a `Nulx-Signature` header of the form
        `t=<unix_timestamp>,v1=<hex hmac-sha256>`, where the signature is
        computed over the string `"<t>.<raw request body>"` using the
        endpoint's signing secret (returned once, at creation time, as
        `secret` in the `POST /api/v1/webhooks` response). To verify a
        delivery, recompute the HMAC the same way and compare it
        (constant-time) to the `v1` value for the `t` in the header,
        rejecting deliveries where `t` is too far in the past.
      properties:
        event:
          type: string
          enum:
            - video.upload.completed
            - video.job.queued
            - video.job.started
            - video.job.failed
            - video.asset.ready
            - video.asset.errored
            - video.asset.deleted
            - video.subtitle.ready
            - video.track.ready
            - tenant.traffic.anomaly
        created_at:
          type: string
          format: date-time
        data:
          type: object
          description: >-
            Shape varies by event - see the top-level Webhooks section's
            event catalog. `asset_id` is present on every asset/job/subtitle
            event.
          properties:
            asset_id: { type: string }
            title:
              type: string
              description: Present on video.upload.completed, video.asset.ready, video.asset.errored, and video.asset.deleted.
            playback_status:
              type: string
              description: Present on video.upload.completed, video.asset.ready, and video.asset.errored.
            playback_url:
              type: string
              nullable: true
              description: Present on video.upload.completed, video.asset.ready, and video.asset.errored.
            watch_url:
              type: string
              nullable: true
              description: Present only for signed-playback-policy assets.
            embed_url:
              type: string
              nullable: true
              description: Present only for signed-playback-policy assets.
            error:
              type: string
              nullable: true
              description: Present on video.asset.errored, and on video.job.failed when an error message is available.
            job_id:
              type: string
              description: Present on video.job.queued, video.job.started, and video.job.failed.
            status:
              type: string
              description: Present on video.job.queued, video.job.started, and video.job.failed. The TranscodingJob status (queued/running/failed).
            stage:
              type: string
              description: Present on video.job.queued, video.job.started, and video.job.failed. The asset's processing stage.
            language:
              type: string
              description: Present on video.subtitle.ready. BCP-47-ish language tag.
            kind:
              type: string
              description: On video.subtitle.ready, the subtitle track's storage format (vtt/srt). On video.track.ready, the asset track's kind (chapter/scene/marker/index).
            name:
              type: string
              description: Present on video.track.ready. The asset track's name.
            cue_count:
              type: integer
              description: Present on video.track.ready. Number of cues on the track (0 for a storage_key-only track). Cue contents are never included in the payload.
      required: [event, created_at, data]

    UsageSummary:
      type: object
      properties:
        tenant:
          type: object
          properties:
            id: { type: integer }
            name: { type: string }
            slug: { type: string }
            plan_name: { type: string }
        usage:
          type: object
          additionalProperties:
            type: number
          description: Metric name to quantity for the current billing period.
        estimate:
          $ref: "#/components/schemas/BillingEstimate"

    BillingEstimate:
      type: object
      properties:
        currency: { type: string, example: usd }
        total_cents: { type: integer }
        tenant_slug: { type: string }
        line_items:
          type: object
          additionalProperties:
            type: object
            properties:
              quantity: { type: number }
              amount_cents: { type: integer }

    CheckoutResponse:
      type: object
      properties:
        statement_id: { type: integer }
        status: { type: string, example: CREATED }
        provider: { type: string, example: paypal }
        approval_url: { type: string, format: uri, nullable: true }
        order_id: { type: string }
        total_cents: { type: integer }
        currency: { type: string }
    CreemCheckoutResponse:
      type: object
      properties:
        statement_id: { type: integer }
        status: { type: string, example: pending }
        provider: { type: string, example: creem }
        checkout_url: { type: string, format: uri, nullable: true }
        checkout_id: { type: string, nullable: true }
        total_cents: { type: integer }
        currency: { type: string, example: usd }

    RefundResponse:
      type: object
      properties:
        statement_id: { type: integer }
        status: { type: string, enum: [draft, finalized, paid, refunded] }
        refund:
          type: object
          properties:
            id: { type: string }
            status: { type: string, example: COMPLETED }

    VideoAssetDownload:
      type: object
      required: [url, expires_in]
      properties:
        url:
          type: string
          description: Short-lived signed GET URL for the original source object.
        expires_in:
          type: integer
          example: 300

    DeovrManifest:
      type: object
      required: [title, id, videoLength, duration, stereoMode, screenType, encodings]
      properties:
        title:
          type: string
        id:
          type: string
          description: VideoAsset external_id.
        videoLength:
          type: number
          description: Duration in seconds for DeoVR-compatible clients.
        duration:
          type: number
          description: Duration in seconds for HereSphere-compatible clients.
        stereoMode:
          type: string
          enum: [mono]
        screenType:
          type: string
          enum: [flat, 180 dome, 360 sphere]
        token:
          type: string
          nullable: true
          description: Present only when playback_policy is signed.
        encodings:
          type: array
          items:
            type: object
            properties:
              name:
                type: string
              video-sources:
                type: array
                items:
                  type: object
                  properties:
                    url:
                      type: string
                      nullable: true
    PlaybackInfo:
      type: object
      properties:
        asset_id: { type: string, description: external_id of the asset }
        playback_url: { type: string, nullable: true }
        hls_url: { type: string, nullable: true }
        dash_url: { type: string, nullable: true }
        thumbnail_url: { type: string, nullable: true }
        subtitles:
          type: array
          items:
            type: object
            properties:
              url: { type: string, nullable: true }
        packaging:
          type: array
          items:
            type: string
            enum: [hls, dash]
        token:
          type: string
          description: Signed, time-limited playback token. Lifetime is token_ttl_seconds (default 900s, env-configurable). Re-mint server-to-server before expiry; there is no public browser refresh.
        token_ttl_seconds:
          type: integer
          nullable: true
          description: Lifetime of the token in seconds; re-mint server-to-server before it elapses. Null when no token is issued.
        playback_policy:
          type: string
          enum: [public, signed]
          description: |
            When "public", the /watch and /embed pages require no token.
            When "signed", those pages 403 without a valid, unexpired
            token (see watch_url/embed_url below, or append
            ?token=<token> yourself).
        watch_url:
          type: string
          nullable: true
          description: >-
            Only present when playback_policy is "signed". A ready-to-use
            /watch page URL with the token query param pre-filled.
        embed_url:
          type: string
          nullable: true
          description: >-
            Only present when playback_policy is "signed". A ready-to-use
            /embed page URL (for <iframe src>) with the token query param
            pre-filled.
        status: { type: string, enum: [draft, processing, ready, errored] }

    TranscodingJob:
      type: object
      properties:
        id: { type: integer }
        status: { type: string, enum: [queued, running, completed, failed] }
        priority: { type: integer }
        progress: { type: integer }
        input_key: { type: string }
        output_prefix: { type: string }
        ffmpeg_profile:
          type: object
          properties:
            ladder:
              type: array
              items: { type: integer }
            packaging:
              type: array
              items: { type: string }
        renditions:
          type: array
          items: { type: object }
        idempotency_key: { type: string }
        lease_token: { type: string, nullable: true }
        lease_expires_at: { type: string, format: date-time, nullable: true }
        last_error: { type: string, nullable: true }

    WorkerContract:
      type: object
      description: The payload a trusted transcoding worker receives to process a job.
      properties:
        id: { type: string }
        tenant_slug: { type: string }
        input_path: { type: string }
        source_url: { type: string, nullable: true }
        source_width: { type: integer }
        source_height: { type: integer }
        output_dir: { type: string }
        lease_token: { type: string }
        status: { type: string }
        progress: { type: integer }
        packaging:
          type: array
          items: { type: string, enum: [hls, dash] }
        rendition_heights:
          type: array
          items: { type: integer }
        thumbnail:
          type: object
          properties:
            enabled: { type: boolean }
            timestamp: { type: string }
            filename: { type: string }
        subtitles:
          type: array
          items:
            type: object
            properties:
              path: { type: string }
              language: { type: string }
              label: { type: string }
        watermark:
          type: object
          properties:
            text: { type: string }
            gravity: { type: string }
            font_size: { type: integer }
        playback_public_prefix: { type: string }
        r2_playback_base_url: { type: string, nullable: true }
    CloudClaimRequest:
      type: object
      description: Optional claim metadata. The server does not interpret it, but canonical JSON is bound to Idempotency-Key replay.
      additionalProperties: true

    CloudAuthority:
      type: object
      additionalProperties: false
      required: [kind, id, version, host, bucket, key_prefix]
      properties:
        kind: { type: string, enum: [platform_r2, tenant_r2] }
        id: { type: string, format: uuid }
        version: { type: integer, minimum: 1 }
        host: { type: string, description: Cloudflare R2 account host. }
        bucket: { type: string }
        key_prefix: { type: string, description: Authority-confined storage key prefix; may be empty. }

    CloudSourceManifest:
      type: object
      additionalProperties: false
      required: [kind, grant_id, authority_id, authority_version, authority_host, authority_bucket, authority_key_prefix, object_key, content_length, sha256, etag, content_type, get_url, required_headers, range_supported, expires_at]
      properties:
        kind: { type: string, enum: [r2_presigned_get] }
        grant_id: { type: string, format: uuid }
        authority_id: { type: string, format: uuid }
        authority_version: { type: integer, minimum: 1 }
        authority_host: { type: string }
        authority_bucket: { type: string }
        authority_key_prefix: { type: string }
        object_key: { type: string }
        content_length: { type: integer, minimum: 1 }
        sha256: { type: string, pattern: "^[a-f0-9]{64}$" }
        etag:
          type: string
          pattern: '^"[\x21\x23-\x7e\x80-\xff]+"$'
          description: Canonical strong quoted ETag bound to `required_headers.if-match`.
        content_type: { type: string }
        get_url: { type: string, format: uri }
        required_headers:
          type: object
          additionalProperties: { type: string }
        range_supported: { type: boolean, enum: [true] }
        expires_at: { type: string, format: date-time }

    CloudOutputContract:
      type: object
      additionalProperties: false
      required: [staged_prefix, publication, completion, expected_version, authority_id, authority_version, attempt_id, lease_token]
      properties:
        staged_prefix: { type: string, description: Attempt-specific prefix to which all output object keys must be confined. }
        publication: { type: string, enum: [compare_and_swap] }
        completion: { type: string, enum: [publish_required] }
        expected_version: { type: integer, minimum: 0 }
        authority_id: { type: string, format: uuid }
        authority_version: { type: integer, minimum: 1 }
        attempt_id: { type: string, format: uuid }
        lease_token: { type: string }

    CloudWorkerContract:
      allOf:
        - $ref: "#/components/schemas/WorkerContract"
        - type: object
          required: [id, tenant_slug, input_path, source_etag, source_bytes, source_width, source_height, output_dir, attempt_namespace, lease_token, lease_duration_seconds, status, progress, packaging, max_duration_seconds, rendition_heights, thumbnail, subtitles, watermark, playback_public_prefix, r2_playback_base_url, attempt_id, lease_expires_at, authority, contract_version, source_manifest, output_contract]
          properties:
            source_etag: { type: string, nullable: true }
            source_bytes: { type: integer, minimum: 0 }
            attempt_namespace: { type: string }
            lease_duration_seconds: { type: integer, minimum: 1 }
            max_duration_seconds: { type: integer, minimum: 1, nullable: true }
            attempt_id: { type: string, format: uuid }
            lease_id:
              type: string
              description: Optional compatibility mirror of `lease_token`; callers must not use it as the canonical lease identity.
            lease_expires_at: { type: string, format: date-time }
            authority:
              $ref: "#/components/schemas/CloudAuthority"
            contract_version: { type: integer, enum: [2] }
            source_manifest:
              $ref: "#/components/schemas/CloudSourceManifest"
            output_contract:
              $ref: "#/components/schemas/CloudOutputContract"

    CloudSourceCapabilityRequest:
      type: object
      additionalProperties: false
      required: [source]
      properties:
        source:
          type: object
          additionalProperties: false
          required: [grant_id, etag]
          properties:
            grant_id:
              type: string
              format: uuid
              description: Existing source-read grant ID from the Cloud v2 claim.
            etag:
              type: string
              pattern: '^"[\x21\x23-\x7e\x80-\xff]+"$'
              description: Canonical strong quoted ETag from the claimed source manifest and provider-full verified evidence.

    CloudSourceCapabilityResponse:
      type: object
      additionalProperties: false
      required: [job_id, attempt_id, source_manifest]
      properties:
        job_id: { type: integer }
        attempt_id: { type: string, format: uuid }
        source_manifest:
          $ref: "#/components/schemas/CloudSourceManifest"
    CloudOutputObject:
      type: object
      additionalProperties: false
      required: [key, content_length, content_type, sha256]
      properties:
        key:
          type: string
          description: Normalized, unique key under the Cloud v2 attempt's staged_prefix and claimed authority key_prefix.
        content_length: { type: integer, minimum: 1 }
        content_type: { type: string, minLength: 1, maxLength: 255 }
        sha256: { type: string, pattern: "^[a-f0-9]{64}$" }

    CloudOutputCapabilityRequest:
      type: object
      additionalProperties: false
      required: [manifest_sha256, objects]
      properties:
        manifest_sha256:
          type: string
          pattern: "^[a-f0-9]{64}$"
          description: |
            SHA-256 of the UTF-8 canonical JSON object
            `{"objects":[...],"staged_prefix":"..."}`. Object keys are sorted
            lexicographically at every level, arrays retain their order except
            `objects`, which is sorted by `key`, and scalar values use JSON
            encoding without whitespace. Computable vector: SHA-256 of
            `{"objects":[{"content_length":1,"content_type":"video/mp2t","key":"outputs/attempts/00000000-0000-4000-8000-000000000000/segment.ts","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}],"staged_prefix":"outputs/attempts/00000000-0000-4000-8000-000000000000"}`
            is `26f909578b06904593a0ace2076301c1c277f8abf33c15f8db26a7940cc88d46`.
        objects:
          type: array
          minItems: 1
          maxItems: 1000
          items:
            $ref: "#/components/schemas/CloudOutputObject"

    CloudOutputCapability:
      type: object
      additionalProperties: false
      required: [grant_id, authority_id, expires_at, verification_deadline, objects]
      properties:
        grant_id: { type: string, format: uuid }
        authority_id: { type: string, format: uuid }
        expires_at: { type: string, format: date-time }
        verification_deadline:
          type: string
          format: date-time
          description: The lease-bounded deadline by which asynchronous output verification must complete.
        objects:
          type: array
          items:
            type: object
            additionalProperties: false
            required: [key, content_length, content_type, sha256, put_url, required_headers]
            properties:
              key: { type: string }
              content_length: { type: integer, minimum: 1 }
              content_type: { type: string }
              sha256: { type: string, pattern: "^[a-f0-9]{64}$" }
              put_url: { type: string, format: uri }
              required_headers:
                type: object
                additionalProperties: { type: string }

    CloudOutputCapabilityResponse:
      type: object
      additionalProperties: false
      required: [job_id, attempt_id, output_capability]
      properties:
        job_id: { type: integer }
        attempt_id: { type: string, format: uuid }
        output_capability:
          $ref: "#/components/schemas/CloudOutputCapability"

    CloudOutputPublishedObject:
      type: object
      additionalProperties: false
      required: [key, content_length, content_type, sha256, etag]
      properties:
        key:
          type: string
          description: Normalized, unique key under the Cloud v2 attempt's staged_prefix and claimed authority key_prefix.
        content_length: { type: integer, minimum: 1 }
        content_type: { type: string, minLength: 1, maxLength: 255 }
        sha256: { type: string, pattern: "^[a-f0-9]{64}$" }
        etag:
          type: string
          pattern: '^"[\x21\x23-\x7e\x80-\xff]+"$'
          description: Canonical strong quoted ETag returned by the provider for the staged object.

    CloudOutputPublishRequest:
      type: object
      additionalProperties: false
      required: [manifest_sha256, objects, publish]
      properties:
        manifest_sha256:
          type: string
          pattern: "^[a-f0-9]{64}$"
          description: |
            SHA-256 uses the canonical JSON algorithm and vector documented on
            `CloudOutputCapabilityRequest.manifest_sha256`; this value must
            cover the identical staged prefix and object set.
        objects:
          type: array
          minItems: 1
          maxItems: 1000
          items:
            $ref: "#/components/schemas/CloudOutputPublishedObject"
        publish:
          type: object
          additionalProperties: false
          required: [grant_id, expected_version]
          properties:
            grant_id: { type: string, format: uuid }
            expected_version: { type: integer, minimum: 0 }

    CloudOutputPublishResponse:
      type: object
      additionalProperties: false
      required: [job_id, attempt_id, publication]
      properties:
        job_id: { type: integer }
        attempt_id: { type: string, format: uuid }
        publication: { type: string, enum: [verification_pending, published] }

    RelinquishLeaseRequest:
      type: object
      additionalProperties: false
      properties:
        lease_id:
          type: string
          description: Optional compatibility value. When present, it must equal the required `X-SSTREAM-JOB-LEASE-TOKEN` header.
        relinquish:
          type: object
          additionalProperties: false
          properties:
            reason:
              type: string
              maxLength: 1024
              description: Optional worker-provided reason for relinquishing the Cloud v2 lease.

    TranscodingLeaseStatus:
      type: object
      additionalProperties: false
      required: [job_id, status]
      properties:
        job_id: { type: integer }
        status: { type: string, enum: [queued] }
        idempotent:
          type: boolean
          description: Present and true only when this is an exact relinquish receipt replay.

    TranscodingResultError:
      type: object
      additionalProperties: false
      required: [error, job_id, status, details]
      properties:
        error: { type: string, enum: [invalid_transcoding_result] }
        job_id: { type: integer }
        status: { type: string, enum: [running] }
        details:
          type: array
          items: { type: string }
    TranscodingContractError:
      type: object
      additionalProperties: false
      required: [error, details]
      properties:
        error: { type: string, enum: [invalid_transcoding_contract] }
        details:
          type: array
          items: { type: string }
