transcribe.so API · v1

API Reference

Everything you need to call the transcribe.so API. Bearer auth, four input sources (same as the dashboard's transcribe form), webhook delivery, errors, idempotency, plus the hosted MCP server for Claude, ChatGPT and other agents. If you've used Stripe's API the patterns will feel familiar.

Quick start

Agents: fetch /llms.txt first. It is the machine-readable version of this page, with the exact calls for the common tasks.

Transcription is asynchronous, so the quickstart is four steps, not one: price it, create it (retry-safe and budget-capped), wait for it, then read the output you asked for. The three tabs are the same flow over three transports.

# 1. price it first (free, starts nothing)
curl -sS -X POST https://transcribe.so/api/v1/quotes \
  -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"source":"youtube","url":"https://youtu.be/dQw4w9WgXcQ"}'

# 2. create it: Idempotency-Key makes the retry safe,
#    max_charge_usd is the server-side ceiling (402 max_charge_exceeded above it)
curl -sS -X POST https://transcribe.so/api/v1/transcriptions \
  -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"source":"youtube","url":"https://youtu.be/dQw4w9WgXcQ","max_charge_usd":2}'

# 3. wait (bounded). Repeat while the body says "_timed_out": true
curl -sS -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  "https://transcribe.so/api/v1/transcriptions/4821/wait?timeout=45"

# 4. read the transcript text (raw body, nothing truncated)
curl -sS -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  "https://transcribe.so/api/v1/transcriptions/4821/transcript?format=txt&speaker_labels=true&timestamps=true"

# ...or a subtitle file for a video editor
curl -sS -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  "https://transcribe.so/api/v1/transcriptions/4821/subtitles?format=srt" -o captions.srt

POST /quotes is free and starts nothing. Idempotency-Key makes step 2 safe to retry, and max_charge_usd is the server-side ceiling: if the computed charge is above it nothing is created and you get 402 max_charge_exceeded carrying both numbers. Step 3 holds the connection for up to 45 s per call and sets _timed_out: true when it has to return early, so you call it again. A webhook replaces step 3 entirely.

Agents: connect, setup, first result

Everything below works from an agent runtime without a browser. Connect the runtime you already use, then four steps, in order.

Connect your runtime

runtimehow to connect
Claude CodePlugin or one MCP command. claude mcp add --transport http transcribe https://transcribe.so/mcp
CodexOne MCP command. codex mcp add transcribe --url https://transcribe.so/mcp
CursorAdd to .cursor/mcp.json (project) or ~/.cursor/mcp.json (global). OAuth on first use. {"mcpServers":{"transcribe":{"url":"https://transcribe.so/mcp"}}}
ChatGPTUse the public transcribe.so GPT, or add https://transcribe.so/mcp under Settings, Connectors, Developer mode (paid ChatGPT plans).
Gemini CLIOne MCP command, or settings.json {"mcpServers":{"transcribe":{"httpUrl":"https://transcribe.so/mcp"}}}. gemini mcp add --transport http transcribe https://transcribe.so/mcp
OpenClaw and skills.sh agentsInstall the skill. npx skills add shsunmoonlee/transcribe-agent
Any MCP clientStreamable HTTP at https://transcribe.so/mcp. OAuth, or an Authorization: Bearer tsk_live_... key.

Setup to first result

  1. Set up. Create a key at /settings/api-keys (the Agent preset gives read + write with a monthly cap and an expiry), export it as TRANSCRIBE_API_KEY, and verify with GET /api/v1/me. For MCP clients: claude mcp add --transport http transcribe https://transcribe.so/mcp.
  2. First transcript. The four-step quickstart above: quote, create with an Idempotency-Key and a max_charge_usd, wait, read.
  3. The output you were asked for. The transcript itself is GET /transcript, a raw text/plain or text/markdown body. Beyond that, threedifferent things get called "captions", and they are three different endpoints:
    • Social post textGET /timestamps, the caption or chapter list a human pastes into a post.
    • Timed subtitles GET /subtitles, an SRT or VTT file for a player or editor.
    • Captioned video POST /clips, a rendered MP4 with the captions burned in.
  4. Who said what. GET /api/v1/search returns matching segments with speaker, start_ms, end_ms and a deep link.

Human approval

Check the price before creating a job. Stay within the user's authorized budget; ask before exceeding it or starting another paid attempt.

Speaker labels are recording-local: SPEAKER_00 is the first voice in that recording, not an identified person, and speaker: null (rendered Unknown: in a text transcript) means the pipeline could not tell. Quote the matching segment, never a whole-section summary, when attributing a line to a speaker.

Conventions

  • Base URL: https://transcribe.so. Versioned prefix: /api/v1.
  • Auth: every request carries Authorization: Bearer tsk_live_….
  • Content type: JSON in, JSON out, UTF-8 — except the raw-body exports. /transcript and /subtitles return the file itself (text/plain, text/markdown, application/x-subrip, text/vtt) on success. Errors always keep the JSON envelope, on every endpoint.
  • Identifiers: 4821 (plain integer) for transcriptions, tsk_live_… for API keys.
  • 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. On subscription plans, transcription via the API is included ($0); Pay as you go bills the wallet at $1/hour.

Authentication

The API authenticates with Bearer tokens on the Authorization header — API keys for scripts and integrations, no cookies. OAuth access tokens issued by the MCP connector flow are accepted on the same header and rate-limited per user rather than per key; the only difference is that webhook endpoints require an API key (OAuth tokens get 403 forbidden). Treat a key like a password.

Keys carry permissions and an optional monthly spend cap, both chosen when you create the key. Full access (default) can do everything; Read only keys are limited to GET endpoints and get 403 scope_forbidden on any POST/DELETE (they cannot create anything, including quotes and uploads; on /mcp they only see the read tools). A capped key stops starting transcription jobs, clip renders, and realtime sessions with 402 spend_cap_exceededonce the month's spend would pass the cap (a realtime session reserves its full 60-minute cap up front, so a monthly cap below one reserve blocks realtime entirely). OAuth tokens are always full access. GET /me echoes both under api_key.

Get a key

  1. Sign in and visit /settings/api-keys.
  2. Click Create key, give it a name (e.g. n8n-prod).
  3. Copy the plaintext immediately — we show it once and never again. The server only stores sha256(key).

Smoke test

bash
curl -sS https://transcribe.so/api/v1/me \
  -H "Authorization: Bearer $TRANSCRIBE_API_KEY"

Returns the authenticated user, current wallet balance, and plan tier.

Limits

  • 20 active keys per user
  • 60 requests / minute per key, on every plan
  • On Pay as you go, the wallet is the spend cap; subscription plans meter processing minutes instead (below)

Per-plan processing limits

Enforced when you create a transcription — they never affect read endpoints:

PlanConcurrent jobsMax file lengthFair-use minutes / rolling 6h
Pay as you go90 min60
Starter1120 min120
Pro35 h360
Business1510 h1,200
Enterprise50+12 hCustom
  • A file over your plan's length cap → 400 invalid_requestwith a message naming the cap. 12 h per file is the current pipeline maximum; your plan's cap from the table above applies first. Direct uploads are also bound by the 1 GB file-size cap; for longer high-bitrate files use a URL or YouTube source.
  • Concurrent-job caps apply to subscription plans. On Pay as you go, the wallet is the effective limiter.
  • Fair-use minutes are a hard wall on Pay as you go: exceeding the window → 429 rate_limited, same code as request throttling; the error.message says which wall you hit. Wait for the window to roll or upgrade.
  • On subscription plans the fair-use figures are guidelines, not walls: jobs past them may queue at lower priority during peak hours, but are never rejected.

