Upload & transcribe
End-to-end walkthrough — upload an audio file through the API, wait for the transcript, and fetch the text, reports or a Markdown export. Includes batch uploads.
The full loop is three calls: upload the file, poll until it's done, read the result. This guide builds a small, production-ready script — and shows how to run whole folders through it.
Create a key with upload permission
Uploads require an explicit permission on the key. In Settings → API & MCP, click
Create key and enable Allow file uploads. Keys created without it (including all older
keys) are read-only and get 403 upload_scope_required on upload.
Upload a file
One POST with the file as the raw body (full reference):
import httpx
BASE = "https://app.specala.ai/api/v1/developer"
HEADERS = {"Authorization": "Bearer sk_live_your_key_here"}
def upload(path: str, language: str | None = None, ai_metadata: bool = False) -> str:
"""Upload a media file, return the transcription UUID."""
params = {"ai_metadata": str(ai_metadata).lower()}
if language:
params["language"] = language
with open(path, "rb") as f:
resp = httpx.post(
f"{BASE}/transcriptions",
params=params,
headers={
**HEADERS,
"Content-Disposition": f'attachment; filename="{path.split("/")[-1]}"',
},
content=f,
timeout=httpx.Timeout(10, write=None),
)
resp.raise_for_status()
return resp.json()["uuid"]
# ai_metadata=True → also generate the AI layer (summary, reports) used in step 3;
# by default API uploads produce the raw transcript only — faster.
uuid = upload("standup.mp3", language="en", ai_metadata=True)
print("queued:", uuid)Files up to 2 GB work the same way — the body streams from disk.
Wait for the transcript
Poll every 5–10 seconds. A short recording is usually ready in a couple of minutes:
import time
def wait(uuid: str, poll_seconds: int = 10) -> dict:
"""Poll until the transcription reaches a terminal status."""
while True:
t = httpx.get(f"{BASE}/transcriptions/{uuid}", headers=HEADERS).json()
if t["status"] == "completed":
return t
if t["status"] == "failed":
raise RuntimeError(f"transcription failed: {t.get('error_code')}")
if t["status"] == "insufficient_balance":
raise RuntimeError("not enough plan minutes — top up and re-upload")
time.sleep(poll_seconds) # queued / processing
t = wait(uuid)
print(t["title"])
print(t["text"][:500])
for report in t["prompt_results"]:
print("Report:", report["name"])queued just means the file is waiting for one of your plan's parallel processing slots — it
starts automatically, nothing to do on your side.
Use the result
You already have text and AI reports from the previous step. Want a file instead? Any read
endpoint works on uploaded transcriptions:
resp = httpx.get(
f"{BASE}/transcriptions/{uuid}/export",
params={"format": "md"},
headers=HEADERS,
)
with open("standup.md", "wb") as f:
f.write(resp.content)Uploading a whole folder
Fire-and-forget is safe: uploads beyond your plan's concurrency simply wait as queued, and the
queue drains automatically. Upload first, poll after:
import random
import uuid as uuidlib
from pathlib import Path
AUDIO = {".mp3", ".m4a", ".wav", ".flac", ".ogg", ".mp4", ".mov", ".mkv"}
def upload_with_retry(path: str, attempts: int = 5) -> str:
# One Idempotency-Key per FILE, reused across attempts: even if a response
# is lost mid-flight, a retry returns the original uuid instead of a
# duplicate (and a second charge).
idem_key = str(uuidlib.uuid4())
for attempt in range(attempts):
try:
with open(path, "rb") as f:
resp = httpx.post(
f"{BASE}/transcriptions",
headers={
**HEADERS,
"Content-Disposition": f'attachment; filename="{Path(path).name}"',
"Idempotency-Key": idem_key,
},
content=f,
timeout=httpx.Timeout(10, write=None),
)
resp.raise_for_status()
return resp.json()["uuid"]
except httpx.HTTPStatusError as e:
if e.response.status_code in (409, 429): # in flight / a cap
# Sleep a RANDOM slice of Retry-After, not the exact value:
# every blocked client waking at the same instant just rebuilds
# the queue that had cleared.
wait = int(e.response.headers.get("Retry-After", "30"))
time.sleep(random.uniform(0, wait))
else:
raise # other 4xx: fix, don't retry
except httpx.TransportError: # network blip — safe thanks to the key
time.sleep(random.uniform(1, 5))
raise RuntimeError(f"gave up on {path}")
uuids = [
upload_with_retry(str(p))
for p in Path("~/recordings").expanduser().iterdir()
if p.suffix.lower() in AUDIO
]
results = [wait(u, poll_seconds=30) for u in uuids]
print(f"done: {len(results)} transcripts")Two 429 codes matter here and both are transient:
too_many_concurrent_uploads— more than a few parallelPOSTs at once. Upload sequentially or honourRetry-After(seconds).too_many_queued_files— over 100 files waiting. The queue is draining; retry after the pause.
The Idempotency-Key makes retries boring — in a good way
Reusing one key per file across attempts means a lost response can't cost you a duplicate
transcription: the retry just returns the original uuid. Details in the
upload reference.
Cost control
Minutes are reserved when processing starts, so a batch can't overshoot your balance — files that
don't fit end as insufficient_balance (and are not stored). Check the budget programmatically
before a big batch with GET /workspace:
ws = httpx.get(f"{BASE}/workspace", headers=HEADERS).json()
minutes_left = ws["balance"]["available_minutes"] # already minus in-flight reservations
if minutes_left < expected_batch_minutes:
raise SystemExit(f"top up first: {minutes_left} minutes left")Next steps
- Upload a file — API reference — every header, parameter and error.
- Get transcription — the full response shape.
- Bulk export — pull finished transcripts out 100 at a time.
Authentication
How Specala AI API keys work — create an sk_live_ key, send it as a Bearer token in the Authorization header, plus scopes, rate limits and security.
Export meetings to Obsidian
Pull your Specala AI meetings into an Obsidian vault as Markdown with YAML frontmatter — via an AI assistant with no code, or a short script.