Audio and video to text, from your code
Send a link or a file. Get back a timestamped transcript as JSON, TXT, SRT, VTT or DOCX, plus a summary with key quotes and named speakers. It's the same service the website runs, in 99+ languages.
- REST + JSON
- One header: X-API-Key
- MCP connector
- Files up to 10 hours
- Credits never expire
From a link to subtitles
- Get a key. Sign in and get API access: it comes with the 5,000-minute pack ($24, credits never expire) or the Workspace plan ($19/month). Then create a key at whipscribe.com/apis/keys. It's shown once, so put it in an environment variable straight away.
- Send a link.
POST /v1/transcribe/urlreturns ajob_idstraight away. - Wait, then fetch. Poll
GET /v1/jobs/{job_id}every few seconds untilstatusisdone, then download?format=srt.
export WHIPSCRIBE_API_KEY="paste-your-key" # 1. Send a link (any public video, podcast episode or media file) curl -s https://whipscribe.com/api/v1/transcribe/url \ -H "X-API-Key: $WHIPSCRIBE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://www.archive.org/download/testmp3testfile/mpthreetest.mp3"}' # → {"job_id": "35f4be54-aa3e-4adc-85b7-b44f284d1fc3", "status": "queued"} JOB_ID=35f4be54-aa3e-4adc-85b7-b44f284d1fc3 # the job_id you got back # 2. Poll every few seconds until "status" is "done" curl -s https://whipscribe.com/api/v1/jobs/$JOB_ID \ -H "X-API-Key: $WHIPSCRIBE_API_KEY" # 3. Download subtitles (or format=json | txt | vtt | docx) curl -s "https://whipscribe.com/api/v1/jobs/$JOB_ID/result?format=srt" \ -H "X-API-Key: $WHIPSCRIBE_API_KEY" -o transcript.srt
# pip install requests import os, time, requests API = "https://whipscribe.com/api/v1" HEADERS = {"X-API-Key": os.environ["WHIPSCRIBE_API_KEY"]} # 1. Send a link r = requests.post(f"{API}/transcribe/url", headers=HEADERS, json={ "url": "https://www.archive.org/download/testmp3testfile/mpthreetest.mp3", }) r.raise_for_status() job_id = r.json()["job_id"] # 2. Poll until it finishes while True: job = requests.get(f"{API}/jobs/{job_id}", headers=HEADERS).json() if job["status"] in ("done", "failed"): break time.sleep(3) if job["status"] == "failed": raise SystemExit(job["error"]) if job.get("locked"): raise SystemExit(f"Add credit to read it in full: {job['unlock_url']}") # 3. Download subtitles srt = requests.get(f"{API}/jobs/{job_id}/result", headers=HEADERS, params={"format": "srt"}) srt.raise_for_status() open("transcript.srt", "w", encoding="utf-8").write(srt.text)
// Node 18+ (built-in fetch). Save as quickstart.mjs const API = "https://whipscribe.com/api/v1"; const headers = { "X-API-Key": process.env.WHIPSCRIBE_API_KEY }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // 1. Send a link const submit = await fetch(`${API}/transcribe/url`, { method: "POST", headers: { ...headers, "Content-Type": "application/json" }, body: JSON.stringify({ url: "https://www.archive.org/download/testmp3testfile/mpthreetest.mp3" }), }); if (!submit.ok) throw new Error(await submit.text()); const { job_id } = await submit.json(); // 2. Poll until it finishes let job; do { await sleep(3000); job = await (await fetch(`${API}/jobs/${job_id}`, { headers })).json(); } while (job.status === "queued" || job.status === "processing"); if (job.status === "failed") throw new Error(job.error); if (job.locked) throw new Error(`Add credit to read it in full: ${job.unlock_url}`); // 3. Download subtitles const srt = await fetch(`${API}/jobs/${job_id}/result?format=srt`, { headers }); if (!srt.ok) throw new Error(await srt.text()); console.log(await srt.text());
Call the API from your server or script, not from a web page. Browsers are blocked from calling it directly, which also keeps your key out of page source. To play audio in a browser, hand it the short-lived link from the playback endpoint.
Pick the door that suits your stack
MCP connector
Add WhipScribe to Claude, Claude Code or any MCP client that supports remote servers. You sign in once; no key to paste.
OpenAPI spec
OpenAPI 3 description of every endpoint on this page. Import it into Postman, Insomnia or a code generator.
Local MCP server
For Claude Desktop, Cursor or Windsurf configs that run a local command. It reads your key from WHIPSCRIBE_API_KEY.
Plain HTTP
curl, Python requests, Node fetch, n8n or Make HTTP nodes. One header and JSON in, JSON out.
AI agents
Setting this up through an assistant or a coding agent? Sign-up, the link that opens each pack, key creation and the calls, with the two steps that need you.
Postman collection
Fork the collection, set WHIPSCRIBE_API_KEY, run the quickstart requests in order.
Apify Actor
Give it a list of links; get transcripts back as a dataset you can export or chain.
What people build with it
These come from the projects developers have actually shipped on the API: call scorecards and CRM notes, meeting digests, subtitle pipelines, Drive and folder importers, and assistants that transcribe inside chat.
Calls and meetings to structured notes
most builtUpload the recording, wait for it to finish, then read two things: the summary (topics, key quotes, who said what) and the timestamped segments. Hand both to your own model or template to produce a scorecard, a CRM note, a decision log or a list of action items, and keep the timestamps so every claim links back to the moment it was said.
# Upload a recording (multipart) curl -s https://whipscribe.com/api/v1/transcribe \ -H "X-API-Key: $WHIPSCRIBE_API_KEY" \ -H "Idempotency-Key: call-2026-10-04-acme" \ -F "file=@sales-call.m4a" \ -F "language=auto" \ -F "word_timestamps=true" # …poll GET /v1/jobs/$JOB_ID until done, then: curl -s https://whipscribe.com/api/v1/jobs/$JOB_ID/insights -H "X-API-Key: $WHIPSCRIBE_API_KEY" curl -s "https://whipscribe.com/api/v1/jobs/$JOB_ID/result?format=json" -H "X-API-Key: $WHIPSCRIBE_API_KEY"
The summary is written the first time you ask for it, so the first /insights call on a job can take a little longer; later calls return straight away.
Video and podcast links to subtitles
Send the page link (YouTube, Vimeo, an Apple Podcasts episode, an RSS feed, a Google Drive share link) or a direct media URL. When it's done, ask for format=srt or format=vtt and drop the file next to your video. Use format=txt for show notes and blog drafts.
curl -s https://whipscribe.com/api/v1/transcribe/url \ -H "X-API-Key: $WHIPSCRIBE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://www.youtube.com/watch?v=VIDEO_ID", "language": "auto"}' # when done: curl -s "https://whipscribe.com/api/v1/jobs/$JOB_ID/result?format=vtt" \ -H "X-API-Key: $WHIPSCRIBE_API_KEY" -o captions.vtt
A link that is private, removed or behind a sign-in is refused up front with a clear message and nothing is queued. Download the file and upload it instead. See links we can fetch.
A folder of recordings
Loop over the files, submit each one with an Idempotency-Key made from the file's name or hash, and poll the list endpoint rather than every job. If your script crashes halfway, run it again: files already sent return their original job instead of being transcribed twice.
import hashlib, os, pathlib, time, requests API = "https://whipscribe.com/api/v1" HEADERS = {"X-API-Key": os.environ["WHIPSCRIBE_API_KEY"]} folder = pathlib.Path("recordings") out = pathlib.Path("transcripts"); out.mkdir(exist_ok=True) jobs = {} for path in sorted(folder.glob("*.mp3")): key = "batch-" + hashlib.sha256(path.read_bytes()).hexdigest()[:32] with path.open("rb") as f: r = requests.post(f"{API}/transcribe", headers={**HEADERS, "Idempotency-Key": key}, files={"file": (path.name, f)}, data={"language": "auto"}) r.raise_for_status() jobs[r.json()["job_id"]] = path while jobs: time.sleep(10) for job_id, path in list(jobs.items()): job = requests.get(f"{API}/jobs/{job_id}", headers=HEADERS).json() if job["status"] == "done": txt = requests.get(f"{API}/jobs/{job_id}/result", headers=HEADERS, params={"format": "txt"}) (out / (path.stem + ".txt")).write_text(txt.text, encoding="utf-8") del jobs[job_id] elif job["status"] == "failed": print(path.name, job["error"]); del jobs[job_id]
An account allows 60 requests a minute across its keys, so a slow poll (every 10 seconds or more) leaves plenty of room for big folders. No code? The Library does the same from the browser, folder for folder.
Large files
For long recordings, skip the multipart form: ask for an upload link, send the bytes straight to it, then commit. It's faster and copes better with slow connections.
path = pathlib.Path("town-hall.mp4") # 1. Ask for an upload ticket ticket = requests.post(f"{API}/uploads/init", headers=HEADERS, json={ "filename": path.name, "content_type": "video/mp4", "size_bytes": path.stat().st_size, "language": "auto", }).json() # 2. Send the bytes exactly as the ticket says (method + headers) with path.open("rb") as f: requests.request(ticket["method"], ticket["upload_url"], headers=ticket.get("headers", {}), data=f).raise_for_status() # 3. Commit: echo object_key and options back unchanged job = requests.post(f"{API}/uploads/{ticket['job_id']}/commit", headers=HEADERS, json={ "object_key": ticket["object_key"], "options": ticket["options"], "original_filename": path.name, }).json() # → {"job_id": …, "status": "queued"}; poll as usual
The upload link expires after expires_in seconds; ask for a new ticket if you miss it.
Transcript beside the audio
For a player where clicking a line jumps to that moment, fetch format=json (each segment has start and end in seconds) and a playback link. The link is short-lived and carries no key, so it's safe to give to the browser; fetch a fresh one when it expires.
// server: GET /v1/jobs/{id}/audio/url → { "url": "https://…", "expires_in": 3600 } const { url } = await (await fetch(`${API}/jobs/${jobId}/audio/url`, { headers })).json(); // page: one line per segment; click to seek audio.src = url; for (const seg of transcript.segments) { const line = document.createElement("p"); line.textContent = seg.text; line.onclick = () => { audio.currentTime = seg.start; audio.play(); }; list.append(line); }
Inside Claude and other MCP clients
The MCP connector gives an assistant tools to transcribe links and files, read transcripts and summaries, list your recent jobs, and organise your library into folders. Ask in plain words: “Transcribe this episode and give me the five best quotes with timestamps.”
- claude.ai and Claude Desktop: Settings → Connectors → Add custom connector, and paste
https://whipscribe.com/mcp. Sign in to WhipScribe when asked. - Claude Code: run the command below, then
/mcpto sign in. - Clients that run a local command (Cursor, Windsurf, Claude Desktop config): use the
whipscribe-mcppackage with your key inWHIPSCRIBE_API_KEY. - Your own MCP client: connect over Streamable HTTP and either complete the sign-in flow or send
Authorization: Bearer <your API key>. On the MCP endpoint the key goes inAuthorization, notX-API-Key.
claude mcp add --transport http whipscribe https://whipscribe.com/mcp
{
"mcpServers": {
"whipscribe": {
"command": "uvx",
"args": ["whipscribe-mcp"],
"env": { "WHIPSCRIBE_API_KEY": "paste-your-key" }
}
}
}
More on the connector, with example prompts: whipscribe.com/claude.
Base URL and authentication
Every path below sits under this base, so /v1/jobs means https://whipscribe.com/api/v1/jobs. There's no separate API hostname.
Send your key on every request in one header:
The key belongs to your account. Jobs you create with it show up in your WhipScribe library, and usage comes out of the same balance as the website. You can hold up to 10 active keys, for example one per environment. Rotating a key at /apis/keys keeps the old one working for 24 hours so a rolling deploy doesn't break. Treat a key like a password: never put it in a web page or a public repo.
Bodies are JSON (Content-Type: application/json), except file uploads, which use multipart/form-data.
Endpoints
| Call | What it does |
|---|---|
POST /v1/transcribe | Upload a file (multipart, field file) and start a job. |
POST /v1/transcribe/url | Start a job from a link. JSON body with url. |
POST /v1/uploads/init | Get a short-lived upload link for a large file. See large files. |
POST /v1/uploads/{job_id}/commit | Start the job once the bytes are uploaded. |
GET /v1/jobs | Your jobs, newest first. limit (1–100, default 20), offset, status. Returns {"jobs": [...], "count", "total", "limit", "offset"}. |
GET /v1/jobs/{job_id} | Status of one job. Poll every 3 seconds or slower. Supports If-None-Match (304). |
DELETE /v1/jobs/{job_id} | Cancel a queued job, or delete a finished one and its files. Returns 204. Time already used isn't refunded. |
GET /v1/jobs/{job_id}/result | The transcript. format=json (default), txt, srt, vtt or docx. |
GET /v1/jobs/{job_id}/insights | Summary, key quotes, topics and named speakers. |
GET /v1/jobs/{job_id}/audio/url | A short-lived link to stream the original audio. |
GET /v1/me | Who the server thinks you are, and how long your audio is kept. |
GET /v1/credits | Transcription time left on the account behind the key. |
POST /v1/checkout | Get a pay link for a pack or the plan, tied to your account. See pay links. |
GET /v1/checkout/{id} | Whether that link is pending, paid or expired. |
The same list, with every field, is in the OpenAPI spec.
Submit options
The file upload, the link submit and the upload ticket all accept these fields: form fields for /v1/transcribe, JSON for the others.
| Field | Notes |
|---|---|
filerequired on /v1/transcribe | The audio or video file. Most formats work: mp3, m4a, wav, aac, flac, ogg, opus, mp4, mov, webm, mkv and more. Up to 10 hours per file. |
urlrequired on /v1/transcribe/url | A public link to a page or a media file. See links we can fetch. |
languageoptional | An ISO 639-1 code (en, es, hi, …) or auto (default) to detect it. Setting it helps on short or noisy clips. |
word_timestampsoptional | true to get a start and end time for every word in format=json. Default false. |
diarizeoptional | true to ask for speaker separation. Default false. Speakers are identified and named in the summary, key quotes and chapters. Don't rely on a per-line speaker label in the transcript. |
sourceoptional | A label for how the recording was made: upload, url, recording or api. Defaults to upload for files and url for links. |
A submit answers 202 with {"job_id", "status": "queued"}. Keep the job_id; everything else hangs off it.
Links we can fetch
- YouTube, Vimeo and most other public video pages
- Podcast episodes: Apple Podcasts pages, RSS feeds and show websites that link their audio
- Google Drive and OneDrive share links set to “anyone with the link”
- Direct links to audio or video files (
.mp3,.m4a,.wav,.mp4,.m3u8and the rest)
A link that's private, removed, members-only or behind a sign-in can't be fetched. The submit returns 400 with a message saying what to do, usually to upload the file instead. A few YouTube videos ask for a sign-in that we can't complete. For those, upload the file too.
The job object
What GET /v1/jobs/{job_id} returns. New fields may appear over time; ignore the ones you don't use.
{
"job_id": "35f4be54-aa3e-4adc-85b7-b44f284d1fc3",
"status": "done", // queued | processing | done | failed
"filename": "sales-call",
"audio_duration_seconds": 967.4,
"source": "upload",
"speech_detected": true,
"created_at": 1791158901.4, // Unix seconds
"completed_at": 1791158934.4,
"locked": false, // see Billing
"error": null // a readable sentence when status is "failed"
}
statusis the field to branch on.progressis a rough hint for a progress bar, not a promise.lockedis separate fromstatus. A job can bedoneand locked at once. Check it before downloading.- No speech, no charge. Music, noise or silence finishes as
donewithspeech_detected: falseand an empty transcript, and no time is used. Branch on that field, not on the message text. - Poll every 3 seconds or slower, and use
GET /v1/jobs?status=processingwhen you're waiting on many.
Transcript formats
| Format | Use it for |
|---|---|
json | Building on the transcript: full text, segments with start and end times, words when you asked for them. |
txt | Plain text for notes, search, or a prompt. |
srt / vtt | Subtitles and captions for video players and editors. |
docx | A Word document to share or edit. |
{
"text": "Welcome back to the show. Today we're talking about pricing.",
"language": "en",
"duration": 967.4,
"speech_detected": true,
"segments": [
{
"start": 0.0,
"end": 4.2,
"text": "Welcome back to the show.",
"words": [ { "start": 0.0, "end": 0.4, "text": "Welcome" } ] // with word_timestamps=true
}
]
}
Summary and insights
A summary of the recording with its topics, key quotes and the people speaking, identified and named. It's written the first time you ask and stored after that. A job that isn't finished returns 409 NOT_READY; a recording with too little speech to summarise returns 409 NO_SPEECH.
{
"insights": {
"summary": "The team agrees to move the launch to March…",
"topics": [ … ],
"quotes": [ … ],
"speakers": [ … ]
}
}
Read the fields you need and ignore the rest. The object grows as we add to the summary.
Billing and locked transcripts
Usage is measured in minutes of audio and comes out of your account balance, the same one the website uses. API access, meaning the right to create keys, comes with the 5,000-minute pack ($24, credits never expire) or the Workspace plan ($19 a month: unlimited transcription through the site and your own keys, with no daily cap, fair use applies). The $6 and $12 packs work on the website and do not include API keys; once you have API access, any pack tops the balance up. Asking for a key without it answers 402 API_ACCESS_REQUIRED with an upgrade_url. GET /v1/credits tells you what's left.
- Failed jobs and recordings with no speech aren't charged.
- If a job finishes when the balance has run out, it's kept and marked
"locked": truewith anunlock_url. You can still read a free preview:format=jsonreturns the opening part. Other formats and/insightsanswer402 transcript_lockeduntil the account has credit, and then the full transcript opens. - Deleting a finished job doesn't refund the time it used.
Pay links: topping up from a script or an agent
A script or an agent can't type a card, and shouldn't. Ask for a pay link instead: it opens card checkout for your account, a person pays it, and the credit lands on the account that asked, whoever pays.
# 1. Ask for a link (sku: mini-4, pro-monthly, team-monthly or workspace-monthly) curl -s https://whipscribe.com/api/v1/checkout \ -H "X-API-Key: $WHIPSCRIBE_API_KEY" -H "Content-Type: application/json" \ -d '{"sku": "team-monthly"}' # → {"id": "txn_…", "status": "pending", "checkout_url": "https://…", "sku": "team-monthly", # "amount_usd": 24.0, "minutes": 5000, "expires_at": 1791391955.7} # 2. Give checkout_url to the person who pays, then poll until "paid" curl -s https://whipscribe.com/api/v1/checkout/txn_… -H "X-API-Key: $WHIPSCRIBE_API_KEY"
- The packs are one-time purchases:
mini-4(500 minutes, $6),pro-monthly(2,000, $12) andteam-monthly(5,000, $24, includes API access).workspace-monthlyis the $19-a-month plan. An unknownskuanswers400 UNKNOWN_SKUwith the current list insellable. - A link works for 48 hours. Asking again for the same
skuwhile one is open returns the same link; an account can make six new links an hour. - When a request is refused for lack of credit (
402 NO_CREDITS,INSUFFICIENT_CREDITS_FOR_DURATION,transcript_locked,API_ACCESS_REQUIRED) and you called with your own key, the answer already carries acheckout_urlfor the pack that covers it. Hand it over, wait forpaid, and send the same request again.
Idempotency
Send Idempotency-Key: <your string> on POST /v1/transcribe, /v1/transcribe/url or /v1/uploads/init. A retry with the same key (for the same API key) returns the original job with 200 and X-Idempotent-Replay: true instead of starting a second one. Keys are up to 255 characters from A–Z a–z 0–9 _ . : / -; anything else returns 400 BAD_IDEMPOTENCY_KEY. Use one per file or per link, for example a hash of the file.
Limits and retention
| Requests | 60 per minute per account, shared by its keys. Past that you get 429 RATE_LIMITED with a Retry-After header. |
| File length | Up to 10 hours per file. On the Workspace plan, no per-file cap. |
| Keys | Up to 10 active keys per account. |
| At once | Workspace runs up to 10 recordings at once per account; API submits beyond that get 429 CONCURRENCY_LIMIT with Retry-After. |
| Transcripts | Kept until you delete them. |
| Original audio | Kept until you delete it (DELETE /v1/jobs/{job_id}). GET /v1/me reports this as "retention_days": 36500. One exception: media fetched from a Kick VOD link is removed a day after you submit it. Once audio is gone, /audio/url answers 410 and the transcript is still there. |
Need more throughput, signed webhooks or an invoice? Tell us what you're building.
Errors
Errors are JSON with a readable sentence and a stable code: {"error": "…", "code": "NOT_FOUND"}. Branch on code and show error to people. A request missing a required field answers 422 with a detail list instead.
| Status | Code | What to do |
|---|---|---|
| 400 | BAD_ID | The job id isn't a valid UUID. |
| 400 | BAD_URL | That isn't a link. Send the full address starting with https://. |
| 400 | UNSUPPORTED_HOST, SOURCE_GONE | We can't fetch audio from that link, or it's gone. Upload the file instead. |
| 400 | BAD_IDEMPOTENCY_KEY | Fix the Idempotency-Key format (see above). |
| 401 | MISSING_API_KEY, INVALID_API_KEY | Send a valid key in X-API-Key. |
| 400 | UNKNOWN_SKU | Pick a sku from the sellable list in the answer. |
| 402 | transcript_locked | Add credit at unlock_url, then fetch again. |
| 402 | NO_CREDITS, INSUFFICIENT_CREDITS_FOR_DURATION | The balance doesn't cover the request. Pay the checkout_url in the answer (see pay links), then send it again. |
| 404 | NOT_FOUND | No such job on your account. We don't say which, on purpose. |
| 409 | NOT_READY, NO_SPEECH | Wait for done, or there's nothing to summarise. |
| 410 | AUDIO_EXPIRED, AUDIO_MISSING | The original audio is past its retention window or gone. The transcript is still readable. |
| 413 | FILE_TOO_LARGE, FILE_TOO_LONG | Use direct upload, or split the recording. |
| 415 | BAD_MIME, BAD_EXT | That file type isn't audio or video we can read. |
| 422 | BAD_SOURCE | source must be one of the four values above. |
| 429 | RATE_LIMITED | Wait for Retry-After seconds, then retry. |
| 429 | CONCURRENCY_LIMIT | Ten recordings are already running on this Workspace account. Wait for Retry-After seconds, then submit again. |
| 5xx | any | Our side. Retry with backoff; with an Idempotency-Key a retried submit can't double up. |
Versioning
Everything under /api/v1 on this page is the public API. Within v1, changes are additive: new fields and new endpoints may appear, and existing fields and error codes keep their names and types. Anything removed gets notice first. Paths that aren't on this page or in the spec are internal and can change without warning, so don't build on them.
Support
Stuck, found a mismatch between these docs and what the API does, or need something it doesn't do yet? Email contact@whipscribe.com with the job_id if you have one. We usually reply within a day.