Specala AIDocs
Guides

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.

Create API key

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 parallel POSTs at once. Upload sequentially or honour Retry-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

On this page