Four input sources

POST /api/v1/transcriptionsaccepts the same four sources as the dashboard's /transcriptions page. All four go through the same quote → wallet hold → enqueue path.

sourcewhen to useduration_seconds
youtubePublic YouTube URL.Not needed — we probe the video.
platform_urlPublic page URL on a supported platform (Apple Podcasts, SoundCloud, Vimeo, Twitch, Loom and similar).Not needed — we probe the page.
external_urlDirect audio/video URL on a public host.Optional. Pass when known to skip a probe round-trip.
uploadFile on your machine; no public URL.Required. S3 isn't probed from the API.

addons (string array, max 8) is accepted on POST /quotes and POST /transcriptions but rarely needed: speaker diarization is built into the standard pipeline, so omit it unless GET /api/v1/pipelines lists an add-on as compatible. Unknown codes return 400 addon_not_supported.

Endpoints

GET/api/v1/me

The authenticated user, wallet, tier, effective plan limits, and a self-discovering links map.

200 response

json
{
  "user_id": "49bf19f6-…",
  "email": "you@example.com",
  "wallet_balance_usd": 97.55,
  "subscription_tier": "free",
  "limits": {
    "max_file_minutes": 720,
    "max_upload_bytes": 1073741824,
    "max_concurrent_jobs": 1
  },
  "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"
  }
}

