openapi: 3.1.0
info:
  title: Videosays API
  version: 1.0.0
  description: >-
    Create asynchronous transcription tasks from supported public video links,
    poll their status, process batches, and inspect account credit usage.
  contact:
    name: Videosays
    url: https://videosays.com/docs
  license:
    name: Proprietary
    url: https://videosays.com/terms
servers:
  - url: https://api.videosays.com
    description: Production
externalDocs:
  description: Videosays API and CLI documentation
  url: https://videosays.com/docs
tags:
  - name: Transcription
  - name: Batches
  - name: Account
security:
  - apiKey: []
  - bearerAuth: []
paths:
  /api/v1/transcribe:
    post:
      operationId: createTranscription
      summary: Create a transcription task
      description: >-
        Accepts a supported public video link or share text and returns an
        asynchronous task. Poll the returned task ID until it reaches a terminal status.
        Duration-based credit is reserved after Queue preparation; an explicit force_new
        request with no available credit can be rejected during admission.
      tags: [Transcription]
      parameters:
        - $ref: '#/components/parameters/OptionalIdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTranscriptionRequest'
            examples:
              tiktok:
                value:
                  input: https://www.tiktok.com/@creator/video/123456
                  options:
                    language: auto
                    sourceMode: auto
                    duplicatePolicy: reuse
      responses:
        '200':
          description: Existing terminal task returned for an idempotent replay or completed-result reuse
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Task' }
        '202':
          description: Task accepted
          headers:
            Location:
              schema: { type: string }
            Retry-After:
              schema: { type: integer }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Task'
                  - type: object
                    properties:
                      success: { type: boolean, const: true }
                      retryAfterSeconds: { type: integer, example: 5 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: An explicit force_new request was rejected because no credit is available
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422': { $ref: '#/components/responses/UnsupportedInput' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/transcribe/{taskId}:
    get:
      operationId: getTranscription
      summary: Get task status and result
      tags: [Transcription]
      parameters:
        - $ref: '#/components/parameters/TaskId'
      responses:
        '200':
          description: Current task state
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Task'
                  - type: object
                    properties:
                      retryAfterSeconds: { type: integer, example: 5 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /api/v1/batches:
    post:
      operationId: createBatch
      summary: Create a resumable batch
      description: >-
        Creates ordinary transcription tasks for 1 to 100 inputs and returns a server-generated
        batchId. Idempotency-Key is the client submission id, not the batch id: replaying the same
        key returns the original batch, while equivalent active work for the same account and
        options converges on one batch. Completed work follows options.duplicatePolicy.
      tags: [Batches]
      parameters:
        - $ref: '#/components/parameters/OptionalIdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateBatchRequest'
      responses:
        '202':
          description: Batch accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Batch' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/UnsupportedInput' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/batches/{batchId}:
    get:
      operationId: getBatch
      summary: Get batch status
      tags: [Batches]
      parameters:
        - $ref: '#/components/parameters/BatchId'
        - $ref: '#/components/parameters/BatchView'
      responses:
        '200':
          description: Current batch state
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Batch' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /api/v1/batches/{batchId}/continue:
    post:
      operationId: continueBatch
      summary: Continue a batch after adding credits
      tags: [Batches]
      parameters:
        - $ref: '#/components/parameters/BatchId'
        - $ref: '#/components/parameters/BatchView'
      responses:
        '200':
          description: Batch resumed
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Batch'
                  - type: object
                    properties:
                      continuedItems: { type: integer }
                      resumedItems: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/transcribe/{taskId}/reuse-decision:
    post:
      operationId: decideTranscriptionReuse
      summary: Reuse the existing result or run a fresh transcription
      tags: [Transcription]
      parameters:
        - $ref: '#/components/parameters/TaskId'
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision]
              properties:
                decision: { type: string, enum: [reuse, force_new] }
      responses:
        '200': { description: Existing result selected }
        '202': { description: Fresh transcription queued }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/batches/{batchId}/reuse-decision:
    post:
      operationId: decideBatchReuse
      summary: Apply one duplicate decision to waiting items in a batch
      tags: [Batches]
      parameters:
        - $ref: '#/components/parameters/BatchId'
        - $ref: '#/components/parameters/RequiredIdempotencyKey'
        - $ref: '#/components/parameters/BatchView'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision]
              properties:
                decision: { type: string, enum: [reuse, force_new] }
      responses:
        '200': { description: Batch decisions applied }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/batches/{batchId}/cancel:
    post:
      operationId: cancelBatch
      summary: Cancel pending work in a batch
      tags: [Batches]
      parameters:
        - $ref: '#/components/parameters/BatchId'
        - $ref: '#/components/parameters/BatchView'
      responses:
        '200':
          description: Updated batch state
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Batch' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/credits:
    get:
      operationId: getCredits
      summary: Get credit balance
      tags: [Account]
      responses:
        '200':
          description: Credit totals in minutes
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Credits'
        '401': { $ref: '#/components/responses/Unauthorized' }
  /api/v1/history:
    get:
      operationId: getHistory
      summary: Get transcription history
      tags: [Account]
      parameters:
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
        - name: search
          in: query
          schema: { type: string }
      responses:
        '200':
          description: Paginated task history
          content:
            application/json:
              schema:
                type: object
                required: [tasks, pagination]
                properties:
                  tasks:
                    type: array
                    items: { $ref: '#/components/schemas/Task' }
                  pagination:
                    $ref: '#/components/schemas/Pagination'
        '401': { $ref: '#/components/responses/Unauthorized' }
