An upload returns a job, not a transcript. Put an Idempotency-Key on every create so that a retry can never create or bill a second transcription; then poll the job until it is done, and read sentences, words or an export from the same id.
A file transcription is the simplest thing the VocalStack API does, and it has the shape of any asynchronous job: upload, wait, read. The upload answers at once with a transcription and its job, but no words yet. While your program polls the job, the server transcribes the file many times faster than the audio plays. When the state is done, you read the transcript as sentences with speakers and timings, or export it as a file in one of four formats, either in the spoken language or translated.
Everything in this shape transfers to any batch API: a create that returns an id, a status you poll, a result you fetch afterwards. Only the field names are specific to VocalStack, and this tutorial walks through them with the TypeScript and Python SDKs, and the HTTP calls where they help.
Open Developers → API keys in the Dashboard and create a key with the Read and Write permissions. The key is shown once; copy it into an environment variable:
export VOCALSTACK_API_KEY="<your API key>"
A key is for servers and scripts. Anything that ships to a browser ships the key with it, so keep it out of page code and out of URLs, which end up in logs. To stream from a browser, your server creates a realtime session for it; that is a different tutorial.
Every call authenticates with the key as a bearer token:
Authorization: Bearer vs_key_...
With the key in your environment, the next thing is a client to send it.
There are two SDKs. Each has one client, VocalStack, with its methods grouped by resource: vs.transcriptions, vs.jobs, vs.recordings, vs.realtime and a few more. The TypeScript SDK runs on Node 20 or newer and in browsers; the Python SDK is built on asyncio and needs Python 3.10 or newer.
The SDKs are not on npm or PyPI. The API serves the builds to every account. The install commands, filled in for your environment, are in the Install section of the quickstart in the API reference, and they send your key only to the API host. If you would rather install nothing, the HTTP calls below need only curl and jq.
Whichever you chose, the first call is the upload.
POST /v1/transcriptions takes the file as multipart/form-data: a file part, plus an optional options part holding JSON. It answers 201 with the transcription. Its job.recordingId is the id of the recording made from the file, and every call that follows is addressed by that id.
Two settings matter for a first run, both inside options.settings:
spokenLanguage: a code such as en. Leave it out and the language is detected. Naming it improves accuracy on noisy recordings and heavy accents.speakerLabels: auto, on or off. With on, every sentence carries a speaker number; with auto, labels appear only once a second speaker has been heard.Most audio and video files are accepted: mp3, wav, flac, ogg, m4a, mp4 and others. A video keeps only its audio. The format barely matters to accuracy, because speech recognition works on 16 kHz mono and everything is resampled to that on arrival. A 48 kHz stereo WAV of one person talking spends most of its bytes describing silence in high fidelity. It transcribes exactly as well as a 64 kbps mono MP3 or Opus file of the same talk, but it is about twenty times larger, so it takes twenty times as long to upload. If you convert before uploading, mono and a modest bitrate are the two settings worth changing.
Send an Idempotency-Key with every upload, and understand why. A network timeout tells you nothing about what the server did: it may have never seen the request, or it may have stored the file and queued the job before the answer was lost. A blind retry therefore risks a second transcription and a second charge. The key fixes that. When a request is repeated with the same key and body within 24 hours, the server returns the first answer instead of creating anything, which turns "at least once" into "effectively once". So the rule is one key per logical upload, generated before the first attempt and reused on every retry. A fresh key per attempt would make each retry a new upload, and the header would protect nothing.
import { VocalStack } from '@vocalstack/sdk';
const vs = new VocalStack({ baseUrl: '{{api}}', apiKey: process.env.VOCALSTACK_API_KEY });
const uploadKey = crypto.randomUUID(); // one key for this upload, however many attempts it takes
const tx = await vs.transcriptions.create(
{ file: './talk.mp3', name: 'Talk', settings: { spokenLanguage: 'en', speakerLabels: 'on' } },
{ idempotencyKey: uploadKey },
);
const recordingId = tx.job!.recordingId;
import asyncio
import os
import uuid
from vocalstack import VocalStack
async def main() -> None:
upload_key = str(uuid.uuid4()) # one key for this upload, however many attempts it takes
async with VocalStack("{{api}}", 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=upload_key,
)
assert tx.job is not None
recording_id = tx.job.recording_id
The same call with curl:
API={{api}}
AUTH="Authorization: Bearer $VOCALSTACK_API_KEY"
UPLOAD_KEY=$(uuidgen)
REC=$(curl -sSf "$API/v1/transcriptions" -H "$AUTH" -H "Idempotency-Key: $UPLOAD_KEY" \
-F file=@talk.mp3 \
-F 'options={"name":"Talk","settings":{"spokenLanguage":"en","speakerLabels":"on"}}' \
| jq -r .job.recordingId)
The 201 comes back before a single word is transcribed; what you hold is a job id, so the next step is waiting on it.
A job moves queued → transcribing → done, or ends in failed with an error message and, when the cause is machine-readable, an errorCode. A fourth state, cropping, means the job is parked until someone chooses which stretch of the recording to transcribe, as the Dashboard's trim step does. An ordinary upload never enters it, though jobs.wait also returns when a job reaches it. Read GET /v1/recordings/{id}/job until the state is done or failed.
There are no webhooks, and that is a deliberate trade. A webhook needs a public HTTPS endpoint on your side, a signature check so that nobody else can call it, and a retry policy for when your endpoint is down, which is a lot of plumbing for a job that takes a minute. Polling needs none of it. Your script will ask "done yet?" every two seconds, like a child in the back seat, and unlike the child it gets a percentage back.
A web page that is already open can follow the transcription's events socket instead (WS /v1/transcriptions/{id}/events, a job.progress frame as the job advances). For a script, polling is the right tool.
The SDKs do the polling: vs.jobs.wait reads the job every 2 seconds and hands each read to onProgress (on_progress in Python). The job's progress carries pct (0 to 100) and processedS, the media seconds transcribed so far. The programs below print a line only when the state or the percentage changes.
let last = '';
const job = await vs.jobs.wait(recordingId, {
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 ?? ''}`);
last = ""
def on_progress(j) -> None:
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}")
Note that wait does not raise on failed; it returns the job and leaves the check to you. Two seconds suits a talk. For a three-hour recording, or a batch of hundreds, widen the interval (intervalMs, interval_s) or back off as the job runs: the first reads tell you the most, and the later ones mostly confirm that the server is still busy. Without an SDK, you write the same loop by hand:
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
When the state reads done, the transcript is ready.
GET /v1/recordings/{id}/transcript answers with the whole transcript in one document:
sentences: what to show people. Each has text, speaker, and start and end in seconds.segments: the engine's lines, each with word-level timings in words (every word has text, start and end).speakers: one entry per speaker number, with its name.Speakers are numbered from 0. A speaker of -1 means speaker labels were off for the recording. Add ?lang=es to read the transcript translated into Spanish; the sentences come back in that language.
const { sentences } = await vs.recordings.transcript(recordingId);
for (const s of sentences) console.log(s.speaker >= 0 ? `[speaker ${s.speaker}] ${s.text}` : s.text);
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)
curl -sSf "$API/v1/recordings/$REC/transcript" -H "$AUTH" \
| jq -r '.sentences[] | if .speaker >= 0 then "[speaker \(.speaker)] \(.text)" else .text end'
The two timing levels serve two jobs. Sentences are the unit of reading, so they are what a subtitle or a transcript page shows. Words are the unit of pointing, so a karaoke-style highlight, a search hit or a clip cut walks segments[].words instead. The export formats are built from sentences.
GET /v1/recordings/{id}/export returns a real file. format is one of srt, json, xlsx or pdf, and the file includes any corrections. Add lang= for a translated file: the export then reads in that language, one line per sentence, and any sentence not yet translated is translated first.
SRT is worth knowing by sight, because it is the subtitle format nearly every player accepts and it is plain text. A file is a list of cues separated by blank lines, and each cue is a counter, a time range and the text. The timestamps use a comma before the milliseconds, a French habit the format never shed:
1
00:00:01,000 --> 00:00:04,200
Welcome, everyone. Let's get started.
One sentence per cue is the right grain: each cue can be read in the time it is on screen, and none cuts off mid-thought. When the recording had speaker labels, each cue's text starts with the speaker's name and a colon.
In the TypeScript SDK, vs.recordings.export returns a Response; read its bytes with arrayBuffer(). In Python it returns bytes.
import { writeFile } from 'node:fs/promises';
const srt = await vs.recordings.export(recordingId, 'srt');
await writeFile('talk.srt', Buffer.from(await srt.arrayBuffer()));
const pdf = await vs.recordings.export(recordingId, 'pdf', { lang: 'es' });
await writeFile('talk.es.pdf', Buffer.from(await pdf.arrayBuffer()));
srt = await vs.recordings.export(recording_id, "srt")
with open("talk.srt", "wb") as f:
f.write(srt)
pdf = await vs.recordings.export(recording_id, "pdf", lang="es")
with open("talk.es.pdf", "wb") as f:
f.write(pdf)
asyncio.run(main())
curl -sSf "$API/v1/recordings/$REC/export?format=srt" -H "$AUTH" -o talk.srt
curl -sSf "$API/v1/recordings/$REC/export?format=pdf&lang=es" -H "$AUTH" -o talk.es.pdf
The transcription is stored, so it appears under Transcriptions in the Dashboard, can be shared by link, and can be read again any time later. That is the core loop: create, wait, read, export. The rest of this tutorial covers the variations: media that is already online, the errors you will meet, and what a file costs.
If the media is already online, hand the API the link and it fetches the file itself: a public http(s) address (an mp3, a video, a podcast episode) or a YouTube video. The call is the same POST /v1/transcriptions, with a JSON body { "url": "..." } in place of the multipart upload, and name and settings as before. In the SDKs, vs.transcriptions.createFromUrl(url) (TypeScript) and vs.transcriptions.create(url=...) (Python) make the same request.
Before creating anything, GET /v1/transcriptions/probe?url= tells you what a link is. Its kind is one of three:
file: a recording with an end; transcribe it here.live: a YouTube live stream, an endless radio stream or an HLS playlist with no end. A live link never ends, so it cannot be transcribed as a file; use it as the source of a live transcription instead.unknown: the server could not tell.When the link does not open as media at all (a web page, for instance), ok is false and problem explains why in one sentence. The probe is optional, and limited to 60 calls a minute.
A link job starts in fetching before it moves to transcribing and done. While it fetches, progress.phase says what the server is doing: fetching while bytes arrive (with fetchedBytes and fetchTotalBytes when the other end says how many), or extracting while the audio is taken out of a video. progress.phasePct says how far through that phase the job is. A long video takes a while, so poll every few seconds; vs.jobs.wait takes intervalMs (interval_s in Python) for that.
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 linkKey = crypto.randomUUID();
const tx = await vs.transcriptions.createFromUrl(url, { name: 'From a link' }, { idempotencyKey: linkKey });
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}%`);
},
});
The reference's Transcribe a link guide has the full program in both languages and HTTP. Fetching from links adds more ways to fail, so here are the errors you will actually see.
Every failed call answers with one JSON envelope. code is the string to branch on and status the HTTP status; message is for people and requestId for support. Under data, retryable says whether sending the same request again can help, and details holds whatever the code needs. The SDKs raise one error type, VocalStackError, with the same fields.
Three codes are worth handling on day one.
402 insufficient_credit: the account's credit is out, and nothing was charged. details.balance is the balance in dollars and details.topUpUrl is where to add credit. When the cost was known up front, as it is for a file of known length, details.required says what it would have been.429 rate_limited: wait Retry-After seconds. When details.metric is creates_per_hour, you have hit the plan's creation rate (new transcriptions an hour), not a burst limit; limit and resetsAt say what the cap is and when it frees up.503 unavailable: the service is briefly unavailable. Send the request again after Retry-After.The SDKs retry a 429 and a 503 after Retry-After, twice by default (maxRetries / max_retries on the client). A 503, though, is retried only on a call that is safe to send twice: a GET, or a create carrying an Idempotency-Key. A create without one is left to you, because the SDK cannot know whether the first attempt went through. That is the practical reason to put a key on every upload.
Every response also carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset, so a busy pipeline can slow down before the 429.
{
"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…"
}
The whole table of codes is in Errors, retries and limits. That 402 is the one error tied to money, so here is what a file costs.
Files are $0.15 per hour of audio, billed per minute ($0.0025 a minute): a 30-minute file costs $0.075. That is seven and a half cents, less than the coffee you will drink while the progress bar fills. The rate is the same on every plan, and the same through the API as in the Dashboard. A new account starts with a one-time $5 signup credit, and no card is required.
Three plan limits touch this tutorial.
GET /v1/realtime/capabilities reports as limits.maxUploadBytes. The smaller of the two applies, and a file over it is refused with a 413 before anything is transcribed. For a long recording, this is one more reason to upload a compressed mono file rather than a WAV.GET /v1/billing reports your plan, balance and limits at any time; the full table is on the pricing page.
From here, the next steps are streaming audio in real time when you cannot wait for a file to finish, or connecting an AI agent to the same account through the MCP server, so it transcribes and searches for you without any of the code above.