subscription_tier is free (Pay as you go), starter, pro, business, or enterprise. The limits object reports the caps your plan is actually enforced with: max_file_minutesis the single-file duration cap (your plan's own cap, or the 12-hour platform ceiling when the plan has none). The linksmap gives your client a stable spot to surface "manage your key" / "top up" / "see docs" actions without hardcoding URLs.

GET/api/v1/pipelines

The capability catalog: the standard pipeline with current per-minute rates, the same rates as the dashboard's /pricing page. Every transcription includes timestamped segments, speaker labels, and AI analysis.

200 response

json
{
  "pipelines": [
    {
      "code": "standard",
      "name": "Standard (timestamps + diarization) + AI Analysis",
      "retail_usd_per_min": 0.0167,
      "retail_usd_per_hour": 1,
      "supported_languages": [
        {
          "code": "en",
          "label": "English",
          "native_name": "English",
          "fleurs_wer": null,
          "benchmark_error_rate_band": "under_5_percent"
        },
        …
      ],
      "word_timestamp_languages": [{ "code": "en", … }, …],
      "timestamp_options": ["sentence", "word"]
    }
  ]
}

benchmark_error_rate_band is the published accuracy band for that language (under_5_percent, 5_to_under_15_percent, or 15_percent_or_higher; null when no published result exists). fleurs_wer is deprecated and always null in API v1; it will be removed in a future version. word_timestamp_languages and timestamp_options describe pipeline capability (what the engine can produce); the API and MCP responses return sentence-level segments today.

POST/api/v1/uploads

Step 1 of the upload flow. Returns a short-lived presigned S3 PUT URL.

Body

json
{
  "filename": "podcast.mp3",
  "content_type": "audio/mpeg",
  "file_size": 8421120
}
  • Allowed content_type (fixed allowlist, anything else is 400 invalid_request): audio/mpeg, audio/mp3, audio/wav, audio/m4a, 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: 1 GB.

200 response

json
{
  "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.

For files over ~50 MB or unstable networks, prefer the resumable variant below.

POST/api/v1/uploads/tus

Resumable upload via tusd. Returns a tusd endpoint URL plus a short-lived HMAC ticket. The client uploads with any tus 1.0 client; tusd writes to S3 chunk by chunk and resumes on network drops.

Body

json
{
  "filename": "podcast.mp3",
  "file_size": 187654321
}
  • Max file_size: 1 GB. The token is bound to this size; tusd rejects uploads that exceed it.
  • No content_type needed at this step. The worker sniffs the file when it processes.

200 response

json
{
  "upload_endpoint": "https://upload.transcribe.so/files/",
  "upload_token": "eyJ1IjoiYWY5...",
  "upload_metadata_key": "upload-token",
  "expires_in": 3600,
  "max_file_size": 1073741824
}

Use any tus 1.0 client. Recommended: tus-js-client (browser + Node) and tus-py-client (Python). Put upload_token in Upload-Metadata under upload_metadata_key.

After the upload finishes

Tusd's Location header has the form <endpoint>/<id>+<resume-token>. Pass upload_id = "tus/<id>+<resume-token>" (or tus/<id> alone — the server normalizes) to POST /api/v1/transcriptions with source: "upload" and duration_seconds. The same quote → wallet-hold → enqueue path the presigned-PUT flow uses.

See the resumable upload recipe for a working end-to-end example.

POST/api/v1/transcriptions202

Submit a transcription. Four source modes; same dance the dashboard does.

Body — youtube

json
{
  "source": "youtube",
  "url": "https://youtu.be/dQw4w9WgXcQ",
  "language": "auto"
}

Body — platform_url

json
{
  "source": "platform_url",
  "url": "https://vimeo.com/123456789",
  "language": "auto"
}

Body — external_url

json
{
  "source": "external_url",
  "url": "https://example.com/podcast.mp3",
  "language": "auto",
  "duration_seconds": 1234
}

Body — upload

json
{
  "source": "upload",
  "upload_id": "user/<uuid>/uploads/...mp3",
  "original_filename": "podcast.mp3",
  "duration_seconds": 1234,
  "language": "auto"
}

pipeline_code is optional and defaults server-side; omit it. Legacy values are accepted and mapped to the current pipeline.

202 response

json
{
  "id": 4821,
  "status": "processing",
  "stage": "queued",
  "pipeline_code": "standard",
  "language": "auto",
  "source": "upload",
  "upload_id": "user/...",
  "duration_seconds": 1234,
  "billed_minutes": 20.6,
  "retail_usd": 0.7457
}

For youtube, platform_url and external_url, the response carries url instead of upload_id. The response also carries addons: [].

Send Idempotency-Key on retries (see below).

GET/api/v1/transcriptions

List your transcriptions, newest first. Cursor-paginated.

Query

  • limit — 1–200, default 50
  • cursor — ISO timestamp of the last item from the previous page
  • api_only=true — filter to API-originated jobs
GET/api/v1/transcriptions/:id

Single transcription metadata + status.

GET/api/v1/transcriptions/:id/result

Transcription metadata plus the analysis you ask for. Only meaningful once status === completed.

Query

  • include — comma-separated list of chapters, acts, sections, qna, segments, posting_chapters, or all. Default: chapters,sections,qna. Segments (the speaker-labelled, timestamped transcript lines) are not in the default set; ask for include=segments or include=all. Unknown values fall back to the default.
  • segments_offset / segments_limit — page through the transcript segments. The default limit is the per-call cap below. The response reports segments_meta: { offset, limit, total, has_more } as a top-level field, a sibling of segments and of included (which is just the string array of what was applied) — not nested inside it. Keep calling with a raised offset while has_more is true, or use /transcript, which is never capped.
  • An include value we do not recognise is 400 invalid_request, not a silent drop, and a requested artifact that fails to load is 500 internal_error with the offending field— never an empty array that reads like "there is nothing there".
  • The response echoes what was applied in included. Caps per call: 50 chapters, 50 sections, 20 qna, 1000 segments.

200 response (include=all)

json
{
  "id": 4821,
  "status": "completed",
  "included": ["acts", "chapters", "posting_chapters", "qna", "sections", "segments"],
  "segments":  [{ "id": 1, "segment_index": 0, "start_seconds": 0.0, "end_seconds": 4.21, "start_ms": 0, "end_ms": 4210, "speaker": "SPEAKER_00", "text": "..." }],
  "segments_meta": { "offset": 0, "limit": 1000, "total": 2417, "has_more": true },
  "chapters":  [{ "id": 7, "chapter_index": 0, "title": "...", "summary": "...", "start_seconds": 0.0, "end_seconds": 145.6, "url": "https://youtu.be/...?t=0" }],
  "sections":  [{ "id": 3, "section_index": 0, "title": "...", "summary": "...", "start_seconds": 0.0, "end_seconds": 60.2, "segment_count": 12, "url": null }],
  "qna":       [{ "question": "...", "answer": "...", "citations": [{ "title": "...", "start_seconds": 12.5, "url": null }], "answer_citations": [] }],
  "acts":      [...],
  "posting_chapters": {
    "standard": { "balanced": { "items": [{ "start_ms": 0, "title": "...", "url": "https://youtu.be/...?t=0" }], "generated_at": "2026-08-17T10:00:00Z", "style": "balanced" } },
    "highlights": { "balanced": { "items": [...], "generated_at": "...", "style": "balanced" } },
    "clips": { "balanced": { "items": [...], "generated_at": "...", "style": "balanced" } },
    "quoted_sections": { "balanced": { "items": [{ "start_ms": 12500, "insight_title": "...", "quote": "...", "url": null }], "generated_at": "...", "style": "balanced" } }
  }
}

Segments are sentence-level; there is no word-level field in the API today. url on chapters, sections and citations is a deep link into the source (YouTube ?t=, Vimeo, SoundCloud, Twitch, Loom) or null.

GET/api/v1/transcriptions/:id/wait

Long-poll: holds the connection until the job reaches completed or failed, or the timeout hits. Same body as /result when include is set.

Query

  • timeout — seconds to hold, 1–45 (default 30). Call again if it times out.
  • include — same values as /result. Omitted = metadata only; sections are only attached once the job is completed.
  • segments_offset / segments_limit — same paging as /result, reported back as the top-level segments_meta.
bash
curl -sS -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  "https://transcribe.so/api/v1/transcriptions/4821/wait?timeout=45&include=all"

The response adds _timed_out: true|false. Loop while it is true; one request per job in flight, which is far cheaper on your rate limit than polling.

GET/api/v1/transcriptions/:id/timestamps

Paste-ready chapter timestamps for a destination platform. Two axes: format (where you paste) x variant (which chapter set). Reads from the LLM-curated posting_chapters cache; 409 not_ready if the requested variant hasn't been generated yet.

Both query params are optional. Each format encodes the destination's rules (character budget, min spacing, first-must-be-0:00, HH:MM:SS) so you paste straight into the field.

bash
curl -sS -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  "https://transcribe.so/api/v1/transcriptions/4821/timestamps?format=spotify&variant=standard"
json
{
  "format": "spotify",
  "variant": "standard",
  "style": "balanced",
  "text": "0:00 How Floga's pre-launch hit $100K\n1:20 Why your first hire defines culture\n4:15 ...",
  "char_count": 412,
  "items_used": 12,
  "items_total": 12,
  "truncated": false,
  "ok_to_paste": true,
  "warnings": [],
  "constraints": {
    "maxChars": 4000,
    "minChapters": 3,
    "firstMustBeZero": true,
    "minSpacingSeconds": 30,
    "titleCap": 40,
    "label": "Spotify"
  },
  "source": {
    "kind": "posting_chapters",
    "generated_at": "2026-05-09T12:34:56Z",
    "model": "standard",
    "regen_count": 0,
    "available_styles": ["balanced"]
  }
}

Query

  • format — where you will paste. One of youtube (default), spotify, apple_podcasts, markdown, x, threads, instagram_caption, linkedin, plain.
  • cta — true/false, default falseover the API and MCP. The default output is the user's post and nothing else; cta=trueappends our footer, which is what the web app does. Never ship a promotional footer into someone's caption without asking them.
  • variant — which chapter set. One of standard (default), highlights, clips, quoted_sections, show_notes, original.
  • style — deprecated. Accepted for back-compat and ignored; the response always reports balanced.

Formats

  • youtube / spotify / apple_podcasts — chapter list formatted per the platform's first-party rules (budget, spacing, first item at 0:00).
  • markdown — Markdown list with timestamps, for show-notes pages and READMEs.
  • x / threads — split into post-sized chunks; the response adds thread: string[] with one entry per post.
  • instagram_caption — caption-length text with the platform's character budget.
  • linkedin — a LinkedIn post: quoted sections inside the 3,000-character budget. No hashtags are invented and no link is auto-appended.
  • plain — bare mm:ss title lines, no platform rules.

Variants

  • standard — the LLM-curated 10–30-item chapter list.
  • highlights — the most notable moments, fewer items.
  • clips — the most shareable moments, for clip lists.
  • quoted_sections — pull-quotes with their timestamps.
  • show_notes — per-chapter summaries. Always Markdown output regardless of format.
  • original — every raw section with its title and timestamp; bypasses LLM curation for max granularity.

The legacy format values clip_ideas, show_notes and original now return 400 invalid_request pointing at the matching ?variant=.

Every truncation is reported in warnings[] and truncated, so a caption that lost material never looks like a clean one. This endpoint returns social post text: the words a human pastes. For a timed subtitle file use /subtitles; for a video with captions burned in use /clips.

409 not_ready envelope

When the requested variant hasn't been generated, the error tells you which regenerate call fixes it (available_styles is retained for legacy clients). The reason field says which of the two situations you are in, because they need different handling: transcription_processing means the job is still running and the call carries a Retry-After header (wait, then retry the same call); artifact_missing means the transcription is done but this variant was never generated, so waiting will never fix it — POST /timestamps/regenerate will, and that response carries no Retry-After.

json
{
  "error": {
    "code": "not_ready",
    "message": "variant=\"standard\" not generated yet. POST /api/v1/transcriptions/4821/timestamps/regenerate to generate it.",
    "request_id": "req_…",
    "doc_url": "https://transcribe.so/developers/docs#endpoints",
    "available_styles": [],
    "requested_style": "balanced",
    "variant": "standard"
  }
}
POST/api/v1/transcriptions/:id/timestamps/regenerate200

Re-runs the LLM curate+polish step. Use to apply a refine prompt, or to generate posting_chapters for a transcription that predates this feature.

json
{
  "refine_prompt": "focus on the case studies"
}

Body

  • style — deprecated. Accepted for back-compat but coerced to balanced.
  • refine_prompt — optional, ≤200 chars. Free-text steer for the LLM (e.g. "focus on case studies"). Validated for prompt-injection markers.

Latency: 30–90 seconds. Synchronous — the response body contains the freshly-generated chapters. Capped at 10 regenerations per transcription per user.

For long-running jobs in general (transcription itself, not regenerate), prefer /wait or webhook subscriptions over loop-polling.

GET/api/v1/transcriptions/:id/words

Paginated word-level timings in milliseconds with the owning segment's speaker. Always 200 when the transcription exists: available:false plus a reason when timings aren't there yet.

Query

  • offset: zero-based index of the first word (default 0).
  • limit: page size, 1-5000 (default 2000). Page until has_more is false; count is the total.
bash
curl -sS -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  "https://transcribe.so/api/v1/transcriptions/4821/words?offset=0&limit=2000"
json
{
  "id": 4821,
  "status": "completed",
  "granularity": "word",
  "available": true,
  "reason": null,
  "language": "en",
  "count": 1832,
  "offset": 0,
  "limit": 2000,
  "has_more": false,
  "words": [
    { "text": "Welcome", "start_ms": 0, "end_ms": 420, "segment_id": 88213, "word_index": 0, "speaker": "A", "punctuation": null },
    { "text": "back", "start_ms": 420, "end_ms": 640, "segment_id": 88213, "word_index": 1, "speaker": "A", "punctuation": "," }
  ]
}

available is the source of truth: false with reason: "not_completed" while the job runs, or reason: "no_word_timestamps" when the pipeline produced sentence timings only (check word_timestamp_languages on /pipelines). This endpoint never returns 409; granularity echoes the raw creation-time setting as metadata.

Casing note: /words is snake_case (start_ms); /subtitles?format=jsonkeeps the subtitle exporter's camelCase (startMs). Use /words for karaoke, word-highlight and Remotion overlays (see Remotion).

GET/api/v1/transcriptions/:id/transcript

The whole transcript as a raw body (text/plain or text/markdown), never capped and never paged. Requires status completed (409 not_ready otherwise); errors keep the JSON envelope.

Query

  • format: txt (default, text/plain) is the transcript text only, no title and no table of contents. md (text/markdown) is the timestamped Markdown document: title, chapters, speaker turns.
  • speaker_labels: true/false (default true). Each speaker turn is prefixed with that recording's own label followed by a colon — SPEAKER_00:, or Unknown: when the pipeline could not attribute the turn. These are the raw diarization labels, not display names and not identified people.
  • timestamps: true/false (default true). Each turn is prefixed [hh:mm:ss] .

The two booleans are independent, so all four combinations are valid, and they apply to both formats. Both default to true: pass speaker_labels=false or timestamps=false to strip them.

bash
curl -sS -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  "https://transcribe.so/api/v1/transcriptions/4821/transcript?format=txt&speaker_labels=true&timestamps=true"

[00:00:00] SPEAKER_00: So the thing nobody tells you about pre-launch is...
[00:00:19] SPEAKER_01: Right, and that is exactly where most people stop.
[00:00:31] Unknown: (a turn the pipeline could not attribute)

This endpoint pages every segment internally, so the last sentence is always in the body. It is the right call whenever you want the transcript itself; /result?include=segments is the right call when you want the segments as JSON objects with ids and times.

A 200 is never an empty body: a transcription that is not completed is 409 not_ready, and one that is not yours is 404 not_found.

GET/api/v1/transcriptions/:id/subtitles

A subtitle file as a raw body (SRT, VTT, karaoke VTT or JSON), not the JSON envelope, so you can pipe it straight to disk. Requires status completed (409 not_ready otherwise); errors keep the envelope.

Query

  • format: srt (default, application/x-subrip), vtt, vtt-karaoke (inline per-word <hh:mm:ss.mmm> tags, text/vtt), json (application/json). Karaoke tags are absolute WebVTT cue timestamps inside the cue, so browsers' native <track> and players that implement cue timestamps reveal words progressively; most video editors ignore the tags and import the cue text. For per-word editing use mode=word SRT or the /words JSON.
  • preset: line-length / cues-per-second rules: youtube (default), tiktok-shorts, instagram-reels, netflix, podcast, broadcast. custom is not accepted over the API.
  • speaker_labels: prefix cues with [Speaker]; true/false (default false).
  • mode: auto (default: word-timed cues when word timestamps exist, else sentence cues), word (word-timed cues under the preset's line rules, not one word per cue; 400 invalid_request when the transcription has no word timestamps), sentence. When source=materialized wins, mode is reported (from the stored cues), not honored.
  • source: auto (default), generated, materialized (your edited cues from the subtitle editor; 404 when there are none).
bash
curl -sS -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  "https://transcribe.so/api/v1/transcriptions/4821/subtitles?format=vtt&preset=tiktok-shorts" \
  -o captions.vtt

Precedence rule: edited cues from the app are served only when source=materialized, or when source=auto AND mode is omitted/auto AND preset is omitted/youtube (you asked for nothing specific, so you get what the app shows). Any explicit mode or non-default preset regenerates from the transcript. Edited cues carry preset line-breaking only.

Response headers: x-transcribe-cue-count, x-transcribe-cue-source (generated | materialized), x-transcribe-cue-mode (word | sentence), and Content-Disposition: inline; filename="<slug>-<preset|sentence>.<ext>". Text formats are ; charset=utf-8. No cue cap: the body is bounded by transcript length.

Casing note: format=jsonis the exporter's camelCase (startMs, endMs, words[].punctuationAfter, words[].speaker); /words is snake_case.

GET/api/v1/search

Find the segments where something was said, across every completed transcription you own. Read-only, never billed, and shares the same rate limit as everything else.

Query

  • q — the phrase to look for. Case-insensitive substring match over segment text.
  • transcription_ids — optional comma-separated allowlist; omitted = your whole library.
  • limit / offset — stable paging. The response carries total and has_more.
bash
curl -sS -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  "https://transcribe.so/api/v1/search?q=pricing%20experiment&limit=5"
json
{
  "query": "pricing experiment",
  "hits": [
    {
      "transcription_id": 4821,
      "title": "How the pre-launch hit $100K",
      "segment_id": 918273,
      "speaker": "SPEAKER_01",
      "start_ms": 742000,
      "end_ms": 749400,
      "text": "the pricing experiment we ran in March changed everything",
      "url": "https://transcribe.so/transcriptions/4821?t=742"
    }
  ],
  "total": 12,
  "offset": 0,
  "limit": 20,
  "has_more": false
}

The response echoes query, offset and limit alongside total and has_more, so a paging loop needs no bookkeeping of its own. Hits are ordered by transcription (newest first) then by start_ms, then by segment id. Every completed, undeleted transcription you own is searched — no recency window and no per-transcription cap, so a match in a two-year-old recording still comes back. title is null for an untitled recording, speaker is the raw diarization label such as SPEAKER_01 (null when the pipeline could not attribute the segment), and url deep-links to the moment.

GET /api/v1/transcriptions/:id/search?q=&limit=&offset= is the same search scoped to one transcription (404 not_found if it is not yours). The library search never reveals a transcription you do not own.

POST/api/v1/transcriptions/:id/clips

Render a hosted, shareable captioned MP4 clip of a range of a completed transcription (audio segment over a branded background with word-by-word captions). Flat $0.05 per started 60 seconds of clip, wallet-only. Returns 202 with the queued clip; renders take several times the clip length (a 60 s clip can take 5-8 minutes).

Body

  • start_seconds, end_seconds: the range; 1-60 s long (current cap), within the transcription. The transcription must be completed and have word timestamps in the range (see /words); good candidates come from /timestamps?variant=clips or quoted_sections.
  • aspect: 9:16 (default, 720x1280), 1:1 (720x720), 16:9 (1280x720).
  • style: captions (default, pill + word pop-in), karaoke (active word highlighted), minimal (plain bottom text).
  • title (optional, up to 120 chars): rendered at the top of the clip.
  • callback_url (optional): receives clip.completed / clip.failed as a signed webhook; the 202 returns callback_secret. Same rules as the transcription callback_url.
bash
curl -X POST https://transcribe.so/api/v1/transcriptions/4821/clips \
  -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "start_seconds": 12.5, "end_seconds": 42.5, "aspect": "9:16", "style": "karaoke", "title": "Why the retry loop was wrong" }'
# 202 -> { "id": 42, "transcription_id": 4821, "status": "queued", "charge_usd": 0.05, "mp4_url": null, ... }

# long-poll until rendered (wait 1-45 s per call; _timed_out: true = call again)
curl "https://transcribe.so/api/v1/transcriptions/4821/clips/42?wait=45" \
  -H "Authorization: Bearer $TRANSCRIBE_API_KEY"
# 200 -> { "id": 42, "status": "completed", "duration_seconds": 30, "mp4_url": "https://...", "mp4_url_expires_at": "...", ... }

Billing: the charge is held on your wallet at request time, settled when the render completes and released if it fails. Subscription plans do not include clips; an empty wallet gets 402 insufficient_funds. Clips created with an API key count toward that key's monthly spend cap. At most 10 clips can be queued or rendering per account (429 rate_limited).

GET /api/v1/transcriptions/:id/clipslists a transcription's clips (newest first, max 50); GET .../clips/:clipId returns one. Completed clips carry a presigned mp4_url valid for one hour (mp4_url_expires_at); every GET issues a fresh one. Send Idempotency-Key on the POST to make retries safe. Errors: 400 invalid_request (range / length / aspect / no words in range), 409 not_ready (transcription not completed), 404 not_found.

POST/api/v1/transcriptions/:id/ask

Ask a live question about one completed transcription. An LLM answers from the transcript's sections (about 10 seconds, non-streaming) with numbered citations that carry real timestamps and deep-links. Metered by the same Q&A allowance as the app, never the wallet.

Body

  • question (required): 1-500 characters, any language; the answer follows the transcript's language.
  • top_k: hybrid-retrieval candidates before rerank, 10-100 (default 60).
bash
curl -sS -X POST -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"question": "What did the guest say about pricing?"}' \
  https://transcribe.so/api/v1/transcriptions/4821/ask
json
{
  "answer": "The guest argued for usage-based pricing [1] and against seat licences [2].",
  "no_answer": false,
  "citations": [
    { "marker": 1, "section_id": 88213, "transcription_id": 4821, "title": "Pricing debate",
      "start_seconds": 1121.5, "url": "https://youtu.be/dQw4w9WgXcQ?t=1121", "quote": "charge for what they use" }
  ],
  "qna_id": 5102,
  "remaining_per_transcript": 2,
  "remaining_per_day": 9,
  "model": "standard"
}

citations[].marker matches the inline [N] in answer; url deep-links to the source at that timestamp (YouTube, Vimeo, SoundCloud, Twitch, Loom) or to the transcribe.so player with ?t=<sec> for uploads. no_answer: true means no relevant context was found: nothing was saved and no allowance was consumed. remaining_* are after this call; null means unlimited on that axis.

Quota.Answered questions consume your Q&A allowance, shared with the transcribe.so app (tier-based, rolling 24 hours: free 10 per day and 3 per transcript, starter 100 per day, pro 200 per day, business 1000 per day; per-transcript unlimited on paid plans). Never billed to the wallet. Cached Q&A pairs are always free via GET /transcriptions/:id/result?include=qna, so read those first. Over the allowance you get 429 qna_quota_exceeded with error.scope (transcript | day) and, for the daily scope, error.retry_after seconds (also the Retry-After header).

Other errors: 404 not_found, 409 not_ready (wait for completed), 400 invalid_request, and 500 internal_error mentioning qna_timeout when the answer did not finish within the 75-second budget (retry).

POST/api/v1/ask

Ask your library: the same live Q&A across all of your transcriptions, or across transcription_ids (max 50). Citations point at the transcript they come from.

bash
curl -sS -X POST -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"question": "Across my interviews, what pricing objections came up?", "transcription_ids": [4821, 4822]}' \
  https://transcribe.so/api/v1/ask
