Quick start
One curl call from a YouTube URL to a queued transcription:
curl -X POST https://transcribe.so/api/v1/transcriptions \
-H "Authorization: Bearer tsk_live_REPLACE_ME" \
-H "Content-Type: application/json" \
-d '{
"source": "youtube",
"url": "https://youtu.be/dQw4w9WgXcQ"
}'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
- Sign in and visit /settings/api-keys.
- Click Create key, give it a name (e.g.
n8n-prod). - Copy the plaintext immediately — we show it once and never again. The server only stores
sha256(key).
Smoke test
curl -sS https://transcribe.so/api/v1/me \
-H "Authorization: Bearer $TRANSCRIBE_API_KEY"Returns the authenticated user, current wallet balance, and 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:
| Plan | Concurrent jobs | Max file length | Fair-use minutes / rolling 6h |
|---|---|---|---|
| Pay as you go | — | 90 min | 60 |
| Starter | 3 | 120 min | 120 |
| Pro | 5 | 5 h | 360 |
| Business | 15 | 10 h | 1,200 |
| Enterprise | 50+ | 12 h | Custom |
- 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; theerror.messagesays 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.
| source | when to use | duration_seconds |
|---|---|---|
youtube | Public YouTube URL. | Not needed — we probe the video. |
platform_url | Public page URL on a supported platform (Apple Podcasts, SoundCloud, Vimeo, Twitch, Loom and similar). | Not needed — we probe the page. |
external_url | Direct audio/video URL on a public host. | Optional. Pass when known to skip a probe round-trip. |
upload | File on your machine; no public URL. | Required. S3 isn't probed from the API. |
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
/api/v1/meThe authenticated user, wallet, tier, effective plan limits, and a self-discovering links map.
200 response
{
"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.
/api/v1/pipelinesThe 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
{
"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.
/api/v1/uploadsStep 1 of the upload flow. Returns a short-lived presigned S3 PUT URL.
Body
{
"filename": "podcast.mp3",
"content_type": "audio/mpeg",
"file_size": 8421120
}- Allowed
content_type(fixed allowlist, anything else is400 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
{
"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.
/api/v1/uploads/tusResumable 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
{
"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_typeneeded at this step. The worker sniffs the file when it processes.
200 response
{
"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.
/api/v1/transcriptions→ 202Submit a transcription. Four source modes; same dance the dashboard does.
Body — youtube
{
"source": "youtube",
"url": "https://youtu.be/dQw4w9WgXcQ",
"language": "auto"
}Body — platform_url
{
"source": "platform_url",
"url": "https://vimeo.com/123456789",
"language": "auto"
}Body — external_url
{
"source": "external_url",
"url": "https://example.com/podcast.mp3",
"language": "auto",
"duration_seconds": 1234
}Body — upload
{
"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
{
"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).
/api/v1/transcriptionsList your transcriptions, newest first. Cursor-paginated.
Query
limit— 1–200, default 50cursor— ISO timestamp of the last item from the previous pageapi_only=true— filter to API-originated jobs
/api/v1/transcriptions/:idSingle transcription metadata + status.
/api/v1/transcriptions/:id/resultTranscription metadata plus the analysis you ask for. Only meaningful once status === completed.
Query
include— comma-separated list ofchapters,acts,sections,qna,segments,posting_chapters, orall. Default:chapters,sections,qna. Segments (the speaker-labelled, timestamped transcript lines) are not in the default set; ask forinclude=segmentsorinclude=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)
{
"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.
/api/v1/transcriptions/:id/waitLong-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.
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.
/api/v1/transcriptions/:id/timestampsPaste-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.
curl -sS -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
"https://transcribe.so/api/v1/transcriptions/4821/timestamps?format=spotify&variant=standard"{
"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 ofyoutube(default),spotify,apple_podcasts,markdown,x,threads,instagram_caption,plain.variant— which chapter set. One ofstandard(default),highlights,clips,quoted_sections,show_notes,original.style— deprecated. Accepted for back-compat and ignored; the response always reportsbalanced.
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 addsthread: string[]with one entry per post.instagram_caption— caption-length text with the platform's character budget.plain— baremm:ss titlelines, 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 offormat.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):
{
"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"
}
}/api/v1/transcriptions/:id/timestamps/regenerate→ 200Re-runs the LLM curate+polish step. Use to apply a refine prompt, or to generate posting_chapters for a transcription that predates this feature.
{
"refine_prompt": "focus on the case studies"
}Body
style— deprecated. Accepted for back-compat but coerced tobalanced.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.
/api/v1/transcriptions/:id/wordsPaginated 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 untilhas_moreis false;countis the total.
curl -sS -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
"https://transcribe.so/api/v1/transcriptions/4821/words?offset=0&limit=2000"{
"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).
/api/v1/transcriptions/:id/subtitlesA 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 usemode=wordSRT or the/wordsJSON.preset: line-length / cues-per-second rules:youtube(default),tiktok-shorts,instagram-reels,netflix,podcast,broadcast.customis 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; 400invalid_requestwhen the transcription has no word timestamps),sentence. Whensource=materializedwins,modeis reported (from the stored cues), not honored.source:auto(default),generated,materialized(your edited cues from the subtitle editor; 404 when there are none).
curl -sS -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
"https://transcribe.so/api/v1/transcriptions/4821/subtitles?format=vtt&preset=tiktok-shorts" \
-o captions.vttPrecedence 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.
/api/v1/transcriptions/:id/clipsRender 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 becompletedand have word timestamps in the range (see /words); good candidates come from/timestamps?variant=clipsorquoted_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): receivesclip.completed/clip.failedas a signed webhook; the 202 returnscallback_secret. Same rules as the transcription callback_url.
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.
/api/v1/transcriptions/:id/askAsk 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).
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{
"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).
/api/v1/askAsk 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.
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{
"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).
/api/v1/transcriptions/:idPermanently deletes the transcription, derived rows, and S3 objects. Returns { id, deleted: true }.
/api/v1/transcriptions/:id/retry→ 202Restart a failed job. Charges run again from scratch. Returns { id, status: 'processing', stage: 'queued', billed_minutes, retail_usd }.
/api/v1/quotesPreview 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[].
/api/v1/openapi.yamlThe 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.
curl -sS https://transcribe.so/api/v1/openapi.yaml | head -40Machine-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:
{
"error": {
"code": "insufficient_funds",
"message": "Wallet balance too low. Top up your wallet at https://transcribe.so/billing.",
"request_id": "req_a1b2c3d4e5f6",
"doc_url": "https://transcribe.so/billing"
}
}messageinlines an actionable URL where one applies. Terminal users see the link without parsing JSON.doc_urlalways points at a stable docs section or dashboard surface for that error.request_idis also returned asX-Request-Idon every response — quote it in support tickets.
| code | HTTP | when |
|---|---|---|
unauthenticated | 401 | Missing Authorization header, or an OAuth bearer token that failed verification (expired, bad signature). |
invalid_api_key | 401 | Key malformed, unknown, revoked, or expired. |
forbidden | 403 | The token type can't use this endpoint (webhook endpoints require an API key; OAuth tokens are rejected). |
entitlement_required | 403 | Your plan doesn't include this capability. |
scope_forbidden | 403 | The 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_found | 404 | Resource doesn't exist or isn't yours. |
not_ready | 409 | GET /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_request | 400 | Body / query / path parameter is missing or malformed. |
unsupported_pipeline | 400 | pipeline_code isn't recognized or isn't available to this key. |
unsupported_language | 400 | language isn't in the pipeline's supported list (or, for realtime, not in the realtime set). |
addon_not_supported | 400 | An addons entry isn't compatible with the pipeline. Diarization is built in; omit addons. |
insufficient_funds | 402 | Wallet can't cover the estimated charge (Pay as you go). |
spend_cap_exceeded | 402 | The 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_limited | 429 | Per-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_exceeded | 429 | POST /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_error | 500 | Server bug; safe to retry with backoff. Quote request_id. |
Retry guidance
- 429: honour the
retry-afterheader (seconds); if absent, back off 60s. - 500: exponential backoff (1, 2, 4, 8s, max 60s), cap at 5 attempts. Use the same
Idempotency-Keyso duplicates don't bill twice. - 402: do not retry until the user tops up.
- 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.
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:
- Webhook — best for fire-and-forget pipelines. Register one URL per API key, or pass
callback_urlon a singlePOST /transcriptions; we deliver an HMAC-signedtranscription.completedPOST when the job hits a terminal state. No connections held open, no rate-limit pressure, scales to any volume. Setup → - Long-poll
/wait— best for synchronous "create-and-wait" flows where you can hold one HTTP connection. Server holds the response open up totimeoutseconds (max 45) and returns as soon as the job finishes. One request per job.bashcurl -H "Authorization: Bearer $TRANSCRIBE_API_KEY" \ "https://transcribe.so/api/v1/transcriptions/4821/wait?timeout=30&include=chapters,sections,qna" - Email notification — falls out automatically. Every user gets an email when their job completes. No code required.
- Loop-polling
GET /transcriptions/:id— don'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 reachedstatus: completed.transcription.failed— your transcription reachedstatus: failed.clip.completed/clip.failed: a clip render created with this key finished;data.clipcarries a fresh presignedmp4_urlon completion.webhook.test— you calledPOST /api/v1/webhooks/test.
Register
curl -X POST https://transcribe.so/api/v1/webhooks \
-H "Authorization: Bearer $TRANSCRIBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/transcribe-so/webhook",
"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
# 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/jsonX-Transcribe-Signature: t=<unix-seconds>,v1=<hex>(see below)X-Transcribe-Event: transcription.completed(ortranscription.failed,clip.completed,clip.failed,webhook.test)User-Agent: transcribe.so-webhook/1
Payload
{
"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).
import { createHmac, timingSafeEqual } from "crypto";
function verify(rawBody: string, header: string, secret: string): boolean {
const m = header.match(/t=(\d+),v1=([0-9a-f]+)/);
if (!m) return false;
const [, t, v1] = m;
// Reject if more than 5 minutes off (replay protection).
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return expected.length === v1.length &&
timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(v1, "utf8"));
}import hmac, hashlib, re, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
m = re.match(r"t=(\d+),v1=([0-9a-f]+)", header)
if not m: return False
t, v1 = m.group(1), m.group(2)
if abs(int(time.time()) - int(t)) > 300: return False
expected = hmac.new(
secret.encode(),
f"{t}.{raw_body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, v1)Retry
We retry any non-2xx (or network failure) at 1m, 5m, 30m, 3h, 12h. Five attempts max, 10s HTTP timeout each. After 5 consecutive failures across deliveries, the webhook itself is auto-disabled — re-enable it from the dashboard once your endpoint is healthy. 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.
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.completedandtranscription.failedare sent.POST /transcriptions/:id/clipstakes acallback_urltoo (eventsclip.completed/clip.failed, secret returned ascallback_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/:idechoescallback_url, never the secret.
Send a test event
curl -X POST https://transcribe.so/api/v1/webhooks/test \
-H "Authorization: Bearer $TRANSCRIBE_API_KEY"Enqueues a synthetic webhook.test delivery — useful to confirm your URL is reachable and signature verification works before any real transcriptions run.
Pricing
Same 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).
| Pipeline | Code | Per minute | Per hour |
|---|---|---|---|
| Standard (timestamps + diarization) + AI AnalysisDefault | standard | $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.
# 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" | jqSame flow in Python:
import os, time, requests
API = "https://transcribe.so/api/v1"
H = {"Authorization": f"Bearer {os.environ['TRANSCRIBE_API_KEY']}"}
with open("podcast.mp3", "rb") as f:
body = f.read()
p = requests.post(f"{API}/uploads", headers=H, json={
"filename": "podcast.mp3",
"content_type": "audio/mpeg",
"file_size": len(body),
}).json()
requests.put(p["upload_url"], data=body, headers={"Content-Type": "audio/mpeg"}).raise_for_status()
job = requests.post(f"{API}/transcriptions",
headers={**H, "Idempotency-Key": "podcast-149"},
json={
"source": "upload",
"upload_id": p["upload_id"],
"original_filename": "podcast.mp3",
"duration_seconds": 60,
},
).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.
/api/v1/realtime/sessions→ 201Start a session. Body { language? } (default auto; must be a realtime-supported code, else 400 unsupported_language).
{
"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.
/api/v1/realtime/sessions/:id/resumeMint 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.
/api/v1/realtime/sessions/:id→ 202Ask 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/mcpto authenticate. - ChatGPT: Settings → Connectors → Developer mode → add
https://transcribe.so/mcp. The read-onlysearchandfetchtools 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.jsonand/.well-known/mcp/server.json.
Tools
| tool | what it does | REST equivalent |
|---|---|---|
getAccount | User, wallet balance, tier, limits, links. | GET /me |
listPipelines | Pipeline catalog with rates and languages. | GET /pipelines |
listTranscriptions | Newest first, cursor-paginated (limit, cursor, api_only). | GET /transcriptions |
getTranscription | Metadata + status for one id. | GET /transcriptions/:id |
getTranscriptionResult | Result with include[] (default chapters, sections, qna); caps 100/100/40/2000. | GET /transcriptions/:id/result |
waitForTranscription | Long-poll up to 100 s (default 90) with optional include[]; adds _timed_out. | GET /transcriptions/:id/wait |
getTranscriptionTimestamps | Paste-ready timestamps: format x variant. | GET /transcriptions/:id/timestamps |
getTranscriptionWords | Paginated word timings in ms with speaker (offset, limit 1-2000 default 1000); available:false + reason when not there. | GET /transcriptions/:id/words |
getSubtitles | Subtitle 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 |
getClip | One rendered clip; wait_seconds (1-45) long-polls until completed/failed and returns the presigned mp4_url. | GET /transcriptions/:id/clips/:clipId |
getQuote | Price a job before committing (any of the four sources). | POST /quotes |
search | Keyword search over titles and transcript text in your library; returns {id, title, url}[]. | (MCP only) |
fetch | One transcription as plain text (sections, else timestamped speaker lines; 40k-char cap) with metadata. | (MCP only) |
askTranscription | Live 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 |
askLibrary | Live cited answer across the whole library or transcription_ids (max 50). Same allowance. | POST /ask |
transcribe | Start a job: youtube, platform_url, external_url, or upload (upload_id + duration_seconds). Charges the wallet. | POST /transcriptions |
createUpload | Presigned PUT URL + upload_id for a local file (15 min TTL); then transcribe with source=upload. | POST /uploads |
renderClip | Hosted 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 |
regeneratePostingChapters | Re-run the chapter curation with an optional refine_prompt. | POST /transcriptions/:id/timestamps/regenerate |
retryTranscription | Restart a failed job. | POST /transcriptions/:id/retry |
deleteTranscription | Permanently 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(resourcehttps://transcribe.so/mcp). Unauthenticated/mcpcalls return 401 with aWWW-Authenticateheader 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).
// 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
| symptom | cause | fix |
|---|---|---|
| 401 unauthenticated on every call | Missing Authorization header. | Add -H 'Authorization: Bearer $KEY'. |
| 401 invalid_api_key | Key revoked, expired, or typo. | Recreate at /settings/api-keys. |
| 400 invalid_request: duration_seconds (>0)… | Forgot duration_seconds on source=upload. | Probe with ffprobe; pass it. |
| S3 PUT 403 | Presigned URL expired (900s). | Re-call POST /uploads, PUT promptly. |
| 402 insufficient_funds | Wallet < estimated charge. | Top up via the dashboard. |
| 429 rate_limited | Exceeded 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.