Quick start
One curl call from a YouTube URL to a queued transcription:
curl -X POST https://transcribe.so/api/v1/transcriptions \
-H "Authorization: Bearer tsk_live_REPLACE_ME" \
-H "Content-Type: application/json" \
-d '{
"source": "youtube",
"url": "https://youtu.be/dQw4w9WgXcQ",
"pipeline_code": "qwen3-asr-flash-filetrans"
}'You'll get a tr_* id back. Poll GET /api/v1/transcriptions/<id> until status is completed, then fetch /result. Or register a webhook (see below) and we'll POST you when it's done.
Conventions
- Base URL:
https://transcribe.so. Versioned prefix:/api/v1. - Auth: every request carries
Authorization: Bearer tsk_live_…. - Content type: JSON in/out, UTF-8.
- Identifiers:
tr_4821for transcriptions,tsk_live_…for keys. Numeric IDs in paths (/transcriptions/4821) also work. - Every response includes
X-Request-Id. Quote it in support tickets. - Rate limit: 60 requests / minute per key. Exceeded →
429 rate_limited. - CORS: every
/api/v1/*endpoint is open to any origin. Bearer auth, no cookies. - Pricing: identical to /pricing. Wallet drains monthly credit first, then top-up balance.
Authentication
The API uses Bearer tokens — no OAuth, no JWT, no cookies. Treat a key like a password.
Get a key
- Sign in and visit /settings/api-keys.
- Click Create key, give it a name (e.g.
n8n-prod). - Copy the plaintext immediately — we show it once and never again. The server only stores
sha256(key).
Smoke test
curl -sS https://transcribe.so/api/v1/me \
-H "Authorization: Bearer $TRANSCRIBE_API_KEY"Returns the authenticated user, current wallet balance, and remaining monthly credit.
Limits
- 20 active keys per user
- 60 requests / minute per key
- Wallet itself is the spend cap; no separate per-key cap in v1
Three input sources
POST /api/v1/transcriptions accepts the same three sources as the dashboard's /transcriptions page. All three go through the same quote → wallet hold → enqueue path.
| source | when to use | duration_seconds |
|---|---|---|
youtube | Public YouTube URL. | Not needed — we probe the video. |
external_url | Direct audio/video URL on a public host. | Optional. Pass when known to skip a probe round-trip. |
upload | File on your machine; no public URL. | Required. S3 isn't probed from the API. |
Endpoints
/api/v1/meThe authenticated user, wallet, tier, and a self-discovering links map.
200 response
{
"user_id": "49bf19f6-…",
"email": "[email protected]",
"wallet_balance_usd": 97.55,
"monthly_credit_remaining_usd": 0.10,
"subscription_tier": "free",
"links": {
"dashboard": "https://transcribe.so/transcriptions",
"api_keys": "https://transcribe.so/settings/api-keys",
"billing": "https://transcribe.so/billing",
"docs": "https://transcribe.so/developers/docs",
"support": "https://transcribe.so/contact"
}
}The links map gives your client a stable spot to surface "manage your key" / "top up" / "see docs" actions without hardcoding URLs.
/api/v1/pipelinesAvailable models with current per-minute pricing — same rates as the dashboard's /pricing page.
200 response
{
"pipelines": [
{
"code": "qwen3-asr-flash-filetrans",
"name": "Qwen3 ASR Flash",
"retail_usd_per_min": 0.0362,
"retail_usd_per_hour": 2.17,
"supported_languages": ["en", "zh", "es", …],
"word_timestamp_languages": ["en", "zh", …],
"timestamp_options": ["sentence", "word"]
}
]
}/api/v1/uploadsStep 1 of the upload flow. Returns a short-lived presigned S3 PUT URL.
Body
{
"filename": "podcast.mp3",
"content_type": "audio/mpeg",
"file_size": 8421120
}- Allowed
content_type: any audio/* or video/* mime —audio/mpeg,audio/wav,audio/mp4,audio/x-m4a,audio/aac,audio/ogg,audio/webm,audio/flac,video/mp4,video/webm,video/quicktime,video/x-msvideo. - Max
file_size: 500 MB.
200 response
{
"upload_id": "user/<uuid>/uploads/1777458021_abe2ea44.mp3",
"upload_url": "https://s3.transcribe.so/...",
"expires_in": 900
}Then PUT the raw file body to upload_url with the same Content-Type header. URL expires in 900s.
/api/v1/transcriptions→ 202Submit a transcription. Three source modes; same dance the dashboard does.
Body — youtube
{
"source": "youtube",
"url": "https://youtu.be/dQw4w9WgXcQ",
"pipeline_code": "qwen3-asr-flash-filetrans",
"language": "auto"
}Body — external_url
{
"source": "external_url",
"url": "https://example.com/podcast.mp3",
"pipeline_code": "qwen3-asr-flash-filetrans",
"language": "auto",
"duration_seconds": 1234
}Body — upload
{
"source": "upload",
"upload_id": "user/<uuid>/uploads/...mp3",
"original_filename": "podcast.mp3",
"duration_seconds": 1234,
"pipeline_code": "qwen3-asr-flash-filetrans",
"language": "auto"
}202 response
{
"id": "tr_4821",
"status": "processing",
"stage": "queued",
"pipeline_code": "qwen3-asr-flash-filetrans",
"language": "auto",
"source": "upload",
"upload_id": "user/...",
"duration_seconds": 1234,
"billed_minutes": 20.6,
"retail_usd": 0.7457
}For youtube and external_url, the response carries url instead of upload_id.
Send Idempotency-Key on retries (see below).
/api/v1/transcriptionsList your transcriptions, newest first. Cursor-paginated.
Query
limit— 1–200, default 50cursor— ISO timestamp of the last item from the previous pageapi_only=true— filter to API-originated jobs
/api/v1/transcriptions/:idSingle transcription metadata + status.
/api/v1/transcriptions/:id/resultFull result body. Only meaningful once status === completed.
{
"id": "tr_4821",
"status": "completed",
"segments": [{ "start_seconds": 0.0, "end_seconds": 4.21, "text": "..." }],
"chapters": [{ "start_seconds": 0.0, "end_seconds": 145.6, "title": "...", "summary": "..." }],
"topics": [{ "label": "...", "summary": "..." }],
"qna": [{ "question": "...", "answer": "...", "citations": [...] }]
}/api/v1/transcriptions/:idPermanently deletes the transcription, derived rows, and S3 objects.
/api/v1/transcriptions/:id/retry→ 202Restart a failed job. Charges run again from scratch.
/api/v1/quotesPreview cost (and reserve a quoted row) without queueing. Same body shape as POST /transcriptions.
Errors
Every error response uses the same envelope:
{
"error": {
"code": "insufficient_funds",
"message": "Wallet balance too low. Top up your wallet at https://transcribe.so/billing.",
"request_id": "req_a1b2c3d4e5f6",
"doc_url": "https://transcribe.so/billing"
}
}messageinlines an actionable URL where one applies. Terminal users see the link without parsing JSON.doc_urlalways points at a stable docs section or dashboard surface for that error.request_idis also returned asX-Request-Idon every response — quote it in support tickets.
| code | HTTP | when |
|---|---|---|
unauthenticated | 401 | Missing Authorization header. |
invalid_api_key | 401 | Key malformed, unknown, revoked, or expired. |
not_found | 404 | Resource doesn't exist or isn't yours. |
invalid_request | 400 | Body / query / path parameter is missing or malformed. |
unsupported_pipeline | 400 | pipeline_code isn't recognized. |
insufficient_funds | 402 | Wallet + monthly credit can't cover the estimated charge. |
rate_limited | 429 | Per-key request rate exceeded (60/min). |
internal_error | 500 | Server bug; safe to retry with backoff. Quote request_id. |
Retry guidance
- 429: back off 60s.
- 500: exponential backoff (1, 2, 4, 8s, max 60s), cap at 5 attempts. Use the same
Idempotency-Keyso duplicates don't bill twice. - 402: do not retry until the user tops up.
- 400 / 401 / 404: don't retry; fix the request.
Idempotency
POST endpoints accept an Idempotency-Key header. Use it on any request that creates or starts something, so retries don't double-bill or double-queue.
POST /api/v1/transcriptions
Idempotency-Key: 2026-04-30-podcast-ep-149- First request runs normally. Subsequent requests with the same
(api_key, idempotency_key)within 24h return the original response unchanged. - Reusing the same key with a different body returns
400 invalid_request. - 2xx and 4xx responses are cached; 5xx are not (so you can retry past transient bugs).
- Max key length: 128 chars. Use a UUID, content hash, or stable composite — anything that doesn't change across retries of the same logical request.
Webhooks
Get a signed POST when a transcription finishes — no polling. One webhook per API key.
Events
transcription.completed— your transcription reachedstatus: completed.transcription.failed— your transcription reachedstatus: failed.webhook.test— you calledPOST /api/v1/webhooks/test.
Register
curl -X POST https://transcribe.so/api/v1/webhooks \
-H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/transcribe-so/webhook" }'Returns the webhook id and a one-time signing_secret. Store it — we never show it again. You can also register a webhook from the dashboard at /settings/api-keys.
Payload
{
"id": "evt_1234",
"event": "transcription.completed",
"created": 1777472458,
"data": {
"transcription": {
"id": "tr_4821",
"status": "completed",
"stage": "completed",
"pipeline_code": "qwen3-asr-flash-filetrans",
"language": "auto",
"source": "upload",
"title": "podcast.mp3",
"duration_seconds": 60,
"billed_seconds": 60,
"charge_usd": 0.03,
"completed_at": "2026-04-29T14:25:27.968Z"
}
}
}Fetch the full result (segments, chapters, topics, qna) via GET /api/v1/transcriptions/:id/result — we don't push the full body inline because it can be large.
Verify the signature
Every delivery carries X-Transcribe-Signature: t=<unix-seconds>,v1=<hex>. The v1 value is hex(hmac_sha256(signing_secret, `$${t}.$${rawBody}`)). Verify on the raw body (re-serializing JSON breaks the HMAC).
import { createHmac, timingSafeEqual } from "crypto";
function verify(rawBody: string, header: string, secret: string): boolean {
const m = header.match(/t=(\d+),v1=([0-9a-f]+)/);
if (!m) return false;
const [, t, v1] = m;
// Reject if more than 5 minutes off (replay protection).
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return expected.length === v1.length &&
timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(v1, "utf8"));
}import hmac, hashlib, re, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
m = re.match(r"t=(\d+),v1=([0-9a-f]+)", header)
if not m: return False
t, v1 = m.group(1), m.group(2)
if abs(int(time.time()) - int(t)) > 300: return False
expected = hmac.new(
secret.encode(),
f"{t}.{raw_body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, v1)Retry
We retry any non-2xx (or network failure) at 1m, 5m, 30m, 3h, 12h. Five attempts max, 10s HTTP timeout each. After 5 consecutive failures across deliveries, the webhook itself is auto-disabled — re-enable it from the dashboard once your endpoint is healthy.
Send a test event
curl -X POST https://transcribe.so/api/v1/webhooks/test \
-H "Authorization: Bearer $TRANSCRIBE_API_KEY"Enqueues a synthetic webhook.test delivery — useful to confirm your URL is reachable and signature verification works before any real transcriptions run.
Pricing
Same per-minute rates as the dashboard. Billed against your wallet — monthly credit first, then top-up balance. No separate API quota, no minimums.
| Model | Pipeline code | Per minute | Per hour |
|---|---|---|---|
| GPT-4o Transcribe (timestamps + diarization) + AI analysis | gpt-4o-transcribe-diarize | $0.0647 | $3.88 |
| Qwen3-ASR-Flash-Filetrans (timestamps) + AI analysis | qwen3-asr-flash-filetrans | $0.0285 | $1.71 |
| Voxtral Mini Transcribe with Diarization + AI Analysis | voxtral-mini-transcribe | $0.0296 | $1.78 |
End-to-end walkthrough
Full upload flow with curl. The hardest path — YouTube and external URL skip steps 2-3.
# 0. Smoke test
curl -sS https://transcribe.so/api/v1/me \
-H "Authorization: Bearer $TRANSCRIBE_API_KEY"
# 1. Get a presigned upload URL
SIZE=$(stat -f%z podcast.mp3 2>/dev/null || stat -c%s podcast.mp3)
PRESIGN=$(curl -sS -X POST https://transcribe.so/api/v1/uploads \
-H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
-H "Content-Type: application/json" \
-d "{ \"filename\": \"podcast.mp3\", \"content_type\": \"audio/mpeg\", \"file_size\": $SIZE }")
UPLOAD_URL=$(echo "$PRESIGN" | jq -r .upload_url)
UPLOAD_ID=$(echo "$PRESIGN" | jq -r .upload_id)
# 2. PUT the file straight to S3
curl -sS -X PUT "$UPLOAD_URL" \
-H "Content-Type: audio/mpeg" \
--data-binary @podcast.mp3
# 3. Submit the transcription
DURATION=$(ffprobe -i podcast.mp3 -show_entries format=duration -v quiet -of csv="p=0" | cut -d'.' -f1)
JOB=$(curl -sS -X POST https://transcribe.so/api/v1/transcriptions \
-H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d "{
\"source\": \"upload\",
\"upload_id\": \"$UPLOAD_ID\",
\"original_filename\": \"podcast.mp3\",
\"duration_seconds\": $DURATION,
\"pipeline_code\": \"qwen3-asr-flash-filetrans\"
}")
TR_ID=$(echo "$JOB" | jq -r .id)
# 4. Poll until done
while true; do
STATE=$(curl -sS "https://transcribe.so/api/v1/transcriptions/$TR_ID" \
-H "Authorization: Bearer $TRANSCRIBE_API_KEY")
echo "$STATE" | jq -r '"\(.status) · \(.stage)"'
S=$(echo "$STATE" | jq -r .status)
[[ "$S" == "completed" || "$S" == "failed" ]] && break
sleep 5
done
# 5. Pull the result
curl -sS "https://transcribe.so/api/v1/transcriptions/$TR_ID/result" \
-H "Authorization: Bearer $TRANSCRIBE_API_KEY" | jqSame flow in Python:
import os, time, requests
API = "https://transcribe.so/api/v1"
H = {"Authorization": f"Bearer {os.environ['TRANSCRIBE_API_KEY']}"}
with open("podcast.mp3", "rb") as f:
body = f.read()
p = requests.post(f"{API}/uploads", headers=H, json={
"filename": "podcast.mp3",
"content_type": "audio/mpeg",
"file_size": len(body),
}).json()
requests.put(p["upload_url"], data=body, headers={"Content-Type": "audio/mpeg"}).raise_for_status()
job = requests.post(f"{API}/transcriptions",
headers={**H, "Idempotency-Key": "podcast-149"},
json={
"source": "upload",
"upload_id": p["upload_id"],
"original_filename": "podcast.mp3",
"duration_seconds": 60,
"pipeline_code": "qwen3-asr-flash-filetrans",
},
).json()
while True:
state = requests.get(f"{API}/transcriptions/{job['id']}", headers=H).json()
if state["status"] in ("completed", "failed"):
break
time.sleep(3)
result = requests.get(f"{API}/transcriptions/{job['id']}/result", headers=H).json()
print(f"segments={len(result['segments'])} chapters={len(result['chapters'])} topics={len(result['topics'])}")Common failure modes
| symptom | cause | fix |
|---|---|---|
| 401 unauthenticated on every call | Missing Authorization header. | Add -H 'Authorization: Bearer $KEY'. |
| 401 invalid_api_key | Key revoked, expired, or typo. | Recreate at /settings/api-keys. |
| 400 invalid_request: duration_seconds (>0)… | Forgot duration_seconds on source=upload. | Probe with ffprobe; pass it. |
| S3 PUT 403 | Presigned URL expired (900s). | Re-call POST /uploads, PUT promptly. |
| 402 insufficient_funds | Wallet < estimated charge. | Top up via the dashboard, or pick a cheaper pipeline. |
| 429 rate_limited | Exceeded 60 req/min on this key. | Back off 60s; consider a second key for parallel pipelines. |
Ready to ship?
Create a key, paste it into your script, and you're transcribing inside a minute.