# VocalStack API: every operation > VocalStack is a speech API: realtime and file transcription with speaker labels, live translation into many languages, spoken translations (text to speech), and hosted broadcasts that listeners join by link. One API, `/v1`: realtime sessions (stateless, streamed over a WebSocket), transcriptions, broadcasts, recordings and voices, with transcripts and translations streamed to subscribers. Base URL: https://api.vocalstack.com. OpenAPI document: https://api.vocalstack.com/v1/openapi.json. Interactive reference: https://api.vocalstack.com/docs. ## Authentication - Send `Authorization: Bearer `. The token is an API key (`vs_key_…`) or a signed-in person's WorkOS access token. - API keys are for servers; create them in the VocalStack app. A key carries scopes: `read`, `write`, `recordings:publish`, or `*` for all. - Each operation lists its auth mode (`public`, `optional`, `user`, `key`, `userOrKey`, …) and the key scopes it needs, in the OpenAPI extensions `x-vocalstack-auth` and `x-vocalstack-scopes`. Every operation is open to every plan; a creation point is refused `402 insufficient_credit` when the account's credit is out. - OAuth protected-resource metadata (RFC 9728): https://api.vocalstack.com/.well-known/oauth-protected-resource # Guides ## Quickstart > An API key, the SDK, and your first transcript in a dozen lines. | Endpoint | What it does | |---|---| | `POST /v1/transcriptions` | Create a transcription | | `GET /v1/recordings/{id}/job` | Get a job | | `GET /v1/recordings/{id}/transcript` | Get a transcript | Three steps: create an API key, install an SDK, transcribe a file. The program at the end uploads `talk.mp3` (any mp3, wav, flac, ogg, m4a or mp4 works), waits for its job and prints the transcript. ### 1. Create an API key Open Developers → API keys and create a key with the **Read** and **Write** permissions. It is shown once: copy it into `VOCALSTACK_API_KEY`. A key is for servers and scripts. The API refuses one sent from a web page or in a URL, so never put it in browser code; a browser streams through a session your server creates (see [Stream a microphone](https://api.vocalstack.com/docs/guides/stream-a-microphone.md)). ### 2. Install an SDK Two SDKs, one client each (`VocalStack`), grouped by resource: `vs.transcriptions`, `vs.jobs`, `vs.recordings`, `vs.realtime` and a few more. TypeScript runs on Node 20 or newer and in browsers; Python is asyncio, 3.10 or newer. **TypeScript SDK** ```sh echo '//api.vocalstack.com/:_authToken=${VOCALSTACK_API_KEY}' >> .npmrc npm install https://api.vocalstack.com/sdk/typescript/vocalstack-sdk-1.0.0-alpha.0.dev.20261008174440.gdafcde5.tgz ``` **Python SDK** ```sh pip install "https://__token__:${VOCALSTACK_API_KEY}@api.vocalstack.com/sdk/python/vocalstack-1.0.0a0.dev20261008174440+gdafcde5-py3-none-any.whl" ``` **HTTP + WebSocket**: no SDK to install (curl, jq and any WebSocket client). These commands are this environment's own. While the SDKs are not on npm or PyPI, the API serves its builds to every account, and the commands send your key to this API's host only. With HTTP + WebSocket there is nothing to install: the programs use curl and jq, and Node 22's built-in WebSocket (any WebSocket client works). ### 3. Transcribe a file Transcribing a file is asynchronous: the upload answers at once with a transcription and its job, the job runs on the server, and the transcript is read when the job is done. There are no webhooks: `vs.jobs.wait` polls the job for you (every 2 s by default). Every call is real usage on your plan: the minutes count against its audio quotas, and once the included usage is used up (or for a route your plan doesn't include) the API answers `402` ([Errors, retries and limits](https://api.vocalstack.com/docs/guides/errors-and-limits.md)). [Transcribe a file](https://api.vocalstack.com/docs/guides/transcribe-a-file.md) covers the job's states, progress and exports. ### 4. Next - [Transcribe a link](https://api.vocalstack.com/docs/guides/transcribe-a-link.md): the server fetches a public URL or a YouTube video. - [Stream a microphone](https://api.vocalstack.com/docs/guides/stream-a-microphone.md): live audio in, transcript events out, over one WebSocket. - [Errors, retries and limits](https://api.vocalstack.com/docs/guides/errors-and-limits.md): what to do with a 402, a 429 or a 503. ### Complete code Each program runs as it is once `VOCALSTACK_API_KEY` holds one of your API keys. ### TypeScript SDK: `Terminal` ```bash export VOCALSTACK_API_KEY="" mkdir vocalstack-quickstart && cd vocalstack-quickstart npm init -y && npm pkg set type=module echo '//api.vocalstack.com/:_authToken=${VOCALSTACK_API_KEY}' >> .npmrc npm install https://api.vocalstack.com/sdk/typescript/vocalstack-sdk-1.0.0-alpha.0.dev.20261008174440.gdafcde5.tgz npm install --save-dev tsx npx tsx quickstart.ts ``` ### TypeScript SDK: `quickstart.ts` ```typescript import { VocalStack } from '@vocalstack/sdk'; const vs = new VocalStack({ baseUrl: 'https://api.vocalstack.com', apiKey: process.env.VOCALSTACK_API_KEY }); const tx = await vs.transcriptions.create({ file: './talk.mp3' }); const job = await vs.jobs.wait(tx.job!.recordingId); // queued → transcribing → done if (job.state !== 'done') throw new Error(`transcription ${job.state}: ${job.error ?? ''}`); const { sentences } = await vs.recordings.transcript(job.recordingId); for (const s of sentences) console.log(s.text); ``` ### Python SDK: `Terminal` ```bash export VOCALSTACK_API_KEY="" python3 -m venv .venv && . .venv/bin/activate pip install "https://__token__:${VOCALSTACK_API_KEY}@api.vocalstack.com/sdk/python/vocalstack-1.0.0a0.dev20261008174440+gdafcde5-py3-none-any.whl" python quickstart.py ``` ### Python SDK: `quickstart.py` ```python import asyncio import os from vocalstack import VocalStack async def main() -> None: async with VocalStack("https://api.vocalstack.com", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs: tx = await vs.transcriptions.create(file="./talk.mp3") assert tx.job is not None job = await vs.jobs.wait(tx.job.recording_id) # queued -> transcribing -> done if job.state != "done": raise RuntimeError(f"transcription {job.state}: {job.error}") snapshot = await vs.recordings.transcript(job.recording_id) for sentence in snapshot.sentences: print(sentence.text) asyncio.run(main()) ``` ### HTTP + WebSocket: `quickstart.sh` ```bash #!/usr/bin/env bash # curl and jq, no SDK. set -euo pipefail export VOCALSTACK_API_KEY="${VOCALSTACK_API_KEY:?create a key under Developers → API keys}" API=https://api.vocalstack.com AUTH="Authorization: Bearer $VOCALSTACK_API_KEY" REC=$(curl -sSf "$API/v1/transcriptions" -H "$AUTH" -F file=@talk.mp3 | jq -r .job.recordingId) until [[ $(curl -sSf "$API/v1/recordings/$REC/job" -H "$AUTH" | jq -r .state) =~ ^(done|failed)$ ]]; do sleep 2 done curl -sSf "$API/v1/recordings/$REC/transcript" -H "$AUTH" | jq -r '.sentences[].text' ``` --- All guides: https://api.vocalstack.com/llms.txt. Every operation: https://api.vocalstack.com/llms-full.txt. The OpenAPI document, with every WebSocket frame under `x-websocket`: https://api.vocalstack.com/v1/openapi.json. ## Transcribe a file > Upload a file, wait for its job, read the transcript and export it. Asynchronous; nothing to keep open. | Endpoint | What it does | |---|---| | `POST /v1/transcriptions` | Create a transcription | | `GET /v1/recordings/{id}/job` | Get a job | | `GET /v1/recordings/{id}/transcript` | Get a transcript | | `GET /v1/recordings/{id}/export` | Export a recording | A file transcription is stored: it is listed under Transcriptions, can be shared and exported, and its transcript can be read any time later. The work is asynchronous. The upload answers at once; the server transcribes at full speed (much faster than the audio plays) while you wait on its job: upload, then read the job until it is `done`, then read the transcript. To transcribe without storing anything, stream the file through a realtime session instead ([Stream a file at full speed](https://api.vocalstack.com/docs/guides/stream-a-file.md)). ### 1. Upload the file `POST /v1/transcriptions` takes the file as `multipart/form-data` (`file`, plus an optional `options` part, JSON) and answers `201` with the transcription. Its `job.recordingId` names the recording the file became: everything that follows is addressed by it. `settings` are optional: `spokenLanguage` (a code such as `en`; absent, the language is detected), `speakerLabels` (`auto`, `on`, `off`). Most audio and video files (mp3, wav, flac, ogg, m4a, mp4…) up to the size in `GET /v1/realtime/capabilities` (`limits.maxUploadBytes`) are accepted; a video keeps only its audio. Send an `Idempotency-Key` with the upload. If the API answers `503 unavailable` while it wakes, the SDKs retry a create only when it carries one, and a retry with the same key returns the first answer instead of a second transcription. ### 2. Wait for the job A job moves `queued` → `transcribing` → `done`, or `failed` with `error` and, when it is machine-readable, `errorCode` (`insufficient_credit`: the file costs more than the account's credit). A link job passes through `fetching` first ([Transcribe a link](https://api.vocalstack.com/docs/guides/transcribe-a-link.md)). There are no webhooks. Read `GET /v1/recordings/{id}/job` until the state is `done` or `failed`: `vs.jobs.wait` does it every 2 s, and hands each read to `onProgress` (`on_progress` in Python) with `progress.pct` (0 to 100) and `progress.processedS`, the media seconds done so far; the program prints a line only when the state or the percentage changes. A page that is open anyway can follow the transcription's events socket instead (`WS /v1/transcriptions/{id}/events`, a `job.progress` frame per step). ### 3. Read the transcript `GET /v1/recordings/{id}/transcript` answers with the whole transcript: `sentences` (what to show people, with `speaker`, `start` and `end` in seconds), `segments` (with word timings in `words`) and `speakers`. Speakers are numbered from 0; `-1` means speaker labels were off. Add `lang=es` to read it translated. ### 4. Export it `GET /v1/recordings/{id}/export` returns a file: `format=srt`, `json`, `xlsx` or `pdf`, with `lang=` for a translated one. In the SDKs, `vs.recordings.export` returns the response; read its bytes. ### Complete code Each program runs as it is once `VOCALSTACK_API_KEY` holds one of your API keys. ### TypeScript SDK: `transcribe.ts` ```typescript import { writeFile } from 'node:fs/promises'; import { VocalStack } from '@vocalstack/sdk'; const vs = new VocalStack({ baseUrl: 'https://api.vocalstack.com', apiKey: process.env.VOCALSTACK_API_KEY }); const tx = await vs.transcriptions.create( { file: './talk.mp3', name: 'Talk', settings: { spokenLanguage: 'en', speakerLabels: 'on' } }, { idempotencyKey: crypto.randomUUID() }, // makes a retried upload safe ); const recordingId = tx.job!.recordingId; let last = ''; const job = await vs.jobs.wait(recordingId, { // Called on every read of the job: print it when it changes. onProgress: (j) => { const line = `${j.state} ${j.progress.pct}%`; if (line !== last) console.log((last = line)); }, }); if (job.state !== 'done') throw new Error(`transcription ${job.state}: ${job.error ?? ''}`); const { sentences } = await vs.recordings.transcript(recordingId); for (const s of sentences) console.log(s.speaker >= 0 ? `[speaker ${s.speaker}] ${s.text}` : s.text); const srt = await vs.recordings.export(recordingId, 'srt'); await writeFile('talk.srt', Buffer.from(await srt.arrayBuffer())); console.log('wrote talk.srt'); ``` ### Python SDK: `transcribe.py` ```python import asyncio import os import uuid from vocalstack import TranscriptionJob, VocalStack async def main() -> None: async with VocalStack("https://api.vocalstack.com", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs: tx = await vs.transcriptions.create( file="./talk.mp3", name="Talk", settings={"spokenLanguage": "en", "speakerLabels": "on"}, idempotency_key=str(uuid.uuid4()), # makes a retried upload safe ) assert tx.job is not None recording_id = tx.job.recording_id last = "" def on_progress(j: TranscriptionJob) -> None: # Called on every read of the job: print it when it changes. nonlocal last line = f"{j.state} {j.progress.pct}%" if line != last: last = line print(line) job = await vs.jobs.wait(recording_id, on_progress=on_progress) if job.state != "done": raise RuntimeError(f"transcription {job.state}: {job.error}") snapshot = await vs.recordings.transcript(recording_id) for s in snapshot.sentences: print(f"[speaker {s.speaker}] {s.text}" if s.speaker >= 0 else s.text) srt = await vs.recordings.export(recording_id, "srt") with open("talk.srt", "wb") as f: f.write(srt) print("wrote talk.srt") asyncio.run(main()) ``` ### HTTP + WebSocket: `transcribe.sh` ```bash #!/usr/bin/env bash # curl and jq, no SDK. set -euo pipefail API=https://api.vocalstack.com AUTH="Authorization: Bearer $VOCALSTACK_API_KEY" REC=$(curl -sSf "$API/v1/transcriptions" -H "$AUTH" -H "Idempotency-Key: $(uuidgen)" \ -F file=@talk.mp3 \ -F 'options={"name":"Talk","settings":{"spokenLanguage":"en","speakerLabels":"on"}}' \ | jq -r .job.recordingId) while :; do JOB=$(curl -sSf "$API/v1/recordings/$REC/job" -H "$AUTH") STATE=$(jq -r .state <<<"$JOB") echo "$STATE $(jq -r .progress.pct <<<"$JOB")%" case $STATE in done) break ;; failed) jq -r .error <<<"$JOB" >&2; exit 1 ;; esac sleep 2 done curl -sSf "$API/v1/recordings/$REC/transcript" -H "$AUTH" \ | jq -r '.sentences[] | if .speaker >= 0 then "[speaker \(.speaker)] \(.text)" else .text end' curl -sSf "$API/v1/recordings/$REC/export?format=srt" -H "$AUTH" -o talk.srt ``` --- All guides: https://api.vocalstack.com/llms.txt. Every operation: https://api.vocalstack.com/llms-full.txt. The OpenAPI document, with every WebSocket frame under `x-websocket`: https://api.vocalstack.com/v1/openapi.json. ## Transcribe a link > The server fetches a public URL or a YouTube video, then transcribes it like a file. Asynchronous, with a fetch phase first. | Endpoint | What it does | |---|---| | `GET /v1/transcriptions/probe` | Probe a link | | `POST /v1/transcriptions` | Create a transcription | | `GET /v1/recordings/{id}/job` | Get a job | | `GET /v1/recordings/{id}/transcript` | Get a transcript | Give the API a link instead of a file and it fetches the media itself: a public `http(s)` address (an mp3, a video, a podcast episode) or a YouTube video. The rest is [Transcribe a file](https://api.vocalstack.com/docs/guides/transcribe-a-file.md): a job to wait on, then the transcript. ### 1. Check the link `GET /v1/transcriptions/probe` says what a link is before anything is stored: `kind` is `file` (transcribe it here), `live` (a YouTube live, an endless radio stream, an HLS playlist with no end) or `unknown`, and `ok` is `false` with a `problem` sentence when the link did not open as media (a web page, an error). A live link never ends, so it is not a file: record it with a live transcription whose source is the link instead ([Go live](https://api.vocalstack.com/docs/guides/go-live.md)). The probe is optional, and limited to 60 calls a minute. Links to private, loopback or cloud metadata addresses are refused. ### 2. Create the transcription `POST /v1/transcriptions` with JSON `{ "url": "…" }` (and optional `name` and `settings`, as for a file) answers `201` at once; the server fetches the link afterwards. `vs.transcriptions.createFromUrl(url)` is the same call. An `Idempotency-Key` makes a retried create return the first transcription instead of a second one. ### 3. Wait for the fetch and the transcription A link job starts in `fetching`, then moves to `transcribing` and `done` (or `failed`, with `error`). While it fetches, `progress.phase` is `fetching` (bytes arriving: `progress.fetchedBytes` of `progress.fetchTotalBytes`, when the other end says) or `extracting` (the audio is being taken out of a video), and `progress.phasePct` is how far that phase is. A long video takes a while to fetch; poll `GET /v1/recordings/{id}/job` every few seconds (`vs.jobs.wait` does) rather than in a tight loop. ### 4. Read the transcript `GET /v1/recordings/{id}/transcript`, as for a file: `sentences`, `segments` with word timings, `speakers`, and `lang=` for a translation. The transcription keeps the link it came from (`job.source`). ### Complete code Each program runs as it is once `VOCALSTACK_API_KEY` holds one of your API keys. ### TypeScript SDK: `link.ts` ```typescript import { VocalStack } from '@vocalstack/sdk'; const vs = new VocalStack({ baseUrl: 'https://api.vocalstack.com', apiKey: process.env.VOCALSTACK_API_KEY }); const url = process.argv[2] ?? 'https://raw.githubusercontent.com/openai/whisper/main/tests/jfk.flac'; const probe = await vs.transcriptions.probe(url); if (!probe.ok) throw new Error(`that link did not open: ${probe.problem}`); if (probe.kind === 'live') throw new Error('a live link: record it with a live transcription instead'); const tx = await vs.transcriptions.createFromUrl(url, { name: 'From a link' }, { idempotencyKey: crypto.randomUUID() }); const job = await vs.jobs.wait(tx.job!.recordingId, { intervalMs: 3000, onProgress: (j) => { const p = j.progress; if (j.state === 'fetching') console.log(`${p.phase ?? 'fetching'} ${p.phasePct ?? 0}%`); else console.log(`${j.state} ${p.pct}%`); }, }); if (job.state !== 'done') throw new Error(`transcription ${job.state}: ${job.error ?? ''}`); const { sentences } = await vs.recordings.transcript(job.recordingId); console.log(sentences.map((s) => s.text).join(' ')); ``` ### Python SDK: `link.py` ```python import asyncio import os import sys import uuid from vocalstack import VocalStack URL = sys.argv[1] if len(sys.argv) > 1 else "https://raw.githubusercontent.com/openai/whisper/main/tests/jfk.flac" def show(job) -> None: p = job.progress if job.state == "fetching": print(f"{p.phase or 'fetching'} {p.phase_pct or 0}%") else: print(f"{job.state} {p.pct}%") async def main() -> None: async with VocalStack("https://api.vocalstack.com", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs: probe = await vs.transcriptions.probe(URL) if not probe.ok: raise SystemExit(f"that link did not open: {probe.problem}") if probe.kind == "live": raise SystemExit("a live link: record it with a live transcription instead") tx = await vs.transcriptions.create(url=URL, name="From a link", idempotency_key=str(uuid.uuid4())) assert tx.job is not None job = await vs.jobs.wait(tx.job.recording_id, interval_s=3, on_progress=show) if job.state != "done": raise SystemExit(f"transcription {job.state}: {job.error}") snapshot = await vs.recordings.transcript(job.recording_id) print(" ".join(s.text for s in snapshot.sentences)) asyncio.run(main()) ``` ### HTTP + WebSocket: `link.sh` ```bash #!/usr/bin/env bash # curl and jq, no SDK. set -euo pipefail API=https://api.vocalstack.com AUTH="Authorization: Bearer $VOCALSTACK_API_KEY" URL=${1:-https://raw.githubusercontent.com/openai/whisper/main/tests/jfk.flac} curl -sSfG "$API/v1/transcriptions/probe" -H "$AUTH" --data-urlencode "url=$URL" | jq '{kind, ok, problem}' REC=$(jq -n --arg url "$URL" '{url: $url, name: "From a link"}' \ | curl -sSf "$API/v1/transcriptions" -H "$AUTH" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" -d @- \ | jq -r .job.recordingId) while :; do JOB=$(curl -sSf "$API/v1/recordings/$REC/job" -H "$AUTH") STATE=$(jq -r .state <<<"$JOB") jq -r '"\(.state) \(.progress.phase // "") \(.progress.phasePct // .progress.pct)%"' <<<"$JOB" case $STATE in done) break ;; failed) jq -r .error <<<"$JOB" >&2; exit 1 ;; esac sleep 3 done curl -sSf "$API/v1/recordings/$REC/transcript" -H "$AUTH" | jq -r '[.sentences[].text] | join(" ")' ``` --- All guides: https://api.vocalstack.com/llms.txt. Every operation: https://api.vocalstack.com/llms-full.txt. The OpenAPI document, with every WebSocket frame under `x-websocket`: https://api.vocalstack.com/v1/openapi.json. ## Stream a microphone > Live audio in, transcript events out, over one WebSocket. Drafts replace the live tail; commits append. | Endpoint | What it does | |---|---| | `POST /v1/realtime/sessions` | Create a realtime session | | `WS /v1/realtime/sessions/{token}/socket` | Connect to the `streamUrl` a realtime session returned (treat it as opaque). | A realtime session is a signed `streamUrl` and one WebSocket. You send raw audio as it is captured; the server sends the transcript back as it is spoken, a few hundred milliseconds behind. Nothing is stored: the transcript is what the socket sends. To keep it, publish to a live transcription instead ([Go live](https://api.vocalstack.com/docs/guides/go-live.md)). 1. You → VocalStack: `POST /v1/realtime/sessions` (with your API key, on your server) 2. VocalStack → you: `{ sessionId, streamUrl }` 3. You → VocalStack: `open a WebSocket to streamUrl` 4. VocalStack → you: `session.loading … session.ready` (a cold machine warms up first) 5. You → VocalStack: `binary PCM, every ~100 ms` 6. VocalStack → you: `transcript.draft` (replaces the live tail) 7. VocalStack → you: `transcript.committed` (appends a final segment) 8. You → VocalStack: `{"type":"eof"}` 9. VocalStack → you: `session.done` (then the socket closes) ### 1. Create a realtime session `POST /v1/realtime/sessions` takes the session's options and answers `{ sessionId, streamUrl, expiresAt }`. `streamUrl` carries its own signed token; connect before `expiresAt`. Options worth knowing: `spokenLanguage` (`en`, or absent to detect it), `translate` (target languages, such as `["es"]`), `speakerLabels` (`auto`, `on`, `off`) and `latencyMode` (`auto`, `fast`, `accuracy`). This call takes an API key, so it belongs on your server. A web page asks your server for a session and connects to the `streamUrl` it gets back: the key never reaches the browser (the TypeScript tab's `browser.ts`). ### 2. Open the socket Connect to `streamUrl` as it is, with no header: the token is in the URL. While a machine warms up the server sends `session.loading` heartbeats (`stage`: `download`, then `model`), then `session.ready`. Send audio only after `session.ready`. The SDKs' `connect` resolves at `session.ready` and fails if the server goes quiet for 30 s (`readyDeadlineMs`): with no frame at all that is `capacity_exceeded` (every machine is busy), after loading frames `engine_unavailable`. A refused upgrade carries the error envelope: `401 invalid_token`, `410 session_expired`, `503 unavailable` (retry after `Retry-After`). ### 3. Send audio Audio goes in as binary frames of raw PCM: 32-bit float little-endian, 16 kHz, mono (`pcm_f32le`). Send it as it is captured, in chunks of about 100 to 250 ms (6,400 to 16,000 bytes); there is no need to wait for the transcript between chunks. In a browser, `streamMicrophone` (`@vocalstack/sdk/mic`) asks for the microphone, resamples to 16 kHz, sends the chunks and the `clock` frames that let the server mark gaps when a tab is throttled. You can change `spokenLanguage`, `translate`, `speakerLabels` or `latencyMode` mid-stream with a `session.update` text frame (`session.update(…)` in the SDKs); it applies to what follows. ### 4. Read the transcript Text frames come back as JSON with a `type`. Two of them build the transcript, and mixing them up is the most common mistake: - `transcript.draft` is the **live tail**: the words heard since the last commit, still changing. Each draft is a full replacement. Redraw the tail with it; never append it. - `transcript.committed` is **final**: one segment, with word timings, numbered by `seq` (0, 1, 2… gapless). Append it, and drop the draft it covers: the next draft starts where the commit ended. Around them: `transcript.translated` attaches translations to a committed `seq`; `transcript.amended` corrects a committed segment's text or speaker in place (same `seq`); `transcript.words` streams word timings for the tail. Ignore frame types you do not know: new ones are added without notice. ```text +0.0s {"type":"session.loading","stage":"model"} +1.2s {"type":"session.ready"} +2.0s {"type":"transcript.draft","segments":[{"start":0.0,"text":"Good","speaker":0,"tentative":true}]} +2.5s {"type":"transcript.draft","segments":[{"start":0.0,"text":"Good morning every","speaker":0,"tentative":true}]} +3.1s {"type":"transcript.committed","segment":{"seq":0,"start":0.0,"end":1.4,"text":"Good morning, everyone.","speaker":0,"words":[{"start":0.0,"end":0.3,"text":"Good"},{"start":0.3,"end":0.8,"text":"morning,"},{"start":0.8,"end":1.4,"text":"everyone."}]}} +3.4s {"type":"transcript.translated","seq":0,"translations":{"es":"Buenos días a todos."}} +3.9s {"type":"transcript.draft","segments":[{"start":1.6,"text":"Thanks for","speaker":0,"tentative":true}]} +4.6s {"type":"transcript.draft","segments":[{"start":1.6,"text":"Thanks for coming in","speaker":0,"tentative":true}]} +5.3s {"type":"transcript.committed","segment":{"seq":1,"start":1.6,"end":3.0,"text":"Thanks for coming in today.","speaker":0,"words":[{"start":1.6,"end":1.9,"text":"Thanks"},{"start":1.9,"end":2.1,"text":"for"},{"start":2.1,"end":2.5,"text":"coming"},{"start":2.5,"end":2.6,"text":"in"},{"start":2.6,"end":3.0,"text":"today."}]}} +5.5s {"type":"transcript.translated","seq":1,"translations":{"es":"Gracias por venir hoy."}} +5.9s {"type":"session.done","reason":"eof","stats":{"audioS":3.2,"processingS":1.4,"realtimeFactor":2.3}} ``` ### 5. Stop Send `{"type":"eof"}` when the audio ends (`session.eof()` in the SDKs). The server transcribes what it still holds, sends the last commits, then `session.done` with `reason` (`eof`, `max_duration`, `cancelled`) and `stats` (`audioS`, `processingS`, `realtimeFactor`), and closes. `cancel` stops at once and drops the rest. There is no resume: a dropped connection ends the session. To go on, create a new one. A session lasts at most `limits.maxDurationS` (`GET /v1/realtime/capabilities`), and its audio spends the account's credit at the plan's live rate (`GET /v1/billing`). ### Complete code Each program runs as it is once `VOCALSTACK_API_KEY` holds one of your API keys. ### TypeScript SDK: `mic.ts` ```typescript // Node: ffmpeg captures the microphone, the SDK streams it. import { spawn } from 'node:child_process'; import { RealtimeSession, VocalStack } from '@vocalstack/sdk'; const vs = new VocalStack({ baseUrl: 'https://api.vocalstack.com', apiKey: process.env.VOCALSTACK_API_KEY }); const { sessionId, streamUrl } = await vs.realtime.createSession({ spokenLanguage: 'en', translate: ['es'] }); const session = await RealtimeSession.connect({ sessionId, streamUrl, onLoading: (stage) => console.error(`warming up (${stage})…`), }); // Raw PCM: f32le, 16 kHz, mono. macOS: -f avfoundation -i :0, Linux: -f pulse -i default. const mic = spawn('ffmpeg', ['-loglevel', 'error', '-f', 'avfoundation', '-i', ':0', '-f', 'f32le', '-ac', '1', '-ar', '16000', '-']); mic.stdout.on('data', (chunk: Buffer) => session.sendAudio(new Uint8Array(chunk))); mic.on('close', () => session.eof()); // the server flushes, then sends session.done process.once('SIGINT', () => mic.kill()); // Ctrl-C ends the audio for await (const ev of session) { if (ev.type === 'transcript.draft') { // The live tail: replace it, never append it. process.stdout.write(`\r\x1b[K\x1b[2m${ev.segments.map((s) => s.text).join(' ')}\x1b[0m`); } else if (ev.type === 'transcript.committed') { // Final: append it (seq 0, 1, 2…), and the tail it covers is gone. process.stdout.write(`\r\x1b[K${ev.segment.text}\n`); } else if (ev.type === 'transcript.translated') { console.log(` es: ${ev.translations.es}`); } } console.log(`done (${session.doneReason})`, session.stats); ``` ### TypeScript SDK: `browser.ts` ```typescript // In a web page: your server creates the session (the API key stays // there), and the page streams the microphone to its streamUrl. import { RealtimeSession, VocalStack } from '@vocalstack/sdk'; import { streamMicrophone } from '@vocalstack/sdk/mic'; // On your server, behind a route of yours (POST /api/transcribe, say): const vs = new VocalStack({ baseUrl: 'https://api.vocalstack.com', apiKey: process.env.VOCALSTACK_API_KEY }); export async function createStream(): Promise<{ sessionId: string; streamUrl: string }> { const { sessionId, streamUrl } = await vs.realtime.createSession({ spokenLanguage: 'en' }); return { sessionId, streamUrl }; } // In the page, with what your route returned: export async function transcribeMicrophone( stream: { sessionId: string; streamUrl: string }, render: (committed: string, tail: string) => void, ): Promise { const session = await RealtimeSession.connect(stream); const mic = await streamMicrophone(session); // asks for the microphone, sends 16 kHz PCM document.querySelector('#stop')?.addEventListener('click', () => { void mic.stop(); session.eof(); }); let committed = ''; for await (const ev of session) { if (ev.type === 'transcript.draft') render(committed, ev.segments.map((s) => s.text).join(' ')); if (ev.type === 'transcript.committed') { committed += `${ev.segment.text} `; render(committed, ''); } } } ``` ### Python SDK: `mic.py` ```python import asyncio import os import signal from vocalstack import RealtimeSession, VocalStack # Raw PCM: f32le, 16 kHz, mono. macOS: -f avfoundation -i :0, Linux: -f pulse -i default. MIC = ["ffmpeg", "-loglevel", "error", "-f", "avfoundation", "-i", ":0", "-f", "f32le", "-ac", "1", "-ar", "16000", "-"] async def main() -> None: async with VocalStack("https://api.vocalstack.com", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs: created = await vs.realtime.create_session(spoken_language="en", translate=["es"]) session = await RealtimeSession.connect(created.session_id, created.stream_url) mic = await asyncio.create_subprocess_exec(*MIC, stdout=asyncio.subprocess.PIPE) async def feed() -> None: assert mic.stdout is not None while chunk := await mic.stdout.read(8000): # 125 ms of audio await session.send_audio(chunk) await session.eof() # the server flushes, then sends session.done feeder = asyncio.create_task(feed()) asyncio.get_running_loop().add_signal_handler(signal.SIGINT, mic.terminate) # Ctrl-C ends the audio async with session: async for event in session: if event.type == "transcript.draft": # The live tail: replace it, never append it. tail = " ".join(s.text for s in event.segments) print(f"\r\x1b[K\x1b[2m{tail}\x1b[0m", end="", flush=True) elif event.type == "transcript.committed": # Final: append it (seq 0, 1, 2...), and the tail it covers is gone. print(f"\r\x1b[K{event.segment.text}", flush=True) elif event.type == "transcript.translated": print(f" es: {event.translations.get('es')}") await feeder print(f"done ({session.done_reason})", session.stats) asyncio.run(main()) ``` ### HTTP + WebSocket: `mic-raw.ts` ```typescript // No SDK: fetch for the session, the built-in WebSocket for the stream // (Node 22 or newer, or a browser once your server has created the session). import { spawn } from 'node:child_process'; const API = 'https://api.vocalstack.com'; const res = await fetch(`${API}/v1/realtime/sessions`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.VOCALSTACK_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ spokenLanguage: 'en', translate: ['es'] }), }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); // the error envelope: { code, message, … } const { streamUrl } = (await res.json()) as { sessionId: string; streamUrl: string; expiresAt: string }; const ws = new WebSocket(streamUrl); // the token is in the URL: no header ws.binaryType = 'arraybuffer'; ws.addEventListener('message', (m: MessageEvent) => { if (typeof m.data !== 'string') return; const f = JSON.parse(m.data); switch (f.type) { case 'session.loading': console.error(`warming up (${f.stage})…`); break; case 'session.ready': startMicrophone(); // send audio only after session.ready break; case 'transcript.draft': // the live tail: replace it process.stdout.write(`\r\x1b[K\x1b[2m${f.segments.map((s: { text: string }) => s.text).join(' ')}\x1b[0m`); break; case 'transcript.committed': // final: append it process.stdout.write(`\r\x1b[K${f.segment.text}\n`); break; case 'transcript.translated': console.log(` es: ${f.translations.es}`); break; case 'session.done': console.log(`done (${f.reason})`, f.stats); break; case 'error': console.error(f.code, f.message); break; } }); function startMicrophone(): void { // Raw PCM: f32le, 16 kHz, mono, as binary frames. const mic = spawn('ffmpeg', ['-loglevel', 'error', '-f', 'avfoundation', '-i', ':0', '-f', 'f32le', '-ac', '1', '-ar', '16000', '-']); mic.stdout.on('data', (chunk: Buffer) => ws.send(new Uint8Array(chunk))); mic.on('close', () => ws.send(JSON.stringify({ type: 'eof' }))); process.once('SIGINT', () => mic.kill()); } ``` --- All guides: https://api.vocalstack.com/llms.txt. Every operation: https://api.vocalstack.com/llms-full.txt. The OpenAPI document, with every WebSocket frame under `x-websocket`: https://api.vocalstack.com/v1/openapi.json. ## Stream a file at full speed > Send a file's bytes through a realtime session and read the transcript as fast as the server makes it. Nothing is stored. | Endpoint | What it does | |---|---| | `POST /v1/realtime/sessions` | Create a realtime session | | `WS /v1/realtime/sessions/{token}/socket` | Connect to the `streamUrl` a realtime session returned (treat it as opaque). | A realtime session can take a whole file instead of a live microphone: you send the file's own bytes (mp3, wav, flac, ogg, m4a, a video…), the server decodes them and transcribes at full speed, many times faster than the audio plays, and the transcript comes back over the same socket. Nothing is stored. When you want the transcript kept, shareable and exportable, create a transcription instead ([Transcribe a file](https://api.vocalstack.com/docs/guides/transcribe-a-file.md)). 1. You → VocalStack: `POST /v1/realtime/sessions` (input: { kind: "file" }) 2. VocalStack → you: `{ streamUrl }` 3. You → VocalStack: `open a WebSocket to streamUrl` 4. VocalStack → you: `session.ready` 5. You → VocalStack: `the file's bytes` (binary frames, as fast as the socket takes them) 6. You → VocalStack: `{"type":"eof"}` (transcription starts here) 7. VocalStack → you: `transcript.progress` (every few seconds) 8. VocalStack → you: `transcript.committed` (appends a final segment) 9. VocalStack → you: `session.done` ### 1. Create a file session `POST /v1/realtime/sessions` with `input: { kind: "file" }` and the usual options (`spokenLanguage`, `translate`, `speakerLabels`). A file session sends no drafts unless you ask (`events: { drafts: true }`): a draft previews a live tail, and a file has none. The file's decoded length must fit `limits.maxDurationS` (`GET /v1/realtime/capabilities`); a longer one is refused with `invalid_audio`. ### 2. Send the bytes Open the socket, wait for `session.ready`, then send the file as binary frames in chunks (the SDKs send 256 KB at a time and hold back while the socket's buffer is full), followed by `{"type":"eof"}`. Transcription starts at `eof`: no transcript frame arrives before it. `vs.realtime.transcribeFile` (TypeScript) and `vs.realtime.transcribe_file` (Python) do all of this in one call. ### 3. Follow the progress `transcript.progress` arrives on a timer (it is also the keep-alive) with `processedS`, the media seconds transcribed so far, and `totalS` once the file's length is known. Commits arrive as they are made, in order, with gapless `seq`; append them. ```text +0.0s {"type":"session.ready"} +2.1s {"type":"transcript.progress","processedS":0,"totalS":184.2} +4.1s {"type":"transcript.progress","processedS":31.5,"totalS":184.2} +4.3s {"type":"transcript.committed","segment":{"seq":0,"start":0.0,"end":4.2,"text":"Welcome back to the show.","speaker":0,"words":[{"start":0.0,"end":0.4,"text":"Welcome"},{"start":0.4,"end":0.7,"text":"back"},{"start":0.7,"end":0.9,"text":"to"},{"start":0.9,"end":1.0,"text":"the"},{"start":1.0,"end":1.4,"text":"show."}]}} +4.4s {"type":"transcript.committed","segment":{"seq":1,"start":4.6,"end":9.8,"text":"Today we are talking about rivers.","speaker":0,"words":[{"start":4.6,"end":4.9,"text":"Today"},{"start":4.9,"end":5.1,"text":"we"},{"start":5.1,"end":5.3,"text":"are"},{"start":5.3,"end":5.8,"text":"talking"},{"start":5.8,"end":6.1,"text":"about"},{"start":6.1,"end":6.7,"text":"rivers."}]}} +6.1s {"type":"transcript.progress","processedS":96.0,"totalS":184.2} +8.0s {"type":"transcript.progress","processedS":184.2,"totalS":184.2} +8.2s {"type":"session.done","reason":"eof","stats":{"audioS":184.2,"processingS":6.1,"realtimeFactor":30.2}} ``` ### 4. Finish After the last commit the server sends `session.done`: `reason` is `eof`, or `max_duration` when a file with no declared length ran past the limit and was cut, and `stats.realtimeFactor` says how much faster than real time it ran. The socket then closes. ### Complete code Each program runs as it is once `VOCALSTACK_API_KEY` holds one of your API keys. ### TypeScript SDK: `file.ts` ```typescript import { VocalStack } from '@vocalstack/sdk'; const vs = new VocalStack({ baseUrl: 'https://api.vocalstack.com', apiKey: process.env.VOCALSTACK_API_KEY }); // transcribeFile creates the file session, sends the bytes and eof, and // collects what comes back. Nothing is stored. const result = await vs.realtime.transcribeFile('./talk.mp3', { spokenLanguage: 'en', speakerLabels: 'on', onProgress: ({ processedS, totalS }) => console.log(`${processedS.toFixed(0)} s of ${totalS?.toFixed(0) ?? '?'} s`), onCommit: (segment) => console.log(`#${segment.seq} [${segment.speaker}] ${segment.text}`), }); console.log(result.text); console.log(`${result.doneReason}: ${result.stats?.realtimeFactor}× real time`); ``` ### Python SDK: `file.py` ```python import asyncio import os from vocalstack import VocalStack async def main() -> None: async with VocalStack("https://api.vocalstack.com", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs: # transcribe_file creates the file session, sends the bytes and eof, # and collects what comes back. Nothing is stored. result = await vs.realtime.transcribe_file( "./talk.mp3", spoken_language="en", speaker_labels="on", on_progress=lambda p: print(f"{p.processed_s:.0f} s of {p.total_s or 0:.0f} s"), on_commit=lambda s: print(f"#{s.seq} [{s.speaker}] {s.text}"), ) print(result.text) print(result.done_reason, result.stats) asyncio.run(main()) ``` ### HTTP + WebSocket: `file-raw.ts` ```typescript // No SDK: fetch for the session, the built-in WebSocket for the bytes (Node 22 or newer). import { createReadStream } from 'node:fs'; const API = 'https://api.vocalstack.com'; const res = await fetch(`${API}/v1/realtime/sessions`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.VOCALSTACK_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ input: { kind: 'file' }, spokenLanguage: 'en' }), }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const { streamUrl } = (await res.json()) as { streamUrl: string }; const ws = new WebSocket(streamUrl); ws.addEventListener('message', async (m: MessageEvent) => { if (typeof m.data !== 'string') return; const f = JSON.parse(m.data); if (f.type === 'session.ready') { for await (const chunk of createReadStream('./talk.mp3', { highWaterMark: 256 * 1024 })) { while (ws.bufferedAmount > 4 * 1024 * 1024) await new Promise((r) => setTimeout(r, 10)); // let the socket drain ws.send(new Uint8Array(chunk as Buffer)); } ws.send(JSON.stringify({ type: 'eof' })); // transcription starts here } if (f.type === 'transcript.progress') console.log(`${f.processedS} s of ${f.totalS ?? '?'} s`); if (f.type === 'transcript.committed') console.log(`#${f.segment.seq} ${f.segment.text}`); if (f.type === 'session.done') console.log(f.reason, f.stats); if (f.type === 'error') console.error(f.code, f.message); }); ``` --- All guides: https://api.vocalstack.com/llms.txt. Every operation: https://api.vocalstack.com/llms-full.txt. The OpenAPI document, with every WebSocket frame under `x-websocket`: https://api.vocalstack.com/v1/openapi.json. ## Go live > Publish live audio to a stored transcription, and put it on a broadcast link listeners open. Control is HTTP; audio is one WebSocket. | Endpoint | What it does | |---|---| | `POST /v1/transcriptions/live` | Create a live transcription | | `POST /v1/broadcasts` | Create a broadcast | | `PUT /v1/transcriptions/{id}/broadcasts/{broadcast}` | Attach a broadcast | | `POST /v1/transcriptions/{id}/recordings` | Start a recording | | `POST /v1/recordings/{id}/pause` | Pause a recording | | `POST /v1/recordings/{id}/resume` | Resume a recording | | `POST /v1/recordings/{id}/stop` | Stop a recording | | `WS /v1/realtime/sessions/{token}/socket` | Connect to the `streamUrl` a realtime session returned (treat it as opaque). | A realtime session is gone when its socket closes. To keep what is said, and let other people follow it as it happens, publish to a **live transcription**: it is stored as it is spoken, listed under Transcriptions, and its recordings can be read, translated, exported and shared afterwards. A **broadcast** is the link listeners open (`code`, `shareUrl`); it is fed by the live transcriptions attached to it. The order: create the live transcription, optionally put it on a broadcast, start a recording, publish audio to its `streamUrl`, and control it over HTTP. ### 1. Create a live transcription `POST /v1/transcriptions/live` with a `name` and optional `settings` (`spokenLanguage`, `speakerLabels`, `latencyMode`…) creates it idle. Nothing is recorded until a recording starts. Its `source` can also be a stream the server pulls itself (a YouTube live, an internet radio address), in which case there is no audio for you to send. ### 2. Put it on a broadcast `POST /v1/broadcasts` creates the link (`code`, and `shareUrl` to hand out; `access: "private"` and a `password` keep it to the people you give them). `PUT /v1/transcriptions/{id}/broadcasts/{broadcast}` feeds it from the live transcription. One transcription can feed several broadcasts and a broadcast several transcriptions. Listeners follow it with the subscribe socket ([Listen](https://api.vocalstack.com/docs/guides/listen.md)). ### 3. Start a recording `POST /v1/transcriptions/{id}/recordings` starts one and answers with a publish grant: `recording` (its `id` is what you control and read it by), `sessionId` and `streamUrl`. A `409 conflict` with `details.takeover` means another device is recording it now; repeat with `{ "takeover": true }` to record from here instead. ### 4. Publish audio Connect to `streamUrl` and publish exactly as on a realtime session ([Stream a microphone](https://api.vocalstack.com/docs/guides/stream-a-microphone.md)): wait for `session.ready`, send raw PCM (`pcm_f32le`, 16 kHz, mono) as binary frames, and read commits back. The difference is that every commit is also stored and fanned out to the recording's subscribers, translated into the languages they read. A cold machine can take a minute to become ready: give `connect` a long ready deadline (`readyDeadlineMs`, `ready_deadline_s`). ### 5. Pause, resume and stop Control is HTTP, never a frame on the socket: `pause` (idempotent), `resume`, and `stop`, which is allowed only from paused. Sending `eof` on the publisher socket also ends the recording. A recording moves `live` ⇄ `paused` → `ended` → `finalized` (its transcript settled); listeners stay connected through `ended`. A recording left paused longer than `settings.maxPauseS` ends by itself. ### Complete code Each program runs as it is once `VOCALSTACK_API_KEY` holds one of your API keys. ### TypeScript SDK: `live.ts` ```typescript import { spawn } from 'node:child_process'; import { VocalStack } from '@vocalstack/sdk'; const vs = new VocalStack({ baseUrl: 'https://api.vocalstack.com', apiKey: process.env.VOCALSTACK_API_KEY }); const tx = await vs.transcriptions.createLive({ name: 'Town hall', settings: { spokenLanguage: 'en' } }); const broadcast = await vs.broadcasts.create({ name: 'Town hall' }); await vs.transcriptions.attachBroadcast(tx.id, broadcast.id); console.log('listeners open', broadcast.shareUrl); const grant = await vs.transcriptions.startRecording(tx.id); const recordingId = grant.recording.id; const stream = await vs.recordings.publish(grant, { readyDeadlineMs: 180_000 }); // a cold machine can take a while // -re reads the file at its own pace, as a microphone would arrive. For a // microphone: -f avfoundation -i :0 (macOS), -f pulse -i default (Linux). const ffmpeg = spawn('ffmpeg', ['-loglevel', 'error', '-re', '-i', './talk.mp3', '-f', 'f32le', '-ac', '1', '-ar', '16000', '-']); ffmpeg.stdout.on('data', (chunk: Buffer) => stream.sendAudio(new Uint8Array(chunk))); // eof ends the recording when the audio does; pause, resume and stop are HTTP. ffmpeg.on('close', () => stream.eof()); process.once('SIGINT', async () => { ffmpeg.kill(); await vs.recordings.pause(recordingId); await vs.recordings.stop(recordingId); // only from paused }); for await (const ev of stream) { if (ev.type === 'transcript.committed') console.log(`#${ev.segment.seq} ${ev.segment.text}`); } console.log('ended:', (await vs.recordings.get(recordingId)).state); ``` ### Python SDK: `live.py` ```python import asyncio import os from vocalstack import VocalStack, pace_realtime # -re is not needed here: pace_realtime sends the PCM at its own pace. DECODE = ["ffmpeg", "-loglevel", "error", "-i", "./talk.mp3", "-f", "f32le", "-ac", "1", "-ar", "16000", "-"] async def main() -> None: async with VocalStack("https://api.vocalstack.com", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs: tx = await vs.transcriptions.create_live(name="Town hall", settings={"spokenLanguage": "en"}) broadcast = await vs.broadcasts.create(name="Town hall") await vs.transcriptions.attach_broadcast(tx.id, broadcast.id) print("listeners open", broadcast.share_url) grant = await vs.transcriptions.start_recording(tx.id) recording_id = grant.recording.id ffmpeg = await asyncio.create_subprocess_exec(*DECODE, stdout=asyncio.subprocess.PIPE) pcm, _ = await ffmpeg.communicate() async with await vs.recordings.publish(grant, ready_deadline_s=180) as stream: feeder = asyncio.create_task(pace_realtime(stream, pcm)) # real time, then eof(): the recording ends async for event in stream: if event.type == "transcript.committed": print(f"#{event.segment.seq} {event.segment.text}", flush=True) await feeder # To end without eof: await vs.recordings.pause(recording_id), then stop(recording_id). print("ended:", (await vs.recordings.get(recording_id)).state) asyncio.run(main()) ``` ### HTTP + WebSocket: `live-raw.ts` ```typescript // No SDK: fetch for the calls, the built-in WebSocket for the audio (Node 22 or newer). import { spawn } from 'node:child_process'; const API = 'https://api.vocalstack.com'; const headers = { Authorization: `Bearer ${process.env.VOCALSTACK_API_KEY}`, 'Content-Type': 'application/json' }; async function call(method: string, path: string, body?: unknown): Promise { const r = await fetch(`${API}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); if (!r.ok) throw new Error(`${method} ${path}: ${r.status} ${await r.text()}`); return (await r.json()) as T; } const tx = await call<{ id: string }>('POST', '/v1/transcriptions/live', { name: 'Town hall', settings: { spokenLanguage: 'en' } }); const broadcast = await call<{ id: string; shareUrl: string }>('POST', '/v1/broadcasts', { name: 'Town hall' }); await call('PUT', `/v1/transcriptions/${tx.id}/broadcasts/${broadcast.id}`); console.log('listeners open', broadcast.shareUrl); const grant = await call<{ recording: { id: string }; streamUrl: string }>('POST', `/v1/transcriptions/${tx.id}/recordings`, {}); const ws = new WebSocket(grant.streamUrl); ws.addEventListener('message', (m: MessageEvent) => { if (typeof m.data !== 'string') return; const f = JSON.parse(m.data); if (f.type === 'session.ready') startAudio(); if (f.type === 'transcript.committed') console.log(`#${f.segment.seq} ${f.segment.text}`); if (f.type === 'session.done') console.log('ended:', f.reason); }); function startAudio(): void { const ffmpeg = spawn('ffmpeg', ['-loglevel', 'error', '-re', '-i', './talk.mp3', '-f', 'f32le', '-ac', '1', '-ar', '16000', '-']); ffmpeg.stdout.on('data', (chunk: Buffer) => ws.send(new Uint8Array(chunk))); ffmpeg.on('close', () => ws.send(JSON.stringify({ type: 'eof' }))); process.once('SIGINT', async () => { ffmpeg.kill(); await call('POST', `/v1/recordings/${grant.recording.id}/pause`); await call('POST', `/v1/recordings/${grant.recording.id}/stop`, {}); }); } ``` --- All guides: https://api.vocalstack.com/llms.txt. Every operation: https://api.vocalstack.com/llms-full.txt. The OpenAPI document, with every WebSocket frame under `x-websocket`: https://api.vocalstack.com/v1/openapi.json. ## Listen > Follow a recording as it happens, in any language: subscribe, catch up from a snapshot, then apply live frames in order. | Endpoint | What it does | |---|---| | `GET /v1/recordings/{id}/transcript` | Get a transcript | | `GET /v1/recordings/{id}` | Get a recording | | `WS /v1/recordings/{id}/subscribe` | Follow a recording: first frame `subscribe.ready {lastSeq}`, then `GET /v1/recordings/{id}/transcript` and discard buffered frames with `seq <= lastSeq`. | Anyone allowed to read a recording can follow it live over its subscribe socket: the owner with an API key, a reader with a share link's code, anyone at all on a public broadcast. The socket sends what changes from the moment it opens; what was said before is read once, from a snapshot. Getting the two to meet without a gap or a duplicate is the whole trick, and the cursor makes it exact. ### 1. Find the recording Subscribing takes a recording id. The owner has it already: the publish grant's `recording.id`, a transcription's or broadcast's `currentRecording.recordingId`, or its list of recordings. A screen waiting for something to go live opens the events socket of the transcription or the broadcast (`WS /v1/transcriptions/{id}/events`, `/v1/broadcasts/{id}/events`) and gets `recording.started` with the `recordingId` the moment it does. ### 2. Subscribe Open `WS /v1/recordings/{id}/subscribe`. The owner sends the API key as `Authorization: Bearer …` (never in the URL); a share link's reader adds `shareCode=`; a public broadcast's listener sends nothing. `lang=es` reads it translated (through a share link, the link must allow translation), and `voice=` picks who speaks it ([Live translation and spoken translations](https://api.vocalstack.com/docs/guides/translation-and-voices.md)). The first frame is `subscribe.ready`: the recording's `state` and `lastSeq`, the cursor the live sequence is at. ### 3. Catch up Read `GET /v1/recordings/{id}/transcript` (same `lang`, same credential): every sentence and segment up to the snapshot's own `lastSeq`. Every durable frame on the socket carries a `cursor`, gapless per recording; skip the frames whose `cursor` is at or below the snapshot's `lastSeq` (they are in it already) and apply the rest in order. A listener gets a `noop` frame in place of one withheld from it, so the cursor never skips. If it does jump (a dropped frame, `subscribe.gap`), read the transcript again with `after=` and carry on from its `lastSeq`. `vs.recordings.subscribe` in the TypeScript SDK does all of this and hands you the snapshot first. ```text +0.0s {"type":"subscribe.ready","recordingId":"5c0d…","lastSeq":41,"state":"live","instance":"api-1"} +0.2s {"type":"(snapshot)","lastSeq":42,"text":"Good morning, everyone. Thanks for coming in today."} +0.6s {"type":"transcript.committed","cursor":42,"seq":42,"segment":{"seq":17,"start":5.1,"end":6.0,"text":"Thanks for coming in today.","speaker":0,"words":[]}} +1.9s {"type":"transcript.committed","cursor":43,"seq":43,"segment":{"seq":18,"start":6.4,"end":8.2,"text":"Let us start with the budget.","speaker":0,"words":[]}} +2.2s {"type":"transcript.translated","cursor":44,"seq":18,"translations":{"es":"Empecemos por el presupuesto."}} +3.0s {"type":"noop","cursor":45,"seq":45} +4.4s {"type":"recording.state","cursor":46,"seq":46,"recordingId":"5c0d…","state":"paused"} ``` ### 4. Follow it live From there, frames arrive as the recording goes: `transcript.committed` appends a segment (a subscriber gets no drafts), `transcript.sentence` replaces a whole sentence by `sentenceId`, `transcript.translated` fills a segment's translations, `transcript.amended` (the engine) and `transcript.corrected` (a person) rewrite one in place, and `recording.state` says `live`, `paused`, `ended`, `finalized`. Change language without reopening: send `{"type":"subscribe.update","lang":"fr"}` (`setLanguage` in the SDK), then read the snapshot again in it. ### 5. When it ends At `ended` the socket stays open: corrections and the last translations can still arrive until `finalized`. Close it when you are done. Afterwards, the snapshot is the whole transcript, and `GET /v1/recordings/{id}` says how it ended. ### Complete code Each program runs as it is once `VOCALSTACK_API_KEY` holds one of your API keys. ### TypeScript SDK: `listen.ts` ```typescript import { VocalStack } from '@vocalstack/sdk'; const vs = new VocalStack({ baseUrl: 'https://api.vocalstack.com', apiKey: process.env.VOCALSTACK_API_KEY }); const recordingId = process.argv[2]!; // a grant's recording.id, or a transcription's currentRecording.recordingId // The owner, with the key. A share link's reader: new VocalStack({ baseUrl }) // and { shareCode: 'CODE', asOwner: false }; a public broadcast's listener: { asOwner: false }. const sub = await vs.recordings.subscribe(recordingId, { lang: 'es' }); // subscribe() resolves once subscribe.ready and the snapshot are in: what // was said before you came, then only frames past its cursor, gaps healed. for (const s of sub.snapshot?.sentences ?? []) console.log(s.text, s.translations?.es ?? ''); for await (const ev of sub) { if (ev.type !== 'frame') continue; const f = ev.frame; if (f.type === 'transcript.committed') console.log(f.segment.text); if (f.type === 'transcript.translated') console.log(` es: ${f.translations.es}`); if (f.type === 'recording.state') { console.log(`[${f.state}]`); if (f.state === 'finalized') sub.close(); } } ``` ### Python SDK: `listen.py` ```python import asyncio import json import os import sys from vocalstack import VocalStack async def main() -> None: async with VocalStack("https://api.vocalstack.com", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs: recording_id = sys.argv[1] # a grant's recording.id, or a transcription's current_recording # The owner, with the key. A share link's reader: share_code="CODE", as_owner=False. socket = await vs.recordings.subscribe(recording_id, lang="es") ready = json.loads(await socket.recv()) # subscribe.ready {lastSeq, state} print("state:", ready["state"]) snapshot = await vs.recordings.transcript(recording_id, lang="es") for s in snapshot.sentences: print(s.text, (s.translations or {}).get("es", "")) cursor = snapshot.last_seq async for message in socket: if isinstance(message, bytes): continue # live audio, when asked for frame = json.loads(message) at = frame.get("cursor") if at is not None: if at <= cursor: continue # already in the snapshot if at > cursor + 1: # a gap: read what was missed, then go on missed = await vs.recordings.transcript(recording_id, lang="es", after=cursor) for seg in missed.segments: print(seg.text) cursor = max(cursor, missed.last_seq) if at <= cursor: continue cursor = at if frame["type"] == "transcript.committed": print(frame["segment"]["text"], flush=True) elif frame["type"] == "transcript.translated": print(" es:", frame["translations"].get("es")) elif frame["type"] == "recording.state": print(f"[{frame['state']}]") if frame["state"] == "finalized": await socket.close() asyncio.run(main()) ``` ### HTTP + WebSocket: `listen-raw.ts` ```typescript // No SDK, no key: a share link's reader (shareCode), with the built-in // WebSocket (Node 22 or newer, or a browser). const API = 'https://api.vocalstack.com'; const [recordingId, shareCode] = [process.argv[2]!, process.argv[3]!]; const q = new URLSearchParams({ shareCode, lang: 'es' }); const ws = new WebSocket(`${API.replace(/^http/, 'ws')}/v1/recordings/${recordingId}/subscribe?${q}`); let cursor: number | null = null; // null until the snapshot is in const early: any[] = []; // frames that arrive while it is read ws.addEventListener('message', async (m: MessageEvent) => { if (typeof m.data !== 'string') return; const f = JSON.parse(m.data); if (f.type === 'subscribe.ready') { const r = await fetch(`${API}/v1/recordings/${recordingId}/transcript?${q}`); const snap = (await r.json()) as { lastSeq: number; sentences: { text: string }[] }; for (const s of snap.sentences) console.log(s.text); cursor = snap.lastSeq; for (const e of early.splice(0)) apply(e); return; } if (cursor === null) early.push(f); else apply(f); }); function apply(f: any): void { if (typeof f.cursor === 'number') { if (f.cursor <= cursor!) return; // already in the snapshot cursor = f.cursor; // (a jump: read /transcript?after= again) } if (f.type === 'transcript.committed') console.log(f.segment.text); if (f.type === 'transcript.translated') console.log(` es: ${f.translations.es}`); if (f.type === 'recording.state') console.log(`[${f.state}]`); if (f.type === 'recording.state' && f.state === 'finalized') ws.close(); } ``` --- All guides: https://api.vocalstack.com/llms.txt. Every operation: https://api.vocalstack.com/llms-full.txt. The OpenAPI document, with every WebSocket frame under `x-websocket`: https://api.vocalstack.com/v1/openapi.json. ## Live translation and spoken translations > Translate a stream as it is spoken, and have each translated sentence spoken back in a voice. Text frames and audio clips, over the same socket. | Endpoint | What it does | |---|---| | `GET /v1/languages` | List languages | | `GET /v1/voices/available` | List available voices | | `GET /v1/recordings/{id}/voice/{sentence}` | Get a spoken sentence | | `GET /v1/recordings/{id}/voice` | Get a recording's voice tracks | | `WS /v1/realtime/sessions/{token}/socket` | Connect to the `streamUrl` a realtime session returned (treat it as opaque). | A realtime session (and every recording, for its listeners) can translate what is said into other languages as it goes, and speak the translation: each finished sentence is synthesised in the language's voice and sent as a short audio clip. Translations follow the transcript by a second or two; a spoken sentence follows its translation. 1. You → VocalStack: `options: translate ["es"], sentences { enabled: true }, voice ["es"]` 2. VocalStack → you: `transcript.committed` (the words as spoken) 3. VocalStack → you: `transcript.sentence (open)` (the sentence so far, a provisional translation) 4. VocalStack → you: `transcript.sentence (closed)` (the sentence and its final translation) 5. VocalStack → you: `transcript.voice` (that sentence, spoken in Spanish (Ogg/Opus)) ### 1. Pick the languages `GET /v1/languages` lists what can be transcribed (`transcription`), translated into (`translation`) and spoken (`voice`), with names. `GET /v1/voices/available?lang=es` lists the voices that speak a language, best first; `default` marks the one used when none is named, and with a key your own cloned voices are there too. ### 2. Ask for translations `translate: ["es", "fr"]` on the session's options. Without sentences, each commit is followed by a `transcript.translated` frame naming its `seq`: attach `translations` to that segment. You can change the list mid-stream (`session.update`, `translate: […]`); it applies to what follows. ### 3. Read them by sentence With `sentences: { enabled: true }`, the server also assembles commits into sentences and translates whole sentences, which reads far better. `transcript.sentence` is a full replacement of one sentence, keyed by `sentenceId`: `status` goes `open` → `closed` (or `dropped`: discard it), `seqs` lists the commits it covers, and `translations` are provisional while it is open and final once it closes. Upsert by `sentenceId`; never append. ### 4. Speak them `voice: ["es"]` (it needs `sentences`) speaks each closed sentence's Spanish translation in the language's default voice; `"es:"` names a voice from the catalogue, and `voiceByLang: { "es": "" }` sets one per language. With `voiceCast` on (the default), each speaker gets a distinct voice of matching gender. Each clip arrives as a `transcript.voice` frame: `sentenceId`, `lang`, `durationS`, and `audio`, the clip itself (base64 Ogg/Opus, 24 kHz mono). A long sentence comes in pieces (`part` of `parts`) so the first can play while the rest are made: play them back to back, in order. ```text +0.0s {"type":"session.ready"} +2.6s {"type":"transcript.committed","segment":{"seq":0,"start":0.0,"end":1.4,"text":"Good morning, everyone.","speaker":0,"words":[]}} +2.7s {"type":"transcript.sentence","sentence":{"sentenceId":0,"status":"open","text":"Good morning, everyone.","seqs":[0],"speaker":0,"start":0.0,"end":1.4,"translations":{"es":"Buenos días a todos."}}} +3.9s {"type":"transcript.sentence","sentence":{"sentenceId":0,"status":"closed","text":"Good morning, everyone.","seqs":[0],"speaker":0,"start":0.0,"end":1.4,"translations":{"es":"Buenos días a todos."}}} +5.0s {"type":"transcript.voice","sentenceId":0,"generation":0,"lang":"es","voice":"es_ES-davefx-medium","format":"ogg","durationS":1.3,"synthS":0.4,"part":0,"parts":1,"voiceKey":"","audio":"T2dnUwACAAAA…"} ``` ### 5. For a recording's listeners A listener on the subscribe socket asks with `lang=es` (translations filtered to Spanish) and `voice=` ([Listen](https://api.vocalstack.com/docs/guides/listen.md)); a clip reaches every listener on that language and voice, synthesised once. On a recording, `transcript.voice` carries a `url` instead of `audio`: fetch it with `GET /v1/recordings/{id}/voice/{sentence}?lang=es` (Ogg/Opus; `format=caf` for Apple). `transcript.voiceStatus` says when a voice is still `preparing` (its clips are held and will come) or `unavailable`. After the recording, `GET /v1/recordings/{id}/voice` lists every clip left behind, language by language. ### Complete code Each program runs as it is once `VOCALSTACK_API_KEY` holds one of your API keys. ### TypeScript SDK: `translate.ts` ```typescript import { createReadStream } from 'node:fs'; import { writeFile } from 'node:fs/promises'; import { VocalStack, applySentence, type SentenceSegment } from '@vocalstack/sdk'; const vs = new VocalStack({ baseUrl: 'https://api.vocalstack.com', apiKey: process.env.VOCALSTACK_API_KEY }); const { voices } = await vs.voices.available('es'); const voice = voices.find((v) => v.default) ?? voices[0]; // A file at full speed here; a microphone works the same (Stream a microphone). const session = await vs.realtime.connect( { input: { kind: 'file' }, spokenLanguage: 'en', translate: ['es'], sentences: { enabled: true }, voice: [`es:${voice.id}`], }, { // The clips arrive as transcript.voice frames: onFrame sees every frame. onFrame: (f) => { if (f.type === 'transcript.voice' && f.audio) { void writeFile(`es-${f.sentenceId}-${f.part ?? 0}.ogg`, Buffer.from(f.audio, 'base64')); } }, }, ); void session.sendFile(createReadStream('./talk.mp3')); let sentences: SentenceSegment[] = []; for await (const ev of session) { if (ev.type !== 'transcript.sentence') continue; sentences = applySentence(sentences, ev.sentence); // upsert by sentenceId if (ev.sentence.status === 'closed') console.log(`${ev.sentence.text}\n es: ${ev.sentence.translations?.es}`); } console.log(`${sentences.length} sentences; the Spanish clips are es--.ogg`); ``` ### Python SDK: `translate.py` ```python import asyncio import base64 import os from vocalstack import VocalStack async def main() -> None: async with VocalStack("https://api.vocalstack.com", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs: catalogue = await vs.voices.available("es") voice = next((v for v in catalogue.voices if v.default), catalogue.voices[0]) # A file at full speed here; a microphone works the same (Stream a microphone). session = await vs.realtime.connect( input={"kind": "file"}, spoken_language="en", translate=["es"], sentences={"enabled": True}, voice=[f"es:{voice.id}"], ) async with session: sender = asyncio.create_task(session.send_file("./talk.mp3")) async for event in session: if event.type == "transcript.sentence" and event.sentence.status == "closed": print(event.sentence.text) print(" es:", (event.sentence.translations or {}).get("es")) elif event.type == "transcript.voice" and event.audio: with open(f"es-{event.sentence_id}-{event.part or 0}.ogg", "wb") as f: f.write(base64.b64decode(event.audio)) await sender asyncio.run(main()) ``` ### HTTP + WebSocket: `translate-raw.ts` ```typescript // No SDK: fetch for the session, the built-in WebSocket for the stream (Node 22 or newer). import { createReadStream } from 'node:fs'; import { writeFile } from 'node:fs/promises'; const API = 'https://api.vocalstack.com'; const auth = { Authorization: `Bearer ${process.env.VOCALSTACK_API_KEY}` }; const cat = (await (await fetch(`${API}/v1/voices/available?lang=es`, { headers: auth })).json()) as { voices: { id: string; default: boolean }[] }; const voice = cat.voices.find((v) => v.default) ?? cat.voices[0]; const res = await fetch(`${API}/v1/realtime/sessions`, { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ input: { kind: 'file' }, spokenLanguage: 'en', translate: ['es'], sentences: { enabled: true }, voice: [`es:${voice.id}`], }), }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const ws = new WebSocket(((await res.json()) as { streamUrl: string }).streamUrl); ws.addEventListener('message', async (m: MessageEvent) => { if (typeof m.data !== 'string') return; const f = JSON.parse(m.data); if (f.type === 'session.ready') { for await (const chunk of createReadStream('./talk.mp3', { highWaterMark: 256 * 1024 })) ws.send(new Uint8Array(chunk as Buffer)); ws.send(JSON.stringify({ type: 'eof' })); } if (f.type === 'transcript.sentence' && f.sentence.status === 'closed') console.log(`${f.sentence.text}\n es: ${f.sentence.translations?.es}`); if (f.type === 'transcript.voice' && f.audio) await writeFile(`es-${f.sentenceId}-${f.part ?? 0}.ogg`, Buffer.from(f.audio, 'base64')); if (f.type === 'session.done') ws.close(); }); ``` --- All guides: https://api.vocalstack.com/llms.txt. Every operation: https://api.vocalstack.com/llms-full.txt. The OpenAPI document, with every WebSocket frame under `x-websocket`: https://api.vocalstack.com/v1/openapi.json. ## Errors, retries and limits > One error envelope everywhere; what to retry (429, 503 unavailable) and how to make a retry safe (Idempotency-Key); credit, plan limits and the 402s. | Endpoint | What it does | |---|---| | `GET /v1/billing` | Get the account's billing | | `POST /v1/broadcasts` | Create a broadcast | Every failed call answers with the same JSON envelope, and the SDKs turn it into one error type (`VocalStackError`) with the same fields. Most errors are final; a few say "try again", and the SDKs already do that for you where it is safe. ```json { "defined": true, "code": "insufficient_credit", "status": 402, "message": "this account has no credit left; add credit to continue", "data": { "retryable": false, "details": { "balance": -0.42, "required": 0.075, "currency": "usd", "plan": "payg", "topUpUrl": "https://…" } }, "requestId": "01J…" } ``` ### 1. Read the error `code` is what to branch on; `message` is for people; `data.retryable` says whether sending the same request again can succeed; `data.details` carries what the code needs; `requestId` (also the `X-Request-Id` header) is what support asks for. In the SDKs: `err.code`, `err.status`, `err.message`, `err.retryable`, `err.details`, `err.retryAfter` (seconds, from `Retry-After`) and `err.headers`. | Status and code | What to do | |---|---| | 400, 422 `invalid_options`, `invalid_body`, `invalid_audio` | Fix the request: the message names the field. | | 401 `invalid_token`, `key_expired`, `key_revoked` | Check `VOCALSTACK_API_KEY`; use the replacement key, or create one. | | 402 `insufficient_credit` | The account's credit is out: `details.balance` (dollars), `plan`, `topUpUrl`, and `required` when the cost was known up front (a file of known length). Add credit; nothing was charged. | | 402 `quota_exceeded` | A plan limit is reached: `details.metric` (`concurrent_transcriptions`), `limit`, `used`, `resetsAt` (null: it frees up as transcriptions end), `plan`, `topUpUrl`. | | 403 `forbidden`, `key_from_browser`, `suspended` | A missing scope (the message names it), a key sent from a web page (keep it on your server), or a suspension. Retrying never helps. | | 404, 409 `not_found`, `conflict`, `invalid_state` | No such thing, a clash (a link code taken, another device recording), or not now (`stop` on a recording that is not paused; a second subscription checkout while one is active). | | 409 `payment_method_required` | Auto top-up (`PUT /v1/billing/auto-top-up`) and the customer portal need a saved payment method: make a payment (`POST /v1/billing/checkouts`) or add a card (`POST /v1/billing/payment-methods/manage`) first. | | 409 `payment_method_in_use`, `billing_details_required`, `auto_top_up_off` | A card that pays the subscription cannot be removed (add another and make it the default first); an invoice needs a billing name and address (`PUT /v1/billing/details`); auto top-up is off, so there is nothing to run now. | | 404 `promo_code_invalid` | The promo code does not exist, has expired, is used up, was redeemed by this account already, or gives nothing for this purchase. | | 400 `range_too_long` | A usage report covers at most 92 days: ask for a shorter range. | | 429 `rate_limited` | Wait `Retry-After` seconds. Retried for you. With `details.metric: "creates_per_hour"` it is the plan's creation rate (new transcriptions an hour), not a burst limit: `limit` and `resetsAt` say when. | | 503 `unavailable` | The service is waking (its database was asleep). Send it again after `Retry-After`. Retried for you when it is safe. | ### 2. Retry what can be retried The SDKs retry a `429 rate_limited` and a `503 unavailable` after `Retry-After`, twice by default (`maxRetries` / `max_retries` on the client), but a `503` only for a call that is safe to send twice: `GET`, `HEAD`, `DELETE`, or any call carrying an `Idempotency-Key`. A create without one is not retried, because the first attempt may have worked. Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`: slow down before the 429. `GET /v1/billing` shows the plan, its rates per hour of audio, the credit balance and the limits (transcriptions at once, new transcriptions an hour, requests a minute, the largest file, the longest live recording). ### 3. Make creates safe to retry Send `Idempotency-Key: ` with a create (`POST /v1/broadcasts`, `POST /v1/transcriptions`, `POST /v1/transcriptions/live`, starting a recording…). Repeated within 24 hours with the same key and the same body, it returns the first answer (with `Idempotent-Replayed: true`) instead of creating a second thing; the same key with a different body is `422 idempotency_key_reused`, and a repeat while the first is still running is `409 conflict`. Keys are per account. In the SDKs: `{ idempotencyKey }` as the call options (TypeScript), `idempotency_key=` (Python). ### 4. Sockets A refused WebSocket upgrade carries the same envelope over HTTP: `401 invalid_token`, `410 session_expired` (a realtime session's `expiresAt` passed: create a new one), `503 unavailable` (retry after `Retry-After`). A fleet with every machine busy can instead hang the connect or answer a bare 503 with no envelope: the SDKs report that as `capacity_exceeded`, and as `engine_unavailable` when the machine started warming up and then went quiet. On an open socket, an `error` frame carries `code`, `message` and `retryable`; `slow_consumer` means you read too slowly and were closed (commits are never dropped, drafts are). There is no resume: create a new session. ### Complete code Each program runs as it is once `VOCALSTACK_API_KEY` holds one of your API keys. ### TypeScript SDK: `errors.ts` ```typescript import { VocalStack, VocalStackError } from '@vocalstack/sdk'; // 429s, and 503s on calls that are safe to repeat, are retried after Retry-After. const vs = new VocalStack({ baseUrl: 'https://api.vocalstack.com', apiKey: process.env.VOCALSTACK_API_KEY, maxRetries: 4 }); try { // The key makes this create safe to retry: a repeat returns the first broadcast. const broadcast = await vs.broadcasts.create({ name: 'Town hall' }, { idempotencyKey: crypto.randomUUID() }); console.log(broadcast.shareUrl); } catch (err) { if (!(err instanceof VocalStackError)) throw err; switch (err.code) { case 'insufficient_credit': case 'quota_exceeded': { const d = err.details as { metric?: string; balance?: number; required?: number; topUpUrl?: string } | undefined; console.error(err.message, d?.metric ?? '', d?.balance ?? '', d?.required ?? '', d?.topUpUrl ?? ''); break; } case 'rate_limited': case 'unavailable': console.error(`still ${err.code} after the retries; try again in ${err.retryAfter ?? 1} s`); break; default: console.error(err.status, err.code, err.message, err.headers?.get('x-request-id') ?? ''); } } const billing = await vs.billing.get(); console.log(billing.plan?.name, `$${billing.balance} of credit`, `${billing.limits?.concurrentTranscriptions ?? '∞'} at once`); ``` ### Python SDK: `errors.py` ```python import asyncio import os import uuid from vocalstack import VocalStack, VocalStackError async def main() -> None: # 429s, and 503s on calls that are safe to repeat, are retried after Retry-After. async with VocalStack("https://api.vocalstack.com", api_key=os.environ["VOCALSTACK_API_KEY"], max_retries=4) as vs: try: # The key makes this create safe to retry: a repeat returns the first broadcast. broadcast = await vs.broadcasts.create(name="Town hall", idempotency_key=str(uuid.uuid4())) print(broadcast.share_url) except VocalStackError as e: if e.code in ("insufficient_credit", "quota_exceeded"): print(e.message, e.details) elif e.code in ("rate_limited", "unavailable"): print(f"still {e.code} after the retries; try again in {e.retry_after or 1} s") else: print(e.status, e.code, e.message) billing = await vs.billing.get() print(billing.plan, f"${billing.balance} of credit", billing.limits) asyncio.run(main()) ``` ### HTTP + WebSocket: `errors.sh` ```bash #!/usr/bin/env bash # curl and jq, no SDK: a create with an Idempotency-Key, retried on 429 and 503. set -euo pipefail API=https://api.vocalstack.com AUTH="Authorization: Bearer $VOCALSTACK_API_KEY" KEY=$(uuidgen) # the same key on every attempt: a repeat returns the first answer for attempt in 1 2 3 4 5; do HEADERS=$(mktemp) STATUS=$(curl -sS -o body.json -D "$HEADERS" -w '%{http_code}' "$API/v1/broadcasts" \ -H "$AUTH" -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \ -d '{"name":"Town hall"}') case $STATUS in 2??) jq -r .shareUrl body.json; exit 0 ;; 429|503) WAIT=$(awk 'tolower($1)=="retry-after:"{print $2+0}' "$HEADERS") echo "$STATUS $(jq -r .code body.json): retrying in ${WAIT:-2} s" >&2 sleep "${WAIT:-2}" ;; *) jq -r '"\(.status) \(.code): \(.message) (request \(.requestId))"' body.json >&2 jq '.data.details' body.json >&2 exit 1 ;; esac done exit 1 ``` --- All guides: https://api.vocalstack.com/llms.txt. Every operation: https://api.vocalstack.com/llms-full.txt. The OpenAPI document, with every WebSocket frame under `x-websocket`: https://api.vocalstack.com/v1/openapi.json. # VocalStack API 1.0.0-alpha.0 ## billing ### GET /v1/billing operationId: `billing.get` auth: userOrKey; key scopes: read Get the account's billing The acting account's plan, rates (dollars an hour of audio), credit balance, whether it runs on in a grace period below zero (`inGrace`), whether a new transcription would start now and what would refuse it (`canStart`, `startBlock`), the billing period and the one before, the monthly credit's use, the counted limits in use, the subscription (next payment, a scheduled interval switch, past due), auto top-up with its last attempt, the low-balance alert, a payment still being processed, the default card, a scheduled rate change, the retention deadline after a downgrade, the plan's limits and whether it has paid. A creation that would overdraw the balance is refused 402 `insufficient_credit`; `topUpUrl` is where to add credit. `enabled: false` means this server does not bill. Responses: - 200: BillingView ### PUT /v1/billing/auto-top-up operationId: `billing.setAutoTopUp` auth: user; key scopes: none Set auto top-up Turn auto top-up on (`threshold` and `amount`: any whole-cent values inside the plan list's `autoTopUp.thresholdRange` and `amountRange`; when the balance falls to the threshold, the amount is charged to the saved payment method, at most a few times a day) or off. `409 payment_method_required` until the account has a saved payment method. The consent (time, hashed address, terms version) is recorded. Request body (application/json): AutoTopUpSettings Responses: - 200: AutoTopUpView Error codes: invalid_options, payment_method_required, forbidden ### POST /v1/billing/auto-top-up/retry operationId: `billing.retryAutoTopUp` auth: user; key scopes: none Run auto top-up now Charge the configured auto top-up amount to the saved payment method now, whatever the balance (after a declined attempt, once the card is fixed). Counts against the day's cap: `429 rate_limited` when it is used. `409 auto_top_up_off` when auto top-up is off. Returns the auto top-up with its `last` attempt (`paid`, `declined` or `pending`). Responses: - 200: AutoTopUpView Error codes: auto_top_up_off, rate_limited, invalid_state, forbidden, unavailable ### GET /v1/billing/charges operationId: `billing.charges` auth: userOrKey; key scopes: read List the charges The account's per-recording charges, newest first, with the transcription's name: open while the audio runs (metered every minute), settled once the recording ends, void when waived. Page with `cursor`. Parameters: - `cursor` (query): string. The `nextCursor` of the previous page. - `limit` (query): integer Responses: - 200: ChargePage Error codes: invalid_options ### POST /v1/billing/checkouts operationId: `billing.createCheckout` auth: user; key scopes: none Open a checkout Start a payment at the payment provider: `{kind: "topup", amount}` adds credit (any whole-cent amount inside the plan list's `topUpRange`), `{kind: "subscribe", planId, interval}` starts a subscription (`month` or `year`); `promoCode` adds a code's bonus credit to a top-up or its discount to a subscription's first payment (`404 promo_code_invalid` when it does not apply). Both need `acceptTerms: true` and `waiveWithdrawal: true` (credit is not refundable; performance starts at once) or the request is `422 invalid_body`. Send the customer to `url`; the provider returns them to the app's billing page with `?checkout=`. A signed-in person only (for an organization, its owner or admin), never an API key. `409 invalid_state` with an active subscription (cancel it first) or a plan billed by hand. Idempotent with `Idempotency-Key`. Parameters: - `Idempotency-Key` (header): string. Makes a retry safe: a request repeated with the same key and the same body within 24 hours returns the first response (with `Idempotent-Replayed: true`) instead of running again. The same key with a different request is 422 `idempotency_key_reused`; a retry while the first is still running is 409 `conflict`. Only a successful (2xx) response is remembered. Keys are per account. Request body (application/json): CreateCheckout Responses: - 201: CheckoutView Error codes: invalid_body, invalid_options, invalid_state, forbidden, not_found, promo_code_invalid, unavailable, idempotency_key_reused ### GET /v1/billing/checkouts/{id} operationId: `billing.getCheckout` auth: user; key scopes: none Get a checkout A checkout's status, for the page the customer comes back to: by its `checkoutId` or the provider's id (`?checkout=` on the return URL). `succeeded` once the payment landed (the credit or the subscription is then in `GET /v1/billing`). Parameters: - `id` (path, required): string Responses: - 200: CheckoutView Error codes: not_found ### GET /v1/billing/details operationId: `billing.details` auth: user; key scopes: none Get the invoice details The name, email, address and tax id invoices and receipts carry. `email` is the sign-in email until another is saved. Responses: - 200: BillingDetails ### PUT /v1/billing/details operationId: `billing.setDetails` auth: user; key scopes: none Set the invoice details Save the invoice details; they are written onto the provider's customer and prefill every checkout. `country` is ISO 3166-1 alpha-2; an EU VAT ID starts with its country's prefix (Greece: `EL`) and has 8 to 12 letters or digits (spaces, dots and dashes are dropped). `422 invalid_body` names the `field`. Request body (application/json): BillingDetails Responses: - 200: BillingDetails Error codes: invalid_body, forbidden ### GET /v1/billing/ledger operationId: `billing.ledger` auth: userOrKey; key scopes: read List the ledger The account's credit movements, newest first: signup credit, top-ups, monthly credit, charges, corrections, refunds. Page with `cursor`. Parameters: - `cursor` (query): string. The `nextCursor` of the previous page. - `limit` (query): integer Responses: - 200: LedgerPage Error codes: invalid_options ### PUT /v1/billing/low-balance-alert operationId: `billing.setLowBalanceAlert` auth: user; key scopes: none Set the low-balance alert Turn the low-balance email on or off and set its line (`threshold`, inside the plan list's `lowBalanceAlert.range`): one email each time the balance falls to it. Request body (application/json): LowBalanceAlertSettings Responses: - 200: LowBalanceAlertView Error codes: invalid_options, forbidden ### GET /v1/billing/payment-methods operationId: `billing.paymentMethods` auth: user; key scopes: none List the payment methods The cards saved at the payment provider: brand, last 4, expiry, wallet, which is the default, and whether the last automatic charge on it was declined. Empty before the first payment or card. Responses: - 200: PaymentMethodList Error codes: unavailable ### POST /v1/billing/payment-methods/manage operationId: `billing.managePaymentMethods` auth: user; key scopes: none Add or change a payment method A link to the payment provider's portal, where a card is added, replaced or made the default (the provider has no API for those); it returns to the app's payment tab. Works before the first payment. Responses: - 200: PortalLink Error codes: forbidden, unavailable ### DELETE /v1/billing/payment-methods/{id} operationId: `billing.deletePaymentMethod` auth: user; key scopes: none Remove a payment method Remove a saved card. `409 payment_method_in_use` while it pays for the subscription. Removing the last one turns auto top-up off. Parameters: - `id` (path, required): string Responses: - 204: Removed. Error codes: not_found, payment_method_in_use, forbidden, unavailable ### GET /v1/billing/payments operationId: `billing.payments` auth: userOrKey; key scopes: read List the payments The account's payments history, newest first: top-ups, auto top-ups and subscription payments (`paid`, `pending` while the provider processes one, `failed` for a declined charge or renewal), refunds (`refunded`) and credit never paid for (`credit`: the signup credit, a promo code's bonus, an adjustment). `document` says whether an `invoice` or a `receipt` can be fetched. Page with `cursor`. Parameters: - `cursor` (query): string. The `nextCursor` of the previous page. - `limit` (query): integer Responses: - 200: PaymentPage Error codes: invalid_options ### GET /v1/billing/payments/{id}/document operationId: `billing.paymentDocument` auth: user; key scopes: none Get a payment's invoice or receipt A link to the payment's invoice (a subscription) or receipt (a top-up), valid for a short while. The provider makes it on the first ask: `202` with `{pending: true}` until then, ask again in a moment. `409 billing_details_required` when it needs a billing name and address (`PUT /v1/billing/details`). Parameters: - `id` (path, required): string Responses: - 200: PaymentDocument - 202: PaymentDocument Error codes: not_found, billing_details_required, unavailable ### GET /v1/billing/plans operationId: `billing.plans` auth: public; key scopes: none List the plans The visible plans with their current rates, prices, included credit, whether running work goes on in a grace period when the credit runs out (`graceOnZero`), limits and features, plus the top-up presets and range, the auto top-up and low-balance alert choices and ranges, the signup credit and the downgrade grace in days. No credential needed. Responses: - 200: PlanList ### GET /v1/billing/portal operationId: `billing.portal` auth: user; key scopes: none Get the customer portal link A link to the payment provider's customer portal (payment methods, invoices, receipts), valid for a short while. `409 payment_method_required` before the first checkout. Responses: - 200: PortalLink Error codes: payment_method_required, forbidden, unavailable ### POST /v1/billing/promo-codes/check operationId: `billing.checkPromoCode` auth: user; key scopes: none Check a promo code What a code gives `for` a `topup` (a bonus credit) or a `subscribe` (a discount off the first payment), before the checkout. `404 promo_code_invalid` when it does not exist, has expired, is used up, was redeemed by this account or gives nothing for this purchase. Rate-limited per account. Request body (application/json): CheckPromoCode Responses: - 200: PromoCodeView Error codes: promo_code_invalid, invalid_options, rate_limited ### POST /v1/billing/subscription/cancel operationId: `billing.cancelSubscription` auth: user; key scopes: none Cancel the subscription Cancel at the end of the current period: nothing changes until then; after it the account moves to the default plan and unspent monthly credit is removed. `409 invalid_state` without a subscription. Responses: - 200: SubscriptionView Error codes: invalid_state, forbidden, unavailable ### POST /v1/billing/subscription/interval operationId: `billing.setInterval` auth: user; key scopes: none Switch the billing interval From the next period the subscription renews `month`ly or `year`ly (nothing is charged now); `subscription.pendingInterval` shows the switch until it applies. `409 invalid_state` without a subscription or when it ends at the period end. Request body (application/json): SetInterval Responses: - 200: SubscriptionView Error codes: invalid_options, invalid_state, forbidden, unavailable ### DELETE /v1/billing/subscription/interval operationId: `billing.unsetInterval` auth: user; key scopes: none Keep the billing interval Take back a scheduled interval switch. Responses: - 200: SubscriptionView Error codes: invalid_state, forbidden, unavailable ### POST /v1/billing/subscription/uncancel operationId: `billing.uncancelSubscription` auth: user; key scopes: none Keep the subscription Take back a cancellation scheduled for the period end. Responses: - 200: SubscriptionView Error codes: invalid_state, forbidden, unavailable ### GET /v1/billing/usage operationId: `billing.usage` auth: userOrKey; key scopes: read Get the usage report What the account spent per day (in `tz`), per source (`dashboard` or the API key that made the transcription, deleted recordings included) and per kind (`file`, `live`): audio seconds and dollars, with running transcriptions at their current amount. `startDate` and `endDate` are inclusive days (`YYYY-MM-DD`); a start before the account's first day is moved to it and an end after today to today (the answer's `startDate` and `endDate` say what was used); at most 92 days (`400 range_too_long`). Parameters: - `startDate` (query): string. The first day, `YYYY-MM-DD` (default: the first of this month). - `endDate` (query): string. The last day (default: today). - `tz` (query): string. An IANA time zone. Responses: - 200: UsageReport Error codes: invalid_options, range_too_long ## broadcasts ### GET /v1/broadcasts operationId: `broadcasts.list` auth: userOrKey; key scopes: read List broadcasts Broadcasts owned by the acting principal. `lifecycle=all` is the active and the archived in one answer (each says which it is in its `state`), up to 500 of each — what a dashboard that shows them all should ask, once. Parameters: - `lifecycle` (query): string Responses: - 200: object ### POST /v1/broadcasts operationId: `broadcasts.create` auth: userOrKey; key scopes: write Create a broadcast Create a broadcast: the link listeners open (`code`), which a host can change later (the old one keeps working). Parameters: - `Idempotency-Key` (header): string. Makes a retry safe: a request repeated with the same key and the same body within 24 hours returns the first response (with `Idempotent-Replayed: true`) instead of running again. The same key with a different request is 422 `idempotency_key_reused`; a retry while the first is still running is 409 `conflict`. Only a successful (2xx) response is remembered. Keys are per account. Request body (application/json): CreateBroadcast Responses: - 201: Broadcast Error codes: invalid_options, conflict, rate_limited, idempotency_key_reused ### GET /v1/broadcasts/{id} operationId: `broadcasts.get` auth: userOrKey; key scopes: read Get a broadcast One broadcast of the acting principal's, by id. Parameters: - `id` (path, required): string Responses: - 200: Broadcast ### PATCH /v1/broadcasts/{id} operationId: `broadcasts.update` auth: userOrKey; key scopes: write Update a broadcast Rename, change access/password, change the link (`code`; the links it had keep opening it), or update default capture settings. Parameters: - `id` (path, required): string Request body (application/json): UpdateBroadcast Responses: - 200: Broadcast ### POST /v1/broadcasts/{id}/archive operationId: `broadcasts.archive` auth: userOrKey; key scopes: write Archive a broadcast Archive the broadcast: hidden, its links dead, its data kept until it is restored or purged. Parameters: - `id` (path, required): string Responses: - 200: Broadcast ### POST /v1/broadcasts/{id}/audio-sources operationId: `broadcasts.addAudioSource` auth: userOrKey; key scopes: write Add an audio source Give a broadcast a stream it can record from — a YouTube live, an internet radio address (`audioSources`). `broadcasts.startRecording` with `audioSource` set to its id records it: each such recording is a live transcription of its own, attached to the broadcast. Returns the broadcast. Parameters: - `id` (path, required): string Request body (application/json): CreateAudioSource Responses: - 201: Broadcast Error codes: invalid_options, invalid_state, not_found ### DELETE /v1/broadcasts/{id}/audio-sources/{source} operationId: `broadcasts.removeAudioSource` auth: userOrKey; key scopes: write Remove an audio source Take an audio source off a broadcast. What was recorded from it stays. Returns the broadcast. Parameters: - `id` (path, required): string - `source` (path, required): string Responses: - 200: Broadcast Error codes: not_found ### GET /v1/broadcasts/{id}/audio-sources/{source}/check operationId: `broadcasts.checkAudioSource` auth: userOrKey; key scopes: read Check an audio source Whether an audio source can be recorded right now: the server opens it as a recording would — a YouTube link resolved to its live stream, a radio address connected, a playlist followed, the first audio decoded — and says whether audio came back (`state`). When it did not: what went wrong (`problem`, `message`, `httpStatus`), and, when one of the likely mistakes — an extension a letter off, the other scheme, the stream a web page plays — was opened and gave audio, that address (`fix`). Nothing is recorded. A ready answer stands for a minute and a failed one for 20 s, unless `fresh` asks again. Parameters: - `id` (path, required): string - `source` (path, required): string - `fresh` (query): boolean Responses: - 200: AudioSourceCheck Error codes: not_found ### POST /v1/broadcasts/{id}/purge operationId: `broadcasts.purge` auth: userOrKey; key scopes: write Purge a broadcast Delete an archived broadcast and everything in it, for good. Parameters: - `id` (path, required): string Responses: - 200: OK ### GET /v1/broadcasts/{id}/recordings operationId: `broadcasts.listRecordings` auth: userOrKey; key scopes: read List a broadcast's recordings The broadcast's recordings. Parameters: - `id` (path, required): string - `lifecycle` (query): string Responses: - 200: object ### POST /v1/broadcasts/{id}/recordings operationId: `broadcasts.startRecording` auth: userOrKey; key scopes: recordings:publish Start a recording Start recording on the broadcast. The recording is made in a new live transcription that hears the broadcast's microphone — or, with `audioSource`, one of its audio sources — and is attached to it: `recording.transcriptionId` names that transcription, the broadcast's link shows it (`currentRecording`), and it is listed with the transcriptions. Mints a publisher realtime session (`streamUrl`, `sessionId`); 409 conflict with details.takeover when another device is recording. `scriptText` gives the recording words to follow like a teleprompter (`Recording.scriptText`). Parameters: - `id` (path, required): string - `Idempotency-Key` (header): string. Makes a retry safe: a request repeated with the same key and the same body within 24 hours returns the first response (with `Idempotent-Replayed: true`) instead of running again. The same key with a different request is 422 `idempotency_key_reused`; a retry while the first is still running is 409 `conflict`. Only a successful (2xx) response is remembered. Keys are per account. Request body (application/json): StartRecording Responses: - 201: PublishGrant Error codes: invalid_state, conflict, insufficient_credit, quota_exceeded, rate_limited, idempotency_key_reused ### POST /v1/broadcasts/{id}/restore operationId: `broadcasts.restore` auth: userOrKey; key scopes: write Restore a broadcast Bring an archived broadcast back. Parameters: - `id` (path, required): string Responses: - 200: Broadcast ### GET /v1/broadcasts/{id}/share-links operationId: `broadcasts.listShareLinks` auth: userOrKey; key scopes: read List a broadcast's share links Active share links of the broadcast. Parameters: - `id` (path, required): string Responses: - 200: object ### POST /v1/broadcasts/{id}/share-links operationId: `broadcasts.createShareLink` auth: userOrKey; key scopes: write Create a share link A named link ("Who is it for?") to the read-only public page. `allowDownload`, `allowExport` and `allowTranslate` are all on by default and per link. Send `allowTranslate: false` to close a link to translation; it takes effect from the next request. Parameters: - `id` (path, required): string Request body (application/json): CreateShareLink Responses: - 201: ShareLink ## corrections ### POST /v1/corrections/{id}/revert operationId: `corrections.revert` auth: userOrKey; key scopes: write Revert a correction Undo one correction. Parameters: - `id` (path, required): string Responses: - 200: Correction ### GET /v1/recordings/{id}/corrections operationId: `corrections.list` auth: userOrKey; key scopes: read List corrections Every correction made to the recording's transcript. Parameters: - `id` (path, required): string Responses: - 200: object ### POST /v1/recordings/{id}/corrections operationId: `corrections.apply` auth: userOrKey; key scopes: write Apply corrections Apply a batch of word edits and line rewrites atomically; every target must be settled. Each correction says whether its line is voiced as corrected (`voiced`): the voice may only say what was said, so a correction is voiced only when every word it says is a word of the recognizer's text (punctuation, case, Latin accents and number formatting aside) or of a fresh reading of the line's audio, taken automatically when the recognizer's text is not enough, and it drops no negation the audio has. A line spanning several segments lists neighbouring segments only (400 `invalid_options` otherwise). Any other correction is saved and read, but voice clips and voice tracks keep speaking what the recognizer heard (`notVoicedReason: rewrite`). VocalStack speaks transcripts, never text a caller wrote. Parameters: - `id` (path, required): string Request body (application/json): CorrectionBatch Responses: - 200: object Error codes: invalid_state, invalid_options ### POST /v1/recordings/{id}/retranscribe operationId: `corrections.retranscribe` auth: userOrKey; key scopes: write Retranscribe a line Read one line's audio again with the language forced, and apply what the recognizer hears as a line rewrite. A line heard in the wrong language cannot be repaired word by word — every word is wrong together — so this is the repair for it. The segments must be settled, like every other correction; the answer is the same `line` corrections a hand-typed rewrite produces, with the same revert behind them. 503 `engine_unavailable` means the audio for that line has not reached this machine yet and the ask is worth repeating. Parameters: - `id` (path, required): string Request body (application/json): RetranscribeLine Responses: - 200: object Error codes: invalid_state, invalid_options, engine_unavailable, not_found, insufficient_credit ## jobs ### GET /v1/recordings/{id}/job operationId: `jobs.get` auth: userOrKey; key scopes: read Get a job The recording's transcription job: its state and progress (done/total segments, media seconds, pct). Parameters: - `id` (path, required): string Responses: - 200: TranscriptionJob ### GET /v1/recordings/{id}/job/preview operationId: `jobs.preview` auth: userOrKey; key scopes: read Get a job's preview media A job waiting in `cropping`: the media for the trim step to draw the waveform from in the browser. From a link (`preview: true`), the file the server fetched — read once, kept until the job is started or for ten minutes, never stored; from an upload, its stored source streamed through. 404 once a link's copy is gone. Parameters: - `id` (path, required): string Responses: - 200: The fetched media, as it came. Error codes: not_found ### POST /v1/recordings/{id}/job/retry operationId: `jobs.retry` auth: userOrKey; key scopes: recordings:publish Retry a job Re-queue a failed job from its stored source. Parameters: - `id` (path, required): string Responses: - 200: TranscriptionJob Error codes: invalid_state, insufficient_credit, quota_exceeded, rate_limited ### POST /v1/recordings/{id}/job/start operationId: `jobs.start` auth: userOrKey; key scopes: recordings:publish Start a cropped job Release a job waiting in `cropping` (created with `crop: true`): the window of the source to transcribe, in seconds — an omitted bound is the start or the end of the file — then the job is queued. Timestamps in the transcript count from the window's start. Parameters: - `id` (path, required): string Request body (application/json): StartJob Responses: - 200: TranscriptionJob Error codes: invalid_options, invalid_state, insufficient_credit, quota_exceeded, rate_limited ## languages ### GET /v1/languages operationId: `languages.list` auth: public; key scopes: none List languages Transcription, translation and voice lists with picker metadata. Responses: - 200: LanguageLists ## me ### GET /v1/me operationId: `me.get` auth: userOrKey; key scopes: none Get the current account Who the credential is: the acting principal, the person (`userId`) or API key (`apiKeyId`) behind it, the principals it may act for and a key's scopes. Responses: - 200: object ## meta ### GET /v1/health operationId: `meta.health` auth: public; key scopes: none Check health Liveness: 200 while this server is up. Responses: - 200: Health ### GET /v1/openapi.json operationId: `meta.openapi` auth: public; key scopes: none Get the OpenAPI document This API's OpenAPI document, with this host in `servers`. Responses: - 200: object ## realtime ### GET /v1/realtime/capabilities operationId: `realtime.capabilities` auth: public; key scopes: none Get realtime capabilities What the realtime socket takes: supported languages, audio formats, and limits. Responses: - 200: Capabilities ### POST /v1/realtime/sessions operationId: `realtime.createSession` auth: key; key scopes: write Create a realtime session Create a realtime session: a signed, stateless token for one WebSocket (`streamUrl`). Nothing is stored server-side. Request body (application/json): SessionOptions Responses: - 200: CreateSessionResponse - 400: ApiErrorBody - 500: ApiErrorBody Error codes: invalid_options, internal, insufficient_credit, quota_exceeded, rate_limited ## recordings ### GET /v1/recordings/{id} operationId: `recordings.get` auth: listener; key scopes: read Get a recording One recording, by id: for its owner, or any listener who can reach its broadcast or transcription. Parameters: - `id` (path, required): string Responses: - 200: Recording ### POST /v1/recordings/{id}/archive operationId: `recordings.archive` auth: userOrKey; key scopes: write Archive a recording Archive the recording: hidden, its data kept. Parameters: - `id` (path, required): string Responses: - 200: Recording ### GET /v1/recordings/{id}/audio operationId: `recordings.audio` auth: listener; key scopes: read Download a recording's audio 302 to a freshly signed URL. kind=delivery (owner + listeners, 1 h), voice (a rendered spoken track, needs lang=, owner + listeners, 1 h; through a share link a language other than the spoken one needs the link's `allowTranslate`), source (owner or a link that allows downloads) or master (owner, 5 min). Through a share link EVERY kind needs the link's `allowDownload` — streaming a file and saving it are the same request, so a link with audio off plays nothing either. The delivery is Opus in two containers: format=ogg (default) and format=caf, the one Safari/iOS plays (WebKit rejects or mis-times Ogg); pick by canPlayType('audio/x-caf; codecs=opus'). format=mp3 is for SAVING (delivery and voice): the one container every player opens, encoded on first request and kept (kinds delivery_mp3, voice_mp3:). The recording's `media` lists which renditions are sealed (kinds delivery, delivery_caf, delivery_mp3); the CAF is remuxed on first request when it is not there yet. Parameters: - `id` (path, required): string - `kind` (query): string - `lang` (query): string. kind=voice: which spoken track - `format` (query): string Responses: - 302: Redirect ### GET /v1/recordings/{id}/audio/clip operationId: `recordings.clip` auth: listener; key scopes: read Get an audio clip A stretch of the audio as WAV (16-bit, 16 kHz, mono), available while the recording is still open — for replaying a line during live correction. Owner + listeners; at most 120 s. Through a share link it needs the link's `allowDownload`, like every other way of asking for the audio. Parameters: - `id` (path, required): string - `startS` (query, required): number. media seconds - `endS` (query, required): number. media seconds, > startS Responses: - 200: audio/wav Error codes: not_found, invalid_options ### GET /v1/recordings/{id}/events operationId: `recordings.events` auth: userOrKey; key scopes: read Get a recording's event log The recording's durable event log in order, each frame stamped with the wall time it was written — replays the recording exactly as the host saw it. Owner only. Parameters: - `id` (path, required): string - `after` (query): integer - `limit` (query): integer Responses: - 200: RecordingEvents ### GET /v1/recordings/{id}/export operationId: `recordings.export` auth: listener; key scopes: read Export a recording A real file: JSON, SRT, XLSX (Excel workbook) or PDF, corrections included. The PDF of a finished recording reads in paragraphs (built by the engine after finalize; a download that finds them missing or stale has them rebuilt first, waiting up to a minute). Owner, or a share link (`shareCode`) that allows export. While the job runs the export is partial (file name suffixed, `partial` flagged). `lang` makes the file read in that language — the translation the reader sees on screen, one line per sentence, sentences not yet translated are translated first; the file name carries the language and JSON's `translated` is true. The spoken language, or no `lang`, is the transcript as spoken. A share link with Translate off may only have that. Parameters: - `id` (path, required): string - `format` (query, required): ExportFormat - `lang` (query): string - `shareCode` (query): string Responses: - 200: The file (Content-Disposition: attachment) Error codes: forbidden, invalid_options ### GET /v1/recordings/{id}/media-token operationId: `recordings.mediaToken` auth: userOrKey; key scopes: read Get a media token The owner's credential for this recording's media URLs: pass it as `media=` to `/audio`, `/audio/clip` and `/voice/{sentence}` in place of `access_token`. It reads this recording's media and nothing else, and it is the same string for a whole UTC day (valid until the end of the next one) — an access token rotates every few minutes, and a credential in the URL is part of the browser's cache key, so clips fetched with one were fetched again at every rotation. Parameters: - `id` (path, required): string Responses: - 200: object ### POST /v1/recordings/{id}/pause operationId: `recordings.pause` auth: userOrKey; key scopes: recordings:publish Pause a recording Pause capture. Idempotent. Parameters: - `id` (path, required): string Responses: - 200: Recording ### POST /v1/recordings/{id}/publish-token operationId: `recordings.publishToken` auth: userOrKey; key scopes: recordings:publish Create a publish token Re-attach a publisher (reconnect or another device; takeover=true demotes the current one). Parameters: - `id` (path, required): string Responses: - 200: PublishGrant Error codes: conflict, invalid_state ### POST /v1/recordings/{id}/purge operationId: `recordings.purge` auth: userOrKey; key scopes: write Purge a recording Delete an archived recording for good. Parameters: - `id` (path, required): string Responses: - 200: OK ### POST /v1/recordings/{id}/restore operationId: `recordings.restore` auth: userOrKey; key scopes: write Restore a recording Bring an archived recording back. Parameters: - `id` (path, required): string Responses: - 200: Recording ### POST /v1/recordings/{id}/resume operationId: `recordings.resume` auth: userOrKey; key scopes: recordings:publish Resume a recording Resume a paused recording. Parameters: - `id` (path, required): string Responses: - 200: Recording ### PATCH /v1/recordings/{id}/settings operationId: `recordings.settings` auth: userOrKey; key scopes: recordings:publish Update recording settings Mid-recording capture settings (mode, language, labels). Parameters: - `id` (path, required): string Request body (application/json): SettingsPatch Responses: - 200: Recording ### PUT /v1/recordings/{id}/speakers/{idx} operationId: `recordings.nameSpeaker` auth: userOrKey; key scopes: write Name a speaker Name a speaker of this recording. Parameters: - `id` (path, required): string - `idx` (path, required): integer Request body (application/json): NameSpeaker Responses: - 200: OK ### POST /v1/recordings/{id}/stop operationId: `recordings.stop` auth: userOrKey; key scopes: recordings:publish Stop a recording End capture; finalize runs asynchronously. Only from paused. Parameters: - `id` (path, required): string Request body (application/json): StopRequest Responses: - 200: Recording Error codes: invalid_state ### POST /v1/recordings/{id}/summarize operationId: `recordings.summarize` auth: userOrKey; key scopes: write Request a summary Ask for the recording's AI title and summary. The engine writes them after finalize on its own; call this when a finished recording has none (or its pass failed) and poll the recording: `ready` carries them, `pending` means the pass is queued or running, `unavailable` means this server has no summariser. Parameters: - `id` (path, required): string - `Idempotency-Key` (header): string. Makes a retry safe: a request repeated with the same key and the same body within 24 hours returns the first response (with `Idempotent-Replayed: true`) instead of running again. The same key with a different request is 422 `idempotency_key_reused`; a retry while the first is still running is 409 `conflict`. Only a successful (2xx) response is remembered. Keys are per account. Responses: - 200: SummaryStatus Error codes: invalid_state, not_found, idempotency_key_reused ### GET /v1/recordings/{id}/summary operationId: `recordings.summary` auth: listener; key scopes: read Get a recording's summary The title and summary as they stand — `ready` with both, `pending` while the engine is still writing them, `unavailable` on a server without a summariser. Nothing is queued (that is `summarize`), so a share-link viewer may ask too. `lang` reads them in that language: the one the reader picked on screen, which the transcript and the downloads follow. The engine translates the summary into every language the recording was listened to or read in as it writes it, so those are ready; any other language is translated once, on the first ask, and kept. `lang` in the answer says the translation was had. The spoken language, or no `lang`, is the summary as written. Through a share link a language other than the spoken one needs the link's `allowTranslate`. Parameters: - `id` (path, required): string - `lang` (query): string - `shareCode` (query): string Responses: - 200: SummaryStatus Error codes: forbidden, invalid_options, not_found ### GET /v1/recordings/{id}/transcript operationId: `recordings.transcript` auth: listener; key scopes: read Get a transcript Snapshot up to lastSeq; the backfill for subscribers (after=cursor). A line scoring under `settings.confidence.hideBelow` (0.60 by default — the line's own mean word probability, not a count of bad words) is hidden as "Audio unclear". Listeners see it blanked (`suppressed`, text=''); anyone shown the text — the owner, a share-link reader — gets `unclear` instead and keeps every word. Either way the line is spoken to nobody: `GET .../voice/{sentence}` answers 404 for it. Listeners also see translations filtered to lang. Through a share link (`shareCode`), `lang` needs the link's `allowTranslate`: without it any language other than the one spoken is `forbidden`, never a silently untranslated snapshot. Parameters: - `id` (path, required): string - `after` (query): integer - `lang` (query): string - `listenerToken` (query): string. listener token (a private broadcast) Responses: - 200: TranscriptSnapshot ### GET /v1/recordings/{id}/translation operationId: `recordings.translation` auth: listener; key scopes: read Get a translation Sentences of the transcript translated into `lang` (a translation target), by sentence id (`sentences`, comma separated): what a reader in another language pages through. The owner, or any listener who can reach its broadcast or transcription; through a share link it needs `allowTranslate`. A listener's new (recording, language) pairs are limited per network an hour. Parameters: - `id` (path, required): string - `lang` (query, required): string - `sentences` (query): string - `shareCode` (query): string - `listenerToken` (query): string Responses: - 200: object Error codes: invalid_options, forbidden, rate_limited ### GET /v1/recordings/{id}/voice operationId: `recordings.voice` auth: listener; key scopes: read Get a recording's voice tracks What this recording can be heard speaking, one row per language: rendered tracks (`sealed`) and their jobs, and the per-sentence clips a live audience left behind (`clips`, with the sentence ids and the voice that spoke them). An ended recording has only clips — this is how a listener finds them again and plays its spoken translation back a line at a time. The owner, or any listener who can reach its broadcast or transcription; `lang` narrows it to one. A spoken translation needs the link's `allowTranslate` when reading through a share link — without it the listing carries only the spoken language, and asking for another is `forbidden`. Parameters: - `id` (path, required): string - `lang` (query): string Responses: - 200: array of VoiceTrack ### POST /v1/recordings/{id}/voice operationId: `recordings.renderVoice` auth: listener; key scopes: read Render voice tracks Render spoken tracks for a finished recording, one job per language (1–10 codes, each one /v1/languages says has a voice). Owner only. Parameters: - `id` (path, required): string - `Idempotency-Key` (header): string. Makes a retry safe: a request repeated with the same key and the same body within 24 hours returns the first response (with `Idempotent-Replayed: true`) instead of running again. The same key with a different request is 422 `idempotency_key_reused`; a retry while the first is still running is 409 `conflict`. Only a successful (2xx) response is remembered. Keys are per account. Request body (application/json): object Responses: - 200: array of VoiceTrack Error codes: invalid_options, not_found, idempotency_key_reused ### GET /v1/recordings/{id}/voice/{sentence} operationId: `recordings.voiceClip` auth: listener; key scopes: read Get a spoken sentence One spoken sentence, Ogg/Opus (`format=caf` for Apple's media stack). The owner, or any listener who can reach its broadcast or transcription. A clip in a language the recording was not spoken in is a spoken translation: through a share link it needs `allowTranslate`. Parameters: - `id` (path, required): string - `sentence` (path, required): integer - `lang` (query, required): string - `format` (query): string Responses: - 200: audio/ogg Error codes: not_found ## rtmp ### GET /v1/rtmp operationId: `rtmp.status` auth: userOrKey; key scopes: read Get RTMP status The account's RTMP server URL and stream key for an encoder, whether one is sending audio on it right now and what (`audio`, e.g. "48 kHz AAC"), and a signed link to listen to it (`monitorUrl`, an hour). A live transcription takes it as its source with `source.url` = `serverUrl`. The key is made on first ask. Responses: - 200: RtmpStatus Error codes: forbidden ## search ### GET /v1/search operationId: `search.query` auth: optional; key scopes: read Search transcripts Sentence search via the English pivot ∪ literal match; results rendered into lang on demand. An owner searches their own transcriptions and broadcasts; a listener names the broadcasts to search by their codes. Parameters: - `q` (query, required): string - `broadcasts` (query): string. Listeners: the broadcasts to search, by code or id, comma-separated. - `listenerTokens` (query): string. Listeners: listener tokens for the private broadcasts among `broadcasts`, comma-separated. - `recording` (query): string - `lang` (query): string - `limit` (query): integer Responses: - 200: SearchResults Error codes: rate_limited ## share-links ### PATCH /v1/share-links/{id} operationId: `shareLinks.update` auth: userOrKey; key scopes: write Update a share link The share sheet's toggles: any of `label`, `allowDownload`, `allowExport`, `allowTranslate`. Omitted fields are left as they are. Parameters: - `id` (path, required): string Request body (application/json): UpdateShareLink Responses: - 200: ShareLink ### DELETE /v1/share-links/{id} operationId: `shareLinks.revoke` auth: userOrKey; key scopes: write Revoke a share link Revoke: the public page 404s at once. Parameters: - `id` (path, required): string Responses: - 200: ShareLink ## transcriptions ### GET /v1/transcriptions operationId: `transcriptions.list` auth: userOrKey; key scopes: read List transcriptions Transcriptions owned by the acting principal (file, link and live). `lifecycle=all` is the active and the archived in one answer, up to 500 of each. Parameters: - `lifecycle` (query): string Responses: - 200: object ### POST /v1/transcriptions operationId: `transcriptions.create` auth: userOrKey; key scopes: write, recordings:publish Create a transcription Transcribe a file or a link: creates the transcription, its recording and a server-owned job. multipart/form-data with a `file` part (+ optional `options` JSON part), or JSON with `url` (fetched server-side). The source is kept in the media store. Follow the job with `jobs.get`. Parameters: - `Idempotency-Key` (header): string. Makes a retry safe: a request repeated with the same key and the same body within 24 hours returns the first response (with `Idempotent-Replayed: true`) instead of running again. The same key with a different request is 422 `idempotency_key_reused`; a retry while the first is still running is 409 `conflict`. Only a successful (2xx) response is remembered. Keys are per account. Request body (application/json): CreateTranscription Request body (multipart/form-data): object Responses: - 201: Transcription Error codes: invalid_options, invalid_audio, invalid_state, insufficient_credit, quota_exceeded, rate_limited, idempotency_key_reused ### POST /v1/transcriptions/live operationId: `transcriptions.createLive` auth: userOrKey; key scopes: write Create a live transcription Create a live transcription: captured as it is spoken — this device's microphone, or a stream the server pulls — rather than read from a file (`live: true`). It is created idle; `transcriptions.startRecording` starts it, and the recording's pause / resume / stop calls drive it. `broadcasts` attaches it to those broadcasts from the start. Parameters: - `Idempotency-Key` (header): string. Makes a retry safe: a request repeated with the same key and the same body within 24 hours returns the first response (with `Idempotent-Replayed: true`) instead of running again. The same key with a different request is 422 `idempotency_key_reused`; a retry while the first is still running is 409 `conflict`. Only a successful (2xx) response is remembered. Keys are per account. Request body (application/json): CreateLiveTranscription Responses: - 201: Transcription Error codes: invalid_options, not_found, rate_limited, idempotency_key_reused ### GET /v1/transcriptions/probe operationId: `transcriptions.probe` auth: userOrKey; key scopes: read Probe a link What a link is before it is transcribed: `live` — a YouTube live on air or scheduled, an endless HTTP stream, an HLS playlist with no end, a dynamic DASH manifest — belongs in a live transcription; `file` is transcribed as one; `unknown` could not be told. `source` names the live form's source (`youtube`, `radio`). Nothing is kept. 60 a minute. Parameters: - `url` (query, required): string Responses: - 200: LinkProbe Error codes: invalid_options, rate_limited ### GET /v1/transcriptions/{id} operationId: `transcriptions.get` auth: userOrKey; key scopes: read Get a transcription One transcription of the acting principal's, by id. Parameters: - `id` (path, required): string Responses: - 200: Transcription ### PATCH /v1/transcriptions/{id} operationId: `transcriptions.update` auth: userOrKey; key scopes: write Update a transcription Rename, or update default capture settings. Parameters: - `id` (path, required): string Request body (application/json): UpdateTranscription Responses: - 200: Transcription ### POST /v1/transcriptions/{id}/archive operationId: `transcriptions.archive` auth: userOrKey; key scopes: write Archive a transcription Archive the transcription: hidden, its links dead, its data kept until it is restored or purged. Parameters: - `id` (path, required): string Responses: - 200: Transcription ### PUT /v1/transcriptions/{id}/broadcasts/{broadcast} operationId: `transcriptions.attachBroadcast` auth: userOrKey; key scopes: write Attach a broadcast Feed a broadcast from the live transcription `id`: listeners on the broadcast's link read the transcription while it records, and its recordings are listed with the broadcast's own. One transcription can feed several broadcasts, and a broadcast can be fed by several. Returns the transcription. Parameters: - `id` (path, required): string - `broadcast` (path, required): string Responses: - 200: Transcription Error codes: invalid_options, invalid_state, not_found ### DELETE /v1/transcriptions/{id}/broadcasts/{broadcast} operationId: `transcriptions.detachBroadcast` auth: userOrKey; key scopes: write Detach a broadcast Stop feeding the broadcast from the live transcription `id`. Returns the transcription. Parameters: - `id` (path, required): string - `broadcast` (path, required): string Responses: - 200: Transcription Error codes: invalid_options, not_found ### POST /v1/transcriptions/{id}/purge operationId: `transcriptions.purge` auth: userOrKey; key scopes: write Purge a transcription Delete an archived transcription and everything in it, for good. Parameters: - `id` (path, required): string Responses: - 200: OK ### GET /v1/transcriptions/{id}/recordings operationId: `transcriptions.listRecordings` auth: userOrKey; key scopes: read List a transcription's recordings The transcription's recordings. Parameters: - `id` (path, required): string - `lifecycle` (query): string Responses: - 200: object ### POST /v1/transcriptions/{id}/recordings operationId: `transcriptions.startRecording` auth: userOrKey; key scopes: recordings:publish Start a recording Start recording a live transcription and mint a publisher realtime session (`streamUrl`, `sessionId`). 409 conflict with details.takeover when another device is recording. When its source is a stream URL the server pulls the URL and publishes it itself: the grant carries no `streamUrl`, and `recording.ingest` (plus `stream.ingest` frames) says where it stands. `scriptText` gives the recording words to follow like a teleprompter (`Recording.scriptText`). Parameters: - `id` (path, required): string - `Idempotency-Key` (header): string. Makes a retry safe: a request repeated with the same key and the same body within 24 hours returns the first response (with `Idempotent-Replayed: true`) instead of running again. The same key with a different request is 422 `idempotency_key_reused`; a retry while the first is still running is 409 `conflict`. Only a successful (2xx) response is remembered. Keys are per account. Request body (application/json): StartRecording Responses: - 201: PublishGrant Error codes: invalid_state, conflict, insufficient_credit, quota_exceeded, rate_limited, idempotency_key_reused ### POST /v1/transcriptions/{id}/recordings/upload operationId: `transcriptions.uploadRecording` auth: userOrKey; key scopes: recordings:publish Upload a recording A file or link recording in this transcription: no live phase, transcribed at full speed. Parameters: - `id` (path, required): string - `Idempotency-Key` (header): string. Makes a retry safe: a request repeated with the same key and the same body within 24 hours returns the first response (with `Idempotent-Replayed: true`) instead of running again. The same key with a different request is 422 `idempotency_key_reused`; a retry while the first is still running is 409 `conflict`. Only a successful (2xx) response is remembered. Keys are per account. Request body (application/json): UploadRecording Responses: - 201: PublishGrant Error codes: insufficient_credit, quota_exceeded, rate_limited, idempotency_key_reused ### POST /v1/transcriptions/{id}/restore operationId: `transcriptions.restore` auth: userOrKey; key scopes: write Restore a transcription Bring an archived transcription back. Parameters: - `id` (path, required): string Responses: - 200: Transcription ### GET /v1/transcriptions/{id}/share-links operationId: `transcriptions.listShareLinks` auth: userOrKey; key scopes: read List a transcription's share links Active share links of the transcription. Parameters: - `id` (path, required): string Responses: - 200: object ### POST /v1/transcriptions/{id}/share-links operationId: `transcriptions.createShareLink` auth: userOrKey; key scopes: write Create a share link A named link ("Who is it for?") to the read-only public page. `allowDownload`, `allowExport` and `allowTranslate` are all on by default and per link. Send `allowTranslate: false` to close a link to translation; it takes effect from the next request. Parameters: - `id` (path, required): string Request body (application/json): CreateShareLink Responses: - 201: ShareLink ## voices ### GET /v1/voices operationId: `voices.list` auth: userOrKey; key scopes: read List voices The account's cloned voices, newest first. Responses: - 200: object ### POST /v1/voices operationId: `voices.create` auth: userOrKey; key scopes: write Create a voice Create a voice from a recorded sample and queue its training: multipart/form-data with a `sample` audio part and a `meta` JSON part. The voice comes back `training` with progress 0; poll GET /v1/voices/{id}. Training reports a real state — it never fakes progress, and a voice that cannot be trained ends `failed` with the reason on `error`. Parameters: - `Idempotency-Key` (header): string. Makes a retry safe: a request repeated with the same key and the same body within 24 hours returns the first response (with `Idempotent-Replayed: true`) instead of running again. The same key with a different request is 422 `idempotency_key_reused`; a retry while the first is still running is 409 `conflict`. Only a successful (2xx) response is remembered. Keys are per account. Request body (multipart/form-data): object Responses: - 201: Voice Error codes: invalid_options, not_found, idempotency_key_reused ### GET /v1/voices/available operationId: `voices.available` auth: optional; key scopes: none List available voices The voices that can speak a language, best first (`default` marks the one a recording that pins none gets). Open to listeners; a bearer token also returns that account's ready cloned voices. A clip is synthesised once per language and fanned out, so this is the set a host may pin in `settings.voiceByLang` — and all a listener can ever be offered. Parameters: - `lang` (query, required): string - `recording` (query): string. Also offer the cloned voices this recording uses, to whoever may read it (its owner, or a listener with `listenerToken` or `shareCode`): what a listener of it may pick from. - `listenerToken` (query): string. With `recording`: the listener token of a private broadcast. - `shareCode` (query): string. With `recording`: a share link's code. - `access_token` (query): string. The bearer token, for a client that cannot set a header (an `