json
{
  "answer": "Two objections recur: per-seat cost [1] and annual lock-in [2].",
  "no_answer": false,
  "citations": [
    { "marker": 1, "section_id": 88213, "transcription_id": 4821, "title": "Interview with Ana",
      "start_seconds": 1121.5, "url": "https://youtu.be/dQw4w9WgXcQ?t=1121", "quote": "too expensive per seat" }
  ],
  "qna_id": 311,
  "remaining_today": 8,
  "saved": true
}

citations[].title is the source transcript's title. saved: false means nothing was persisted (no answer, or the history insert failed) and no allowance was consumed. Same daily allowance and errors as the per-transcript endpoint (no per-transcript scope).

DELETE/api/v1/transcriptions/:id

Permanently deletes the transcription, derived rows, and S3 objects. Returns { id, deleted: true }.

POST/api/v1/transcriptions/:id/retry202

Restart a failed job. Charges run again from scratch. Returns { id, status: 'processing', stage: 'queued', billed_minutes, retail_usd }.

POST/api/v1/quotes

Preview cost (and reserve a quoted row) without queueing. Same body shape as POST /transcriptions; returns transcription_id, duration_seconds, billed_minutes, retail_usd, per-minute/hour rates, subscription_tier, expires_at and addons[].

GET/api/v1/openapi.yaml

