openapi: 3.1.0
info:
  title: TranscriptDock API
  version: 1.0.0
  description: |
    Single API for transcripts from YouTube, TikTok, Instagram, and your own media.
    This document is the single source of truth. JSON Schemas under specs/schemas/
    and TypeScript types in apps/web/src/lib/api/types.ts are generated from it by
    `pnpm contract:build`. Runtime request validation uses those generated schemas,
    so prose, examples, and enforcement always agree.
servers:
  - url: https://api.transcriptdock.com
    description: Production
  - url: http://localhost:3000
    description: Local development
tags:
  - name: jobs
    description: Submit and track transcription work
  - name: batches
    description: Multi URL submissions with aggregate status
  - name: transcripts
    description: Read, export, and delete stored results
  - name: uploads
    description: Signed direct to Storage media intake
  - name: account
    description: Usage, keys, billing, profile (session or scoped key)
  - name: webhooks
    description: Customer event endpoint registration
  - name: callbacks
    description: Provider and billing ingress (secret verified, never public)
security:
  - bearer: []
  - cookie: []
paths:
  /v1/capabilities:
    get:
      tags: [jobs]
      summary: Supported platforms, modes, and limits
      description: |
        Without a credential this returns the public matrix, cached at the edge.
        With a key, a Supabase access token or a session cookie it returns the
        matrix for that workspace's plan with Cache-Control private, no-store.
        A credential that is sent and rejected gets 401, never the public matrix.
      security:
        - {}
        - bearer: []
        - cookie: []
      operationId: getCapabilities
      responses:
        '200':
          description: Machine readable capability matrix
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Capabilities'
              example:
                youtube: { enabled: true, captions_only: true, auto: false, transcribe: false, social_acquisition_fee: true }
                instagram: { enabled: false, captions_only: false, auto: false, transcribe: false, social_acquisition_fee: true, native_captions: unverified_disabled }
                upload: { enabled: false, captions_only: false, auto: false, transcribe: false, social_acquisition_fee: false }
                direct: { enabled: false, captions_only: false, auto: false, transcribe: false, social_acquisition_fee: false }
                max_duration_ms: 7200000
                max_media_bytes: 262144000
                export_formats: [txt, json, srt, vtt]
                recognition_profiles: [standard]
                transcribe_credits_per_minute: 2
                minimum_billable_seconds: 60
                price_version: '2026-09-14'
                asr_enabled: false
                instagram_enabled: false
                free_caption_credits: 50
                free_caption_credits_period: month
        '401':
          description: A credential was sent and rejected (unknown key, expired token)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          description: Anonymous per IP budget or rejected credential budget exhausted; obey Retry-After
          headers:
            Retry-After: { schema: { type: integer }, description: Seconds until the window resets. }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Session or credential lookup failed; retryable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
  /v1/jobs:
    post:
      tags: [jobs]
      summary: Submit one source
      operationId: createJob
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/JobRequest'
            example:
              source: { url: https://www.youtube.com/watch?v=dQw4w9WgXcQ }
              mode: captions_only
      responses:
        '202':
          description: Accepted; work queued. Location points at GET /v1/jobs/{id}; Retry-After suggests the first poll delay.
          headers:
            Location: { schema: { type: string }, description: Path of the job to poll. }
            Retry-After: { schema: { type: integer }, description: Seconds before the first poll. }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Job'
        '200':
          description: Reused a retained same workspace result for free
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Job'
        '402':
          $ref: '#/components/responses/BillableError'
        '409':
          description: Idempotency key already used with a different body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '422':
          $ref: '#/components/responses/ValidationError'
    get:
      tags: [jobs]
      summary: Workspace job history (cursor pagination)
      operationId: listJobs
      parameters:
        - name: cursor
          in: query
          schema: { type: string }
          description: Opaque cursor from a previous page
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: platform
          in: query
          schema: { $ref: '#/components/schemas/Platform' }
          description: With source_id, only jobs for that one video (any mode). Both or neither.
        - name: source_id
          in: query
          schema: { type: string, maxLength: 128 }
          description: The platform media id, as returned in job.source.media_id
      responses:
        '200':
          description: Newest first, ordered by created_at and id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobList'
  /v1/language-discoveries:
    post:
      tags: [jobs]
      summary: Queue bounded YouTube timed text language discovery
      operationId: createLanguageDiscovery
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LanguageDiscoveryRequest'
      responses:
        '202':
          description: Discovery queued or already processing
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LanguageDiscovery'
        '200':
          description: Same workspace discovery result reused from the short cache
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LanguageDiscovery'
        '422':
          $ref: '#/components/responses/ValidationError'
  /v1/language-discoveries/{id}:
    get:
      tags: [jobs]
      summary: Read a workspace scoped language discovery
      operationId: getLanguageDiscovery
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Discovery status and exact source language inventory
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LanguageDiscovery' }
  /v1/video-discoveries:
    post:
      tags: [jobs]
      summary: Queue a video listing discovery
      operationId: createVideoDiscovery
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VideoDiscoveryRequest'
      responses:
        '202':
          description: Discovery queued or already processing
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VideoDiscovery'
        '200':
          description: Same workspace discovery result reused from the short cache
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VideoDiscovery'
        '422':
          $ref: '#/components/responses/ValidationError'
  /v1/video-discoveries/{id}:
    get:
      tags: [jobs]
      summary: Read a workspace scoped video discovery
      operationId: getVideoDiscovery
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Discovery status, video listing, and paging cursor
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VideoDiscovery' }
  /v1/jobs/{id}:
    get:
      tags: [jobs]
      summary: Job status, stage, error, result reference, billing
      operationId: getJob
      parameters:
        - $ref: '#/components/parameters/JobId'
        - name: wait
          in: query
          required: false
          schema: { type: integer, minimum: 0, maximum: 25, default: 0 }
          description: Long poll. Hold the request up to this many seconds and return as soon as the job is succeeded, failed or cancelled. One rate-limited request replaces a client poll loop.
      responses:
        '200':
          description: Job record (terminal failures stay HTTP 200 with status failed)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Job'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/jobs/{id}/cancel:
    post:
      tags: [jobs]
      summary: Cancel before AssemblyAI submission starts
      operationId: cancelJob
      parameters:
        - $ref: '#/components/parameters/JobId'
      responses:
        '200':
          description: Cancelled, or not allowed once submission started
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Job'
        '409':
          description: Cancellation window has passed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
  /v1/jobs/{id}/retry:
    post:
      tags: [jobs]
      summary: New job from a retryable terminal failure
      operationId: retryJob
      parameters:
        - $ref: '#/components/parameters/JobId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/JobRetryRequest'
      responses:
        '202':
          description: Retry accepted as a new job
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Job'
        '200':
          description: Same retry idempotency key replayed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Job'
  /v1/batches:
    post:
      tags: [batches]
      summary: Submit 1 to plan limit jobs atomically
      operationId: createBatch
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BatchRequest'
      responses:
        '202':
          description: Batch accepted; children reserved together or whole batch rejected
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Batch'
        '200':
          description: Same batch idempotency key replayed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Batch'
        '402':
          $ref: '#/components/responses/BillableError'
        '422':
          $ref: '#/components/responses/ValidationError'
  /v1/batches/{id}:
    get:
      tags: [batches]
      summary: Batch counters and child job ids
      operationId: getBatch
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Aggregate status derived from child counts
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Batch'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/transcripts/{id}:
    get:
      tags: [transcripts]
      summary: Normalized stored result
      operationId: getTranscript
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Transcript with segment timing; words null when unavailable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TranscriptResult'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [transcripts]
      summary: Hide immediately and schedule purge
      operationId: deleteTranscript
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '202':
          description: Deletion scheduled; cache entry invalidated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TranscriptDeletion'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/transcripts/{id}/export:
    get:
      tags: [transcripts]
      summary: Export a stored result without paying again
      operationId: exportTranscript
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: format
          in: query
          required: true
          schema: { type: string, enum: [txt, json, srt, vtt] }
      responses:
        '200':
          description: File body with a download filename
          content:
            text/plain:
              schema: { type: string }
            application/json:
              schema: { type: object }
            text/vtt:
              schema: { type: string }
            text/srt:
              schema: { type: string }
        '422':
          description: No usable timing for a timed format
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
  /v1/uploads:
    post:
      tags: [uploads]
      summary: Reserve an upload slot and get a signed URL
      operationId: createUpload
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UploadRequest'
      responses:
        '201':
          description: Slot reserved; PUT the file to signed_upload_url, then complete
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Upload'
  /v1/uploads/{id}/complete:
    post:
      tags: [uploads]
      summary: Verify a finished upload and mark it ready
      operationId: completeUpload
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Upload ready for use as a job source
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UploadCompletion'
  /v1/usage:
    get:
      tags: [account]
      summary: Caption allowance, wallet balance, and reservations
      operationId: getUsage
      responses:
        '200':
          description: Current plan, allowance, and wallet in USD strings
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Usage'
  /v1/webhook-endpoints:
    get:
      tags: [webhooks]
      summary: List workspace endpoints (secrets never returned)
      operationId: listWebhookEndpoints
      responses:
        '200':
          description: Enabled and disabled endpoints
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointList'
    post:
      tags: [webhooks]
      summary: Register an HTTPS endpoint (owner session in MVP)
      operationId: createWebhookEndpoint
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointRequest'
      responses:
        '201':
          description: Endpoint with the signing secret shown exactly once
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointWithSecret'
  /v1/webhook-endpoints/{id}:
    delete:
      tags: [webhooks]
      summary: Disable an endpoint
      operationId: deleteWebhookEndpoint
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204': { description: Disabled }
  /app/keys:
    get:
      tags: [account]
      summary: List API keys (hashes never returned)
      operationId: listApiKeys
      responses:
        '200':
          description: Workspace keys newest first
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKeyList'
    post:
      tags: [account]
      summary: Create a scoped API key (owner session only)
      operationId: createApiKey
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ApiKeyRequest'
      responses:
        '201':
          description: Key with the raw secret shown exactly once
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKeyWithSecret'
  /app/keys/{id}:
    delete:
      tags: [account]
      summary: Revoke an API key
      operationId: revokeApiKey
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204': { description: Revoked }
  /app/billing/checkout:
    post:
      tags: [account]
      summary: Start a Paddle checkout (subscription or credits)
      operationId: billingCheckout
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CheckoutRequest'
      responses:
        '200':
          description: Redirect the browser to url
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CheckoutSession'
        '503':
          description: Billing provider not configured yet
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
  /app/billing/quote:
    get:
      tags: [account]
      summary: Price a credit purchase without starting a checkout
      operationId: billingQuote
      parameters:
        - in: query
          name: units
          required: true
          schema: { type: integer, minimum: 1000, maximum: 100000, multipleOf: 1000 }
      responses:
        '200':
          description: Price for this workspace's plan, volume discount applied
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreditQuote'
        '422':
          description: units out of range or plan cannot buy credits
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
  /app/billing/portal:
    post:
      tags: [account]
      summary: Open the customer billing portal
      operationId: billingPortal
      responses:
        '200':
          description: Redirect the browser to url
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CheckoutSession'
  /app/profile:
    get:
      tags: [account]
      summary: Read profile and workspace preferences
      operationId: getProfile
      responses:
        '200':
          description: Display name, plan, retention window
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Profile'
    patch:
      tags: [account]
      summary: Update display name or retention window
      operationId: updateProfile
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProfileUpdate'
      responses:
        '200':
          description: Updated profile
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Profile'
  /app/account:
    delete:
      tags: [account]
      summary: Delete the account (type DELETE to confirm)
      operationId: deleteAccount
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AccountDeletion'
      responses:
        '204': { description: Workspace disabled and purge scheduled }
  /app/webhooks/deliveries:
    get:
      tags: [account]
      summary: Recent customer webhook delivery attempts (redacted)
      operationId: listWebhookDeliveries
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        '200':
          description: Newest attempts first; no secrets or response bodies
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDeliveryList'
  /app/webhooks/{event}/redeliver:
    post:
      tags: [account]
      summary: Redeliver an exhausted customer webhook event
      operationId: redeliverWebhook
      parameters:
        - name: event
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Event requeued for delivery
          content:
            application/json:
              schema:
                type: object
                required: [redelivered]
                additionalProperties: false
                properties:
                  redelivered: { type: boolean }
  /app/ops/status:
    get:
      tags: [account]
      summary: Operator status (workspace owner only)
      operationId: opsStatus
      responses:
        '200':
          description: Worker heartbeats, queue depth, oldest eligible job
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OpsStatus'
  /auth/callback:
    get:
      tags: [account]
      summary: OAuth and email link callback (PKCE code exchange)
      operationId: authCallback
      security: []
      parameters:
        - name: code
          in: query
          schema: { type: string }
        - name: next
          in: query
          schema: { type: string, default: /dashboard }
          description: Local path only; anything else falls back to /dashboard
      responses:
        '302': { description: Redirect to next }
  /auth/signout:
    post:
      tags: [account]
      summary: Sign out and clear the session cookie
      operationId: signOut
      responses:
        '302': { description: Redirect to / }
  /callbacks/assemblyai:
    post:
      tags: [callbacks]
      summary: AssemblyAI transcript completion callback
      operationId: assemblyaiCallback
      security: []
      parameters:
        - name: attempt
          in: query
          required: true
          schema: { type: string }
          description: Provider attempt token issued by begin_provider_attempt
      responses:
        '200':
          description: Inbox recorded (or duplicate); body never trusted
          content:
            application/json:
              schema:
                type: object
                required: [outcome]
                properties:
                  outcome: { type: string, enum: [accepted, duplicate, unknown_attempt] }
  /callbacks/paddle:
    post:
      tags: [callbacks]
      summary: Paddle event webhook (signature verified, replay safe)
      operationId: paddleCallback
      security: []
      responses:
        '200':
          description: Event recorded; unknown types acknowledged without effect
          content:
            application/json:
              schema:
                type: object
                required: [received]
                properties:
                  received: { type: boolean }
  /health:
    get:
      tags: [jobs]
      summary: Process liveness only (no extraction detail)
      operationId: health
      security: []
      responses:
        '200':
          description: Healthy
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: { type: string, enum: [ok] }
components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      description: API key td_live_<random> with a workspace binding and scopes. First party apps may instead send a Supabase access token, which acts as the signed in owner's session.
    cookie:
      type: apiKey
      in: cookie
      name: sb-access-token
      description: Supabase SSR session cookie used by the dashboard
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, minLength: 8, maxLength: 128, pattern: '^[ -~]+$' }
      description: Workspace plus endpoint scoped replay key, 8 to 128 ASCII chars
    JobId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
  responses:
    ValidationError:
      description: Request shape or capability rejected
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    BillableError:
      description: Reservation refused (balance, budget, plan)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    NotFound:
      description: Unknown id, or an id from another workspace
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
  schemas:
    ErrorCode:
      type: string
      enum: [INVALID_REQUEST, INVALID_URL, INVALID_CURSOR, UNSUPPORTED_SOURCE, UNSUPPORTED_MEDIA,
        CAPABILITY_UNAVAILABLE, UNAUTHENTICATED, FORBIDDEN, EMAIL_UNVERIFIED, NOT_FOUND,
        INSUFFICIENT_BALANCE, PLAN_REQUIRED, BUDGET_EXCEEDED, IDEMPOTENCY_CONFLICT,
        JOB_NOT_RETRYABLE, CANCELLATION_NOT_ALLOWED, RATE_LIMITED, SOURCE_NOT_FOUND,
        SOURCE_PRIVATE, SOURCE_AUTH_REQUIRED, SOURCE_REGION_RESTRICTED, NO_CAPTIONS,
        LANGUAGE_UNAVAILABLE, LANGUAGE_UNSUPPORTED, NO_SPEECH, NO_AUDIO,
        TIMESTAMPS_UNAVAILABLE, DURATION_LIMIT_EXCEEDED, FILE_TOO_LARGE, UNSAFE_URL,
        INVALID_MEDIA, SOURCE_RATE_LIMITED, SOURCE_BLOCKED, SOURCE_TIMEOUT,
        SOURCE_CHANGED, PROVIDER_UNAVAILABLE, STORAGE_UNAVAILABLE, PROVIDER_REJECTED,
        INTERNAL_ERROR]
    ErrorObject:
      type: object
      required: [code, message, retryable]
      additionalProperties: false
      properties:
        code: { $ref: '#/components/schemas/ErrorCode' }
        message: { type: string, maxLength: 500 }
        retryable: { type: boolean }
        retry_after_seconds: { type: integer, minimum: 1 }
        details: { type: object }
        doc_url: { type: string, format: uri, description: 'Where this code is documented.' }
    ErrorEnvelope:
      type: object
      required: [error, request_id]
      additionalProperties: false
      properties:
        error: { $ref: '#/components/schemas/ErrorObject' }
        request_id: { type: string, format: uuid }
    Platform:
      type: string
      enum: [youtube, tiktok, instagram, upload, direct]
    JobMode:
      type: string
      enum: [captions_only, auto, transcribe]
    JobStatus:
      type: string
      enum: [queued, processing, awaiting_provider, succeeded, failed, cancelled]
    JobStage:
      type: string
      enum: [resolve, captions, acquire_audio, submit_asr, wait_asr, finalize]
    JobSource:
      type: object
      additionalProperties: false
      properties:
        url: { type: string, format: uri, maxLength: 2048, description: 'YouTube or TikTok video URL (captions_only); any https media URL for direct sources (AI transcription). Exactly one of url or upload_id.' }
        upload_id: { type: string, format: uuid, description: 'Id from POST /v1/uploads after the file was PUT and the upload is ready. Exactly one of url or upload_id.' }
      oneOf:
        - required: [url]
        - required: [upload_id]
    JobRequest:
      type: object
      required: [source, mode]
      additionalProperties: false
      description: |
        Only source and mode are required. captions_only (1 credit) works for
        YouTube and TikTok. auto and transcribe run AI transcription
        (transcribe_credits_per_minute per started minute, 60 s minimum) and
        need GET /v1/capabilities to report it enabled for the source type;
        otherwise they return 422 CAPABILITY_UNAVAILABLE.
        recognition_profile and language only apply to AI transcription.
        max_credits is an optional ceiling; the charge is
        transcribe_credits_per_minute on measured duration (see GET /v1/capabilities).
      properties:
        source: { $ref: '#/components/schemas/JobSource' }
        mode: { $ref: '#/components/schemas/JobMode' }
        language: { type: [string, 'null'], maxLength: 35, default: null, description: 'Only applies to AI transcription (auto/transcribe).' }
        caption_languages: { type: array, maxItems: 5, items: { type: string, maxLength: 35 }, default: [], description: 'BCP 47 tags in preference order for the caption track, e.g. ["en", "es"]. Empty means the video default.' }
        caption_preference:
          type: string
          enum: [prefer_creator, creator_only, automatic_only]
          default: prefer_creator
        recognition_profile: { type: string, enum: [standard], default: standard, description: 'Only applies to AI transcription (auto/transcribe).' }
        max_credits: { type: integer, minimum: 1, description: 'Optional ceiling in credits for auto/transcribe. AI transcription costs transcribe_credits_per_minute per started minute after minimum_billable_seconds (see GET /v1/capabilities); a job whose measured cost exceeds the ceiling fails with BUDGET_EXCEEDED before any provider spend. Default covers max_duration_ms. Ignored for captions_only.' }
        webhook_endpoint_id: { type: [string, 'null'], format: uuid, default: null, description: 'Endpoint from POST /v1/webhooks that receives job.succeeded / job.failed for this job.' }
        metadata:
          type: object
          maxProperties: 10
          additionalProperties: { type: string, maxLength: 256 }
          propertyNames: { maxLength: 64 }
          default: {}
    JobRetryRequest:
      type: object
      additionalProperties: false
      properties:
        max_credits: { type: integer, minimum: 1, description: 'New ceiling in credits; defaults to the original job ceiling.' }
    JobBilling:
      type: object
      required: [credits_reserved, credits_charged, kind]
      additionalProperties: false
      properties:
        credits_reserved: { type: integer, minimum: 0, description: 'Credits held while the job runs; released or settled when it finishes.' }
        credits_charged: { type: integer, minimum: 0, description: 'Credits charged after settlement (0 until the job finishes). 1 for a caption transcript, transcribe_credits_per_minute per started minute for AI transcription, 0 for a cached result.' }
        kind: { type: string, enum: [captions, ai_transcription, cached], description: 'What the job paid for: captions (1 credit), ai_transcription (per minute), or cached (a reused result, free within the workspace).' }
    Job:
      type: object
      required: [id, status, stage, result_id, error, billing, created_at]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid, description: 'Job id; use with GET /v1/jobs/{id}.' }
        status: { $ref: '#/components/schemas/JobStatus', description: 'queued, processing or awaiting_provider are in flight; succeeded, failed and cancelled are final.' }
        stage: { type: [string, 'null'], enum: [resolve, captions, acquire_audio, submit_asr, wait_asr, finalize, null], description: 'Current pipeline step while in flight; null once final.' }
        result_id: { type: [string, 'null'], format: uuid, description: 'Transcript id once status is succeeded; fetch with GET /v1/transcripts/{id}. Null otherwise.' }
        error: { anyOf: [{ $ref: '#/components/schemas/ErrorObject' }, { type: 'null' }], description: 'Set only when status is failed; error.retryable says whether POST /v1/jobs/{id}/retry can help.' }
        billing: { $ref: '#/components/schemas/JobBilling', description: 'What this job consumed: caption requests and AI credits.' }
        source:
          type: object
          required: [platform, media_id, canonical_url, title, upload_id]
          additionalProperties: false
          properties:
            platform: { $ref: '#/components/schemas/Platform' }
            media_id: { type: [string, 'null'] }
            canonical_url: { type: [string, 'null'] }
            title: { type: [string, 'null'] }
            upload_id: { type: [string, 'null'] }
        options:
          type: object
          required: [mode, language, caption_preference]
          additionalProperties: false
          properties:
            mode: { $ref: '#/components/schemas/JobMode' }
            language: { type: [string, 'null'] }
            caption_preference: { type: string }
        created_at: { type: string, format: date-time, description: 'Submission time, UTC.' }
    JobList:
      type: object
      required: [items, next_cursor]
      additionalProperties: false
      properties:
        items: { type: array, items: { $ref: '#/components/schemas/Job' } }
        next_cursor: { type: [string, 'null'] }
    LanguageDiscoveryRequest:
      type: object
      required: [source]
      additionalProperties: false
      properties:
        source:
          type: object
          required: [url]
          additionalProperties: false
          properties:
            url: { type: string, format: uri, maxLength: 2048 }
    LanguageDiscoveryStatus:
      type: string
      enum: [queued, processing, succeeded, failed]
    LanguageDiscoveryLanguage:
      type: object
      required: [code, display_name, native_name, origin, is_original, is_default, timed_text]
      additionalProperties: false
      properties:
        code: { type: string, minLength: 1, maxLength: 35 }
        display_name: { type: string, minLength: 1, maxLength: 120 }
        native_name: { type: string, minLength: 1, maxLength: 120 }
        origin: { type: string, enum: [creator, automatic] }
        is_original: { type: boolean }
        is_default: { type: boolean }
        timed_text: { type: boolean }
    LanguageDiscovery:
      type: object
      required: [id, status, source, languages, default_language, original_language, error, created_at, updated_at, expires_at]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/LanguageDiscoveryStatus' }
        source:
          type: object
          required: [platform, media_id, canonical_url]
          additionalProperties: false
          properties:
            platform: { type: string, enum: [youtube] }
            media_id: { type: string, minLength: 1 }
            canonical_url: { type: string, format: uri }
        languages:
          type: array
          maxItems: 100
          items: { $ref: '#/components/schemas/LanguageDiscoveryLanguage' }
        default_language: { type: [string, 'null'], maxLength: 35 }
        original_language: { type: [string, 'null'], maxLength: 35 }
        error: { anyOf: [{ $ref: '#/components/schemas/ErrorObject' }, { type: 'null' }] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        expires_at: { type: ['string', 'null'], format: date-time }
    VideoDiscoveryKind:
      type: string
      enum: [youtube_search, youtube_channel_videos, youtube_channel_search,
        youtube_playlist_videos, tiktok_user_videos]
    VideoDiscoveryRequest:
      type: object
      required: [kind]
      additionalProperties: false
      properties:
        kind: { $ref: '#/components/schemas/VideoDiscoveryKind' }
        query: { type: string, minLength: 1, maxLength: 200 }
        channel: { type: string, minLength: 1, maxLength: 256 }
        playlist: { type: string, minLength: 1, maxLength: 256 }
        user: { type: string, minLength: 1, maxLength: 256 }
        limit: { type: integer, minimum: 1, maximum: 50, default: 20 }
        cursor: { type: string, minLength: 1, maxLength: 4096 }
        include_details: { type: boolean, default: false }
    VideoDiscoveryStatus:
      type: string
      enum: [queued, processing, succeeded, failed]
    TikTokVideoMusic:
      type: object
      additionalProperties: false
      properties:
        id: { type: [string, 'null'] }
        title: { type: [string, 'null'] }
        author: { type: [string, 'null'] }
        original: { type: [boolean, 'null'] }
        duration_s: { type: [integer, 'null'], minimum: 0 }
    TikTokVideoAuthor:
      type: object
      additionalProperties: false
      properties:
        id: { type: [string, 'null'] }
        unique_id: { type: [string, 'null'] }
        nickname: { type: [string, 'null'] }
        verified: { type: [boolean, 'null'] }
        avatar_url: { type: [string, 'null'], format: uri }
    TikTokVideoData:
      type: object
      additionalProperties: false
      description: Full item data TikTok embeds in the user listing. Never carries signed play or download URLs.
      properties:
        description: { type: [string, 'null'] }
        created_at: { type: [string, 'null'], format: date-time }
        duration_s: { type: [integer, 'null'], minimum: 0 }
        width: { type: [integer, 'null'], minimum: 0 }
        height: { type: [integer, 'null'], minimum: 0 }
        ratio: { type: [string, 'null'] }
        cover_url: { type: [string, 'null'], format: uri }
        dynamic_cover_url: { type: [string, 'null'], format: uri }
        origin_cover_url: { type: [string, 'null'], format: uri }
        play_count: { type: [integer, 'null'], minimum: 0 }
        like_count: { type: [integer, 'null'], minimum: 0 }
        comment_count: { type: [integer, 'null'], minimum: 0 }
        share_count: { type: [integer, 'null'], minimum: 0 }
        collect_count: { type: [integer, 'null'], minimum: 0 }
        hashtags: { type: array, items: { type: string } }
        mentions: { type: array, items: { type: string } }
        music: { anyOf: [{ $ref: '#/components/schemas/TikTokVideoMusic' }, { type: 'null' }] }
        author: { anyOf: [{ $ref: '#/components/schemas/TikTokVideoAuthor' }, { type: 'null' }] }
        is_pinned: { type: [boolean, 'null'] }
        is_ad: { type: [boolean, 'null'] }
        is_duet_enabled: { type: [boolean, 'null'] }
        is_stitch_enabled: { type: [boolean, 'null'] }
        language: { type: [string, 'null'] }
        subtitle_languages: { type: array, items: { type: string } }
        location: { type: [string, 'null'] }
        video_url_expires: { type: [boolean, 'null'] }
        private_item: { type: [boolean, 'null'] }
        details_error: { type: [string, 'null'] }
    TikTokProfile:
      type: object
      additionalProperties: false
      properties:
        id: { type: [string, 'null'] }
        unique_id: { type: [string, 'null'] }
        nickname: { type: [string, 'null'] }
        signature: { type: [string, 'null'] }
        verified: { type: [boolean, 'null'] }
        private_account: { type: [boolean, 'null'] }
        avatar_url: { type: [string, 'null'], format: uri }
        follower_count: { type: [integer, 'null'], minimum: 0 }
        following_count: { type: [integer, 'null'], minimum: 0 }
        heart_count: { type: [integer, 'null'], minimum: 0 }
        video_count: { type: [integer, 'null'], minimum: 0 }
        region: { type: [string, 'null'] }
        language: { type: [string, 'null'] }
        created_at: { type: [string, 'null'], format: date-time }
    VideoDiscoveryVideo:
      type: object
      required: [platform, video_id, url, title, channel_id, channel_title,
        duration_s, published_at, published_text, view_count, view_count_text, thumbnail_url]
      additionalProperties: false
      properties:
        platform: { type: string, enum: [youtube, tiktok] }
        video_id: { type: string, minLength: 1 }
        url: { type: string, format: uri }
        title: { type: [string, 'null'] }
        channel_id: { type: [string, 'null'] }
        channel_title: { type: [string, 'null'] }
        duration_s: { type: [integer, 'null'], minimum: 0 }
        published_at: { type: [string, 'null'], format: date-time }
        published_text: { type: [string, 'null'] }
        view_count: { type: [integer, 'null'], minimum: 0 }
        view_count_text: { type: [string, 'null'] }
        thumbnail_url: { type: [string, 'null'], format: uri }
        tiktok: { anyOf: [{ $ref: '#/components/schemas/TikTokVideoData' }, { type: 'null' }] }
    VideoDiscovery:
      type: object
      required: [id, kind, status, request, videos, next_cursor, error, created_at, updated_at, expires_at]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        kind: { $ref: '#/components/schemas/VideoDiscoveryKind' }
        status: { $ref: '#/components/schemas/VideoDiscoveryStatus' }
        request:
          type: object
          description: Canonical discovery request as queued
        videos:
          type: array
          maxItems: 50
          items: { $ref: '#/components/schemas/VideoDiscoveryVideo' }
        profile: { anyOf: [{ $ref: '#/components/schemas/TikTokProfile' }, { type: 'null' }] }
        next_cursor: { type: [string, 'null'] }
        error: { anyOf: [{ $ref: '#/components/schemas/ErrorObject' }, { type: 'null' }] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        expires_at: { type: ['string', 'null'], format: date-time }
    TranscriptSegment:
      type: object
      required: [start, end, text]
      additionalProperties: false
      properties:
        start: { type: number, minimum: 0 }
        end: { type: number, minimum: 0 }
        text: { type: string, minLength: 1 }
    TranscriptWord:
      type: object
      required: [start, end, text]
      additionalProperties: false
      properties:
        start: { type: number, minimum: 0 }
        end: { type: number, minimum: 0 }
        text: { type: string, minLength: 1 }
        confidence: { type: number, minimum: 0, maximum: 1 }
    TranscriptResult:
      type: object
      required: [id, source, source_origin, language, text, segments, words,
        timing_granularity, duration_seconds, recognition, extraction_version, created_at]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid, description: 'Transcript id (the job result_id).' }
        source:
          type: object
          required: [platform, media_id]
          additionalProperties: false
          properties:
            platform: { $ref: '#/components/schemas/Platform' }
            media_id: { type: string }
            canonical_url: { type: [string, 'null'] }
            title: { type: [string, 'null'] }
        source_origin: { type: string, enum: [creator_captions, platform_captions, speech_recognition], description: 'Where the text came from: captions uploaded by the creator, captions generated by the platform, or our speech recognition.' }
        language: { type: [string, 'null'], description: 'BCP 47 tag of the transcript text, or null when unknown.' }
        text: { type: string, description: 'Full plain text, segments joined in order.' }
        segments: { type: array, items: { $ref: '#/components/schemas/TranscriptSegment' }, description: 'Caption sized cues with start and end seconds; what SRT and VTT exports use.' }
        words: { type: ['array', 'null'], items: { $ref: '#/components/schemas/TranscriptWord' }, description: 'Per word timings when timing_granularity is word; null otherwise.' }
        timing_granularity: { type: string, enum: [word, segment, none], description: 'Finest timing available: word (speech recognition), segment (caption cues) or none.' }
        duration_seconds: { type: [number, 'null'], minimum: 0, description: 'Measured media length in seconds; null for caption sources that did not report it.' }
        recognition:
          type: ['object', 'null']
          additionalProperties: false
          properties:
            profile: { type: string }
            model: { type: string }
            quality_status: { type: string, enum: [validated_language, provider_supported, experimental_language], description: 'validated_language: accuracy checked by us. provider_supported: the AI model lists the language but we have not benchmarked it. experimental_language: anything else; quality may vary.' }
        extraction_version: { type: string }
        created_at: { type: string, format: date-time }
    TranscriptDeletion:
      type: object
      required: [id, status]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [deleting] }
    BatchRequest:
      type: object
      required: [items]
      additionalProperties: false
      properties:
        items: { type: array, minItems: 1, maxItems: 50, items: { $ref: '#/components/schemas/JobRequest' } }
        metadata:
          type: object
          maxProperties: 10
          additionalProperties: { type: string, maxLength: 256 }
          propertyNames: { maxLength: 64 }
          default: {}
        webhook_endpoint_id: { type: [string, 'null'], format: uuid, default: null }
    BatchItem:
      type: object
      required: [job_id, item_index, status]
      additionalProperties: false
      properties:
        job_id: { type: string, format: uuid }
        item_index: { type: integer, minimum: 0 }
        status: { $ref: '#/components/schemas/JobStatus' }
    Batch:
      type: object
      required: [id, status, total_items, succeeded_items, failed_items, cancelled_items,
        child_job_ids, items, created_at]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid, description: 'Batch id; use with GET /v1/batches/{id}.' }
        status: { type: string, enum: [queued, processing, succeeded, partial_success, failed, cancelled], description: 'Aggregate of the child jobs: partial_success when some succeeded and some failed.' }
        total_items: { type: integer, minimum: 0, description: 'Number of submitted items; duplicate URLs share one child job.' }
        succeeded_items: { type: integer, minimum: 0, description: 'Items whose child job succeeded.' }
        failed_items: { type: integer, minimum: 0, description: 'Items whose child job failed.' }
        cancelled_items: { type: integer, minimum: 0, description: 'Items whose child job was cancelled.' }
        child_job_ids: { type: array, items: { type: string, format: uuid }, description: 'Distinct child job ids, in item order.' }
        items: { type: array, items: { $ref: '#/components/schemas/BatchItem' }, description: 'One entry per submitted item with its job id and status.' }
        created_at: { type: string, format: date-time, description: 'Submission time, UTC.' }
    UploadRequest:
      type: object
      required: [filename, content_type, bytes]
      additionalProperties: false
      properties:
        filename: { type: string, minLength: 1, maxLength: 255 }
        content_type: { type: string, maxLength: 127 }
        bytes: { type: integer, minimum: 1, maximum: 262144000 }
    Upload:
      type: object
      required: [id, object_key, signed_upload_url, expires_at, status]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid, description: 'Upload id to pass as source.upload_id on job creation.' }
        object_key: { type: string, description: 'Storage key of the object; informational.' }
        signed_upload_url: { type: string, format: uri, description: 'PUT the file bytes here with the declared Content-Type before expires_at.' }
        expires_at: { type: string, format: date-time, description: 'When the signed URL and the pending upload stop being accepted.' }
        status: { type: string, enum: [pending, ready, failed, expired, purged], description: 'pending until the PUT completes; ready means a job can reference it.' }
    UploadCompletion:
      type: object
      required: [id, object_key, status]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        object_key: { type: string }
        status: { type: string, enum: [ready] }
    Usage:
      type: object
      required: [plan, credits_remaining, credits_total, credits_reserved, prepaid_credits, transcribe_credits_per_minute, minimum_billable_seconds]
      additionalProperties: false
      properties:
        plan: { type: string, description: 'Current plan: trial, wallet_only, starter, pro or scale.' }
        period_end: { type: string, format: date-time, nullable: true, description: 'When the current monthly allowance resets; null on trial.' }
        credits_remaining: { type: integer, minimum: 0, description: 'Credits available right now: allowance left this period plus prepaid credits, minus holds.' }
        credits_total: { type: integer, minimum: 0, description: 'Allowance granted this period plus prepaid credits.' }
        credits_reserved: { type: integer, minimum: 0, description: 'Credits held by running jobs.' }
        prepaid_credits: { type: integer, minimum: 0, description: 'Purchased credits (never expire), net of holds.' }
        transcribe_credits_per_minute: { type: integer, minimum: 1, description: 'Credits per started minute of AI transcription.' }
        minimum_billable_seconds: { type: integer, minimum: 0, description: 'Every AI transcription bills at least this many seconds.' }
    Capabilities:
      type: object
      required: [youtube, tiktok, instagram, upload, direct, max_duration_ms,
        max_media_bytes, export_formats, recognition_profiles]
      additionalProperties: false
      properties:
        youtube:
          type: object
          required: [enabled, captions_only, auto, transcribe, social_acquisition_fee]
          properties:
            enabled: { type: boolean, description: 'Whether this source can be submitted at all right now.' }
            captions_only: { type: boolean }
            auto: { type: boolean }
            transcribe: { type: boolean }
            social_acquisition_fee: { type: boolean }
        tiktok:
          type: object
          required: [enabled, captions_only, auto, transcribe, social_acquisition_fee]
          properties:
            enabled: { type: boolean, description: 'Whether this source can be submitted at all right now.' }
            captions_only: { type: boolean }
            auto: { type: boolean }
            transcribe: { type: boolean }
            social_acquisition_fee: { type: boolean }
        instagram:
          type: object
          required: [enabled, captions_only, auto, transcribe, social_acquisition_fee, native_captions]
          properties:
            enabled: { type: boolean, description: 'Whether this source can be submitted at all right now (INSTAGRAM_ENABLED).' }
            captions_only: { type: boolean }
            auto: { type: boolean }
            transcribe: { type: boolean }
            social_acquisition_fee: { type: boolean }
            native_captions: { type: string, enum: [unverified_disabled, supported] }
        upload:
          type: object
          required: [enabled, captions_only, auto, transcribe, social_acquisition_fee]
          properties:
            enabled: { type: boolean, description: 'Whether this source can be submitted at all right now.' }
            captions_only: { type: boolean }
            auto: { type: boolean }
            transcribe: { type: boolean }
            social_acquisition_fee: { type: boolean }
        direct:
          type: object
          required: [enabled, captions_only, auto, transcribe, social_acquisition_fee]
          properties:
            enabled: { type: boolean, description: 'Whether this source can be submitted at all right now.' }
            captions_only: { type: boolean }
            auto: { type: boolean }
            transcribe: { type: boolean }
            social_acquisition_fee: { type: boolean }
        max_duration_ms: { type: integer, description: 'Longest media accepted for AI transcription, in milliseconds.' }
        max_media_bytes: { type: integer, description: 'Largest upload or direct file accepted, in bytes.' }
        export_formats: { type: array, items: { type: string }, description: 'Values accepted by GET /v1/transcripts/{id}/export?format=.' }
        recognition_profiles: { type: array, items: { type: string }, description: 'Values accepted for recognition_profile on job creation.' }
        transcribe_credits_per_minute: { type: integer, minimum: 1, description: 'Credits per started minute of AI transcription. A caption transcript always costs 1 credit.' }
        minimum_billable_seconds: { type: integer, minimum: 0, description: 'Every AI transcription bills at least this many seconds.' }
        price_version: { type: string, description: 'Identifier of the price schedule in force; snapshotted onto each job.' }
        asr_enabled: { type: boolean, description: 'ASR_ENABLED: AI transcription (auto/transcribe modes) is available.' }
        instagram_enabled: { type: boolean, description: 'INSTAGRAM_ENABLED: Instagram sources are accepted.' }
        free_caption_credits: { type: integer, minimum: 0, description: 'Trial plan caption credits granted at signup.' }
        free_caption_credits_period: { type: string, enum: [once, month], description: 'Grant frequency: once (non-renewing) or month.' }
    WebhookEndpointRequest:
      type: object
      required: [url]
      additionalProperties: false
      properties:
        url: { type: string, format: uri, maxLength: 2048, pattern: '^https://' }
        description: { type: string, maxLength: 200 }
    WebhookEndpoint:
      type: object
      required: [id, url, description, enabled, created_at]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        description: { type: [string, 'null'] }
        enabled: { type: boolean }
        created_at: { type: string, format: date-time }
    WebhookEndpointWithSecret:
      allOf:
        - $ref: '#/components/schemas/WebhookEndpoint'
        - type: object
          required: [secret]
          properties:
            secret: { type: string }
    WebhookEndpointList:
      type: object
      required: [items]
      additionalProperties: false
      properties:
        items: { type: array, items: { $ref: '#/components/schemas/WebhookEndpoint' } }
    WebhookEvent:
      type: object
      required: [event_id, type, created_at]
      additionalProperties: false
      properties:
        event_id: { type: string, format: uuid }
        type: { type: string, enum: [job.succeeded, job.failed, batch.completed] }
        created_at: { type: string, format: date-time }
        job_id: { type: string, format: uuid }
        batch_id: { type: [string, 'null'], format: uuid }
        result_id: { type: [string, 'null'], format: uuid }
        metadata: { type: object }
        error: { type: object }
    WebhookDelivery:
      type: object
      required: [id, endpoint_id, event_type, http_status, delivered_at, ok]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        endpoint_id: { type: string, format: uuid }
        event_type: { type: string }
        http_status: { type: integer }
        delivered_at: { type: [string, 'null'], format: date-time }
        ok: { type: boolean }
    WebhookDeliveryList:
      type: object
      required: [items]
      additionalProperties: false
      properties:
        items: { type: array, items: { $ref: '#/components/schemas/WebhookDelivery' } }
    ApiKeyRequest:
      type: object
      required: [name, scopes]
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1, maxLength: 64 }
        scopes:
          type: array
          minItems: 1
          maxItems: 4
          items: { type: string, enum: ['jobs:read', 'jobs:write', 'transcripts:read', 'uploads:write'] }
    ApiKey:
      type: object
      required: [id, name, prefix, scopes, created_at, last_used_at]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        prefix: { type: string }
        scopes: { type: array, items: { type: string } }
        created_at: { type: string, format: date-time }
        last_used_at: { type: [string, 'null'], format: date-time }
    ApiKeyWithSecret:
      allOf:
        - $ref: '#/components/schemas/ApiKey'
        - type: object
          required: [secret]
          properties:
            secret: { type: string }
    ApiKeyList:
      type: object
      required: [items]
      additionalProperties: false
      properties:
        items: { type: array, items: { $ref: '#/components/schemas/ApiKey' } }
    CheckoutRequest:
      type: object
      required: [kind]
      additionalProperties: false
      properties:
        kind: { type: string, enum: [subscription, credits] }
        plan: { type: string, enum: [starter, pro, scale] }
        units: { type: integer, minimum: 1000, maximum: 100000, multipleOf: 1000, description: 'Credits to buy (kind: credits), in steps of 1,000. Priced at the plan rate per 1,000 with volume discounts: 10% from 10,000, 20% from 50,000.' }
    CheckoutSession:
      type: object
      required: [url]
      additionalProperties: false
      properties:
        url: { type: string, format: uri }
        units: { type: integer, description: 'Credits in this purchase (kind: credits).' }
        price_usd: { type: string, description: 'Total price charged at checkout, after any volume discount.' }
        discount_pct: { type: integer, description: 'Volume discount applied (0, 10 or 20).' }
    CreditQuote:
      type: object
      required: [units, price_usd, discount_pct, unit_price_per_1000_usd]
      additionalProperties: false
      properties:
        units: { type: integer }
        price_usd: { type: string }
        discount_pct: { type: integer }
        unit_price_per_1000_usd: { type: string, description: 'Plan rate per 1,000 credits before discount.' }
    Profile:
      type: object
      required: [display_name, plan, retention_days, email_verified]
      additionalProperties: false
      properties:
        display_name: { type: [string, 'null'] }
        plan: { type: string }
        retention_days: { type: integer, minimum: 1, maximum: 90 }
        email_verified: { type: boolean }
    ProfileUpdate:
      type: object
      minProperties: 1
      additionalProperties: false
      properties:
        display_name: { type: string, maxLength: 80 }
        retention_days: { type: integer, minimum: 1, maximum: 90 }
    AccountDeletion:
      type: object
      required: [confirm]
      additionalProperties: false
      properties:
        confirm: { type: string, enum: [DELETE] }
    OpsStatus:
      type: object
      required: [workers, queue_depth, oldest_eligible_job_age_seconds, time]
      additionalProperties: false
      properties:
        workers:
          type: array
          items:
            type: object
            required: [worker_id, version, last_seen_at]
            properties:
              worker_id: { type: string }
              version: { type: [string, 'null'] }
              last_seen_at: { type: string, format: date-time }
        queue_depth: { type: integer, minimum: 0 }
        oldest_eligible_job_age_seconds: { type: [number, 'null'] }
        time: { type: string, format: date-time }
