# Whisper Web Developer API

Base URL: https://whisperweb.dev/v1

Website Unlimited and Developer API use separate billing and records.

## Quickstart

Turn a direct media URL into a transcript and subtitle file.

Use an active Developer account, an API key with read and write permissions, and API credits. Website Unlimited and Developer API use separate billing and records.

Set WHISPERWEB_API_KEY on your server. Replace the example URL with a direct audio or video file you are authorized to process. Python uses requests; Node.js uses built-in fetch. No Whisper Web SDK is required.

Each example prints a fresh Idempotency-Key before its first POST. Save it. Before rerunning after an uncertain POST response, set WHISPERWEB_REQUEST_ID to that printed value and keep the same request body.

The examples stop waiting after about ten minutes; they do not cancel the service job. Keep the task ID and poll it later. If a create request has an uncertain response, retry with its original Idempotency-Key and body.

1. **Create** — POST /transcriptions returns 202 for a new task. Save its id; media validation happens next.
2. **Wait** — GET /transcriptions/{id} at least five seconds apart until completed or failed. A webhook can notify you instead.
3. **Download** — After completion, GET /transcriptions/{id}/result for JSON, text or subtitles.

### cURL · create, poll and download

```
# Requires a POSIX shell, curl 7.76+, jq and uuidgen.
set -eu
: "${WHISPERWEB_API_KEY:?Set WHISPERWEB_API_KEY first}"
BASE="https://whisperweb.dev/v1"
# Save the printed key. Set WHISPERWEB_REQUEST_ID to it before retrying an uncertain POST.
REQUEST_ID="${WHISPERWEB_REQUEST_ID:-$(uuidgen)}"
printf 'Idempotency-Key: %s\n' "$REQUEST_ID"
api() {
  path="$1"; shift
  curl --silent --show-error --fail-with-body --max-time 30 \
    -H "Authorization: Bearer $WHISPERWEB_API_KEY" "$BASE$path" "$@"
}

# 1. Create a task. Replace the direct media URL below.
job=$(api /transcriptions -X POST \
  -H "Idempotency-Key: $REQUEST_ID" \
  -H "Content-Type: application/json" \
  -d '{"source":{"audio_url":"https://example.com/recording.mp3"},"task":"transcribe","language":"auto"}')
id=$(printf '%s' "$job" | jq -er '.id')
printf 'Task: %s\n' "$id"

# 2. Poll for up to ten minutes. This only limits this script's waiting.
deadline=$(($(date +%s) + 600))
while :; do
  status=$(printf '%s' "$job" | jq -er '.status')
  case "$status" in
    completed) break ;;
    failed) printf '%s' "$job" | jq '.error' >&2; exit 1 ;;
    validating|queued|processing) ;;
    *) printf 'Unexpected task status: %s\n' "$status" >&2; exit 1 ;;
  esac
  if [ "$(date +%s)" -ge "$deadline" ]; then
    printf 'Stopped waiting. Poll GET /transcriptions/%s later; do not create another task.\n' "$id" >&2
    exit 1
  fi
  sleep 5
  job=$(api "/transcriptions/$id")
done

# 3. Read the JSON result, then save subtitles.
api "/transcriptions/$id/result?format=json" | jq .
api "/transcriptions/$id/result?format=srt" -o transcript.srt
```

## Authentication

Send a Bearer API key with each API request.

Create a named key in Developer Console → API keys. The full value is shown once. Keep it in server-side secret storage; never put it in frontend code, URLs or public repositories.

Keys belong to one Developer account. Expired or revoked keys return 401; a suspended account returns 403. Create a replacement before revoking a key used by your application.

| Scope | Required for |
| --- | --- |
| transcriptions:write | All /uploads operations, including GET; POST /transcriptions |
| transcriptions:read | List tasks, read status and download results |
| transcriptions:delete | DELETE /transcriptions/{id}; not selected by default |
| usage:read | GET /usage |

### Check your API balance

```
curl --fail-with-body --silent --show-error "https://whisperweb.dev/v1/usage" \
  -H "Authorization: Bearer $WHISPERWEB_API_KEY"
```

## Files and public URLs

Choose a local file upload or a publicly reachable media URL.

For a local file, follow the four steps below. Completing an upload checks its stored size; decoding and duration checks happen after you create the task. A successful PUT alone does not start transcription.

For a remote file, skip the upload steps and send source.audio_url to POST /transcriptions. Use a direct HTTP or HTTPS media URL. Web pages, private network addresses and unsafe redirects are rejected. Supply either upload_id or audio_url, never both.