The OpenAPI 3.1 spec for everything on this page. Public, no auth. Import it into a Custom GPT action, generate a client, or diff it against your own types.

bash
curl -sS https://transcribe.so/api/v1/openapi.yaml | head -40

Machine-readable discovery lives next to it: /.well-known/api-catalog (RFC 9727 linkset), /.well-known/agent-skills/index.json (agent skills), /.well-known/mcp/server-card.json and /.well-known/mcp/server.json (MCP), and the plain-text guides /llms.txt, /llms-full.txt, /auth.md.

Worked recipes

A URL to an Instagram caption

This is four calls, not two, and the fourth one can legitimately fail in a way that waiting will not fix. The sequence below is the one that actually works.

bash
ID=$(curl -sS -X POST https://transcribe.so/api/v1/transcriptions \
  -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  -H "Idempotency-Key: ig-$(date +%F)-ep149" \
  -H "Content-Type: application/json" \
  -d '{"source":"youtube","url":"https://youtu.be/dQw4w9WgXcQ","max_charge_usd":2}' \
  | jq -r .id)

# bounded wait, repeated while "_timed_out": true
curl -sS -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  "https://transcribe.so/api/v1/transcriptions/$ID/wait?timeout=45"

# the caption itself (social post text, no promotional footer)
curl -sS -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  "https://transcribe.so/api/v1/transcriptions/$ID/timestamps?format=instagram_caption&variant=highlights"

# 409 not_ready with reason "artifact_missing" -> generate it, then repeat
curl -sS -X POST -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  "https://transcribe.so/api/v1/transcriptions/$ID/timestamps/regenerate"

Quote first if the price matters (POST /quotes, free). Handle all five outcomes of the wait: queued or still processing (call wait again), completed (go on), failed and cancelled (stop; retrying re-charges from scratch), and the timeout, which is not a failure at all — it is _timed_out: true and means call again.

Swap format for x, threads or linkedin to target another platform. All four return social post text. If what you actually wanted was a subtitle file, that is /subtitles (try preset=instagram-reels); if it was a video with captions burned in, that is /clips.

Who said what, and when

bash
curl -sS -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  "https://transcribe.so/api/v1/search?q=deferred%20revenue&limit=5" | jq '.hits[]'

# scope it to one recording
curl -sS -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  "https://transcribe.so/api/v1/transcriptions/4821/search?q=deferred%20revenue"

Answer from the hit itself: its text is the quote, speaker is who said it, start_ms / end_ms is when, and url is the link that lands on the moment. A chapter or section summary is not evidence for a particular quote, and SPEAKER_01 is just the second voice in that recording, not a named person.

Errors

Every error response uses the same envelope:

