> ## Documentation Index
> Fetch the complete documentation index at: https://docs.audivo.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Create or attach to a transcription job, or get an instant result

> Accepts exactly one of three input shapes (`url`, `feed_url` + `guid`, or `episode_id`); supplying more than one, or fields from more than one shape, fails schema validation. Returns a synchronous result on a cache hit or publisher-transcript passthrough, or `202` with a job to poll on a miss. `dry_run: true` returns a quote and creates neither a job nor a reservation.

**Every job belongs to a group.** A single-episode submission is a job group of one: the same submission sequence a quote's confirm fans out over N members, run once, so the two can never disagree. The accepted job carries its `group_id`, which `GET /v1/groups/{group_id}` and `POST /v1/groups/{group_id}/cancel` accept. An `Idempotency-Key`, when sent, is honoured at the group: a repeat returns the original job — or the original cached read — and charges nothing further; its `status` is then the job's current state. A repeat that arrives while the original is still running — its group begun, its member not yet landed — is `409 request_in_progress`: retry with the same key shortly, never a new one, which would start a second job for the same episode.

**Not yet served on this path (0.4.0).** `format` other than `json` is refused with `invalid_request` here — the read operations
deliver the raw formats — and a cache hit above the inline limit is delivered by reference as `TranscriptRead.transcript_url`; `language` is accepted and recorded nowhere.

**Delivery format.** `format: json` (or omitted) returns `application/json`. `format: text|srt|vtt|md` returns the raw artifact under its own media type (`text/plain`, `application/x-subrip`, `text/vtt`, `text/markdown`) — never a JSON wrapper around a string. Because a raw body has nowhere to put provenance, those responses carry it in headers instead: `X-Transcript-Episode-Id`, `X-Transcript-Source`, `X-Transcript-Timing-Precision`, and `X-Credits-Charged`. **The single content-type switch:** a payload over the ~5 MB inline limit returns `200 application/json` with `{ transcript_url, expires_at }` in *every* format — including the raw ones, whose declared media type therefore does not apply to that one case. A `202`, a dry-run quote, and every error are always `application/json` regardless of `format`.



## OpenAPI

````yaml /openapi.yaml post /v1/transcripts
openapi: 3.1.0
info:
  title: Audivo API
  version: 0.11.0
  description: >-
    The Audivo API turns a public podcast episode into a clean, timed
    transcript. Name an episode by an Apple Podcasts link, by its RSS feed URL
    and GUID, or by the episode id an earlier answer gave you; get back
    structured, timed text — at once for an episode already transcribed, or as a
    job you poll for fresh audio.


    Two authentication schemes, and which an operation takes is part of what it
    is: `apiKeyBearer` — an `hk_live_` key sent as `Authorization: Bearer` —
    authenticates the public API, and `dashboardJwt` authenticates the
    dashboard's own operations. No operation that takes one accepts the other in
    its place. An API-key caller reads its balance and reservations from any
    quote response.


    Deferred on purpose, and absent from this document rather than hidden:
    webhooks, subscriptions, speaker labels beyond the frozen `diarized: false`
    shape, custom vocabulary, caller-selected engines, raw-audio URLs and
    uploads, SDKs, translation, and cross-corpus search. What changed in each
    version is on the documentation site's changelog.
  license:
    name: Proprietary
    url: https://docs.audivo.dev/legal/terms
servers:
  - url: https://k8mfogcvz4.execute-api.us-east-1.amazonaws.com/prod
    description: Production API. Append the operation path, including /v1.
security: []
tags:
  - name: Transcripts
    description: Create, poll, retrieve, and cancel transcription jobs.
  - name: Catalog
    description: >-
      Public podcast show/episode search, charts, detail, and resolution. Every
      metadata/search/list call in this tag costs 1 credit; no catalog response
      echoes `credits_charged`, so the charge is stated here rather than
      per-response. Not yet applied: the discovery operations shipped in 0.2.0
      (`GET /v1/search/shows`, `GET /v1/charts`) are metered by request rate
      only and write no credit-bearing row; the per-call charge lands with the
      catalog reads that bill.
  - name: Quotes
    description: >-
      Price a selection before spending. A quote resolves every episode in it,
      records what it resolved under a short TTL, and reserves nothing; the
      confirm that follows is the operation that spends, and it answers with a
      job group.
  - name: Groups
    description: >-
      The job group: what one confirm created. A group holds N ≥ 1 members, each
      a transcription job or a settled cache read; a single-episode `POST
      /v1/transcripts` is a group of one. A caller reads a group's rollup, lists
      their own groups newest first, and cancels a group, which cancels every
      member not yet started and releases each reservation exactly once.
  - name: Session
    description: >-
      Dashboard-JWT session establishment. One operation, called by the
      dashboard's backend-for-frontend whenever it turns a Cognito sign-in into
      a Audivo session, and the one place a caller who does not yet have an
      account is served.
  - name: Account
    description: Usage, limits, and supported languages for the calling account.
  - name: API Keys
    description: Dashboard-JWT key lifecycle management.
  - name: Billing
    description: Dashboard-JWT Stripe Checkout, Customer Portal, invoices, and refunds.
  - name: Data
    description: Dashboard-JWT portable export and account deletion.