One upload belongs to one logical task. To transcribe it again as a new task, create a new upload. The signed URL is valid for one hour; the upload session expires after 24 hours. Treat signed URLs as temporary credentials.

1. **Create an upload session** — POST /uploads with the file's exact size_bytes and content_type. Save upload.id, upload_url and headers from the 201 response.
2. **Send the bytes** — PUT the raw file bytes to upload_url using the returned headers. Send exactly the declared size; do not send a multipart/form-data body or your API key to storage.
3. **Complete the upload** — POST /uploads/{id}/complete with your Bearer key. A 200 response with upload.status = ready means the bytes are ready to submit.
4. **Create the task** — POST /transcriptions with source.upload_id and an Idempotency-Key. Continue with the same polling and result workflow as the quickstart.

| POST /uploads field | Type | Required | Meaning |
| --- | --- | --- | --- |
| size_bytes | integer | Yes | Exact byte count, from 1 to 5,000,000,000 |
| content_type | string | Yes | Media MIME type, for example audio/mpeg or video/mp4; the server also validates the media |

### 201 Created · example upload response

```
{
  "upload": {
    "id": "upl_example",
    "status": "uploading",
    "size_bytes": 2480128,
    "content_type": "audio/mpeg",
    "expires_at": "2026-11-05T12:00:00Z"
  },
  "upload_url": "https://storage.example.com/temporary-signed-upload",
  "headers": {
    "content-type": "audio/mpeg"
  },
  "method": "PUT",
  "upload_url_expires_in_seconds": 3600
}
```

## Create a transcription

Transcribe speech in its original language or translate audio into English.

Both tasks cost $0.01 per minute, billed by the second. Use language: auto, or a supported source-language code such as en, es, fr, de, ja or zh. English translation always returns English, regardless of the source language.

Send an Idempotency-Key of 8–128 letters, numbers, dots, colons, underscores or hyphens. A new request returns 202 with duplicate: false; an identical retry returns the existing task with 200 and duplicate: true.

The create response contains status and metadata, not transcript text. HTTP 202 does not guarantee media validation or sufficient credits for the measured duration. Check the task until it reaches a terminal state.

| JSON field | Type | Required | Values / default |
| --- | --- | --- | --- |
| source | object | Yes | Exactly one of { upload_id: string } or { audio_url: string } |
| model | string | No | whisperweb-standard |
| task | string | No | transcribe (default) or translate_to_english |
| language | string | No | auto (default) or a supported code from the OpenAPI enum |
| max_cost_credits | integer | No | 1–36,000; maximum audio-second credits you authorize for this task |
| webhook_id | string | No | An endpoint registered in the same Developer account |

### 202 Accepted · example task response

```
{
  "id": "tr_example",
  "status": "validating",
  "model": "whisperweb-standard",
  "task": "transcribe",
  "language": "auto",
  "source_language": null,
  "output_language": null,
  "duration_ms": null,
  "usage": {
    "credits": null,
    "status": "unreserved",
    "price_version": "2026-11-03"
  },
  "error": null,
  "created_at": "2026-11-04T12:00:00Z",
  "completed_at": null,
  "expires_at": "2026-12-04T12:00:00Z",
  "retry_after_ms": 5000,
  "duplicate": false
}
```

## Task status and results

Wait for completion, then retrieve the format your application needs.

GET /transcriptions/{id} reports status, error and usage. While waiting, use retry_after_ms and poll at least five seconds apart. usage.credits can represent a quoted or reserved amount; only usage.status = settled represents the final charge.

GET /transcriptions/{id}/result?format=json returns the shape below. Use format=txt, srt or vtt for a downloadable file. Segment times are milliseconds; these are speech segments, not word-level timings or speaker labels.

GET /transcriptions lists records with items and next_cursor. Pass next_cursor as cursor for the next page; limit is 1–200 (default 20), and status is an optional filter. DELETE /transcriptions/{id} removes a completed or failed result; active tasks return 409. Billing history remains.

| Task status | What it means | What to do |
| --- | --- | --- |
| validating | Downloading or checking the media and its measured cost | Wait; no completed transcript is available |
| queued | Media passed validation and credits are reserved | Continue polling or wait for the webhook |
| processing | Transcription is running | Continue polling or wait for the webhook |
| completed | The result is saved and credits are settled | Fetch /result before expires_at |
| failed | The task could not finish; error explains why | Read error.code before deciding what to submit next |

### 200 OK · example JSON result