json
{
  "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"
  }
}
  • message inlines an actionable URL where one applies. Terminal users see the link without parsing JSON.
  • doc_url always points at a stable docs section or dashboard surface for that error.
  • request_id is also returned as X-Request-Id on every response — quote it in support tickets.
codeHTTPwhen
unauthenticated401Missing Authorization header, or an OAuth bearer token that failed verification (expired, bad signature).
invalid_api_key401Key malformed, unknown, revoked, or expired.
forbidden403The token type can't use this endpoint (webhook endpoints require an API key; OAuth tokens are rejected).
entitlement_required403Your plan doesn't include this capability.
scope_forbidden403The API key is read-only and the request is a POST/DELETE (quotes and uploads included). Create a key with write access at /settings/api-keys.
not_found404Resource doesn't exist or isn't yours.
not_ready409GET /timestamps: carries reason — transcription_processing (still running; honour Retry-After, then retry the same call) or artifact_missing (done, but this variant was never generated; POST /timestamps/regenerate, no Retry-After). GET /subtitles and GET /transcript: the transcription isn't completed yet; the envelope carries status. Wait via /wait, then retry.
invalid_request400Body / query / path parameter is missing or malformed.
unsupported_pipeline400pipeline_code isn't recognized or isn't available to this key.
unsupported_language400language isn't in the pipeline's supported list (or, for realtime, not in the realtime set).
addon_not_supported400An addons entry isn't compatible with the pipeline. Diarization is built in; omit addons.
video_private400POST /transcriptions source=youtube: the video is private. Permanent for this URL; don't retry.
video_unavailable400POST /transcriptions source=youtube: the video is private, removed, or the id is invalid — confirmed via YouTube's official API. Permanent for this URL; don't retry.
video_unavailable_probe400POST /transcriptions source=youtube: the video is unavailable (removed, region-restricted, blocked) per the download probe. Permanent for this URL; don't retry.
video_members_only400POST /transcriptions source=youtube: the video is available to channel members only, so it cannot be fetched from the link. Permanent for this URL; submit the file with source=upload instead.
video_age_restricted400POST /transcriptions source=youtube: the video is age-restricted and requires a signed-in adult account, so it cannot be fetched from the link. Permanent for this URL; submit the file with source=upload instead.
probe_proxy_auth_quota503POST /transcriptions source=youtube: our video-lookup infrastructure failed (vendor auth/quota) — your request may be fine. Honour the Retry-After header (seconds) and retry.
probe_proxy_connectivity503POST /transcriptions source=youtube: our video-lookup infrastructure failed (proxy connectivity) — your request may be fine. Honour the Retry-After header (seconds) and retry.
probe_runtime_misconfigured503POST /transcriptions source=youtube: our probe runtime is misconfigured — your request may be fine. Honour the Retry-After header (seconds) and retry.
insufficient_funds402Wallet can't cover the estimated charge (Pay as you go).
max_charge_exceeded402POST /transcriptions with max_charge_usd: the computed charge is above your ceiling. Nothing was created and nothing was charged; the envelope carries charge_usd and max_charge_usd. Raise the ceiling only with the user's say-so.
spend_cap_exceeded402The API key's monthly spend cap would be passed by this job (settled month spend + in-flight holds + this charge). Raise the cap at /settings/api-keys.
rate_limited429Per-key request rate exceeded (60/min; the retry-after header says how many seconds to wait), or (Pay as you go only) fair-use processing minutes exhausted for the current 6h window; error.message says which.
qna_quota_exceeded429POST /ask endpoints and the MCP ask tools: the Q&A allowance shared with the app is used up. error.scope is transcript or day; error.retry_after (seconds, also the Retry-After header) for the daily scope. Cached Q&A via /result?include=qna stays free.
internal_error500Server bug; safe to retry with backoff. Quote request_id.

Retry guidance

  • 429: honour the retry-after header (seconds); if absent, back off 60s.
  • 500: exponential backoff (1, 2, 4, 8s, max 60s), cap at 5 attempts. Use the same Idempotency-Key so duplicates don't bill twice.
  • 402: do not retry until the user tops up.
  • 409 not_ready: read reason. transcription_processing → honour Retry-After and retry the same call. artifact_missing → call POST /timestamps/regenerate, then retry. Not every 409 resolves by waiting.
  • 402 max_charge_exceeded: nothing was created. Do not retry with a higher ceiling on your own — tell the user the real price and ask.
  • 400 / 401 / 403 / 404: don't retry; fix the request. The video_private / video_unavailable / video_unavailable_probe / video_members_only / video_age_restricted codes are permanent for that URL — no request change will help (for the two gated codes, upload the file with source=upload).
  • 503 probe_*: temporary failure on our side; honour the Retry-After header (seconds). Same Idempotency-Key is safe — 5xx responses are never cached.

Idempotency

POST endpoints accept an Idempotency-Keyheader. Use it on any request that creates or starts something, so retries don't double-bill or double-queue.

bash
POST /api/v1/transcriptions
Idempotency-Key: 2026-04-30-podcast-ep-149
  • First request runs normally. Subsequent requests with the same (principal, method, path, idempotency_key) within 24h return the original response unchanged. The principal is the API key, or the user plus client for an OAuth token — both auth types get the same protection, and two callers can never collide on one key.
  • The path is part of the identity, so reusing one key across a quote and a create is not a replay: each path keeps its own entry and you never receive a quote where you expected a created job.
  • 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.

Async patterns (don't poll)

Transcriptions are async (~60s for 1-min audio, ~5min for an hour-long podcast). For long-running ops, ranked best-to-worst:

  1. Webhook — best for fire-and-forget pipelines. Register one URL per API key, or pass callback_url on a single POST /transcriptions; we deliver an HMAC-signed transcription.completed POST when the job hits a terminal state. No connections held open, no rate-limit pressure, scales to any volume. Setup →
  2. Long-poll /wait — best for synchronous "create-and-wait" flows where you can hold one HTTP connection. Server holds the response open up to timeout seconds (max 45) and returns as soon as the job finishes. One request per job.
    bash
    curl -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
      "https://transcribe.so/api/v1/transcriptions/4821/wait?timeout=30&include=chapters,sections,qna"
  3. Email notification — falls out automatically. Every user gets an email when their job completes. No code required.
  4. Loop-polling GET /transcriptions/:iddon't. Naive polling every few seconds wastes tokens, eats rate limit, and gives you no faster signal than the long-poll. If your runtime can't hold a connection, use the webhook.

The synchronous endpoints (POST /timestamps/regenerate, POST /transcriptions) intentionally block until they have something to return; you don't poll those — you await the single response.

Webhooks

Get a signed POST when a transcription finishes — no polling. Two ways in: a registered webhook (one per API key, fires for every job the key starts) or a per-request callback_url on POST /transcriptions (fires for that one job; see below). Webhook endpoints are API-key only: OAuth bearer tokens (from the MCP connector flow) get 403 forbidden here, but they can still use callback_url.

Events

  • transcription.completed — your transcription reached status: completed.
  • transcription.failed — your transcription reached status: failed.
  • clip.completed / clip.failed: a clip render created with this key finished; data.clip carries a fresh presigned mp4_url on completion.
  • webhook.test — you called POST /api/v1/webhooks/test.

Register

bash
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",
    "events": ["transcription.completed", "transcription.failed"]
  }'

events is optional and defaults to all four events (transcription.* and clip.*); unknown names are dropped (an empty result is 400 invalid_request). Registering again replaces the previous webhook on that key. The 201 response is { id, url, events, signing_secret, created_at } with a one-time signing_secret (whsec_…). Store it — we never show it again. You can also register a webhook from the dashboard at /settings/api-keys.

