Specala AIDocs
API Reference

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.mp3
import 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": []
}
FieldTypeDescription
uuidUUIDUse this with Get transcription to poll and fetch results.
statusstringAlways queued on success.
reports[]arrayThe 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:

statusMeaning
queuedAccepted; waiting for a processing slot.
processingTranscription in progress.
completedDone — the response now includes text and prompt_results. Terminal.
failedCouldn't be processed — see error_code below. Terminal.
insufficient_balanceNot 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:

CodeMeaning
FILE_CORRUPTEDThe file isn't readable media (wrong bytes, damaged container).
FILE_NO_AUDIONo audio stream (e.g. a video with no sound).
RECORDING_TOO_LONGLonger than 10 hours.
UPLOAD_INCOMPLETEFewer 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:

PartValue
BodyThe file bytes, as-is (--data-binary in curl).
AuthorizationBearer sk_live_… (key with upload permission).
Content-LengthRequired. HTTP clients set it automatically for file bodies; requests without it are rejected with 411.
Content-DispositionRequired: attachment; filename="meeting.mp3". The extension determines the accepted format; the name becomes the transcription title.
Idempotency-KeyOptional but recommended: a unique string per file (e.g. a UUID). Makes retries safe — see Retries.
X-External-IdOptional. 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-MetadataOptional. A JSON object of your own data, up to 4096 bytes. Comes back with the transcription.
X-AuthorOptional. 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:

ParameterTypeDescription
languagestringSpoken language. Default auto (detected from the audio) — see Supported languages.
project_idUUIDAttach the transcription to a project. Get UUIDs from List projects.
diarizebooleanDetect speakers. Default true.
speakers_countintegerNumber of speakers (1–20), if known — improves diarization.
ai_metadatabooleanGenerate the AI layer: summary, title, speaker names and topics. Default false — see below. AI reports are separate — request them with reports.
reportsstringComma-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.mp3

Both headers are optional and independent — send one without the other if that is all you need.

What you can send

HeaderRules
X-External-IdUp to 128 characters, printable ASCII, no line breaks. Does not have to be unique.
X-MetadataA 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=sales

Matching 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>:

ValueMeaning
email:name@company.comA 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-0a1b2c3d4e5fA 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.mp3

What 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 200 with the original uuid instead of a duplicate — no double billing, quotas untouched.
  • A request with the same key still in flight returns 409 idempotency_conflict — wait Retry-After and retry.
  • Add jitter: sleep a random slice of Retry-After rather 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.mp3

Without 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

LimitValue
File size1 KB – 2 GB, any plan with API access.
Duration1 second – 10 hours.
FormatsAudio: 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.
ConcurrencyFiles 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.
BillingStandard 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

StatusCodeWhen
400unsupported_file_format / file_too_small / filename_requiredBad extension, < 1 KB, or missing Content-Disposition.
402insufficient_balanceNo minutes left on the plan.
403upload_scope_requiredKey doesn't have upload permission.
403reports_scope_required?reports= was sent, but the key has no Allow AI reports permission.
422prompt_not_found / too_many_reportsA slug in ?reports= is unknown to this key's owner (detail.slug says which), or more than 3 were requested. Nothing is uploaded.
422external_id_invalid / metadata_*The X-External-Id or X-Metadata did not validate — the code says which.
422invalid_author / unsupported_author_type / author_not_memberX-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.
408upload_timeoutThe upload stalled (no data for 60 s) or exceeded 90 minutes.
411length_requiredMissing Content-Length.
413file_size_limit_exceededLarger than 2 GB.
429too_many_concurrent_uploads / too_many_queued_files / upload_quota_exceededConcurrency, queue or daily caps — honour Retry-After.
429daily_reports_quota_exceeded / too_many_queued_reportsAI report caps (300 per workspace per day, 200 waiting per workspace) — honour Retry-After.

See Errors for the full reference and retry guidance.

On this page