> ## 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.

# Announce a file to upload

> Records what the caller knows about an audio file and returns a presigned PUT for it. The URL is signed against the declared SHA-256, so S3 refuses a body whose hash differs; the API never reads the bytes. Send the file with exactly the `put_headers` returned. Then name it in a quote as `uploads: [{ upload_id }]`.
The upload is private to the account: its transcript is cached for this account alone. The upload becomes unavailable at `retained_until`; a quote after that reports `upload_not_found`. Physical object and record cleanup is asynchronous. Each account may announce at most 10 GiB across 100 unexpired uploads. Announcements reserve this allowance even if no file is sent; capacity returns at their logical expiry. A refusal returns `429 upload_quota_exceeded` before a PUT URL is issued.



## OpenAPI

````yaml /openapi.yaml post /v1/uploads
openapi: 3.1.0
info:
  title: Audivo API
  version: 0.12.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://api.audivo.dev
    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.
  - name: Uploads
    description: Audio the caller puts in Audivo's bucket and names as a source (ADR-0031).
paths:
  /v1/uploads:
    post:
      tags:
        - Uploads
      summary: Announce a file to upload
      description: >-
        Records what the caller knows about an audio file and returns a
        presigned PUT for it. The URL is signed against the declared SHA-256, so
        S3 refuses a body whose hash differs; the API never reads the bytes.
        Send the file with exactly the `put_headers` returned. Then name it in a
        quote as `uploads: [{ upload_id }]`.

        The upload is private to the account: its transcript is cached for this
        account alone. The upload becomes unavailable at `retained_until`; a
        quote after that reports `upload_not_found`. Physical object and record
        cleanup is asynchronous. Each account may announce at most 10 GiB across
        100 unexpired uploads. Announcements reserve this allowance even if no
        file is sent; capacity returns at their logical expiry. A refusal
        returns `429 upload_quota_exceeded` before a PUT URL is issued.
      operationId: createUpload
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUploadRequest'
            examples:
              default:
                $ref: '#/components/examples/CreateUploadRequestExample'
      responses:
        '201':
          description: The upload is recorded; PUT the file next.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UploadCreated'
              examples:
                default:
                  $ref: '#/components/examples/UploadCreatedExample'
        '400':
          $ref: '#/components/responses/BadRequestApiKey'
        '401':
          $ref: '#/components/responses/UnauthorizedApiKey'
        '409':
          $ref: '#/components/responses/ConflictApiKeyUploadAccountState'
        '429':
          $ref: '#/components/responses/TooManyRequestsApiKey'
        '500':
          $ref: '#/components/responses/InternalErrorApiKey'
      security:
        - apiKeyBearer: []