Inspect and remove

bash
# Current webhook on this key (null if none)
curl -sS https://transcribe.so/api/v1/webhooks \
  -H "Authorization: Bearer $TRANSCRIBE_API_KEY"
# -> { "webhook": { "id", "url", "events", "consecutive_failures", "disabled_at",
#                   "last_delivery_at", "last_success_at", "created_at" } }

# Revoke it
curl -sS -X DELETE https://transcribe.so/api/v1/webhooks \
  -H "Authorization: Bearer $TRANSCRIBE_API_KEY"
# -> { "revoked": true }

Delivery headers

  • Content-Type: application/json
  • X-Transcribe-Signature: t=<unix-seconds>,v1=<hex> (see below)
  • X-Transcribe-Event: transcription.completed (or transcription.failed, clip.completed, clip.failed, webhook.test)
  • User-Agent: transcribe.so-webhook/1

Payload

json
{
  "id": "evt_1234",
  "event": "transcription.completed",
  "created": 1777472458,
  "data": {
    "transcription": {
      "id": 4821,
      "status": "completed",
      "stage": "completed",
      "pipeline_code": "standard",
      "language": "auto",
      "detected_language": "en",
      "source": "upload",
      "title": "podcast.mp3",
      "duration_seconds": 60,
      "charge_usd": 0.03,
      "error": null,
      "created_at": "2026-04-29T14:20:01.120Z",
      "processing_started_at": "2026-04-29T14:20:05.004Z",
      "completed_at": "2026-04-29T14:25:27.968Z"
    }
  }
}

transcription.failed carries the same shape with status: "failed" and a non-null error. webhook.test sends data: { message, delivered_at } instead.

Fetch the full result (segments, chapters, sections, qna) via GET /api/v1/transcriptions/:id/result?include=all— 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).

typescript
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"));
}
python
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. Per-request callbacks have nothing to disable; each one just stops after its 5th attempt.

Per-request callback (callback_url)

Only care about one job, or calling through OAuth / the MCP server where you cannot register a webhook? Pass callback_url on POST /api/v1/transcriptions (or the MCP transcribe tool). The 202 then carries callback: { url, secret }; secret is the whsec_...HMAC key for that transcription's deliveries. It is deterministic per transcription (an idempotent replay returns the same value) and is never returned by GET, so store it.

bash
curl -X POST https://transcribe.so/api/v1/transcriptions \
  -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "youtube",
    "url": "https://youtu.be/dQw4w9WgXcQ",
    "callback_url": "https://example.com/hooks/transcribe"
  }'
# 202 -> { "id": 4821, "status": "processing", ...,
#          "callback": { "url": "https://example.com/hooks/transcribe",
#                        "secret": "whsec_..." } }
  • Same payload, headers and signature scheme as a registered webhook; only transcription.completed and transcription.failed are sent. POST /transcriptions/:id/clips takes a callback_url too (events clip.completed / clip.failed, secret returned as callback_secret).
  • Fires in addition to a registered webhook when both apply (two deliveries).
  • Must be a public http(s) URL (max 2048 chars). Private, loopback, link-local and internal hosts are rejected with 400 invalid_request, and the target is re-checked right before every POST.
  • GET /transcriptions/:id echoes callback_url, never the secret.

Send a test event

bash
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 rates as the dashboard, and no separate API quota or minimums. On subscription plans, transcriptions run on the included unlimited allowance ($0, no wallet hold). On Pay as you go, jobs bill the wallet at $1/hour. Captioned clip renders are a flat $0.05 per started 60 seconds of clip on every plan, billed to the wallet (held at request, settled on completion, released on failure).

PipelineCodePer minutePer hour
Standard (timestamps + diarization) + AI AnalysisDefaultstandard$0.0167$1.00

End-to-end walkthrough

Full upload flow with curl. The hardest path — YouTube and external URL skip steps 2-3.

For files over ~50 MB or unstable networks, see the resumable upload recipe instead. Same auth, same continuation step.

bash
# 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
  }")
TR_ID=$(echo "$JOB" | jq -r .id)

# 4. Wait until done (long-poll, up to 45 s per call; loop while _timed_out)
while true; do
  STATE=$(curl -sS "https://transcribe.so/api/v1/transcriptions/$TR_ID/wait?timeout=45" \
    -H "Authorization: Bearer $TRANSCRIBE_API_KEY")
  echo "$STATE" | jq -r '"\(.status) · \(.stage)"'
  S=$(echo "$STATE" | jq -r .status)
  [[ "$S" == "completed" || "$S" == "failed" ]] && break
done

# 5. Pull the result (segments are only included when you ask for them)
curl -sS "https://transcribe.so/api/v1/transcriptions/$TR_ID/result?include=all" \
  -H "Authorization: Bearer $TRANSCRIBE_API_KEY" | jq

Same flow in Python:

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,
    },
).json()

while True:
    state = requests.get(f"{API}/transcriptions/{job['id']}/wait", headers=H,
                         params={"timeout": 45}).json()
    if state["status"] in ("completed", "failed"):
        break

# Default include is chapters,sections,qna; ask for everything explicitly.
result = requests.get(f"{API}/transcriptions/{job['id']}/result", headers=H,
                      params={"include": "all"}).json()
print(f"segments={len(result['segments'])} chapters={len(result['chapters'])} sections={len(result['sections'])}")

Realtime sessions (beta)