paths:
  /v1/transcripts:
    post:
      tags:
        - Transcripts
      summary: Create or attach to a transcription job, or get an instant result
      description: >-
        Accepts exactly one of three input shapes (`url`, `feed_url` + `guid`,
        or `episode_id`); supplying more than one, or fields from more than one
        shape, fails schema validation. Returns a synchronous result on a cache
        hit or publisher-transcript passthrough, or `202` with a job to poll on
        a miss. `dry_run: true` returns a quote and creates neither a job nor a
        reservation.


        **Every job belongs to a group.** A single-episode submission is a job
        group of one: the same submission sequence a quote's confirm fans out
        over N members, run once, so the two can never disagree. The accepted
        job carries its `group_id`, which `GET /v1/groups/{group_id}` and `POST
        /v1/groups/{group_id}/cancel` accept. An `Idempotency-Key`, when sent,
        is honoured at the group: a repeat returns the original job — or the
        original cached read — and charges nothing further; its `status` is then
        the job's current state. A repeat that arrives while the original is
        still running — its group begun, its member not yet landed — is `409
        request_in_progress`: retry with the same key shortly, never a new one,
        which would start a second job for the same episode.


        **Not yet served on this path (0.4.0).** `format` other than `json` is
        refused with `invalid_request` here — the read operations

        deliver the raw formats — and a cache hit above the inline limit is
        delivered by reference as `TranscriptRead.transcript_url`; `language` is
        accepted and recorded nowhere.


        **Delivery format.** `format: json` (or omitted) returns
        `application/json`. `format: text|srt|vtt|md` returns the raw artifact
        under its own media type (`text/plain`, `application/x-subrip`,
        `text/vtt`, `text/markdown`) — never a JSON wrapper around a string.
        Because a raw body has nowhere to put provenance, those responses carry
        it in headers instead: `X-Transcript-Episode-Id`, `X-Transcript-Source`,
        `X-Transcript-Timing-Precision`, and `X-Credits-Charged`. **The single
        content-type switch:** a payload over the ~5 MB inline limit returns
        `200 application/json` with `{ transcript_url, expires_at }` in *every*
        format — including the raw ones, whose declared media type therefore
        does not apply to that one case. A `202`, a dry-run quote, and every
        error are always `application/json` regardless of `format`.
      operationId: createTranscript
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TranscriptCreateRequest'
            examples:
              byUrl:
                summary: Apple Podcasts episode URL
                value:
                  url: https://podcasts.apple.com/us/podcast/x/id123?i=456
                  language: en
                  format: json
                  dry_run: false
              byFeedAndGuid:
                summary: RSS feed URL plus episode GUID
                value:
                  feed_url: https://example.com/feed.xml
                  guid: episode-guid
                  language: null
              byEpisodeId:
                summary: Canonical episode_id
                value:
                  episode_id: ep_6cvvz2gskmrjfsze
                  format: vtt
      responses:
        '200':
          description: >-
            A cache hit or publisher-transcript passthrough (charges
            `credits_charged`), or a dry-run quote (`dry_run: true`; creates no
            job or reservation). The media type follows `format`; see the
            operation description for the one documented switch back to
            `application/json`.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-Transcript-Episode-Id:
              $ref: '#/components/headers/XTranscriptEpisodeId'
            X-Transcript-Source:
              $ref: '#/components/headers/XTranscriptSource'
            X-Transcript-Timing-Precision:
              $ref: '#/components/headers/XTranscriptTimingPrecision'
            X-Credits-Charged:
              $ref: '#/components/headers/XCreditsCharged'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/TranscriptRead'
                  - $ref: '#/components/schemas/TranscriptQuote'
                  - $ref: '#/components/schemas/TranscriptUrlRef'
              examples:
                cacheHit:
                  $ref: '#/components/examples/CacheHitTranscript'
                publisherSourced:
                  $ref: '#/components/examples/PublisherSegmentTranscript'
                dryRunQuote:
                  $ref: '#/components/examples/DryRunQuote'
                oversizedArtifact:
                  $ref: '#/components/examples/OversizedArtifactRef'
            text/plain:
              schema:
                $ref: '#/components/schemas/RawTranscriptArtifact'
            application/x-subrip:
              schema:
                $ref: '#/components/schemas/RawTranscriptArtifact'
              examples:
                srt:
                  $ref: '#/components/examples/SrtArtifact'
            text/vtt:
              schema:
                $ref: '#/components/schemas/RawTranscriptArtifact'
            text/markdown:
              schema:
                $ref: '#/components/schemas/RawTranscriptArtifact'
        '202':
          description: >-
            Accepted for fresh work after supported-pointer resolution and an
            atomic quote-ceiling reservation. The job may sit in `validating`
            while a bounded enclosure probe completes.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TranscriptJobAccepted'
              examples:
                freshJobAccepted:
                  $ref: '#/components/examples/FreshJobAccepted'
                testModeJob:
                  $ref: '#/components/examples/TestModeJobAccepted'
        '400':
          $ref: '#/components/responses/BadRequestApiKey'
        '401':
          $ref: '#/components/responses/UnauthorizedApiKey'
        '402':
          $ref: '#/components/responses/PaymentRequiredApiKey'
        '404':
          $ref: '#/components/responses/NotFoundApiKey'
        '409':
          $ref: '#/components/responses/ConflictApiKeyIdempotency'
        '422':
          $ref: '#/components/responses/UnprocessableApiKey'
        '429':
          $ref: '#/components/responses/TooManyRequestsApiKey'
        '451':
          $ref: '#/components/responses/ContentBlockedApiKey'
        '500':
          $ref: '#/components/responses/InternalErrorApiKey'
        '503':
          $ref: '#/components/responses/ServiceUnavailableApiKey'
      security:
        - apiKeyBearer: []
