openapi: 3.1.0
info:
  title: Voicera Public API
  version: 1.0.0-alpha.1
  description: |
    Programmatic access to Voicera narration projects. Submit English SRT, quote and start
    multilingual narration batches, and receive signed terminal webhooks.
servers:
  - url: /api/v1
security:
  - bearerAuth: []
tags:
  - name: Projects
  - name: Assets
  - name: Transcriptions
  - name: Renders
  - name: Voices
  - name: API keys
  - name: Billing
  - name: Narration batches
  - name: Webhooks
paths:
  /languages:
    get:
      tags: [Voices]
      summary: List supported narration languages
      operationId: listLanguages
      parameters:
        - name: capability
          in: query
          schema: { type: string, const: narration }
      responses:
        "200": { description: Supported narration language catalog. }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /narration-batches:
    get:
      tags: [Narration batches]
      summary: List narration batches
      operationId: listNarrationBatches
      responses:
        "200":
          description: Workspace narration batches.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/NarrationBatch" } }
    post:
      tags: [Narration batches]
      summary: Import an English SRT and create an aggregate quote
      operationId: createNarrationBatchDraft
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreatePublicNarrationBatch" }
          multipart/form-data:
            schema:
              type: object
              required: [srt, metadata]
              properties:
                srt: { type: string, format: binary }
                metadata:
                  type: string
                  description: JSON containing title, voiceId, targetLanguages, webhookEndpointId, and optional externalId.
      responses:
        "201":
          description: Draft batch and aggregate 15-minute quote.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NarrationBatch" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /narration-batches/{batchId}:
    parameters:
      - $ref: "#/components/parameters/BatchId"
    get:
      tags: [Narration batches]
      summary: Retrieve authoritative batch status and result URLs
      operationId: getNarrationBatch
      responses:
        "200":
          description: Batch, aggregate quote, and per-language jobs.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NarrationBatch" }
        "404": { $ref: "#/components/responses/NotFound" }
  /narration-batches/{batchId}/quote:
    parameters:
      - $ref: "#/components/parameters/BatchId"
    post:
      tags: [Narration batches]
      summary: Reissue an aggregate quote for an unstarted batch
      operationId: requoteNarrationBatch
      responses:
        "201":
          description: New 15-minute quote.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingQuote" }
        "409": { $ref: "#/components/responses/Conflict" }
  /narration-batches/{batchId}/start:
    parameters:
      - $ref: "#/components/parameters/BatchId"
      - $ref: "#/components/parameters/IdempotencyKey"
    post:
      tags: [Narration batches]
      summary: Atomically reserve tokens and queue all language jobs
      operationId: startNarrationBatch
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [quoteId]
              properties: { quoteId: { type: string, format: uuid } }
      responses:
        "202":
          description: Entire batch accepted and queued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NarrationBatch" }
        "402": { description: Insufficient token balance; no jobs were created. }
        "409": { $ref: "#/components/responses/Conflict" }
  /webhook-endpoints:
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      operationId: listWebhookEndpoints
      responses: { "200": { description: Active and disabled endpoints. } }
    post:
      tags: [Webhooks]
      summary: Register a public HTTPS webhook endpoint
      operationId: createWebhookEndpoint
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateWebhookEndpoint" }
      responses:
        "201":
          description: Endpoint plus the signing secret, returned only once.
        "422": { $ref: "#/components/responses/Unprocessable" }
  /webhook-endpoints/{endpointId}:
    parameters:
      - $ref: "#/components/parameters/EndpointId"
    patch:
      tags: [Webhooks]
      summary: Rename, change, enable, or disable an endpoint
      operationId: updateWebhookEndpoint
      responses: { "200": { description: Updated endpoint. } }
    delete:
      tags: [Webhooks]
      summary: Soft-delete an endpoint
      operationId: deleteWebhookEndpoint
      responses: { "204": { description: Endpoint deleted. } }
  /webhook-endpoints/{endpointId}/rotate-secret:
    parameters: [{ $ref: "#/components/parameters/EndpointId" }]
    post:
      tags: [Webhooks]
      summary: Rotate and reveal a new signing secret
      operationId: rotateWebhookSecret
      responses: { "200": { description: Endpoint plus its new one-time secret. } }
  /webhook-endpoints/{endpointId}/test:
    parameters: [{ $ref: "#/components/parameters/EndpointId" }]
    post:
      tags: [Webhooks]
      summary: Queue a signed test delivery
      operationId: testWebhookEndpoint
      responses: { "202": { description: Test event queued. } }
  /webhook-endpoints/{endpointId}/events:
    parameters: [{ $ref: "#/components/parameters/EndpointId" }]
    get:
      tags: [Webhooks]
      summary: List delivery history
      operationId: listWebhookEvents
      responses: { "200": { description: Newest delivery events first. } }
  /webhook-events/{eventId}/replay:
    parameters:
      - name: eventId
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Webhooks]
      summary: Replay an event with the same event ID
      operationId: replayWebhookEvent
      responses: { "202": { description: Event replay queued. } }
  /billing/wallet:
    get:
      tags: [Billing]
      summary: Retrieve the token wallet
      operationId: getTokenWallet
      responses:
        "200":
          description: Available, reserved, purchased, and spent token totals.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenWallet"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /billing/quotes:
    post:
      tags: [Billing]
      summary: Calculate an action cost
      description: Quotes expire after 15 minutes. Creating a quote does not reserve or spend tokens.
      operationId: createBillingQuote
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateBillingQuote"
      responses:
        "201":
          description: Server-verified token estimate and balance sufficiency.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BillingQuote"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /billing/transactions:
    get:
      tags: [Billing]
      summary: List immutable token ledger entries
      operationId: listTokenTransactions
      responses:
        "200":
          description: Newest ledger entries first.
        "401":
          $ref: "#/components/responses/Unauthorized"
  /billing/packages:
    get:
      tags: [Billing]
      summary: List token packages
      operationId: listTokenPackages
      security: []
      parameters:
        - name: currency
          in: query
          schema:
            type: string
            enum: [USD, NGN]
      responses:
        "200":
          description: Active USD and NGN packages. Cash amounts use the currency's minor unit.
  /billing/checkout:
    post:
      tags: [Billing]
      summary: Initialize token checkout with Paystack or Flutterwave
      operationId: createTokenCheckout
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [packageId, email]
              properties:
                packageId: { type: string }
                email: { type: string, format: email }
                provider:
                  type: string
                  enum: [paystack, flutterwave]
                  default: paystack
      responses:
        "201":
          description: Provider authorization URL and payment reference.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          description: Payments are not configured.
  /voices:
    get:
      tags: [Voices]
      summary: List available narration voices
      operationId: listVoices
      responses:
        "200":
          description: Sanitized voices from the connected ElevenLabs workspace.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Voice"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /voices/{voiceId}/preview:
    parameters:
      - name: voiceId
        in: path
        required: true
        schema:
          type: string
    get:
      tags: [Voices]
      summary: Stream an authenticated voice preview
      operationId: getVoicePreview
      responses:
        "200":
          description: Provider voice preview.
          content:
            audio/mpeg:
              schema:
                type: string
                format: binary
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /projects:
    get:
      tags: [Projects]
      summary: List projects
      operationId: listProjects
      responses:
        "200":
          description: Projects visible to the authenticated workspace.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Project"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Projects]
      summary: Create a project
      operationId: createProject
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateProject"
      responses:
        "201":
          description: Project created.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Project"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /projects/{projectId}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      summary: Retrieve a project
      operationId: getProject
      responses:
        "200":
          description: Project details.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Project"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /projects/{projectId}/assets:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Assets]
      summary: List source assets
      operationId: listAssets
      responses:
        "200":
          description: Source asset metadata for the project.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Asset"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Assets]
      summary: Reserve a source upload
      description: Creates an upload reservation for a source file up to 2,000,000,000 bytes. Files above 100,000,000 bytes use resumable multipart upload.
      operationId: createAssetUpload
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateAsset"
      responses:
        "201":
          description: Upload reserved.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssetUpload"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /projects/{projectId}/assets/imports:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Assets]
      summary: Import audio or video from a public HTTPS URL
      description: |
        Probes and asynchronously copies remote media into private R2 storage. The source must
        return Content-Length or Content-Range. Redirect destinations are revalidated and private,
        loopback, link-local, credential-bearing, and non-HTTPS URLs are rejected. API-key clients
        must provide Idempotency-Key.
      operationId: createRemoteAssetImport
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateRemoteAssetImport"
      responses:
        "202":
          description: Remote media import accepted.
          headers:
            Location:
              description: Relative URL used to poll the import.
              schema: { type: string }
            Retry-After:
              description: Suggested polling interval in seconds.
              schema: { type: integer }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RemoteAssetImport"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "503":
          $ref: "#/components/responses/Unavailable"
  /projects/{projectId}/assets/imports/{importId}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - name: importId
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Assets]
      summary: Retrieve remote media import status
      operationId: getRemoteAssetImport
      responses:
        "200":
          description: Current import and asset state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RemoteAssetImport"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /projects/{projectId}/assets/{assetId}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/AssetId"
    get:
      tags: [Assets]
      summary: Retrieve source asset metadata
      operationId: getAsset
      responses:
        "200":
          description: Source asset metadata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Asset"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /projects/{projectId}/assets/{assetId}/content:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/AssetId"
    put:
      tags: [Assets]
      summary: Upload source asset content
      operationId: uploadAssetContent
      parameters:
        - name: Content-Length
          in: header
          required: true
          schema:
            type: integer
            minimum: 1
            maximum: 100000000
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        "200":
          description: Upload completed and verified.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Asset"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "410":
          $ref: "#/components/responses/Gone"
        "503":
          $ref: "#/components/responses/Unavailable"
    get:
      tags: [Assets]
      summary: Download source asset content
      operationId: downloadAssetContent
      responses:
        "200":
          description: Private source object stream.
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          $ref: "#/components/responses/Unavailable"
  /projects/{projectId}/assets/{assetId}/complete:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/AssetId"
    post:
      tags: [Assets]
      summary: Verify and complete a direct R2 upload
      operationId: completeAssetUpload
      responses:
        "200":
          description: The private R2 object was verified and the asset is ready.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Asset"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
  /projects/{projectId}/assets/{assetId}/multipart:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/AssetId"
    get:
      tags: [Assets]
      summary: Retrieve resumable upload progress
      operationId: getMultipartUpload
      responses:
        "200":
          description: Uploaded parts that can be skipped when resuming.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MultipartUploadStatus"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Assets]
      summary: Abort a resumable upload
      operationId: abortMultipartUpload
      responses:
        "204":
          description: Upload aborted.
  /projects/{projectId}/assets/{assetId}/multipart/parts/{partNumber}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/AssetId"
      - name: partNumber
        in: path
        required: true
        schema:
          type: integer
          minimum: 1
          maximum: 10000
    put:
      tags: [Assets]
      summary: Upload one resumable part
      operationId: uploadMultipartPart
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        "200":
          description: Persisted part number, ETag, and byte size.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MultipartUploadedPart"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "410":
          $ref: "#/components/responses/Gone"
  /projects/{projectId}/assets/{assetId}/multipart/complete:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/AssetId"
    post:
      tags: [Assets]
      summary: Complete a resumable upload
      operationId: completeMultipartUpload
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [parts]
              properties:
                parts:
                  type: array
                  minItems: 1
                  maxItems: 10000
                  items:
                    type: object
                    required: [partNumber, etag]
                    properties:
                      partNumber: { type: integer, minimum: 1 }
                      etag: { type: string }
      responses:
        "200":
          description: The complete object was verified and marked uploaded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Asset"
        "409":
          $ref: "#/components/responses/Conflict"
  /projects/{projectId}/transcription-jobs:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Transcriptions]
      summary: Start an asynchronous transcription
      description: |
        Transcribes uploaded or URL-imported audio up to 2 GB / 8 hours. Video sources are
        converted to bounded M4A audio windows and currently support up to 100 MB / 10 minutes;
        durationSeconds is required for video.
      operationId: createTranscriptionJob
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateTranscriptionJob"
      responses:
        "202":
          description: Transcription queued.
          headers:
            Location:
              description: Relative URL of the new job.
              schema:
                type: string
            Retry-After:
              description: Suggested polling interval in seconds.
              schema:
                type: integer
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TranscriptionJob"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "503":
          $ref: "#/components/responses/Unavailable"
  /projects/{projectId}/transcription-jobs/{jobId}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/JobId"
    get:
      tags: [Transcriptions]
      summary: Retrieve transcription job status
      operationId: getTranscriptionJob
      responses:
        "200":
          description: Current transcription job state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TranscriptionJob"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /projects/{projectId}/transcripts:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Transcriptions]
      summary: Import a pasted or uploaded transcript
      operationId: importTranscript
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ImportTranscript"
      responses:
        "201":
          description: Persisted imported transcript version.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TranscriptVersion"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/Unprocessable"
  /projects/{projectId}/transcripts/latest:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Transcriptions]
      summary: Retrieve the latest transcript version
      operationId: getLatestTranscript
      responses:
        "200":
          description: Latest persisted transcript.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TranscriptVersion"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /projects/{projectId}/transcripts/{transcriptId}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/TranscriptId"
    get:
      tags: [Transcriptions]
      summary: Retrieve a transcript version
      operationId: getTranscript
      responses:
        "200":
          description: Persisted transcript version and ordered segments.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TranscriptVersion"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /projects/{projectId}/render-jobs:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Renders]
      summary: Start a production dubbing workflow
      operationId: createRenderJob
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 200
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateRenderJob"
      responses:
        "202":
          description: Render workflow started.
          headers:
            Location:
              description: Relative URL of the render job.
              schema:
                type: string
            Retry-After:
              description: Suggested polling interval in seconds.
              schema:
                type: integer
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RenderJob"
        "200":
          description: Existing idempotent render job.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RenderJob"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "503":
          $ref: "#/components/responses/Unavailable"
  /projects/{projectId}/render-jobs/{jobId}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/JobId"
    get:
      tags: [Renders]
      summary: Retrieve render status
      operationId: getRenderJob
      responses:
        "200":
          description: Current render state and language tracks.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RenderJob"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /projects/{projectId}/narration-jobs:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Voices]
      summary: List transcript narration jobs
      operationId: listNarrationJobs
      responses:
        "200":
          description: Narrations for the project, newest first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/NarrationJob"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Voices]
      summary: Generate transcript speech with a selected voice
      operationId: createNarrationJob
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 200
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateNarrationJob"
      responses:
        "202":
          description: Durable queue-backed narration started.
          headers:
            Location:
              description: Relative URL of the narration job.
              schema:
                type: string
            Retry-After:
              description: Suggested polling interval in seconds.
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NarrationJob"
        "200":
          description: Existing idempotent narration job.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NarrationJob"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/Unprocessable"
  /projects/{projectId}/narration-jobs/{jobId}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/JobId"
    get:
      tags: [Voices]
      summary: Retrieve narration status
      operationId: getNarrationJob
      responses:
        "200":
          description: Current narration state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NarrationJob"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /projects/{projectId}/narration-jobs/batch:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Voices]
      summary: Start multiple selected-voice narrations
      operationId: createNarrationBatch
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 160
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateNarrationBatch"
      responses:
        "202":
          description: Per-language narration creation results.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NarrationBatchResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /projects/{projectId}/narration-jobs/{jobId}/content:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/JobId"
      - name: download
        in: query
        schema:
          type: integer
          enum: [1]
      - name: Range
        in: header
        schema:
          type: string
          example: bytes=0-1048575
    get:
      tags: [Voices]
      summary: Stream or download narration MP3
      operationId: getNarrationContent
      responses:
        "200":
          description: Complete private MP3 stream.
          content:
            audio/mpeg:
              schema:
                type: string
                format: binary
        "206":
          description: Partial MP3 stream.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /projects/{projectId}/dub-tracks:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Renders]
      summary: List project dub tracks
      operationId: listDubTracks
      responses:
        "200":
          description: All persisted language outputs for the project.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/DubTrack"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /projects/{projectId}/dub-tracks/{trackId}/content:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - name: trackId
        in: path
        required: true
        schema:
          type: string
      - name: format
        in: query
        schema:
          type: string
          enum: [mp3, wav, flac]
      - name: download
        in: query
        schema:
          type: integer
          enum: [1]
      - name: Range
        in: header
        schema:
          type: string
          example: bytes=0-1048575
    get:
      tags: [Renders]
      summary: Stream or download a dub track
      operationId: getDubTrackContent
      responses:
        "200":
          description: Complete private audio stream.
          content:
            audio/wav:
              schema:
                type: string
                format: binary
            audio/mpeg:
              schema:
                type: string
                format: binary
            audio/flac:
              schema:
                type: string
                format: binary
        "206":
          description: Requested byte range.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
  /api-keys:
    get:
      tags: [API keys]
      summary: List API keys
      operationId: listApiKeys
      security:
        - sessionCookie: []
      responses:
        "200":
          description: API key metadata. Secret tokens are never included.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ApiKey"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [API keys]
      summary: Create an API key
      operationId: createApiKey
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateApiKey"
      responses:
        "201":
          description: API key created. The token is returned only in this response.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ApiKey"
                  - type: object
                    required: [token]
                    properties:
                      token:
                        type: string
                        pattern: ^vca_live_
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api-keys/{apiKeyId}:
    parameters:
      - name: apiKeyId
        in: path
        required: true
        schema:
          type: string
    delete:
      tags: [API keys]
      summary: Revoke an API key
      operationId: revokeApiKey
      security:
        - sessionCookie: []
      responses:
        "204":
          description: API key revoked.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Voicera API key
    sessionCookie:
      type: apiKey
      in: cookie
      name: session
  parameters:
    ProjectId:
      name: projectId
      in: path
      required: true
      schema:
        type: string
    AssetId:
      name: assetId
      in: path
      required: true
      schema:
        type: string
    JobId:
      name: jobId
      in: path
      required: true
      schema:
        type: string
    TranscriptId:
      name: transcriptId
      in: path
      required: true
      schema:
        type: string
    BatchId:
      name: batchId
      in: path
      required: true
      schema: { type: string, format: uuid }
    EndpointId:
      name: endpointId
      in: path
      required: true
      schema: { type: string, format: uuid }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, minLength: 1, maxLength: 160 }
  headers:
    RequestId:
      description: Identifier used to correlate a request with server logs.
      schema:
        type: string
  responses:
    BadRequest:
      description: Invalid request.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: Missing, invalid, expired, revoked, or insufficiently scoped credentials.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: Resource not found in the authenticated workspace.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Conflict:
      description: The resource is not in a state that permits this operation.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Gone:
      description: The upload reservation has expired.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unavailable:
      description: Object storage is temporarily unavailable or not configured.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    PayloadTooLarge:
      description: The source is too large for this processing workflow.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unprocessable:
      description: The source type is not supported by this processing workflow.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
  schemas:
    ProjectStatus:
      type: string
      enum: [draft, processing, ready, failed]
    Project:
      type: object
      required: [id, workspaceId, title, sourceLanguage, status, createdAt, updatedAt]
      properties:
        id:
          type: string
        workspaceId:
          type: string
        title:
          type: string
        sourceLanguage:
          type: string
          pattern: "^[a-z]{2,3}(?:-[A-Z]{2})?$"
        status:
          $ref: "#/components/schemas/ProjectStatus"
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    CreateProject:
      type: object
      additionalProperties: false
      required: [title, sourceLanguage]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 200
        sourceLanguage:
          type: string
          pattern: "^[a-z]{2,3}(?:-[A-Z]{2})?$"
    Asset:
      type: object
      required:
        [
          id,
          projectId,
          kind,
          fileName,
          contentType,
          sizeBytes,
          status,
          durationSeconds,
          uploadedAt,
          createdAt,
        ]
      properties:
        id:
          type: string
        projectId:
          type: string
        kind:
          type: string
          enum: [audio, video, transcript]
        fileName:
          type: string
        contentType:
          type: string
        sizeBytes:
          type: integer
          minimum: 0
        status:
          type: string
          enum: [pending, uploaded, failed]
        durationSeconds:
          type: [integer, "null"]
          minimum: 0
        uploadedAt:
          type: [string, "null"]
          format: date-time
        createdAt:
          type: string
          format: date-time
    CreateAsset:
      type: object
      additionalProperties: false
      required: [kind, fileName, contentType, sizeBytes]
      properties:
        kind:
          type: string
          enum: [audio, video, transcript]
        fileName:
          type: string
          minLength: 1
          maxLength: 255
        contentType:
          type: string
        sizeBytes:
          type: integer
          minimum: 1
          maximum: 2000000000
        durationSeconds:
          type: integer
          minimum: 1
          maximum: 86400
          description: Required for audio/video cost quotes; obtain from media metadata and round up.
    CreateRemoteAssetImport:
      type: object
      additionalProperties: false
      required: [url, durationSeconds]
      properties:
        url:
          type: string
          format: uri
          maxLength: 4096
          description: Public HTTPS URL for an audio or video file.
        kind:
          type: string
          enum: [audio, video]
          description: Optional when the response has an accurate audio/* or video/* Content-Type.
        fileName:
          type: string
          minLength: 1
          maxLength: 255
        durationSeconds:
          type: integer
          minimum: 1
          maximum: 86400
          description: Required so transcription can be quoted before the job starts.
    RemoteAssetImport:
      type: object
      required:
        [
          id,
          projectId,
          asset,
          status,
          sourceHost,
          errorCode,
          errorMessage,
          createdAt,
          updatedAt,
          completedAt,
        ]
      properties:
        id: { type: string, format: uuid }
        projectId: { type: string }
        asset: { $ref: "#/components/schemas/Asset" }
        status:
          type: string
          enum: [queued, running, succeeded, failed]
        sourceHost:
          type: string
          description: Source hostname only; signed query values are never returned.
        errorCode: { type: [string, "null"] }
        errorMessage: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        completedAt: { type: [string, "null"], format: date-time }
    AssetUpload:
      type: object
      required: [asset, upload]
      properties:
        asset:
          $ref: "#/components/schemas/Asset"
        upload:
          oneOf:
            - type: object
              required: [method, url, expiresAt, headers, transport, completionUrl]
              properties:
                method: { type: string, const: PUT }
                url: { type: string, format: uri }
                expiresAt: { type: string, format: date-time }
                headers:
                  type: object
                  additionalProperties: { type: string }
                transport: { type: string, enum: [direct-r2, worker-proxy] }
                completionUrl: { type: string, format: uri }
            - type: object
              required:
                [
                  method,
                  uploadId,
                  partSizeBytes,
                  partUrlTemplate,
                  statusUrl,
                  completionUrl,
                  expiresAt,
                  headers,
                  transport,
                ]
              properties:
                method: { type: string, const: MULTIPART }
                uploadId: { type: string }
                partSizeBytes: { type: integer, minimum: 5242880 }
                partUrlTemplate: { type: string }
                statusUrl: { type: string, format: uri }
                completionUrl: { type: string, format: uri }
                expiresAt: { type: string, format: date-time }
                headers:
                  type: object
                  additionalProperties: { type: string }
                transport: { type: string, const: multipart-r2 }
    MultipartUploadedPart:
      type: object
      required: [partNumber, etag, sizeBytes]
      properties:
        partNumber: { type: integer, minimum: 1, maximum: 10000 }
        etag: { type: string }
        sizeBytes: { type: integer, minimum: 1 }
    MultipartUploadStatus:
      type: object
      required: [uploadId, partSizeBytes, parts]
      properties:
        uploadId: { type: string }
        partSizeBytes: { type: integer, minimum: 5242880 }
        parts:
          type: array
          items:
            $ref: "#/components/schemas/MultipartUploadedPart"
    CreateTranscriptionJob:
      type: object
      additionalProperties: false
      required: [assetId, quoteId]
      properties:
        assetId:
          type: string
        language:
          type: string
          pattern: "^[a-z]{2,3}(?:-[A-Z]{2})?$"
        quoteId:
          type: string
          format: uuid
    ImportedTranscriptSegment:
      type: object
      additionalProperties: false
      required: [startMs, endMs, speaker, text]
      properties:
        startMs:
          type: integer
          minimum: 0
        endMs:
          type: integer
          minimum: 1
        speaker:
          type: [string, "null"]
          maxLength: 100
        text:
          type: string
          minLength: 1
          maxLength: 10000
    ImportTranscript:
      type: object
      additionalProperties: false
      required: [sourceAssetId, language, segments]
      properties:
        sourceAssetId:
          type: string
        language:
          type: string
          pattern: "^[a-z]{2,3}(?:-[A-Z]{2})?$"
        segments:
          type: array
          minItems: 1
          maxItems: 5000
          items:
            $ref: "#/components/schemas/ImportedTranscriptSegment"
    JobStatus:
      type: string
      enum: [queued, running, succeeded, failed, cancelled]
    TranscriptionJob:
      type: object
      required:
        [
          id,
          projectId,
          assetId,
          status,
          progress,
          language,
          model,
          attemptCount,
          transcriptVersionId,
          errorCode,
          errorMessage,
          startedAt,
          completedAt,
          createdAt,
          updatedAt,
        ]
      properties:
        id:
          type: string
        projectId:
          type: string
        assetId:
          type: string
        status:
          $ref: "#/components/schemas/JobStatus"
        progress:
          type: integer
          minimum: 0
          maximum: 100
        language:
          type: [string, "null"]
          pattern: "^[a-z]{2,3}(?:-[A-Z]{2})?$"
        model:
          type: string
        attemptCount:
          type: integer
          minimum: 0
        transcriptVersionId:
          type: [string, "null"]
        errorCode:
          type: [string, "null"]
        errorMessage:
          type: [string, "null"]
        startedAt:
          type: [string, "null"]
          format: date-time
        completedAt:
          type: [string, "null"]
          format: date-time
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    TranscriptSegment:
      type: object
      required: [id, startMs, endMs, speaker, text]
      properties:
        id:
          type: string
        startMs:
          type: integer
          minimum: 0
        endMs:
          type: integer
          minimum: 1
        speaker:
          type: [string, "null"]
        text:
          type: string
    TranscriptVersion:
      type: object
      required: [id, projectId, version, language, segments, createdAt]
      properties:
        id:
          type: string
        projectId:
          type: string
        version:
          type: integer
          minimum: 1
        language:
          type: string
          pattern: "^[a-z]{2,3}(?:-[A-Z]{2})?$"
        segments:
          type: array
          items:
            $ref: "#/components/schemas/TranscriptSegment"
        createdAt:
          type: string
          format: date-time
    CreateRenderJob:
      type: object
      additionalProperties: false
      required: [sourceAssetId, targetLanguages, quoteId]
      properties:
        sourceAssetId:
          type: string
        quoteId:
          type: string
          format: uuid
        targetLanguages:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            type: string
            enum:
              [
                af,
                ak,
                sq,
                am,
                ar,
                ar-EG,
                hy,
                as,
                az,
                eu,
                be,
                bs,
                bg,
                my,
                yue,
                ca,
                ceb,
                zh,
                zh-TW,
                hr,
                cs,
                da,
                dgo,
                nl,
                en,
                en-AU,
                en-CA,
                en-GB,
                en-US,
                et,
                fil,
                fi,
                fr,
                fr-CA,
                fr-FR,
                gl,
                ka,
                de,
                el,
                gu,
                ha,
                he,
                hi,
                hu,
                is,
                id,
                it,
                ja,
                jv,
                kn,
                kk,
                ki,
                rw,
                rn,
                ko,
                ky,
                lv,
                lt,
                lg,
                mk,
                ms,
                ml,
                cmn,
                mr,
                mn,
                ne,
                no,
                fa,
                pl,
                pt,
                pt-BR,
                pt-PT,
                pa,
                ro,
                ru,
                nso,
                st,
                sd,
                sk,
                sl,
                es,
                es-AR,
                es-CL,
                es-MX,
                es-ES,
                su,
                sw,
                ss,
                sv,
                tg,
                ta,
                te,
                th,
                bo,
                ts,
                tn,
                tr,
                uk,
                ur,
                ug,
                uz,
                ve,
                vi,
                war,
                cy,
                wo,
                yo,
                zu,
              ]
    DubTrack:
      type: object
      required:
        [
          id,
          renderJobId,
          projectId,
          language,
          status,
          progress,
          durationSeconds,
          availableFormats,
          errorCode,
          errorMessage,
          createdAt,
          updatedAt,
        ]
      properties:
        id:
          type: string
        renderJobId:
          type: string
        projectId:
          type: string
        language:
          type: string
        status:
          $ref: "#/components/schemas/JobStatus"
        progress:
          type: integer
          minimum: 0
          maximum: 100
        durationSeconds:
          type: [integer, "null"]
        availableFormats:
          type: array
          items:
            type: string
            enum: [mp3, wav, flac]
        errorCode:
          type: [string, "null"]
        errorMessage:
          type: [string, "null"]
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    RenderJob:
      type: object
      required:
        [
          id,
          projectId,
          sourceAssetId,
          provider,
          status,
          progress,
          tracks,
          errorCode,
          errorMessage,
          createdAt,
          updatedAt,
        ]
      properties:
        id:
          type: string
        projectId:
          type: string
        sourceAssetId:
          type: string
        provider:
          type: string
          const: elevenlabs
        status:
          $ref: "#/components/schemas/JobStatus"
        progress:
          type: integer
          minimum: 0
          maximum: 100
        tracks:
          type: array
          items:
            $ref: "#/components/schemas/DubTrack"
        errorCode:
          type: [string, "null"]
        errorMessage:
          type: [string, "null"]
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    Voice:
      type: object
      required: [id, name, category, description, labels, hasPreview]
      properties:
        id:
          type: string
        name:
          type: string
        category:
          type: [string, "null"]
        description:
          type: [string, "null"]
        labels:
          type: object
          additionalProperties:
            type: string
        hasPreview:
          type: boolean
    CreateNarrationJob:
      type: object
      additionalProperties: false
      required: [transcriptId, voiceId, targetLanguage, quoteId]
      properties:
        transcriptId:
          type: string
        voiceId:
          type: string
          minLength: 1
          maxLength: 200
        targetLanguage:
          type: string
          enum:
            [
              en,
              ja,
              zh,
              de,
              hi,
              fr,
              ko,
              pt,
              it,
              es,
              id,
              nl,
              tr,
              fil,
              pl,
              sv,
              bg,
              ro,
              ar,
              cs,
              el,
              fi,
              hr,
              ms,
              sk,
              da,
              ta,
              uk,
              ru,
            ]
        quoteId:
          type: string
          format: uuid
    CreateNarrationBatch:
      type: object
      additionalProperties: false
      required: [transcriptId, voiceId, targets]
      properties:
        transcriptId:
          type: string
        voiceId:
          type: string
          minLength: 1
          maxLength: 200
        targets:
          type: array
          minItems: 1
          maxItems: 20
          items:
            type: object
            additionalProperties: false
            required: [language, quoteId]
            properties:
              language:
                type: string
              quoteId:
                type: string
                format: uuid
    NarrationBatchResult:
      type: object
      required: [jobs, failures]
      properties:
        jobs:
          type: array
          items:
            $ref: "#/components/schemas/NarrationJob"
        failures:
          type: array
          items:
            type: object
            required: [language, code, message]
            properties:
              language:
                type: string
              code:
                type: string
              message:
                type: string
    NarrationJob:
      type: object
      required:
        [
          id,
          projectId,
          transcriptId,
          voiceId,
          voiceName,
          targetLanguage,
          provider,
          status,
          progress,
          audioAvailable,
          errorCode,
          errorMessage,
          createdAt,
          updatedAt,
        ]
      properties:
        id:
          type: string
        projectId:
          type: string
        transcriptId:
          type: string
        voiceId:
          type: string
        voiceName:
          type: string
        targetLanguage:
          type: string
        provider:
          type: string
          const: elevenlabs
        status:
          $ref: "#/components/schemas/JobStatus"
        progress:
          type: integer
          minimum: 0
          maximum: 100
        stage:
          type: string
          enum:
            [
              queued,
              planning,
              translating,
              planning_speech,
              generating_speech,
              finalizing,
              complete,
              failed,
            ]
          description: Durable processing stage. Long narrations are translated and generated as independently retryable chunks.
        audioAvailable:
          type: boolean
        contentUrl:
          type: [string, "null"]
          format: uri-reference
          description: Permanent public R2 URL for new outputs; older private outputs use the authenticated API content route.
        r2Url:
          type: [string, "null"]
          format: uri
          description: Permanent public R2 URL, present after successful generation into public output storage.
        errorCode:
          type: [string, "null"]
        errorMessage:
          type: [string, "null"]
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    ApiKey:
      type: object
      required: [id, workspaceId, name, prefix, scopes, lastUsedAt, expiresAt, revokedAt, createdAt]
      properties:
        id:
          type: string
        workspaceId:
          type: string
        name:
          type: string
        prefix:
          type: string
        scopes:
          type: array
          items:
            type: string
            enum:
              - "projects:read"
              - "projects:write"
              - "assets:read"
              - "assets:write"
              - "transcriptions:read"
              - "transcriptions:write"
              - "renders:read"
              - "renders:write"
              - "billing:read"
              - "billing:write"
              - "webhooks:read"
              - "webhooks:write"
        lastUsedAt:
          type: [string, "null"]
          format: date-time
        expiresAt:
          type: [string, "null"]
          format: date-time
        revokedAt:
          type: [string, "null"]
          format: date-time
        createdAt:
          type: string
          format: date-time
    CreateApiKey:
      type: object
      additionalProperties: false
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
        scopes:
          type: array
          minItems: 1
          items:
            type: string
            enum:
              - "projects:read"
              - "projects:write"
              - "assets:read"
              - "assets:write"
              - "transcriptions:read"
              - "transcriptions:write"
              - "renders:read"
              - "renders:write"
              - "billing:read"
              - "billing:write"
              - "webhooks:read"
              - "webhooks:write"
          default:
            - "projects:read"
            - "projects:write"
            - "assets:read"
            - "assets:write"
            - "transcriptions:read"
            - "transcriptions:write"
            - "renders:read"
            - "renders:write"
            - "billing:read"
            - "billing:write"
            - "webhooks:read"
            - "webhooks:write"
        expiresAt:
          type: [string, "null"]
          format: date-time
    TokenWallet:
      type: object
      required:
        [
          workspaceId,
          availableTokens,
          reservedTokens,
          lifetimePurchasedTokens,
          lifetimeSpentTokens,
          updatedAt,
        ]
      properties:
        workspaceId: { type: string }
        availableTokens: { type: integer, minimum: 0 }
        reservedTokens: { type: integer, minimum: 0 }
        lifetimePurchasedTokens: { type: integer, minimum: 0 }
        lifetimeSpentTokens: { type: integer, minimum: 0 }
        updatedAt: { type: string, format: date-time }
    CreateBillingQuote:
      oneOf:
        - type: object
          required: [action, projectId, assetId]
          properties:
            action: { const: transcription }
            projectId: { type: string }
            assetId: { type: string }
            language: { type: string }
        - type: object
          required: [action, projectId, sourceAssetId, targetLanguages]
          properties:
            action: { const: dubbing }
            projectId: { type: string }
            sourceAssetId: { type: string }
            targetLanguages:
              type: array
              minItems: 1
              uniqueItems: true
              items: { type: string }
        - type: object
          required: [action, projectId, transcriptId, voiceId, targetLanguage]
          properties:
            action: { const: narration }
            projectId: { type: string }
            transcriptId: { type: string }
            voiceId: { type: string }
            targetLanguage: { type: string }
      discriminator:
        propertyName: action
    BillingQuote:
      type: object
      required:
        [
          id,
          workspaceId,
          projectId,
          action,
          pricingVersion,
          quantity,
          estimatedTokens,
          breakdown,
          status,
          sufficientBalance,
          availableTokens,
          expiresAt,
          createdAt,
        ]
      properties:
        id: { type: string, format: uuid }
        workspaceId: { type: string }
        projectId: { type: string }
        action: { type: string, enum: [transcription, dubbing, narration] }
        pricingVersion: { type: string }
        quantity: { type: integer, minimum: 1 }
        estimatedTokens: { type: integer, minimum: 1 }
        breakdown: { type: object, additionalProperties: true }
        status: { type: string, enum: [open, reserved, expired] }
        sufficientBalance: { type: boolean }
        availableTokens: { type: integer, minimum: 0 }
        expiresAt: { type: string, format: date-time }
        createdAt: { type: string, format: date-time }
    CreatePublicNarrationBatch:
      type: object
      additionalProperties: false
      required: [title, srt, voiceId, targetLanguages, webhookEndpointId]
      properties:
        title: { type: string, minLength: 1, maxLength: 200 }
        srt: { type: string, minLength: 1, maxLength: 1000000 }
        voiceId: { type: string, minLength: 1, maxLength: 200 }
        targetLanguages:
          type: array
          minItems: 1
          uniqueItems: true
          items: { type: string }
        webhookEndpointId: { type: string, format: uuid }
        externalId: { type: string, minLength: 1, maxLength: 200 }
    NarrationBatch:
      type: object
      required:
        [
          id,
          projectId,
          transcriptId,
          voiceId,
          targetLanguages,
          webhookEndpointId,
          status,
          jobs,
          expiresAt,
        ]
      properties:
        id: { type: string, format: uuid }
        projectId: { type: string }
        transcriptId: { type: string }
        externalId: { type: [string, "null"] }
        voiceId: { type: string }
        voiceName: { type: string }
        targetLanguages: { type: array, items: { type: string } }
        webhookEndpointId: { type: string, format: uuid }
        status:
          { type: string, enum: [draft, queued, running, partially_succeeded, succeeded, failed] }
        quote:
          oneOf:
            - $ref: "#/components/schemas/BillingQuote"
            - type: "null"
        jobs: { type: array, items: { $ref: "#/components/schemas/NarrationJob" } }
        expiresAt: { type: string, format: date-time }
        startedAt: { type: [string, "null"], format: date-time }
        completedAt: { type: [string, "null"], format: date-time }
    CreateWebhookEndpoint:
      type: object
      additionalProperties: false
      required: [name, url]
      properties:
        name: { type: string, minLength: 1, maxLength: 100 }
        url: { type: string, format: uri, pattern: "^https://" }
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, requestId]
          properties:
            code:
              type: string
            message:
              type: string
            requestId:
              type: string
            details:
              type: object
              additionalProperties: true