components:
  schemas:
    CreateUploadRequest:
      type: object
      additionalProperties: false
      required:
        - sha256
        - bytes
        - content_type
        - declared_duration_seconds
      properties:
        sha256:
          type: string
          pattern: ^[a-f0-9]{64}$
          description: The file's SHA-256, lowercase hex. The PUT is signed against it.
        bytes:
          type: integer
          minimum: 1
          maximum: 5368709120
          description: >-
            The file's exact length; the PUT must carry the same
            `Content-Length`.
        content_type:
          $ref: '#/components/schemas/UploadContentType'
        declared_duration_seconds:
          type: number
          exclusiveMinimum: 0
          maximum: 36000
          description: >-
            What the quote is priced from. A job whose audio runs past the
            ceiling this reserves fails as `declared_duration_exceeded` and
            releases the reservation.
        title:
          type: string
          minLength: 1
          maxLength: 300
          description: How the entry is labelled; the private show is always "Uploads".
    UploadCreated:
      type: object
      additionalProperties: false
      required:
        - upload_id
        - put_url
        - put_headers
        - put_url_expires_at
        - retained_until
        - bytes
        - content_type
        - declared_duration_seconds
        - title
      properties:
        upload_id:
          $ref: '#/components/schemas/UploadId'
        put_url:
          type: string
          format: uri
          maxLength: 4096
          description: >-
            Presigned `PUT`; send the file body with `put_headers` and nothing
            else.
        put_headers:
          type: object
          additionalProperties:
            type: string
          description: >-
            Every header the PUT must carry, verbatim; they are part of the
            signature.
        put_url_expires_at:
          type: string
          format: date-time
        retained_until:
          type: string
          format: date-time
          description: >-
            Logical upload expiry and allowance-release time; physical cleanup
            is asynchronous.
        bytes:
          type: integer
          minimum: 1
        content_type:
          $ref: '#/components/schemas/UploadContentType'
        declared_duration_seconds:
          type: number
          exclusiveMinimum: 0
        title:
          anyOf:
            - type: string
              maxLength: 300
            - enum:
                - null
    UploadContentType:
      type: string
      enum:
        - audio/mpeg
        - audio/mp3
        - audio/mp4
        - audio/m4a
        - audio/x-m4a
        - audio/aac
        - audio/x-aac
        - audio/ogg
        - audio/opus
        - audio/flac
        - audio/x-flac
        - audio/wav
        - audio/x-wav
        - audio/webm
    UploadId:
      type: string
      pattern: ^upl_[A-Za-z0-9]{16,32}$
      maxLength: 40
      description: Opaque, server-generated upload identifier.
    Error:
      type: object
      additionalProperties: false
      required:
        - error
      properties:
        error:
          $ref: '#/components/schemas/ErrorDetail'
    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
                - upload_quota_exceeded
            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
    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
        - upload_quota_exceeded
      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`, `upload_quota_exceeded` →
        429; `upload_quota_exceeded` is retryable, at the earliest capacity
        return time its message names. `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
  examples:
    CreateUploadRequestExample:
      summary: A 30-minute interview recorded locally
      value:
        sha256: 3f8a9c2b1d4e9f2c1d4e9f2c1d4e9f2c3f8a9c2b1d4e9f2c1d4e9f2c1d4e9f2c
        bytes: 28934112
        content_type: audio/mpeg
        declared_duration_seconds: 1807.4
        title: Interview take two
    UploadCreatedExample:
      summary: Where to PUT it
      value:
        upload_id: upl_7c1f0a9b3e2d4c5b6a7f8e9d
        put_url: >-
          https://hark-uploads-prod-061039778683.s3.us-east-1.amazonaws.com/acct_v3tptvk6pnf5cdiq/upl_7c1f0a9b3e2d4c5b6a7f8e9d?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&X-Amz-SignedHeaders=content-length%3Bcontent-type%3Bhost%3Bx-amz-checksum-sha256&X-Amz-Signature=0123456789abcdef
        put_headers:
          content-type: audio/mpeg
          content-length: '28934112'
          x-amz-checksum-sha256: P4qcKx1OnywdTp88HU6fLD+KnCsdTp8sHU6fLB1Onyw=
        put_url_expires_at: '2026-09-17T11:00:00Z'
        retained_until: '2026-09-24T10:00:00Z'
        bytes: 28934112
        content_type: audio/mpeg
        declared_duration_seconds: 1807.4
        title: Interview take two
  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'
    ConflictApiKeyUploadAccountState:
      description: >-
        The account behind the key may not hold an upload allowance, so the
        announcement was refused and no row was written and no PUT URL signed.
        Codes: `account_suspended` (a billing or abuse hold — settle it or
        contact support), `account_closed` (the account is being deleted, or is
        deleted), `account_not_found` (no account row behind this credential;
        sign in to the dashboard, which creates it). None is retryable: the same
        request meets the same account.


        This operation's only `409`. An announcement spends no credits, but it
        holds storage against the account and hands back a URL that puts bytes
        in our bucket, so it is refused on the same three states a spending
        operation is.
      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`, `concurrency_limited`, or `upload_quota_exceeded`,
        from three different limits and three 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.
        `upload_quota_exceeded` is `POST /v1/uploads`' own admission refusal,
        returned before a PUT URL is issued when the account already holds its
        allowance (10 GiB across 100 unexpired uploads): like
        `concurrency_limited` it carries `X-RateLimit-Limit` and no
        `Retry-After`, because it clears when an upload's `retained_until`
        passes rather than after a fixed wait; its `message` names the limits
        hit and the earliest time enough capacity returns.
      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'
          examples:
            uploadQuotaExceeded:
              summary: >-
                Upload allowance exhausted; names the earliest capacity return
                time
              value:
                error:
                  type: rate_limited
                  code: upload_quota_exceeded
                  message: >-
                    This account already holds 100 unexpired uploads totalling
                    10 GiB, the account's allowance; enough capacity returns at
                    2026-09-24T10:00:00Z, when the oldest upload's reservation
                    releases.
                  doc_url: https://docs.audivo.dev/errors#upload_quota_exceeded
                  request_id: req_3c8f1a9d2b4e
                  retryable: true
    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'
  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
    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
  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`.

````