components:
  parameters:
    IdempotencyKeyHeader:
      name: Idempotency-Key
      in: header
      required: false
      description: >-
        Account-scoped for 24 hours; stores a canonical request-body hash. Reuse
        with a different body returns `409 idempotency_conflict`; a repeat while
        the original request is still running returns `409 request_in_progress`
        (retry with the same key). This is separate from the cache lease used to
        coalesce inference.
      schema:
        type: string
        minLength: 1
        maxLength: 255
        pattern: ^[!-~]{1,255}$
  schemas:
    TranscriptCreateRequest:
      description: >-
        Exactly one of `url`, `feed_url`+`guid`, or `episode_id`. `format` is
        delivery-only and never affects the cache key or the produced
        transcript. Raw-audio URLs, uploads, engine selection, diarization,
        vocabulary, and callback URLs are rejected rather than ignored.
      oneOf:
        - $ref: '#/components/schemas/TranscriptCreateByUrl'
        - $ref: '#/components/schemas/TranscriptCreateByFeedGuid'
        - $ref: '#/components/schemas/TranscriptCreateByEpisodeId'
    TranscriptRead:
      type: object
      additionalProperties: false
      required:
        - format
        - is_cached
        - credits_charged
      description: >-
        A synchronous JSON transcript read: a cache hit or publisher passthrough
        (`POST /v1/transcripts`), a direct cached fetch (`GET
        /v1/episodes/{episode_id}/transcript`), or the re-delivery of a read
        that was already paid for (`GET /v1/reads/{read_id}`). `credits_charged`
        is what *this* request charged, which is why it is `0` on the last of
        the three and the cached-read price for this account on the other two.
        `format` is always `json` here — a `text|srt|vtt|md` read of the same
        transcript is delivered as a raw body with `X-Credits-Charged` carrying
        this envelope's `credits_charged`. See the comment above for why this
        shape is self-contained rather than composed via `allOf`.
      properties:
        format:
          const: json
        is_cached:
          type: boolean
        credits_charged:
          $ref: '#/components/schemas/CreditAmount'
        transcript:
          $ref: '#/components/schemas/CanonicalTranscript'
        transcript_url:
          type: string
          format: uri
          maxLength: 2048
        expires_at:
          type: string
          format: date-time
      oneOf:
        - required:
            - transcript
        - required:
            - transcript_url
            - expires_at
    TranscriptQuote:
      type: object
      additionalProperties: false
      required:
        - episode_id
        - dry_run
        - is_cached
        - estimated_credits
        - quote_ceiling_credits
        - quote_basis
        - estimated_seconds
      description: >-
        A dry-run quote: the `dry_run: true` response of `POST /v1/transcripts`.
        Explicitly carries no `job_id` and no reservation fields — a dry run
        creates neither. The accepted ceiling from a quote is honored exactly if
        a job is later submitted: probing may reduce settled usage, but the
        system never raises the ceiling or settles above it without a new
        customer action.


        `GET /v1/episodes/{episode_id}/transcript` returns the same figures for
        its "nothing cached yet" case, but as `EpisodeQuoteHint` — that request
        was not a dry run, so it must not claim `dry_run: true`.
      properties:
        episode_id:
          $ref: '#/components/schemas/EpisodeId'
        dry_run:
          const: true
        is_cached:
          type: boolean
        estimated_credits:
          $ref: '#/components/schemas/CreditAmount'
        quote_ceiling_credits:
          $ref: '#/components/schemas/CreditAmount'
          description: estimate + 25%, rounded up.
        quote_basis:
          $ref: '#/components/schemas/QuoteBasis'
        estimated_seconds:
          $ref: '#/components/schemas/EstimatedProcessingSeconds'
    TranscriptUrlRef:
      type: object
      additionalProperties: false
      required:
        - transcript_url
        - expires_at
      description: >-
        The oversized-payload response — the single content-type switch in this
        API. A transcript above the ~5 MB inline limit is never sent inline in
        any format; instead the response is `200 application/json` with a
        presigned URL (24 h) and its expiry, even when `format` asked for
        `text`, `srt`, `vtt`, or `md`. For `format=json` the media type does not
        change, and the same two fields appear inside the JSON envelope
        (`TranscriptRead.transcript_url` / `TranscriptArtifact.transcript_url`)
        in place of the inline transcript — the behavior is identical in every
        format; only the raw formats also change media type.
      properties:
        transcript_url:
          type: string
          format: uri
          maxLength: 2048
        expires_at:
          type: string
          format: date-time
    RawTranscriptArtifact:
      type: string
      maxLength: 5000000
      description: >-
        A derived transcript artifact delivered as a raw body under its own
        media type: `text/plain` (`format=text`), `application/x-subrip`
        (`srt`), `text/vtt` (`vtt`), or `text/markdown` (`md`). Provenance
        travels in the `X-Transcript-Episode-Id`, `X-Transcript-Source`, and
        `X-Transcript-Timing-Precision` response headers, because a raw body has
        no field to carry it. Bodies above the ~5 MB inline limit are not sent
        here at all: that response switches to `application/json` carrying a
        `TranscriptUrlRef`.
    TranscriptJobAccepted:
      type: object
      additionalProperties: false
      required:
        - job_id
        - group_id
        - status
        - episode_id
        - estimated_credits
        - quote_ceiling_credits
        - quote_basis
        - reserved_credits
        - is_cached
        - estimated_seconds
      description: >-
        Returned on `202` when fresh work is accepted. The job is the one member
        of the group `group_id` names.
      properties:
        job_id:
          $ref: '#/components/schemas/JobId'
        group_id:
          $ref: '#/components/schemas/GroupId'
        status:
          $ref: '#/components/schemas/JobState'
          description: >-
            `validating` or `queued` on a first acceptance. A repeat under the
            same `Idempotency-Key` returns the same job in whatever state it has
            reached since, so a client that retried is never told a job is
            queued when it is already transcribing.
        episode_id:
          $ref: '#/components/schemas/EpisodeId'
        estimated_credits:
          $ref: '#/components/schemas/CreditAmount'
        quote_ceiling_credits:
          $ref: '#/components/schemas/CreditAmount'
        quote_basis:
          $ref: '#/components/schemas/QuoteBasis'
        reserved_credits:
          $ref: '#/components/schemas/CreditAmount'
          description: The quote ceiling, atomically reserved at accept time.
        is_cached:
          type: boolean
        estimated_seconds:
          $ref: '#/components/schemas/EstimatedProcessingSeconds'
    TranscriptCreateByUrl:
      type: object
      additionalProperties: false
      required:
        - url
      properties:
        url:
          $ref: '#/components/schemas/PodcastUrl'
        language:
          $ref: '#/components/schemas/NullableLanguage'
        format:
          $ref: '#/components/schemas/TranscriptFormat'
        dry_run:
          type: boolean
          default: false
    TranscriptCreateByFeedGuid:
      type: object
      additionalProperties: false
      required:
        - feed_url
        - guid
      properties:
        feed_url:
          $ref: '#/components/schemas/FeedUrl'
        guid:
          $ref: '#/components/schemas/EpisodeGuid'
        language:
          $ref: '#/components/schemas/NullableLanguage'
        format:
          $ref: '#/components/schemas/TranscriptFormat'
        dry_run:
          type: boolean
          default: false
    TranscriptCreateByEpisodeId:
      type: object
      additionalProperties: false
      required:
        - episode_id
      properties:
        episode_id:
          $ref: '#/components/schemas/EpisodeId'
        language:
          $ref: '#/components/schemas/NullableLanguage'
        format:
          $ref: '#/components/schemas/TranscriptFormat'
        dry_run:
          type: boolean
          default: false
    EpisodeId:
      type: string
      pattern: ^ep_[a-z2-7]{16}$
      description: Canonical episode identifier.
    TranscriptSource:
      type: string
      enum:
        - qwen3-asr
        - whisper-large-v3-turbo
        - whisper-large-v3
        - publisher
      description: >-
        The ASR engine that produced this transcript, or `publisher` for
        passthrough.
    TimingPrecision:
      type: string
      enum:
        - word
        - segment
        - none
      description: >-
        How precisely segments/words are timed. ASR output targets `word`.
        Publisher-sourced transcripts report the precision the source actually
        provides and never claim word timing they do not have: plain text is
        `none`, segment-timed sources are `segment`, and only genuine word-timed
        sources are `word`.
    CreditAmount:
      type: integer
      minimum: 0
      maximum: 9007199254740991
      description: >-
        A non-negative JavaScript-safe integer credit amount. The upper bound
        matches `Number.MAX_SAFE_INTEGER`, which is enforced by the domain
        ledger so arithmetic and JSON round-trips cannot silently lose cents
        worth of credit precision.
    CanonicalTranscript:
      type: object
      additionalProperties: false
      required:
        - episode_id
        - show_id
        - language
        - duration_sec
        - source
        - source_revision
        - model_version
        - pipeline_version
        - timing_precision
        - diarized
        - warnings
        - segments
        - created_at
      properties:
        episode_id:
          $ref: '#/components/schemas/EpisodeId'
        show_id:
          $ref: '#/components/schemas/ShowId'
        language:
          $ref: '#/components/schemas/Language'
          description: Resolved BCP-47 language code.
        duration_sec:
          type: number
          minimum: 0
          maximum: 129600
        source:
          $ref: '#/components/schemas/TranscriptSource'
        source_revision:
          type: string
          minLength: 1
          maxLength: 150
          description: >-
            e.g. `sha256:...` — the asset revision this transcript was produced
            from.
        model_version:
          type: string
          minLength: 1
          maxLength: 100
          description: Provider-pinned model version, or `publisher` for passthrough.
        pipeline_version:
          type: string
          minLength: 1
          maxLength: 60
        timing_precision:
          $ref: '#/components/schemas/TimingPrecision'
        diarized:
          const: false
          description: Always `false` in the MVP; diarization is post-MVP.
        warnings:
          type: array
          maxItems: 1000
          items:
            $ref: '#/components/schemas/TranscriptWarning'
        segments:
          type: array
          maxItems: 200000
          items:
            $ref: '#/components/schemas/TranscriptSegment'
        created_at:
          type: string
          format: date-time
    QuoteBasis:
      type: string
      enum:
        - feed_metadata
        - probed
      description: >-
        `feed_metadata` when the estimate is derived from feed-published
        duration; `probed` when a bounded enclosure probe measured it.
    EstimatedProcessingSeconds:
      type: integer
      minimum: 0
      maximum: 72000
      description: >-
        Estimated **wall-clock** seconds until the job reaches a terminal state
        — intended for choosing a poll interval. This is *not* audio duration:
        audio duration has its own field (`duration_sec` on the transcript,
        `duration_sec` on `EpisodeSummary`), and conflating the two silently
        misprices everything a caller derives from it. The committed examples
        only make sense under this reading: `estimated_seconds: 420` alongside
        `estimated_credits: 268` cannot be audio seconds, because 7 audio
        minutes bills 7 credits at `CREDITS_PER_AUDIO_MINUTE`, while 268 credits
        is about 4.5 hours of audio.


        The bound is derived from that meaning rather than copied from the audio
        cap: twice the published 10-hour `max_audio_duration_seconds` ceiling,
        an end-to-end envelope that still holds at a pathological 1× realtime
        factor. The real hard per-job timeout is duration-scaled and much
        shorter.
    JobId:
      type: string
      pattern: ^job_[A-Za-z0-9]{16,32}$
      maxLength: 40
      description: Opaque, server-generated job identifier.
    GroupId:
      type: string
      pattern: ^grp_[A-Za-z0-9]{16,32}$
      maxLength: 40
      description: Opaque, server-generated job group identifier.
    JobState:
      type: string
      enum:
        - validating
        - queued
        - downloading
        - transcribing
        - merging
        - completed
        - failed
        - cancelled
      description: >-
        Legal transitions: validating → queued|failed|cancelled; queued →
        downloading|failed|cancelled; downloading → transcribing|failed;
        transcribing → merging|failed; merging → completed|failed. Terminal
        states (completed, failed, cancelled) have no further transitions.
        Cancellation is legal only from validating/queued; every non-terminal
        state may fail.
    Error:
      type: object
      additionalProperties: false
      required:
        - error
      properties:
        error:
          $ref: '#/components/schemas/ErrorDetail'
    PodcastUrl:
      type: string
      format: uri
      minLength: 1
      maxLength: 2048
      description: An Apple Podcasts episode URL.
    NullableLanguage:
      description: >-
        A BCP-47 language tag, or `null`/omitted to auto-detect. Automatic
        detection never selects the ASR engine: it always routes to the
        configured default engine, and detected language is recorded only as a
        reporting field. Reaching a language outside the default engine's
        coverage requires passing this field explicitly.
      anyOf:
        - $ref: '#/components/schemas/Language'
        - enum:
            - null
    TranscriptFormat:
      type: string
      enum:
        - json
        - text
        - srt
        - vtt
        - md
      description: >-
        Delivery-only: never affects the cache key or the produced transcript
        content. Every format is derived at read time from the one canonical
        transcript. Response media type per format: `json` → `application/json`,
        `text` → `text/plain`, `srt` → `application/x-subrip`, `vtt` →
        `text/vtt`, `md` → `text/markdown`. The one exception is an oversized
        payload, which is `application/json` in every format (see
        `TranscriptUrlRef`).
    FeedUrl:
      type: string
      format: uri
      minLength: 1
      maxLength: 2048
      description: An RSS/Atom feed URL.
    EpisodeGuid:
      type: string
      minLength: 1
      maxLength: 512
      description: >-
        An episode GUID as published in the feed's `<guid>` element. Used
        verbatim — GUIDs are case- and whitespace-significant.
    ShowId:
      type: string
      pattern: ^sh_[a-z2-7]{16}$
      description: Canonical show identifier.
    Language:
      type: string
      pattern: ^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$
      maxLength: 35
      description: >-
        A BCP-47 language tag (general syntax check, not full IANA subtag
        validation).
    TranscriptWarning:
      type: object
      additionalProperties: false
      required:
        - segment
        - type
        - detail
      description: >-
        A quality warning from the pipeline's own checks, attached to the
        segment it concerns. Presented as a heuristic, not an accuracy
        guarantee; the transcript is delivered and charged as usual. `type` is
        an open string rather than a closed enum; the documentation's
        output-formats page lists the values in use.
      properties:
        segment:
          type: integer
          minimum: 0
          maximum: 1000000
        type:
          type: string
          minLength: 1
          maxLength: 60
        detail:
          type: string
          minLength: 1
          maxLength: 500
    TranscriptSegment:
      type: object
      additionalProperties: false
      required:
        - id
        - start
        - end
        - speaker
        - text
      properties:
        id:
          type: integer
          minimum: 0
          maximum: 1000000
        start:
          type: number
          minimum: 0
          maximum: 129600
          description: Start time in seconds.
        end:
          type: number
          minimum: 0
          maximum: 129600
          description: End time in seconds.
        speaker:
          enum:
            - null
          description: >-
            Always `null` in the MVP — diarization is post-MVP. A future
            diarized-speaker field is a deliberate, reviewed type change, not a
            silent widening.
        text:
          type: string
          maxLength: 20000
        words:
          type: array
          maxItems: 20000
          items:
            $ref: '#/components/schemas/TranscriptWord'
          description: Present when `timing_precision` is `word`; absent otherwise.
    ErrorDetail:
      type: object
      additionalProperties: false
      required:
        - type
        - code
        - message
        - doc_url
        - request_id
        - retryable
      properties:
        type:
          $ref: '#/components/schemas/ErrorType'
        code:
          $ref: '#/components/schemas/ErrorCode'
        message:
          type: string
          minLength: 1
          maxLength: 1000
        doc_url:
          type: string
          format: uri
          maxLength: 300
        request_id:
          $ref: '#/components/schemas/RequestId'
        retryable:
          type: boolean
      oneOf:
        - required:
            - code
            - type
          properties:
            code:
              enum:
                - invalid_request
                - invalid_url
                - source_not_supported
                - unsafe_source
            type:
              const: invalid_request
        - required:
            - code
            - type
          properties:
            code:
              enum:
                - duration_exceeded
                - size_exceeded
                - unsupported_codec
                - unsupported_language
                - credits_not_refundable
            type:
              const: unprocessable_input
        - required:
            - code
            - type
          properties:
            code:
              const: unauthenticated
            type:
              const: unauthenticated
        - required:
            - code
            - type
          properties:
            code:
              enum:
                - idempotency_conflict
                - job_not_completed
                - quote_expired
                - quote_mismatch
                - quote_unverified
                - expected_total_mismatch
                - request_in_progress
                - account_suspended
                - account_closed
                - account_not_found
                - api_key_limit_reached
                - tier_unchanged
            type:
              const: conflict
        - required:
            - code
            - type
          properties:
            code:
              enum:
                - feed_dead
                - episode_not_found
                - show_not_found
                - job_not_found
                - quote_not_found
                - group_not_found
                - api_key_not_found
                - credit_lot_not_found
            type:
              const: not_found
        - required:
            - code
            - type
          properties:
            code:
              const: content_blocked
            type:
              const: content_blocked
        - required:
            - code
            - type
          properties:
            code:
              const: payment_required
            type:
              const: payment_required
        - required:
            - code
            - type
          properties:
            code:
              enum:
                - rate_limited
                - concurrency_limited
            type:
              const: rate_limited
        - required:
            - code
            - type
          properties:
            code:
              enum:
                - engine_unavailable
                - processing_failed
                - discovery_unavailable
                - internal_error
            type:
              const: unavailable
        - required:
            - code
            - type
          properties:
            code:
              const: nothing_to_quote
            type:
              const: unprocessable_input
    TranscriptWord:
      type: object
      additionalProperties: false
      required:
        - w
        - s
        - e
      properties:
        w:
          type: string
          minLength: 1
          maxLength: 200
          description: The word text.
        s:
          type: number
          minimum: 0
          maximum: 129600
          description: Start time in seconds.
        e:
          type: number
          minimum: 0
          maximum: 129600
          description: End time in seconds.
    ErrorType:
      type: string
      enum:
        - invalid_request
        - unprocessable_input
        - unauthenticated
        - conflict
        - not_found
        - content_blocked
        - payment_required
        - rate_limited
        - unavailable
      description: >-
        The family an error belongs to, for handling; `code` is the precise
        reason. `invalid_request`: something in the request is wrong — fix the
        integration. `unprocessable_input`: the request is well-formed but the
        episode or resource is outside published support. `unauthenticated`:
        missing, malformed, revoked or expired credential. `payment_required`:
        the account's credits cannot cover the ceiling. `conflict`: the
        resource's or the account's state refuses this request as sent — a
        lapsed quote, a reused idempotency key, a held account. `not_found`: the
        job, quote, group or key the request named does not exist for this
        account. `rate_limited`: over a plan limit; wait and retry.
        `unavailable`: a failure on our side or at a provider; retry, and quote
        `request_id` if it persists.
    ErrorCode:
      type: string
      enum:
        - invalid_request
        - invalid_url
        - unauthenticated
        - idempotency_conflict
        - job_not_completed
        - source_not_supported
        - feed_dead
        - episode_not_found
        - show_not_found
        - unsafe_source
        - unsupported_codec
        - unsupported_language
        - duration_exceeded
        - size_exceeded
        - content_blocked
        - payment_required
        - rate_limited
        - concurrency_limited
        - job_not_found
        - api_key_not_found
        - api_key_limit_reached
        - credit_lot_not_found
        - credits_not_refundable
        - engine_unavailable
        - processing_failed
        - discovery_unavailable
        - nothing_to_quote
        - internal_error
        - quote_not_found
        - group_not_found
        - quote_expired
        - quote_mismatch
        - quote_unverified
        - expected_total_mismatch
        - request_in_progress
        - account_suspended
        - account_closed
        - account_not_found
        - tier_unchanged
      description: >-
        Every code the API returns, and the HTTP status it comes with.
        `invalid_request`, `invalid_url` → 400. `unauthenticated` → 401.
        `payment_required` → 402. `job_not_found`, `quote_not_found`,
        `group_not_found`, `api_key_not_found`, `credit_lot_not_found` → 404.
        `idempotency_conflict`, `request_in_progress`, `job_not_completed`,
        `quote_expired`, `quote_mismatch`, `quote_unverified`,
        `expected_total_mismatch`, `account_suspended`, `account_closed`,
        `account_not_found`, `api_key_limit_reached`, `tier_unchanged` → 409.
        `source_not_supported`, `feed_dead`, `episode_not_found`,
        `show_not_found`, `unsafe_source`, `unsupported_codec`,
        `unsupported_language`, `duration_exceeded`, `size_exceeded`,
        `nothing_to_quote`, `credits_not_refundable` → 422. `content_blocked` →
        451. `rate_limited`, `concurrency_limited` → 429. `internal_error`,
        `processing_failed` → 500. `discovery_unavailable`, `engine_unavailable`
        → 503. `processing_failed` also appears inside a failed job's `error`
        field rather than as a live status. Each code has a heading on the
        documentation site's errors page, which is what `doc_url` links to.
    RequestId:
      type: string
      pattern: ^req_[A-Za-z0-9-]+$
      maxLength: 100
  headers:
    XRequestId:
      description: >-
        Echoed on every response, success or error. A refusal generated at the
        edge (401, 429, and the unmetered-key 500) carries API Gateway's own
        hyphenated request id after the `req_` prefix; a response from a
        function carries the same id without hyphens.
      schema:
        type: string
        pattern: ^req_[A-Za-z0-9-]+$
        maxLength: 100
    XRateLimitLimit:
      description: >-
        The caller's requests-per-minute ceiling for the current tier. Present
        on every response for a resolved caller, success and error alike; absent
        from `401 unauthenticated`, which has no account to report a ceiling
        for, and from the edge's own `429`, which is emitted before any function
        runs.
      schema:
        type: integer
        minimum: 0
    XTranscriptEpisodeId:
      description: >-
        The canonical `episode_id` of the delivered transcript, so a raw
        artifact is not provenance-free. Absent on responses that carry no
        transcript.
      schema:
        $ref: '#/components/schemas/EpisodeId'
    XTranscriptSource:
      description: >-
        The delivered transcript's `source` — the pinned ASR engine, or
        `publisher` for passthrough. Absent on responses that carry no
        transcript.
      schema:
        $ref: '#/components/schemas/TranscriptSource'
    XTranscriptTimingPrecision:
      description: >-
        The delivered transcript's `timing_precision` (`word | segment | none`).
        every transcript exposes it, so it travels with every delivery format —
        a raw SRT body reports the same precision the canonical JSON would.
        Absent on responses that carry no transcript.
      schema:
        $ref: '#/components/schemas/TimingPrecision'
    XCreditsCharged:
      description: >-
        Credits charged for this read, mirroring the `credits_charged` field of
        the JSON envelope so a raw-artifact delivery does not silently drop the
        billing figure. Absent on responses that carry no charge.
      schema:
        $ref: '#/components/schemas/CreditAmount'
    RetryAfter:
      description: >-
        Seconds to wait before retrying. Sent with the edge's `rate_limited`
        throttle refusal. Not sent with `concurrency_limited`, which clears when
        one of the account's own jobs reaches a terminal state — a moment the
        API cannot put a number on.
      schema:
        type: integer
        minimum: 0
  examples:
    CacheHitTranscript:
      summary: Cache hit (charges 1 credit)
      value:
        format: json
        is_cached: true
        credits_charged: 1
        transcript:
          episode_id: ep_a3urk6fzq2mvbxyc
          show_id: sh_k4n7qzvw2mtxyabc
          language: en
          duration_sec: 3841
          source: qwen3-asr
          source_revision: sha256:8f14e45fceea167a5a36dedd4bea2543deca1c7c8b3c7e2b
          model_version: qwen3-asr-1.7b
          pipeline_version: 2026.08-vad4-fw1.2
          timing_precision: word
          diarized: false
          warnings:
            - segment: 41
              type: repetition_loop
              detail: phrase repeated 14x
          segments:
            - id: 0
              start: 0
              end: 6.48
              speaker: null
              text: Welcome back to the show…
              words:
                - w: Welcome
                  s: 0
                  e: 0.31
          created_at: '2026-08-20T09:00:00Z'
    PublisherSegmentTranscript:
      summary: Publisher-sourced transcript with segment-level timing
      value:
        format: json
        is_cached: true
        credits_charged: 1
        transcript:
          episode_id: ep_f4tcqk2zmvbxyna7
          show_id: sh_q7zmxbv2ctkn4yaf
          language: en
          duration_sec: 1834
          source: publisher
          source_revision: sha256:6c1d4e9f2c1d4e9f2c1d4e9f2c1d4e9f2c1d4e9f
          model_version: publisher
          pipeline_version: 2026.08-vad4-fw1.2
          timing_precision: segment
          diarized: false
          warnings: []
          segments:
            - id: 0
              start: 0
              end: 12.5
              speaker: null
              text: Welcome back to the show.
          created_at: '2026-08-20T09:05:00Z'
    DryRunQuote:
      summary: Dry-run quote — no job_id, no reservation
      value:
        episode_id: ep_hbrpoigf3cbfnobm
        dry_run: true
        is_cached: false
        estimated_credits: 268
        quote_ceiling_credits: 335
        quote_basis: probed
        estimated_seconds: 420
    OversizedArtifactRef:
      summary: Oversized payload — the single content-type switch
      description: >-
        A transcript above the ~5 MB inline limit is returned as `200
        application/json` carrying a presigned URL and its expiry, in *every*
        `format` — so a caller that asked for `srt` receives JSON here rather
        than `application/x-subrip`.
      value:
        transcript_url: https://cdn.hark.dev/transcripts/ep_f4tcqk2zmvbxyna7.srt?sig=abc
        expires_at: '2026-08-21T09:14:22Z'
    SrtArtifact:
      summary: '`?format=srt` on a completed transcript — a raw SRT body'
      description: >-
        Served as `application/x-subrip`, not as a JSON-wrapped string.
        Provenance travels alongside it in `X-Transcript-Episode-Id`,
        `X-Transcript-Source`, and `X-Transcript-Timing-Precision`.
      value: |
        1
        00:00:00,000 --> 00:00:06,480
        Welcome back to the show…

        2
        00:00:06,480 --> 00:00:12,500
        Today we are talking about podcasts.
    FreshJobAccepted:
      summary: Accepted fresh work
      value:
        job_id: job_9k2fA7bQ3xzM1LpN
        group_id: grp_7bQ2vXpL9mZaK4tR
        status: validating
        episode_id: ep_hbrpoigf3cbfnobm
        estimated_credits: 268
        quote_ceiling_credits: 335
        quote_basis: probed
        reserved_credits: 335
        is_cached: false
        estimated_seconds: 420
    TestModeJobAccepted:
      summary: Test-mode key — deterministic fixture, no live credit event
      value:
        job_id: job_test000000000001
        group_id: grp_test000000000001
        status: validating
        episode_id: ep_2o4rak3vrjnvgfyg
        estimated_credits: 10
        quote_ceiling_credits: 13
        quote_basis: feed_metadata
        reserved_credits: 13
        is_cached: false
        estimated_seconds: 60
  responses:
    BadRequestApiKey:
      description: >-
        Invalid request shape or an unrecognized pointer. Codes:
        `invalid_request`, `invalid_url`.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    UnauthorizedApiKey:
      description: >-
        Code `unauthenticated` — the API key is missing, malformed, revoked, or
        does not exist. Every operation in this document is authenticated, so
        this is the most common error path a client will hit; it is declared on
        all of them rather than left implicit. The response never distinguishes
        "no such key" from "revoked key". Unlike every other API-key response it
        carries no `X-RateLimit-Limit`: that is a per-account figure, and an
        unauthenticated request has no resolved account to report one for.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    PaymentRequiredApiKey:
      description: >-
        Code `payment_required` — available account credits cannot cover the
        quote ceiling.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFoundApiKey:
      description: >-
        Codes: `feed_dead`, `episode_not_found`, `show_not_found`, or
        `job_not_found` (the job does not exist or belongs to another account —
        cross-account access is indistinguishable from nonexistence).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    ConflictApiKeyIdempotency:
      description: >-
        The `Idempotency-Key`'s two conflicts, and the account's three. Codes:
        `idempotency_conflict` (the same key was reused with a different request
        body; not retryable as sent) and `request_in_progress` (the original
        request under this key is still running and has no answer to repeat yet;
        retryable — the same request under the same key, in a few seconds. Never
        a new key, which would start a second job for the same episode and
        charge both); plus `account_suspended`, `account_closed`, and
        `account_not_found`, which refuse this submission because the account
        behind the key may not spend (see `ErrorCode`). None of the account
        three is retryable: the same request meets the same account.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    UnprocessableApiKey:
      description: >-
        The request was well-formed but cannot be fulfilled. Codes:
        `source_not_supported`, `feed_dead`, `episode_not_found`,
        `show_not_found`, `unsafe_source`, `unsupported_codec`,
        `unsupported_language`, `duration_exceeded`, `size_exceeded`. These
        carry three different `error.type` values, not two: `invalid_request`
        for `source_not_supported`/`unsafe_source` (fix your integration),
        `unprocessable_input` for
        `unsupported_codec`/`unsupported_language`/`duration_exceeded`/
        `size_exceeded` (this episode is outside published launch support), and
        `not_found` for `feed_dead`/`episode_not_found`/`show_not_found`
        (resolution failed with a typed catalog reason — the same three codes
        `NotFoundApiKey` carries at 404 for operations where the same failure is
        a lookup miss instead of an unprocessable request). A client branching
        on `type` must handle all three, not assume every code documented here
        is one of two things.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    TooManyRequestsApiKey:
      description: >-
        Codes `rate_limited` or `concurrency_limited`, from two different limits
        and two different places. `rate_limited` is the per-account request
        rate, refused at the edge before the operation runs: it carries
        `Retry-After` and no `X-RateLimit-Limit`, because nothing that knows the
        caller's tier has run yet. `concurrency_limited` is the cap on jobs open
        at once, refused by the operation itself: it carries `X-RateLimit-Limit`
        like every other answered response, and no `Retry-After`, because it
        clears when one of the account's own jobs finishes rather than after a
        fixed wait.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    ContentBlockedApiKey:
      description: Code `content_blocked` — a publisher or legal block is active.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    InternalErrorApiKey:
      description: >-
        Code `internal_error` — a failure on our side that no other code
        describes. The message is fixed text and says nothing about the cause;
        `request_id` is what identifies it in our logs. Retryable.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    ServiceUnavailableApiKey:
      description: >-
        Code `engine_unavailable` — the pinned provider route is unavailable; no
        silent provider/model switch occurs.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  securitySchemes:
    apiKeyBearer:
      type: http
      scheme: bearer
      bearerFormat: hk_live_... or hk_test_...
      description: >-
        `Authorization: Bearer hk_live_...` for live keys or `Authorization:
        Bearer hk_test_...` for test-mode keys. `hk_test_` keys resolve real
        public catalog metadata but return deterministic committed fixtures,
        never call inference, and never mutate live credits. This is the *only*
        transport for the credential: the `x-api-key` alias once documented was
        removed in 0.2.0, because the edge authorizer reads `Authorization` as
        its single identity source and a request on any other header is refused
        before it is authenticated.


        Never accepted on a `dashboardJwt` operation, and there are no
        exceptions. `GET /v1/usage` and `GET /v1/limits` briefly declared both
        schemes (0.8.0); that was withdrawn in 0.8.1 because no deployed route
        could honor it — both operations are served by the control-plane API,
        whose authorizer verifies a Cognito token and refuses an `hk_live_`
        credential on shape, and the customer API does not route either path. An
        API-key holder reads its balance and reservation from `QuoteResponse`,
        which carries `balance_credits` and `reserved_credits` on every quote.
        Every operation in this document takes one scheme or the other and
        refuses the wrong one as `unauthenticated`.

````