```
{
  "text": "Welcome to the interview.",
  "source_language": "en",
  "output_language": "en",
  "duration_ms": 4200,
  "segments": [
    {
      "start_ms": 0,
      "end_ms": 4200,
      "text": "Welcome to the interview."
    }
  ]
}
```

## Credits and billing

$0.01 per minute for transcription and translation into English.

One credit is one audio second. The measured duration is rounded up to the next whole second: 62.3 seconds uses 63 credits. Set max_cost_credits to cap a task's cost; a higher measured cost produces a failed task before transcription starts.

Credits are held before processing, charged after the result is saved, and released after confirmed failure. Status reads, result downloads and webhook delivery have no transcription charge. Retry the same task with its original Idempotency-Key to avoid creating another billable task.

Credit packs are one-time purchases, with no automatic recharge or overage charges. Credits appear after payment confirmation. Website Unlimited does not provide API credits; API purchases do not change your website subscription.

GET /usage returns account-wide balances, pricing, limits, items and next_cursor. Use cursor for older activity; before filters by timestamp and api_key_id filters task activity by key. Reserved and released entries are movements, not extra charges to add to settled usage.

| One-time purchase | Transcription minutes | Audio-second credits |
| --- | --- | --- |
| $10 | 1,000 | 60,000 |
| $25 | 2,500 | 150,000 |
| $100 | 10,000 | 600,000 |

## Errors, limits and retries

Distinguish a rejected HTTP request from a task that fails after acceptance.

An HTTP error returns error.code, message, request_id and retryable. The X-Request-Id response header also identifies the request. For an accepted task, GET can return HTTP 200 with status: failed and an error; this is a task failure, not a failed GET.

After an uncertain create response, send the same Idempotency-Key and identical body. Changing the body under the same key returns 409. Do not create a new task merely because polling or your client times out. Respect Retry-After on 429; fix permission, balance or validation errors before retrying.

Media limits are 5 GB decimal (5,000,000,000 bytes) and 10 hours. Each account can have 20 nonterminal tasks, 2 concurrent executions per processing phase, and 10 retained uploads totaling at most 10 GB.

Request limits are 120 GET requests and 30 write requests per minute, enforced for both the account and each key. GET /usage returns the configured limits.

| HTTP / task state | Examples | Recovery |
| --- | --- | --- |
| 400 | INVALID_REQUEST, INVALID_IDEMPOTENCY_KEY | Check fields, types and the idempotency key |
| 401 / 403 | INVALID_API_KEY, INSUFFICIENT_SCOPE, ACCOUNT_SUSPENDED | Check the key, permissions and account status |
| 402 | INSUFFICIENT_CREDITS | Add API credits before submitting work |
| 404 | NOT_FOUND | Check the ID and Developer account; deleted records are not returned |
| 409 | IDEMPOTENCY_CONFLICT, UPLOAD_NOT_READY, RESULT_NOT_READY, TASK_IN_PROGRESS | Fix the conflict or wait for the required state |
| 410 | UPLOAD_EXPIRED, RESULT_EXPIRED | Provide a new input if the expired output is still needed |
| 413 / 415 / 422 | REQUEST_TOO_LARGE, INVALID_CONTENT_TYPE, UNSUPPORTED_LANGUAGE | Reduce the JSON body, use application/json, or use a supported language code |
| 429 | RATE_LIMITED, UPLOAD_LIMIT, CONCURRENCY_LIMIT | Wait for Retry-After; do not bypass the limit with another key |
| 503 | SERVICE_ERROR | Retry later; reuse the original key and body for an uncertain create |
| 200 + status: failed | COST_LIMIT_EXCEEDED, INSUFFICIENT_CREDITS, INVALID_MEDIA, NO_AUDIO_TRACK, NO_SPEECH, URL_FETCH_FAILED | Inspect error.code; correct the input or cost limit before an intentional new task |

### 402 · example HTTP error response

```
{
  "error": {
    "code": "INSUFFICIENT_CREDITS",
    "message": "Add API credits before starting this request.",
    "request_id": "req_example",
    "requestId": "req_example",
    "retryable": false
  }
}
```

## Webhooks

Receive a signed event when a task completes or fails.

Webhooks are optional; polling remains available. Events carry the task ID and status, not transcript content. Retrieve the result with your API key after a completion event.

Verify the untouched request body with the signing secret and Standard Webhooks headers. The Node.js standardwebhooks package provides Webhook.verify. Verification must happen before parsing or re-serializing the body; reject invalid signatures and stale timestamps.

Return 2xx after durably accepting the event. Deduplicate by event ID and handle out-of-order delivery. Automatic delivery retries are bounded to 10 attempts within a 24-hour window. A manual retry uses the same event ID and starts a new retry window. Delivery failure does not undo the result or charge again.

