openapi: 3.1.0
info:
  title: subtitle.ai Job API
  version: "1.0.0"
  description: |
    Turn media into subtitles without the web app: create a job, upload the
    media, poll until it is ready, then fetch the transcript or a subtitle
    file. Every job is an ordinary project in the organisation, so a person
    can open `editorUrl` to review it in the editor.

    Authenticate with an organisation API key (Settings → API keys):
    `Authorization: Bearer sk_live_...`. A key acts for its whole
    organisation. Each key may make 120 requests per minute.

    Every error is JSON: `{ "error": "<for humans>", "code": "<for code>" }`,
    sometimes with more fields. Switch on `code`.
servers:
  - url: https://app.subtitle.ai/api/v1
security:
  - apiKey: []

paths:
  /jobs:
    post:
      operationId: createJob
      summary: Create a job and get an upload URL
      description: |
        Creates the job and returns a presigned URL. PUT the media there with
        exactly the headers in `upload.headers`. The upload starts
        transcription. Until then the job stays `uploading`. The URL expires
        after an hour.

        Uploading audio only is enough, and it is much faster than video. The
        editor then shows a waveform instead of a picture.

        Credits are checked here (402) and charged when transcription starts.
        A job that fails is refunded.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateJobRequest" }
            example:
              filename: journaal-2026-10-01.wav
              contentType: audio/wav
              sizeBytes: 52428800
              durationSeconds: 1520.4
              presetId: 65f1c2a9e4b0a1b2c3d4e5f7
              settings:
                language: nl
                customVocabulary: [VRT NWS, Radio 1]
      responses:
        "201":
          description: Job created; upload the media next.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreatedJob" }
        "400":
          description: |
            `invalid_json`, or `invalid_request` with a `fields` list naming
            every problem. Unknown fields are rejected, not ignored.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: "`insufficient_credits`: the organisation cannot pay for this job."
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Error"
                  - type: object
                    properties:
                      requiredCents: { type: integer }
                      availableCents: { type: integer }
        "403":
          description: "`platform_quota_reached`: the platform's monthly upload quota is used up."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404":
          description: "`folder_not_found` or `preset_not_found` (in this organisation)."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "409":
          description: "`no_active_owner`: the organisation has no active owner to own the job."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "413":
          description: "`plan_limit_exceeded`: the file is larger or longer than the plan allows. `details` says by how much."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "501":
          description: "`source_url_not_supported`: `sourceUrl` is reserved for a later version."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: "`billing_unavailable`: credits could not be checked; retry later."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /jobs/{jobId}:
    get:
      operationId: getJob
      summary: Get a job's status and transcripts
      description: |
        Poll this until `status` is `ready` or `failed`. Every 10–30 seconds is
        plenty. Transcription usually takes a fraction of the media's
        duration.
      parameters: [{ $ref: "#/components/parameters/JobId" }]
      responses:
        "200":
          description: The job.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "400": { $ref: "#/components/responses/InvalidJobId" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/JobNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /jobs/{jobId}/transcript:
    get:
      operationId: getTranscript
      summary: Get a finished transcript
      description: |
        JSON (default) returns cues with word timings. Text returns one cue
        per line, with a blank line where the speaker changes. Ask for text
        with `format=text` or `Accept: text/plain`.
      parameters:
        - $ref: "#/components/parameters/JobId"
        - name: format
          in: query
          schema: { type: string, enum: [json, text] }
        - name: maxChars
          in: query
          description: |
            Cap the text, e.g. for a chat preview. JSON keeps whole cues, at
            least one; text is cut near a word boundary and the response
            carries `X-Truncated: true`.
          schema: { type: integer, minimum: 1, maximum: 1000000 }
        - $ref: "#/components/parameters/Language"
        - $ref: "#/components/parameters/TranscriptId"
      responses:
        "200":
          description: The transcript.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TranscriptResponse" }
            text/plain:
              schema: { type: string }
              example: "Goedemorgen en welkom.\nHet weer is zonnig.\n\nDank je wel, Jan.\n"
        "400":
          description: "`invalid_format`, `invalid_max_chars`, `invalid_transcript_id` or `invalid_job_id`."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/TranscriptOrJobNotFound" }
        "409": { $ref: "#/components/responses/NotReady" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /jobs/{jobId}/subtitles:
    get:
      operationId: getSubtitles
      summary: Download a finished transcript as a subtitle file
      description: |
        Generated by the editor's own exporter from the same stored words, so
        the file matches a download from the editor byte for byte, edits
        included. The one exception is subtitle lead-in: the editor applies a
        project's `inCueLeadInFrames` to its downloads and this endpoint does
        not. Sent as an attachment named `<job name>_<language>.<ext>`.
      parameters:
        - $ref: "#/components/parameters/JobId"
        - name: format
          in: query
          schema:
            type: string
            enum: [srt, vtt, webvtt, ttml, ass, ebu-stl]
            default: srt
        - name: offsetSeconds
          in: query
          description: |
            Shift every cue, e.g. `36000` to start at a 10:00:00:00 programme
            timecode. EBU-STL ignores it.
          schema: { type: number, minimum: -86400, maximum: 86400, default: 0 }
        - $ref: "#/components/parameters/Language"
        - $ref: "#/components/parameters/TranscriptId"
      responses:
        "200":
          description: The subtitle file.
          content:
            application/x-subrip: { schema: { type: string } }
            text/vtt: { schema: { type: string } }
            application/ttml+xml: { schema: { type: string } }
            text/x-ssa: { schema: { type: string } }
            application/octet-stream:
              schema: { type: string, format: binary }
        "400":
          description: "`unsupported_format`, `invalid_offset`, `invalid_transcript_id` or `invalid_job_id`."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/TranscriptOrJobNotFound" }
        "409": { $ref: "#/components/responses/NotReady" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: sk_live_...

  parameters:
    JobId:
      name: jobId
      in: path
      required: true
      schema: { type: string, pattern: "^[a-f0-9]{24}$" }
    Language:
      name: language
      in: query
      description: |
        Which transcript, by language code. A clean variant shares its
        original's code, so this picks the original first, then the oldest.
        Defaults to the original.
      schema: { type: string, example: en }
    TranscriptId:
      name: transcriptId
      in: query
      description: Which transcript, by id (from `Job.transcripts`). Wins over `language`.
      schema: { type: string, pattern: "^[a-f0-9]{24}$" }

  responses:
    Unauthorized:
      description: "`missing_api_key`, `malformed_api_key` or `invalid_api_key` (unknown or revoked)."
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    RateLimited:
      description: "`rate_limited`. Wait for `Retry-After` seconds."
      headers:
        Retry-After: { schema: { type: integer } }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    InvalidJobId:
      description: "`invalid_job_id`."
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    JobNotFound:
      description: "`job_not_found`: no such job in this key's organisation."
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    TranscriptOrJobNotFound:
      description: |
        `job_not_found`, or `transcript_not_found` with a `transcripts` list
        of what the job does have.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotReady:
      description: |
        `job_not_ready` (with the job's `status`), `transcript_not_ready`
        (with the transcript's `status`), `job_failed` or `transcript_failed`.
        Keep polling on the first two.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  schemas:
    Error:
      type: object
      required: [error, code]
      properties:
        error: { type: string, description: A message for a person. }
        code: { type: string, description: A stable identifier for code. }

    ValidationError:
      allOf:
        - $ref: "#/components/schemas/Error"
        - type: object
          properties:
            fields:
              type: array
              items:
                type: object
                properties:
                  field: { type: string, example: settings.maxCharsPerLine }
                  message: { type: string }

    CreateJobRequest:
      type: object
      additionalProperties: false
      required: [filename, contentType, sizeBytes, durationSeconds]
      properties:
        filename: { type: string, maxLength: 255 }
        contentType:
          type: string
          description: An `audio/*` or `video/*` type. The upload must be sent with this type.
          example: audio/wav
        sizeBytes:
          type: integer
          minimum: 1
          maximum: 5368709120
          description: Exact size of the file to upload; the upload must match it.
        durationSeconds:
          type: number
          exclusiveMinimum: 0
          description: |
            The media's duration. Required: billing and plan limits use it.
            It is the up-front estimate; after recognition the charge is
            settled against the measured duration, so under-declaring does
            not save credits.
        name:
          type: string
          maxLength: 200
          description: Job name; defaults to the filename without its extension.
        folderId:
          type: string
          description: File the job into this folder; its members get access.
        presetId:
          type: string
          description: Apply this organisation upload preset. `settings` override it.
        settings: { $ref: "#/components/schemas/JobSettings" }
        sourceUrl:
          type: string
          description: Reserved. Rejected with 501 for now.

    JobSettings:
      type: object
      additionalProperties: false
      description: |
        Same settings as the web upload form, with the same defaults. Layered
        as: defaults, then `presetId`, then these.
      properties:
        language:
          type: string
          description: |
            Spoken language: `auto` (default), `multilingual`, or a code such
            as `nl`, `en`, `zh-TW`.
        customVocabulary:
          type: array
          maxItems: 1000
          items: { type: string, maxLength: 100 }
          description: Names and terms to recognise. Replaces the preset's list.
        formatSubtitles: { type: boolean, default: true, description: AI subtitle formatting. }
        maxCharsPerLine: { type: integer, minimum: 10, maximum: 250, default: 42 }
        maxLinesPerSubtitle: { type: integer, minimum: 1, maximum: 5, default: 2 }
        minDuration: { type: number, minimum: 0, maximum: 60, description: Seconds. }
        maxDuration: { type: number, minimum: 0.1, maximum: 60, description: Seconds. }
        minFramesBetweenSubtitles: { type: integer, minimum: 0, maximum: 100, default: 3 }
        contentProfile:
          type: string
          enum: [balanced, dramatic, dense, auto]
          default: balanced
          description: Reading pace.
        subtitleMode: { type: string, enum: [default, audiogram], default: default }
        splitOnPunctuation: { type: boolean, default: false }
        emDashOnSpeakerChange: { type: boolean, default: true }
        ellipsisContinuation: { type: boolean, default: false }
        shiftMs: { type: integer, minimum: -10000, maximum: 10000, default: 0 }
        forbiddenCharacters:
          type: array
          maxItems: 100
          items:
            type: object
            required: [chars, replacement]
            properties:
              chars: { type: string, minLength: 1 }
              replacement: { type: string }
        prompt: { type: string, maxLength: 4000, description: Context for the AI formatting pass. }
        createHighlights: { type: boolean, default: false }
        createChapters: { type: boolean, default: false }

    CreatedJob:
      type: object
      properties:
        id: { type: string }
        status: { type: string, enum: [uploading] }
        name: { type: string }
        editorUrl: { type: string, format: uri }
        upload:
          type: object
          properties:
            method: { type: string, enum: [PUT] }
            url: { type: string, format: uri }
            headers:
              type: object
              description: Send exactly these with the PUT; they are signed into the URL.
              additionalProperties: { type: string }
              example: { Content-Type: audio/wav, Content-Length: "52428800" }
            expiresAt: { type: string, format: date-time }
        links: { $ref: "#/components/schemas/Links" }

    Job:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        status:
          type: string
          enum: [uploading, transcribing, formatting, ready, failed]
          description: |
            `uploading` until the media arrives. `transcribing` and
            `formatting` are in progress. `ready` and `failed` are final. New
            internal stages always map onto one of these.
        error:
          type: [string, "null"]
          description: Why the job failed; null otherwise.
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        media:
          type: [object, "null"]
          properties:
            filename: { type: string }
            contentType: { type: string }
            sizeBytes: { type: integer }
            durationSeconds: { type: number }
        language:
          type: [string, "null"]
          description: The original transcript's language once transcribed; until then, what was requested.
        transcripts:
          type: array
          description: The original first, then translations and clean variants, oldest first.
          items: { $ref: "#/components/schemas/Transcript" }
        editorUrl: { type: string, format: uri }
        links: { $ref: "#/components/schemas/Links" }

    Transcript:
      type: object
      properties:
        id: { type: string }
        language: { type: string }
        kind: { type: string, enum: [original, translation, clean] }
        label: { type: [string, "null"] }
        status: { type: string, enum: [pending, processing, ready, failed] }

    TranscriptResponse:
      type: object
      properties:
        jobId: { type: string }
        transcript: { $ref: "#/components/schemas/Transcript" }
        truncated: { type: boolean }
        cueCount: { type: integer, description: Cues in the whole transcript, before truncation. }
        cues:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              start: { type: number, description: Seconds. }
              end: { type: number }
              speaker: { type: integer }
              lines: { type: array, items: { type: string } }
              words:
                type: array
                items:
                  type: object
                  properties:
                    text: { type: string }
                    start: { type: number }
                    end: { type: number }

    Links:
      type: object
      properties:
        self: { type: string, format: uri }
        transcript: { type: string, format: uri }
        subtitles: { type: string, format: uri }
        editor: { type: string, format: uri }
