Upload a file
POST /transcriptions — upload an audio or video file (up to 2 GB) for transcription with one HTTP request, then poll until it's completed.
POST /transcriptions
Uploads one audio/video file as a raw request body and starts transcription. Returns
immediately with a transcription id — poll
Get transcription until status is completed.
Requires upload permission
Only keys created with Allow file uploads enabled can call this endpoint. Existing keys are read-only — create a new key if yours predates uploads. See Authentication → Key permissions.
Request format
The file is the request body — no multipart forms, no JSON wrapping, no chunk protocol:
| Part | Value |
|---|---|
| Body | The file bytes, as-is (--data-binary in curl). |
| Authorization | Bearer sk_live_… (key with upload permission). |
| Content-Length | Required. HTTP clients set it automatically for file bodies; requests without it are rejected with 411. |
| Content-Disposition | Required: attachment; filename="meeting.mp3". The extension determines the accepted format; the name becomes the transcription title. |
| Idempotency-Key | Optional but recommended: a unique string per file (e.g. a UUID). Makes retries safe — see Retries. |
Why the filename lives in a header
Filenames often contain personal data. Headers stay out of URL logs — that's why
?filename= is not accepted. For non-ASCII names (accents, Turkish characters, any
non-Latin script) use the standard RFC 5987 form:
Content-Disposition: attachment; filename*=UTF-8''Reuni%C3%B3n.mp3 (decodes to Reunión.mp3).
Query parameters
All optional:
| Parameter | Type | Description |
|---|---|---|
language | string | Spoken language. Default auto (detected from the audio) — see Supported languages. |
project_id | UUID | Attach the transcription to a project. Get UUIDs from List projects. |
diarize | boolean | Detect speakers. Default true. |
speakers_count | integer | Number of speakers (1–20), if known — improves diarization. |
ai_metadata | boolean | Generate the AI layer: summary, title, speaker names, topics and default AI reports. Default false — see below. |
AI analysis is off by default for API uploads
With ai_metadata=false (the default) you get the full transcript faster: text with speaker
labels, but summary and topics stay null, prompt_results is empty, the title is the
filename, and speakers keep generic labels. Pass ai_metadata=true when you want the same AI
layer the app produces. Either way the transcription shows up in the app, where AI reports can
still be generated on demand later. Billing is identical in both modes.
Supported languages
auto (default — the language is detected from the audio) or one of:
en, es, ru, zh, hi, ar, pt, bn, fr, de, ja, ko, tr, it, vi, pl, uk, nl, id, th, fa, sv, cs, ro, el, hu, da, fi, no, sk, he, ms, bg, hr, sr, sl, lt, lv, et, ta, te, mr, ur, sw, ml, kn, gu, pa, my, ne, si, km, lo, az, kk, uz, ka, hy, sq, bs, mk, is, mn, tg, tk, tt, ca, eu, gl, cy, af, tl, jw, su, yo, ha, so, am, sn, mg, ln, mt, lb, oc, br, nn, fo, sd, ps, ht, mi, haw, yi, ba, be, as, bo, sa, la
Two-letter ISO 639-1 codes (plus haw and jw). An unsupported value returns 422.
Request
curl -X POST "https://app.specala.ai/api/v1/developer/transcriptions?language=en" \
-H "Authorization: Bearer sk_live_your_key_here" \
-H 'Content-Disposition: attachment; filename="standup.mp3"' \
--data-binary @standup.mp3import httpx
with open("standup.mp3", "rb") as f:
resp = httpx.post(
"https://app.specala.ai/api/v1/developer/transcriptions",
params={"language": "en"},
headers={
"Authorization": "Bearer sk_live_your_key_here",
"Content-Disposition": 'attachment; filename="standup.mp3"',
},
content=f, # streamed from disk — works for 2 GB files too
timeout=httpx.Timeout(10, write=None), # don't time out mid-upload
)
resp.raise_for_status()
t = resp.json()
print(t["uuid"], t["status"]) # "3fa85f64-…" "queued"import { openAsBlob } from "node:fs"; // Node 20+
const res = await fetch(
"https://app.specala.ai/api/v1/developer/transcriptions?language=en",
{
method: "POST",
headers: {
Authorization: "Bearer sk_live_your_key_here",
"Content-Disposition": 'attachment; filename="standup.mp3"',
},
body: await openAsBlob("standup.mp3"), // streamed, Content-Length set for you
},
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const t = await res.json();
console.log(t.uuid, t.status); // "3fa85f64-…" "queued"With the local MCP server connected, just ask:
Upload ~/Recordings/standup.mp3 to Specala and give me the action items.
The assistant calls specala_upload_file, then polls specala_get_transcription until the
transcript is ready. Uploads need a key with upload permission and are local-server only —
the remote connector can't read files from your disk.
Response
201 Created — the file was received and queued. Transcription happens asynchronously.
{
"id": 12345,
"uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "queued"
}| Field | Type | Description |
|---|---|---|
id | integer | Internal numeric id. |
uuid | UUID | Use this with Get transcription to poll and fetch results. |
status | string | Always queued on success. |
Status lifecycle & polling
Poll GET /transcriptions/{uuid} every 5–10 seconds until a terminal status:
status | Meaning |
|---|---|
queued | Accepted; waiting for a processing slot. |
processing | Transcription in progress. |
completed | Done — the response now includes text and prompt_results. Terminal. |
failed | Couldn't be processed — see error_code below. Terminal. |
insufficient_balance | Not enough minutes on your plan. The file is not stored — top up, then upload again. Terminal. |
import time
while True:
t = httpx.get(f"{BASE}/transcriptions/{uuid}", headers=HEADERS).json()
if t["status"] in ("completed", "failed", "insufficient_balance"):
break
time.sleep(10)Common error_code values on failed:
| Code | Meaning |
|---|---|
FILE_CORRUPTED | The file isn't readable media (wrong bytes, damaged container). |
FILE_NO_AUDIO | No audio stream (e.g. a video with no sound). |
RECORDING_TOO_LONG | Longer than 10 hours. |
UPLOAD_INCOMPLETE | Fewer bytes arrived than Content-Length promised — upload again. |
Limits & billing
| Limit | Value |
|---|---|
| File size | 1 KB – 2 GB, any plan with API access. |
| Duration | 1 second – 10 hours. |
| Formats | Audio: mp3, wav, m4a, aac, ogg, flac, opus, amr, wma, aiff and more. Video: mp4, mov, mkv, avi, webm, mpeg and more — audio is extracted automatically. |
| Concurrency | Files processed in parallel per your plan — extra uploads are not rejected, they wait as queued and start automatically. Simultaneous uploads in flight are capped separately (10 per key, 16 per workspace); over that you get 429, so retry with jitter. |
| Billing | Standard plan minutes, same as uploading in the app. Minutes are reserved when processing starts. |
Anti-abuse safeguards (per-key daily caps, concurrent-upload limits) sit far above normal usage —
if you hit 429 with upload_quota_exceeded, contact support to raise them.
Retries
Without precautions, retrying an upload whose response you never saw (connection dropped after
the last byte) would create a second transcription and a second charge. The Idempotency-Key
header solves this:
- Send a unique value per file (a UUID is perfect) with every attempt.
- If the file was already accepted under that key, you get
200with the originaluuidinstead of a duplicate — no double billing, quotas untouched. - A request with the same key still in flight returns
409 idempotency_conflict— waitRetry-Afterand retry. - Add jitter: sleep a random slice of
Retry-Afterrather than the exact value. When many clients are throttled at once, waking them all on the same second just recreates the queue. - Only success is remembered (for 24 hours); after a failed attempt the key is free to reuse.
curl -X POST "https://app.specala.ai/api/v1/developer/transcriptions" \
-H "Authorization: Bearer sk_live_your_key_here" \
-H 'Content-Disposition: attachment; filename="standup.mp3"' \
-H "Idempotency-Key: 7f3a2b1c-9d4e-4f5a-8b6c-1d2e3f4a5b6c" \
--data-binary @standup.mp3Without an Idempotency-Key, retry on errors only
If you don't send the header, never re-POST after a 201 — retry only on network failures,
429 (after the Retry-After pause) and 5xx.
Errors
| Status | Code | When |
|---|---|---|
400 | unsupported_file_format / file_too_small / filename_required | Bad extension, < 1 KB, or missing Content-Disposition. |
402 | insufficient_balance | No minutes left on the plan. |
403 | upload_scope_required | Key doesn't have upload permission. |
408 | upload_timeout | The upload stalled (no data for 60 s) or exceeded 90 minutes. |
411 | length_required | Missing Content-Length. |
413 | file_size_limit_exceeded | Larger than 2 GB. |
429 | too_many_concurrent_uploads / too_many_queued_files / upload_quota_exceeded | Concurrency, queue or daily caps — honour Retry-After. |
See Errors for the full reference and retry guidance.