Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: Audivo Uploads API
version: 0.16.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, by the episode id an earlier answer gave you, or by an upload of audio you hold; get back structured, timed text — at once for an episode already transcribed, or as a job you poll for fresh audio. One call does it: `POST /v1/transcripts`. Quotes are for pricing a selection of many episodes before confirming it.
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.
The hosted MCP server at `/mcp` and the OAuth 2.1 endpoints that connect an assistant to it — `/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server`, and `/oauth/authorize`, `/oauth/token`, `/oauth/register`, `/oauth/revoke` — are protocol transports on the same host, not contract operations: an MCP client discovers and describes them from those two metadata documents (RFC 9728, RFC 8414), and they are documented with the MCP server. Their access tokens are checked by the same authorizer as an API key and metered by the same plan; the consent they need is two dashboard operations below, under **Connected Apps**.
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, 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.
tags:
- name: Uploads
description: Audio the caller puts in Audivo's bucket and names as a source (ADR-0031).
paths:
/v1/uploads:
post:
operationId: createUpload
tags:
- Uploads
summary: Announce a file to upload
security:
- apiKeyBearer: []
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 transcribe it with `POST /v1/transcripts` and `{ "upload_id": … }`, or name it in a quote as `uploads: [{ upload_id }]` alongside others.
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.'
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'
components:
headers:
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
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
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
schemas:
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
Error:
type: object
additionalProperties: false
required:
- error
properties:
error:
$ref: '#/components/schemas/ErrorDetail'
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 `processing_failed`, with a message naming the declared-duration ceiling, and releases the reservation.
title:
type: string
minLength: 1
maxLength: 300
description: How the entry is labelled; the private show is always "Uploads".
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
- upload_not_found
- upload_not_received
- upload_mismatch
- max_credits_exceeded
- oauth_request_not_found
- connected_app_not_found
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`, `upload_not_found`, `oauth_request_not_found`, `connected_app_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`, `upload_not_received` → 409; `upload_not_received` is retryable once the file's PUT has landed. `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`, `upload_mismatch`, `max_credits_exceeded` → 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.
UploadId:
type: string
pattern: ^upl_[A-Za-z0-9]{16,32}$
maxLength: 40
description: Opaque, server-generated upload identifier.
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
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.'
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
- upload_mismatch
- max_credits_exceeded
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
- upload_not_received
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
- upload_not_found
- oauth_request_not_found
- connected_app_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
RequestId:
type: string
pattern: ^req_[A-Za-z0-9-]+$
maxLength: 100
responses:
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'
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
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'
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'
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
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`.'
dashboardJwt:
type: http
scheme: bearer
bearerFormat: JWT
description: 'A Cognito access token, issued through the dashboard''s own sign-up and sign-in pages (a backend-for-frontend; there is no Cognito Hosted UI) and sent as `Authorization: Bearer <jwt>`. Dashboard operations require this scheme and cannot be called with an API key, with no exception. `GET /v1/usage` and `GET /v1/limits` offered both schemes from 0.8.0; 0.8.1 withdrew that, because the control-plane API serves them alone and its authorizer has no API-key branch. An `account_id` supplied by the client is ignored; all reads/writes are scoped server-side to the authenticated account.'