components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: >-
        Create a labeled API key in the Videosays developer dashboard. Keys are
        shown once, may expire or be revoked, and are limited to their assigned
        transcription/credit scopes. They cannot manage billing or account settings.
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Better Auth JWT
      description: A short-lived Better Auth access token for the complete signed-in account surface.
  parameters:
    OptionalIdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: >-
        Optional UUID. When supplied, replaying the same request with the same key returns the
        same resource, while using that key for a different request returns 409. When omitted,
        the submission is accepted without request-replay protection; same-user transcription
        reuse still follows options.duplicatePolicy.
      schema: { type: string, format: uuid }
    RequiredIdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: >-
        UUID required for a reuse decision. Replaying the same decision with the same key is
        safe; using that key for a different decision returns 409.
      schema: { type: string, format: uuid }
    TaskId:
      name: taskId
      in: path
      required: true
      schema: { type: string, format: uuid }
    BatchId:
      name: batchId
      in: path
      required: true
      schema: { type: string, format: uuid }
    BatchView:
      name: view
      in: query
      required: false
      description: Use status for lightweight polling without transcript text, timeline segments, or media payloads.
      schema:
        type: string
        enum: [full, status]
        default: full
  schemas:
    CreateTranscriptionRequest:
      type: object
      required: [input]
      properties:
        input:
          type: string
          description: Supported public video URL or share text containing one.
        options:
          type: object
          properties:
            language: { type: string, default: auto }
            sourceMode:
              type: string
              enum: [auto, asr, platform]
              default: auto
            duplicatePolicy:
              type: string
              enum: [reuse, prompt, force_new]
              default: reuse
        tracking:
          type: object
          additionalProperties: true
    CreateBatchRequest:
      type: object
      required: [items]
      properties:
        items:
          type: array
          minItems: 1
          maxItems: 100
          items: { type: string }
        options:
          type: object
          properties:
            language: { type: string, default: auto }
            sourceMode:
              type: string
              enum: [auto, asr, platform]
              default: auto
            duplicatePolicy:
              type: string
              enum: [reuse, prompt, force_new]
              default: reuse
        tracking:
          type: object
          additionalProperties: true
    Task:
      type: object
      required: [taskId, status, video, billing, result, media, error]
      properties:
        taskId: { type: string, format: uuid }
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [pending, analyzing, submitting, processing, awaiting_reuse, completed, failed, cancelled]
        input: { type: string }
        source:
          type: object
          required: [kind, sourceId, sourceItemId]
          properties:
            kind:
              type: string
              enum: [manual_links, douyin_collection, douyin_creator]
            sourceId: { type: [string, 'null'] }
            sourceItemId: { type: [string, 'null'] }
        video:
          type: object
          required: [platform, platformVideoId, inputUrl, title, author, coverUrl, durationSeconds]
          properties:
            platform: { type: [string, 'null'] }
            platformVideoId: { type: [string, 'null'] }
            inputUrl: { type: string }
            title: { type: [string, 'null'] }
            author: { type: [string, 'null'] }
            coverUrl: { type: [string, 'null'], format: uri }
            durationSeconds: { type: [number, 'null'] }
        billing:
          type: object
          required: [creditMinutes]
          properties:
            creditMinutes: { type: number }
            creditStatus: { type: string }
            reservedAt: { type: [string, 'null'], format: date-time }
            settledAt: { type: [string, 'null'], format: date-time }
            releasedAt: { type: [string, 'null'], format: date-time }
        result:
          type: object
          required: [text, segments, sourceType, provider]
          properties:
            text: { type: [string, 'null'] }
            segments:
              type: [array, 'null']
              items: { type: object, additionalProperties: true }
            sourceType: { type: [string, 'null'] }
            provider: { type: [string, 'null'] }
        media:
          type: [object, 'null']
          additionalProperties: true
        error:
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/TaskError'
        reuse: { $ref: '#/components/schemas/TaskReuse' }
        createdAt: { type: [string, 'null'], format: date-time }
        startedAt: { type: [string, 'null'], format: date-time }
        completedAt: { type: [string, 'null'], format: date-time }
    TaskError:
      type: object
      required: [code, message]
      properties:
        code: { type: [string, 'null'] }
        message: { type: [string, 'null'] }
    TaskReuse:
      type: object
      required: [state, reused, canonicalTaskId, detectedBy, decisionExpiresAt]
      properties:
        state:
          type: string
          enum: [none, awaiting_decision, redirected_active, redirected_completed, force_new, timed_out]
        reused: { type: boolean }
        canonicalTaskId: { type: [string, 'null'], format: uuid }
        detectedBy: { type: [string, 'null'], enum: [request_fingerprint, video_identity, null] }
        decisionExpiresAt: { type: [string, 'null'], format: date-time }
    BatchIdentity:
      type: object
      required: [sourceKind, sourceId, title, authorName, coverUrl]
      properties:
        sourceKind:
          type: string
          enum: [manual_links, douyin_collection, douyin_creator]
        sourceId: { type: [string, 'null'] }
        title: { type: [string, 'null'] }
        authorName: { type: [string, 'null'] }
        coverUrl: { type: [string, 'null'], format: uri }
    BatchSummary:
      type: object
      required: [total, waiting, preparing, pending, analyzing, submitting, processing, awaitingReuse, completed, failed, cancelled, skipped]
      properties:
        total: { type: integer, minimum: 0 }
        waiting: { type: integer, minimum: 0 }
        preparing: { type: integer, minimum: 0 }
        pending: { type: integer, minimum: 0 }
        analyzing: { type: integer, minimum: 0 }
        submitting: { type: integer, minimum: 0 }
        processing: { type: integer, minimum: 0 }
        awaitingReuse: { type: integer, minimum: 0 }
        completed: { type: integer, minimum: 0 }
        failed: { type: integer, minimum: 0 }
        cancelled: { type: integer, minimum: 0 }
        skipped: { type: integer, minimum: 0 }
    BatchItemStatus:
      type: string
      enum: [pending, analyzing, submitting, processing, awaiting_reuse, completed, failed, cancelled, skipped]
    BatchItem:
      type: object
      required: [position, input, taskId, status, task, error]
      properties:
        position: { type: integer, minimum: 0 }
        input: { type: string }
        taskId: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/BatchItemStatus' }
        task: { $ref: '#/components/schemas/Task' }
        error:
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/TaskError'
        reuse: { $ref: '#/components/schemas/TaskReuse' }
    BatchStatusItem:
      type: object
      required: [position, taskId, status, error]
      properties:
        position: { type: integer, minimum: 0 }
        taskId: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/BatchItemStatus' }
        error:
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/TaskError'
        reuse: { $ref: '#/components/schemas/TaskReuse' }
    BatchContinuation:
      type: object
      required: [required, reason, resumableItems]
      properties:
        required: { type: boolean, const: true }
        reason: { type: string, enum: [insufficient_credits] }
        resumableItems: { type: integer, minimum: 1 }
    Batch:
      type: object
      required: [batchId, identity, status, summary, items, createdAt, completedAt, stopReason, stoppedAt, stoppedTaskId, continuation, retryAfterSeconds]
      properties:
        batchId: { type: string, format: uuid }
        identity: { $ref: '#/components/schemas/BatchIdentity' }
        status:
          type: string
          enum: [pending, processing, completed, partial, failed, cancelled]
        summary: { $ref: '#/components/schemas/BatchSummary' }
        items:
          type: array
          items:
            oneOf:
              - $ref: '#/components/schemas/BatchItem'
              - $ref: '#/components/schemas/BatchStatusItem'
        createdAt: { type: string, format: date-time }
        completedAt: { type: [string, 'null'], format: date-time }
        stopReason: { type: [string, 'null'] }
        stoppedAt: { type: [string, 'null'], format: date-time }
        stoppedTaskId: { type: [string, 'null'] }
        continuation:
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/BatchContinuation'
        retryAfterSeconds: { type: [integer, 'null'] }
    Credits:
      type: object
      required: [balance, reservedBalance, availableBalance, totalPurchased, totalUsed]
      properties:
        balance: { type: number }
        reservedBalance: { type: number }
        availableBalance: { type: number }
        totalPurchased: { type: number }
        totalUsed: { type: number }
    Pagination:
      type: object
      required: [page, limit, total, totalPages, hasMore]
      properties:
        page: { type: integer }
        limit: { type: integer }
        total: { type: integer }
        totalPages: { type: integer }
        hasMore: { type: boolean }
    Error:
      type: object
      properties:
        error: { type: string }
        code: { type: string }
        retryable: { type: boolean }
        decisionTaskId:
          type: string
          format: uuid
          description: Shadow task to use with the reuse-decision endpoint when code is duplicate_completed.
        candidate:
          $ref: '#/components/schemas/Task'
  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: API key or bearer token is missing or invalid
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    InsufficientCredits:
      description: Not enough transcription credit
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UnsupportedInput:
      description: Unsupported platform or link format
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource not found or not owned by the authenticated user
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: Idempotency conflict or duplicate decision required/unavailable
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Too many requests
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
