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
The smallest call that works: a key, a filename and the bytes. Every header is listed under Request format, the optional switches under Query parameters.
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.
{
"uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "queued",
"reports": []
}| Field | Type | Description |
|---|---|---|
uuid | UUID | Use this with Get transcription to poll and fetch results. |
status | string | Always queued on success. |
reports[] | array | The AI reports you asked for with ?reports=, as { id, slug, status } — status is queued here. Empty when none were requested. See AI reports. |
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. |
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. |
| X-External-Id | Optional. Your own identifier — a CRM deal id, a telephony call id. Up to 128 printable ASCII characters. Comes back in every response, see Your own identifiers. |
| X-Metadata | Optional. A JSON object of your own data, up to 4096 bytes. Comes back with the transcription. |
| X-Author | Optional. Which workspace member owns the transcription — email:name@company.com or user:<uuid>. Without it the key's creator is the author. See Assigning an author. |
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 and topics. Default false — see below. AI reports are separate — request them with reports. |
reports | string | Comma-separated report slugs to generate as soon as the transcript is ready, e.g. reports=summary,tasks. Up to 3 per upload; requires a key with Allow AI reports. Slugs come from List reports. |
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. AI reports are never generated automatically: ask for them with
reports= on this call or request them later — or generate them in
the app. 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.
Your own identifiers
A transcription almost always belongs to something in your system: a deal, a
call, a ticket. Send your own identifier with the upload and get it back
everywhere, instead of keeping a table mapping our id to yours.
curl -X POST 'https://app.specala.ai/api/v1/developer/transcriptions' \
-H 'Authorization: Bearer sk_live_your_key_here' \
-H 'Content-Disposition: attachment; filename="call.mp3"' \
-H 'X-External-Id: deal-A-1043' \
-H 'X-Metadata: {"crm":"hubspot","owner_id":17,"queue":"sales"}' \
--data-binary @call.mp3Both headers are optional and independent — send one without the other if that is all you need.
What you can send
| Header | Rules |
|---|---|
X-External-Id | Up to 128 characters, printable ASCII, no line breaks. Does not have to be unique. |
X-Metadata | A JSON object, up to 4096 bytes. Keys up to 64 characters. Values are strings, numbers, true/false, null, or arrays of those. Nesting up to 3 levels. |
Anything that does not fit is refused at upload with 422, and the code in the
response says what was wrong: external_id_too_long, metadata_too_deep,
metadata_value_invalid, and so on. Nothing is silently truncated or dropped.
What comes back
Both fields appear in GET /transcriptions/{id} and in every list item:
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"external_id": "deal-A-1043",
"metadata": { "crm": "hubspot", "owner_id": 17, "queue": "sales" },
"status": "completed"
}For transcriptions that did not come through the API both are null. That is
not an error.
Searching by your own identifiers
You never need our id to find a recording — the list filters on your values.
GET /transcriptions?external_id=deal-A-1043
GET /transcriptions?metadata.queue=salesMatching is exact and one key at a time; this is not a full-text search. The
filters combine with the rest, e.g. ?metadata.queue=sales&status=completed.
One identifier across several transcriptions
X-External-Id does not have to be unique. A deal usually means several
calls, and all of them may carry the same deal-A-1043 — in which case
?external_id=deal-A-1043 returns all of them.
If you need protection against uploading the same file twice, that is what
Idempotency-Key is for, with a 24-hour window — see Retries.
Keep personal data and secrets out of these
Unlike the transcript, these two fields are not encrypted: they back a search, and you cannot search what is encrypted. They are stored exactly as you send them.
Keep identifiers and labels here — deal-A-1043, queue: sales — not names,
phone numbers or keys. Values shaped like tokens and access keys are refused at
upload, but treat that as a safety net rather than a guarantee.
Assigning an author
By default a transcription uploaded through the API belongs to the person who created the key. For a team that is rarely right: a CRM or telephony integration uploads calls made by different managers, and each of them should see their own calls under "My meetings", get the "ready" notification and show up in per-person reports.
Send the owner in the X-Author header. The value is typed — <type>:<value>:
| Value | Meaning |
|---|---|
email:name@company.com | A workspace member, by the email they signed up with. Case-insensitive. The zero-setup option: every CRM knows its managers' emails. |
user:9c2d4e6f-1a2b-4c3d-8e9f-0a1b2c3d4e5f | A workspace member, by their id — the author.id you see in responses and items[].id in Members. A UUID, not a number. |
external:<provider>:<id> | Your own identifier of the person — a CRM user id, a telephony extension: external:hubspot:12345, external:aircall:101. Works once the identifier is linked to the member: a workspace owner does that in the app under Settings → Members → External identifiers, or your integration does it through Members & identifiers. provider is a free lowercase label you choose; the id is matched exactly. |
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="call-1043.mp3"' \
-H 'X-Author: email:alex@company.com' \
-H 'X-External-Id: deal-A-1043' \
--data-binary @call-1043.mp3What the author gets and what stays with the key:
- The transcription is the author's: it is in their list, they get notified when it is
ready, and it counts as theirs in team reports. Every response returns the owner as
author: { id, email, name }. - Permissions, limits and billing are the key's. The project you attach to must be one the key's creator can upload to; minutes are charged to the workspace as usual.
- The author does not have to be a member of the project you attach to. They will still see the transcription — it is theirs — and an admin can add them to the project later. Uploads are never rejected for this, so automations keep working.
If the author cannot be found, the upload is rejected with 422 author_not_member and
nothing is charged — there is no silent fallback to the key's creator, because a
mis-attributed call is harder to notice than a failed one. The same code is returned
whether the email is unknown to us or belongs to someone outside your workspace. Invite
the person to the workspace, or upload without X-Author and
assign the author later.
Building a CRM mapping
Most CRMs store a manager's email, so email: usually needs no mapping table at all: send
the CRM user's email and handle author_not_member by showing "this manager is not in the
workspace yet" in your settings screen. Keep X-External-Id on the same request so the
transcription also links back to the deal or call.
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.
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-workspace daily caps, concurrent-upload limits) sit far above normal usage —
if you hit 429 with upload_quota_exceeded, contact support to raise them.
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. |
403 | reports_scope_required | ?reports= was sent, but the key has no Allow AI reports permission. |
422 | prompt_not_found / too_many_reports | A slug in ?reports= is unknown to this key's owner (detail.slug says which), or more than 3 were requested. Nothing is uploaded. |
422 | external_id_invalid / metadata_* | The X-External-Id or X-Metadata did not validate — the code says which. |
422 | invalid_author / unsupported_author_type / author_not_member | X-Author is malformed, uses a type we don't support yet, or names someone who is not an active member of the workspace. See Assigning an author. |
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. |
429 | daily_reports_quota_exceeded / too_many_queued_reports | AI report caps (300 per workspace per day, 200 waiting per workspace) — honour Retry-After. |
See Errors for the full reference and retry guidance.