1. **Register** — Add a public HTTPS endpoint in Console → Webhooks and save the signing secret shown once.
2. **Attach** — Include that endpoint's webhook_id in POST /transcriptions.
3. **Verify and accept** — Check the signature on the raw body, deduplicate the event, then acknowledge it with 2xx.
4. **Fetch or investigate** — Download the completed result, or inspect the task error. Use Console delivery history to diagnose and retry failed delivery.

| Header | Purpose |
| --- | --- |
| webhook-id | Stable event ID; matches the id in the event body |
| webhook-timestamp | Unix timestamp used in signature verification |
| webhook-signature | Standard Webhooks signature over the original body |

### Example event · transcription.completed or transcription.failed

```
{
  "id": "evt_example",
  "type": "transcription.completed",
  "created_at": "2026-11-04T12:00:00Z",
  "data": {
    "id": "tr_example",
    "status": "completed"
  }
}
```

## AI agents (MCP)

Use Whisper Web from Claude, ChatGPT, Claude Code, Codex, Cursor and other MCP clients.

The remote MCP server at https://whisperweb.dev/mcp exposes the Developer API to AI agents over Streamable HTTP. Add the URL to your client and sign in with your Whisper Web account; OAuth discovery, PKCE and client registration are automatic, so there is no API key to copy.

MCP uses the same Developer account, credits and records as the REST API: $0.01 per audio minute, billed per second. MCP is available to Developer accounts that have bought API credits; uploads and transcription also need a positive balance. Status checks and reading results do not use credits. Agents are instructed to confirm before spending credits.

Agents can transcribe direct media URLs, single YouTube, Facebook and X (Twitter) video links, files uploaded through a signed URL (create_upload, then PUT with curl, then transcribe_upload), and files picked in the in-chat uploader on clients that support MCP Apps. Only transcribe media you own or are authorized to use.

Deleting transcriptions is a separate permission that is not granted by default. Disconnect any agent in Developer Console → AI agents (MCP); its next call is refused.

| Tool | What it does | Credits |
| --- | --- | --- |
| get_account | Balance, pricing and limits | Free |
| estimate_cost | Quote for an audio length | Free |
| transcribe_url | Start a task from a media URL or a YouTube / Facebook / X video link | Per audio second |
| create_upload | Signed PUT URL for a local file | Free |
| transcribe_upload | Start a task from a finished upload | Per audio second |
| start_upload | In-chat file uploader (MCP Apps) | Per audio second when started |
| get_transcription | Task status, duration and cost | Free |
| get_transcript | Text, timestamped segments, SRT, VTT or JSON, paged | Free |
| list_transcriptions | Recent tasks | Free |
| delete_transcription | Delete a finished result (separate permission) | Free |

### Connect a client

```
# Claude Code
claude mcp add --transport http whisperweb https://whisperweb.dev/mcp

# Codex
codex mcp add whisperweb --url https://whisperweb.dev/mcp
codex mcp login whisperweb

# Cursor and other clients (mcp.json)
{ "mcpServers": { "whisperweb": { "url": "https://whisperweb.dev/mcp" } } }

# Claude: Settings → Connectors → Add custom connector → https://whisperweb.dev/mcp
# ChatGPT: Settings → Apps → Advanced settings → Developer mode → Create app → https://whisperweb.dev/mcp
```

## Data and retention

API data is cloud-processed and separate from your website recordings.

API keys can access only their own Developer account. Your website recordings and Unlimited benefits remain separate. Keep API keys, webhook secrets and signed storage URLs private.

An unused upload session expires after 24 hours. Its signed PUT URL expires sooner, as shown by upload_url_expires_in_seconds. Temporary source media is cleaned up after a confirmed terminal state; a new transcription requires a new input.

Task results expire 30 days after task creation, not completion. Download before expires_at. A result that is not ready returns 409; an expired result returns 410. Delete a completed or failed task early with a key that has deletion permission.

Request logs exclude media, transcript text, full source URLs and credentials. Deleting a transcript does not erase payment or usage history. Console logs, purchases and webhook deliveries show up to 100 recent records; their filters apply to the loaded records.

## API reference