Live speech-to-text over a WebSocket. The REST trio below mints a session and a short-lived ticket; the audio itself streams to ws_url. Sessions hold a 60-minute wallet quote up front (402 insufficient_funds if the wallet is short), allow one active session per user, and are gated behind the realtime pipeline (400 unsupported_pipeline when your plan doesn't include it). Not exposed over MCP.

POST/api/v1/realtime/sessions201

Start a session. Body { language? } (default auto; must be a realtime-supported code, else 400 unsupported_language).

json
{
  "session_id": "rts_0123456789abcdef",
  "transcription_id": 4822,
  "ws_url": "wss://api.transcribe.so/v1/realtime",
  "ticket": "…",
  "cap_minutes": 60,
  "language": "auto"
}

The ticket is valid for 60 seconds; connect to ws_url promptly. Global concurrency is capped: 429 rate_limited means try again shortly.

POST/api/v1/realtime/sessions/:id/resume

Mint a fresh ticket for a live session after a dropped socket. Returns { session_id, ws_url, ticket, cap_minutes }. 404 not_found if unknown, 410 once the session has ended.

DELETE/api/v1/realtime/sessions/:id202

Ask the session to end and settle the wallet hold. Returns { session_id, status: 'ending' }.

MCP server + OAuth

The same API is exposed as tools over the Model Context Protocol (server version 1.6.0) at https://transcribe.so/mcp (Streamable HTTP, stateless, POST-only). Auth is identical: send Authorization: Bearer tsk_live_…, or let the client run the OAuth 2.0 authorization-code + PKCE flow it discovers via /.well-known/oauth-protected-resource. Rate limit is shared with /api/v1 (60 requests / minute).

Connect

  • Claude.ai / Claude Desktop: Settings → Connectors → Add custom connector → URL https://transcribe.so/mcp. Sign in when prompted (OAuth), or paste an API key as the Authorization header where the client supports it.
  • Claude Code: claude mcp add --transport http transcribe https://transcribe.so/mcp, then /mcp to authenticate.
  • ChatGPT: Settings → Connectors → Developer mode → add https://transcribe.so/mcp. The read-only search and fetch tools follow the ChatGPT connector contract, so the server also works for chat search and deep research.
  • Codex: codex mcp add transcribe --url https://transcribe.so/mcp.
  • Cursor: add the server to .cursor/mcp.json (project) or ~/.cursor/mcp.json (global): {"mcpServers":{"transcribe":{"url":"https://transcribe.so/mcp"}}}. OAuth runs on first use.
  • Gemini CLI: gemini mcp add --transport http transcribe https://transcribe.so/mcp, or the equivalent settings.json entry {"mcpServers":{"transcribe":{"httpUrl":"https://transcribe.so/mcp"}}}.
  • Anything else: any MCP client that speaks Streamable HTTP. Tool schemas come from tools/list; discovery hints at /.well-known/mcp/server-card.json and /.well-known/mcp/server.json.

Tools

toolwhat it doesREST equivalent
getAccountUser, wallet balance, tier, limits, links.GET /me
listPipelinesPipeline catalog with rates and languages.GET /pipelines
listTranscriptionsNewest first, cursor-paginated (limit, cursor, api_only).GET /transcriptions
getTranscriptionMetadata + status for one id.GET /transcriptions/:id
getTranscriptionResultResult with include[] (default chapters, sections, qna); caps 100/100/40/2000. segments_offset / segments_limit page the segments and segments_meta reports has_more.GET /transcriptions/:id/result
getTranscriptThe whole transcript as text (format txt | md, speaker_labels, timestamps). Notes its own size so a large transcript is never silently clipped.GET /transcriptions/:id/transcript
searchSegmentsMatching segments across every completed transcription you own, or a given transcription_ids list: speaker, start_ms, end_ms, text, deep link.GET /search
waitForTranscriptionLong-poll up to 100 s (default 90) with optional include[]; adds _timed_out.GET /transcriptions/:id/wait
getTranscriptionTimestampsPaste-ready timestamps: format x variant.GET /transcriptions/:id/timestamps
getTranscriptionWordsPaginated word timings in ms with speaker (offset, limit 1-2000 default 1000); available:false + reason when not there.GET /transcriptions/:id/words
getSubtitlesSubtitle file as content (srt, vtt, vtt-karaoke, json; preset, speaker_labels, mode, source) capped at 60k chars, plus mime_type, filename and download_url.GET /transcriptions/:id/subtitles
getClipOne rendered clip; wait_seconds (1-45) long-polls until completed/failed and returns the presigned mp4_url.GET /transcriptions/:id/clips/:clipId
getQuotePrice a job before committing (any of the four sources).POST /quotes
searchKeyword search over titles and transcript text in your library; returns {id, title, url}[].(MCP only)
fetchOne transcription as plain text (sections, else timestamped speaker lines; 40k-char cap) with metadata.(MCP only)
askTranscriptionLive cited answer to a question about one completed transcription (about 10 s). Consumes the daily Q&A allowance shared with the app (not the wallet); no_answer is free; check getTranscriptionResult include=["qna"] first for cached pairs.POST /transcriptions/:id/ask
askLibraryLive cited answer across the whole library or transcription_ids (max 50). Same allowance.POST /ask
transcribeStart a job: youtube, platform_url, external_url, or upload (upload_id + duration_seconds). Charges the wallet.POST /transcriptions
createUploadPresigned PUT URL + upload_id for a local file (15 min TTL); then transcribe with source=upload.POST /uploads
renderClipHosted captioned MP4 clip of a 1-60 s range of a completed transcription (aspect, style, title, callback_url). Charges the wallet $0.05 per started 60 s.POST /transcriptions/:id/clips
regeneratePostingChaptersRe-run the chapter curation with an optional refine_prompt.POST /transcriptions/:id/timestamps/regenerate
retryTranscriptionRestart a failed job.POST /transcriptions/:id/retry
deleteTranscriptionDelete a transcription (destructive). Disappears from every listing immediately; purged permanently after a short retention window. No API undo.DELETE /transcriptions/:id

Every tool declares MCP annotations (title, readOnlyHint, destructiveHint) and returns one JSON text block plus structuredContent. Errors use the same { error: { code, message, request_id, doc_url } } envelope as REST. Registered webhooks, realtime sessions, tus uploads and addons are REST-only; the transcribe tool accepts callback_url for per-job notifications.

OAuth details

  • Authorization server metadata: /.well-known/oauth-authorization-server (authorization code + PKCE S256, refresh tokens, dynamic client registration).
  • Protected resource metadata: /.well-known/oauth-protected-resource (resource https://transcribe.so/mcp). Unauthenticated /mcp calls return 401 with a WWW-Authenticate header pointing there.
  • OAuth tokens are rate-limited per user rather than per key and cannot manage webhooks. Plain-text guide for agents: /auth.md.

Remotion captions

@transcribe-so/remotion (in the repo at packages/remotion/, private for now, zero runtime deps) wraps /words and /subtitles for Remotion projects: wordsToCaptions() maps the full word list (all: true) to @remotion/captions Caption[] (one caption per word, leading space on every token except the first, timestampMs = start_ms), chaptersToSequences() turns chapters into <Sequence> props, and useTranscribeWords() is a delayRender-aware hook for the case where the fetch has to happen inside the composition (returns status, words, captions, available, reason, error).

typescript
// Root.tsx: fetch outside the composition, pass words in as props
import { createClient } from '@transcribe-so/remotion'

export const calculateMetadata = async ({ props }) => {
  const client = createClient({ apiKey: process.env.TRANSCRIBE_API_KEY! })
  const { words } = await client.getWords(props.id, { all: true })
  return { props: { ...props, words } }
}

// TikTokCaptions.tsx: no fetch, no process.env
import { createTikTokStyleCaptions } from '@remotion/captions'
import { frameToMs, wordsToCaptions } from '@transcribe-so/remotion'
import type { TranscribeWord } from '@transcribe-so/remotion'
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from 'remotion'
import { useMemo } from 'react'

export const TikTokCaptions: React.FC<{ id: number; words: TranscribeWord[] }> = ({ words }) => {
  const nowMs = frameToMs(useCurrentFrame(), useVideoConfig().fps)
  const { pages } = useMemo(
    () => createTikTokStyleCaptions({ captions: wordsToCaptions(words), combineTokensWithinMilliseconds: 1200 }),
    [words],
  )
  const page = pages.find((p) => nowMs >= p.startMs && nowMs < p.startMs + p.durationMs)
  return (
    <AbsoluteFill style={{ justifyContent: 'flex-end', alignItems: 'center', paddingBottom: 120 }}>
      <div style={{ fontSize: 64, color: 'white' }}>{page?.text}</div>
    </AbsoluteFill>
  )
}

Word timings exist only for word-timestamp pipelines: check available on /words first. Fetch in a script or calculateMetadata and pass words via inputProps; never read the API key from process.env inside a composition or bundle it into a public one. Full README, karaoke and chapters snippets: packages/remotion/README.md; a fetch-to-render recipe is in the cookbook.

Common failure modes

symptomcausefix
401 unauthenticated on every callMissing Authorization header.Add -H 'Authorization: Bearer $KEY'.
401 invalid_api_keyKey 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 403Presigned URL expired (900s).Re-call POST /uploads, PUT promptly.
402 insufficient_fundsWallet < estimated charge.Top up via the dashboard.
429 rate_limitedExceeded 60 req/min on this key, or Pay as you go fair-use minutes exhausted (check error.message).Request rate: back off 60s. Fair-use: wait for the 6h window to roll or upgrade.

Ready to ship?

Create a key, paste it into your script, and you're transcribing inside a minute.