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

One curl call from a YouTube URL to a queued transcription:

bash
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"
  }'

You'll get an integer id back. Block on GET /api/v1/transcriptions/<id>/wait?include=all (up to 45 s per call) until status is completed, or fetch /resultlater. Or register a webhook (see below) and we'll POST you when it's done. Prefer tools over HTTP? The same flow is exposed over MCP at https://transcribe.so/mcp (see below).

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: 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 with 402 spend_cap_exceededonce the month's spend would pass the cap (realtime sessions are not counted). 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
Starter3120 min120
Pro55 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.
  • 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, "speaker": "SPEAKER_00", "text": "..." }],
  "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.
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, plain.
  • 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.
  • 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=.

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

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/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.

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.

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: the requested variant hasn't been generated yet; the envelope carries variant + available_styles. POST /timestamps/regenerate fixes it. GET /subtitles: 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.
insufficient_funds402Wallet can't cover the estimated charge (Pay as you go).
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: call POST /timestamps/regenerate, then retry.
  • 400 / 401 / 403 / 404: don't retry; fix the request.

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 (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.

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 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.
  • 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.GET /transcriptions/:id/result
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
deleteTranscriptionPermanently delete a transcription (destructive).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.