| Method | Path | Purpose |
| --- | --- | --- |
| POST | /uploads | Create a private signed upload session. |
| POST | /uploads/{id}/complete | Verify the stored bytes and mark the upload ready. |
| GET | /uploads/{id} | Read upload status and expiry. |
| POST | /transcriptions | Create a task, or recover an identical idempotent request. |
| GET | /transcriptions | List API tasks with cursor pagination. |
| GET | /transcriptions/{id} | Read task status, usage and errors. |
| GET | /transcriptions/{id}/result | Retrieve JSON, TXT, SRT or VTT. |
| DELETE | /transcriptions/{id} | Delete a completed or failed task's result. |
| GET | /usage | Read balances, limits and paginated credit activity. |

OpenAPI: https://whisperweb.dev/api/developer/openapi

## Python quickstart

Install requests with `pip install requests`.

```python
import os
import time
import uuid
import requests

base = "https://whisperweb.dev/v1"
headers = {"Authorization": f"Bearer {os.environ['WHISPERWEB_API_KEY']}"}

def request(method, path, **options):
    extra_headers = options.pop("headers", {})
    response = requests.request(method, base + path,
        headers={**headers, **extra_headers}, timeout=30, **options)
    try:
        body = response.json()
    except ValueError:
        raise RuntimeError(f"HTTP {response.status_code}: expected a JSON response") from None
    if not response.ok:
        error = body.get("error", {})
        retry_after = response.headers.get("Retry-After")
        raise RuntimeError(f"HTTP {response.status_code} {error.get('code', '')}: "
            f"{error.get('message', 'Request failed')} "
            f"(request {error.get('request_id', 'unknown')}, Retry-After {retry_after})")
    return body

# Save this value before submitting. Set it again to retry an uncertain POST.
request_id = os.environ.get("WHISPERWEB_REQUEST_ID") or str(uuid.uuid4())
print("Idempotency-Key:", request_id)
job = request("POST", "/transcriptions",
    headers={"Idempotency-Key": request_id},
    json={"source": {"audio_url": "https://example.com/recording.mp3"},
          "task": "transcribe", "language": "auto"})
print("Task:", job["id"])
deadline = time.monotonic() + 600
while job["status"] in ("validating", "queued", "processing"):
    if time.monotonic() >= deadline:
        raise TimeoutError(f"Stopped waiting for {job['id']}. Poll this task later; do not create another one.")
    time.sleep(min(30, max(5, job.get("retry_after_ms", 5000) / 1000)))
    job = request("GET", f"/transcriptions/{job['id']}")
if job["status"] != "completed":
    raise RuntimeError(job.get("error") or "Transcription failed")
result = request("GET", f"/transcriptions/{job['id']}/result?format=json")
print(result["text"])
```

## Node.js quickstart

Run with a Node.js version that supports fetch and AbortSignal.timeout.

```javascript
import { randomUUID } from "node:crypto";

const base = "https://whisperweb.dev/v1";
if (!process.env.WHISPERWEB_API_KEY) throw new Error("Set WHISPERWEB_API_KEY first");
const headers = { Authorization: "Bearer " + process.env.WHISPERWEB_API_KEY };
async function request(path, options = {}) {
  const response = await fetch(base + path, {
    ...options,
    headers: { ...headers, ...options.headers },
    signal: AbortSignal.timeout(30_000),
  });
  let body;
  try { body = await response.json(); }
  catch { throw new Error("HTTP " + response.status + ": expected a JSON response"); }
  if (!response.ok) {
    const error = body.error ?? {};
    throw new Error("HTTP " + response.status + " " + (error.code ?? "") + ": " +
      (error.message ?? "Request failed") + " (request " + (error.request_id ?? "unknown") +
      ", Retry-After " + response.headers.get("Retry-After") + ")");
  }
  return body;
}

// Save this value before submitting. Set it again to retry an uncertain POST.
const requestId = process.env.WHISPERWEB_REQUEST_ID || randomUUID();
console.log("Idempotency-Key:", requestId);
let job = await request("/transcriptions", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Idempotency-Key": requestId },
  body: JSON.stringify({ source: { audio_url: "https://example.com/recording.mp3" },
    task: "transcribe", language: "auto" }),
});
console.log("Task:", job.id);
const deadline = Date.now() + 600_000;
while (["validating", "queued", "processing"].includes(job.status)) {
  if (Date.now() >= deadline) {
    throw new Error("Stopped waiting for " + job.id + ". Poll this task later; do not create another one.");
  }
  await new Promise(resolve => setTimeout(resolve, Math.min(30_000, Math.max(5000, job.retry_after_ms ?? 5000))));
  job = await request("/transcriptions/" + job.id);
}
if (job.status !== "completed") throw new Error(job.error?.message ?? "Transcription failed");
const result = await request("/transcriptions/" + job.id + "/result?format=json");
console.log(result.text);
```