# Scriptivox — full documentation

> Every Scriptivox API documentation page, concatenated. This is the companion to
> https://platform.scriptivox.com/llms.txt, which is an index; this file is the content itself, so an
> agent can read the whole surface in one request instead of ten.
>
> Each page is also available on its own: append `.md` to its path, or send
> `Accept: text/markdown` to the HTML URL.

Base URL for the API: `https://api.scriptivox.com/v1`
Machine-readable description: https://platform.scriptivox.com/openapi.json
MCP server manifest: https://platform.scriptivox.com/.well-known/mcp

---
<!-- source: https://platform.scriptivox.com/docs -->
<!-- updated: 2026-08-25 -->

# Scriptivox API

Transcribe audio and video at **$0.20/hour**, billed per second. Speaker diarization, word-level timestamps, and automatic language detection across **119 languages** — all included.

Powered by Whisper. Pay-as-you-go, no minimums, no subscriptions.

{% hero title="Developer quickstart" description="Transcribe audio and video files with word-level timestamps, speaker diarization, and language detection. Build transcription into your product in minutes." ctaLabel="Get started" ctaHref="/docs/quickstart" tabs="quickstartTabs" /%}

## What the API does

Send a recorded audio or video file — by public URL, or by uploading it to a presigned URL we hand you — and get back a structured transcript. Every response carries the full text, an array of utterances with start and end times, and, unless you opt out, a timestamp for every individual word. Speaker labels are added when you ask for diarization. The same transcript can be exported as SRT or WebVTT subtitles, or as plain text, by adding a query parameter to the fetch.

Jobs are asynchronous. `POST /v1/transcribe` accepts the work and returns immediately with `status: "created"`; the file is downloaded and validated afterwards. That ordering matters more than anything else on this page: **a bad URL, an unreadable file or an unsupported codec surfaces on the poll, as `status: "failed"`, not as an error on the submit call.** Clients that only check the response to `POST` will believe every job succeeded.

This is not a streaming or real-time dictation API. Every job takes a complete file, between 1 second and 10 hours long and at most 5 GB.

## Authentication

Every request needs an API key from the [dashboard](/keys), passed as either `Authorization: sk_live_…` (a `Bearer` prefix is accepted) or `X-Api-Key: sk_live_…`. See [Authentication](/docs/authentication) for rotation, the key limit, and what each `401` means.

## Endpoints

Base URL: `https://api.scriptivox.com/v1`

| Method | Path | What it does |
| --- | --- | --- |
| POST | `/v1/upload` | Get a presigned URL for a file you host yourself. |
| POST | `/v1/transcribe` | Start a job from a public URL or a completed upload. |
| GET | `/v1/transcribe/{id}` | Poll status, read the transcript, or export SRT, WebVTT or plain text. |
| DELETE | `/v1/transcribe/{id}` | Soft-delete a finished transcription. |
| POST | `/v1/transcribe/{id}/cancel` | Stop an in-flight job and release its reserved balance. |
| GET | `/v1/transcriptions` | List jobs with filters and cursor pagination. |
| GET | `/v1/balance` | Check remaining balance and estimated audio hours. |

Every operation, with typed parameters and response schemas, is described in the [API reference](/docs/api-reference) and in the OpenAPI 3.1 document at [/openapi.json](/openapi.json) (YAML at [/openapi.yaml](/openapi.yaml)).

## Errors

Errors are always JSON, always the same shape, and always carry a stable machine-readable `code` you can branch on. The `message` is for humans and may be reworded; the `code` will not be.

```json
{
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Add funds to continue transcribing.",
    "docs_url": "https://platform.scriptivox.com/docs/api-reference#error-codes"
  }
}
```

Two failure paths exist, and they need different handling. Synchronous errors — a bad key, a malformed body, no balance — come back on the call you just made. Asynchronous errors come back later on `GET /v1/transcribe/{id}` with `status: "failed"` and a `error` object describing why. The full code list for both is in the [API reference](/docs/api-reference#error-codes).

## Rate limits

Limits are per endpoint and per IP, and every response advertises them, including responses you get before authenticating:

```http
RateLimit-Policy: "endpoint";q=100;w=60, "ip";q=300;w=60
RateLimit: "endpoint";r=94;t=41
```

A `429` carries `Retry-After` in seconds. Back off on it rather than retrying immediately — the counters are per-minute windows, so a short wait clears them.

## Formats and languages

25 container formats are accepted, covering everything common in audio and video: MP3, WAV, FLAC, M4A, AAC, OGG, Opus, MP4, MOV, MKV, WebM and the rest. 119 languages are supported with automatic detection.

**Pass `language` when you know it.** Auto-detection usually works, but it can misclassify short clips, code-switched audio, or files that open with music. An explicit ISO code is both faster and more accurate.

## Billing

$0.20 per hour of audio, charged per second of actual duration, with no subscription and no minimum. Cost is reserved once the file's duration is known and settled when the job finishes. **Failed and cancelled jobs are free** — the reservation is released. See [Pricing](/docs/pricing).

## Service health

Real-time status, uptime history, and incident reports for every part of the API are published at **[status.scriptivox.com](https://status.scriptivox.com)**. Subscribe there to get notified about incidents and scheduled maintenance.

## Start building

{% card-grid variant="row" %}
{% nav-card variant="row" icon="rocket" title="Quickstart" description="Get your first transcription running in under 5 minutes" href="/docs/quickstart" /%}
{% nav-card variant="row" icon="code" title="API Reference" description="Upload, transcribe, and retrieve results via REST endpoints" href="/docs/api-reference" /%}
{% nav-card variant="row" icon="book" title="Webhooks" description="Receive real-time notifications when transcriptions complete" href="/docs/webhooks" /%}
{% nav-card variant="row" icon="credit-card" title="Pricing" description="Simple pay-as-you-go at $0.20/hour of audio processed" href="/docs/pricing" /%}
{% /card-grid %}

## Capabilities

{% card-grid variant="row" %}
{% feature-card variant="row" title="Fast Transcription" description="High-accuracy transcription powered by Whisper. Supports 119 languages with automatic detection." icon="mic" /%}
{% feature-card variant="row" title="Speaker Diarization" description="Identify who said what. Detect and label multiple speakers in your audio automatically." icon="users" /%}
{% feature-card variant="row" title="Word-level Timestamps" description="Precise start and end times for every word, plus confidence scores where the alignment model supports them. On by default — pass align: false to opt out." icon="type" /%}
{% feature-card variant="row" title="Webhook Notifications" description="Get notified when transcriptions complete. HMAC-signed payloads for security." icon="zap" /%}
{% feature-card variant="row" title="URL Transcription" description="Transcribe from any public URL — Google Drive, Dropbox, OneDrive, or direct file links. No upload step needed." icon="link" /%}
{% feature-card variant="row" title="119 Languages" description="Automatic language detection across 119 languages. Just send your audio — no configuration needed." icon="globe" /%}
{% /card-grid %}

## Every page in these docs

- [Quickstart](/docs/quickstart) — first transcription in five minutes, in Python, JavaScript and curl.
- [API reference](/docs/api-reference) — every endpoint, parameter, error code, rate limit, format and language.
- [Authentication](/docs/authentication) — API keys, the two accepted headers, rotation, and what a 401 means.
- [Webhooks](/docs/webhooks) — HMAC-signed completion callbacks instead of polling.
- [Pricing](/docs/pricing) — pay-as-you-go rates and how reservations settle.
- [Versioning](/docs/versioning) — what can change in `/v1` without notice, and how deprecations are announced.
- [Use cases](/docs/use-cases) — folder watchers, batch pipelines and other worked examples.
- [MCP server](/docs/mcp) — transcription as native tool calls from Claude, ChatGPT and other MCP clients.
- [CLI](/docs/cli) — `npx @scriptivox-api/cli`, for shells and scripts.

## For AI agents

Every page here has a markdown twin: append `.md` to its path, or request the same URL with `Accept: text/markdown`. [/llms.txt](/llms.txt) describes what this service is for and when not to reach for it, and [/llms-full.txt](/llms-full.txt) is all of this documentation in a single fetch.

---

<!-- source: https://platform.scriptivox.com/docs/quickstart -->
<!-- updated: 2026-08-25 -->

# Quickstart

Get your first transcription running in under 5 minutes.

---

{% step number=1 title="Create an API key" %}
Go to the [API Keys page](/platform/keys) in your dashboard and create a new key. Copy it — you'll only see it once.

Your key looks like: `sk_live_12ab34cd...`
{% /step %}

{% step number=2 title="Add balance" %}
Transcription costs `$0.20/hour` of audio, billed per second. Add funds on the [Billing page](/platform/billing). Minimum deposit is $5.00 (~25 hours of audio).
{% /step %}

{% step number=3 title="Transcribe" %}
Send a URL and we'll download, validate, and transcribe it. Supports direct file links, Google Drive, Dropbox, and OneDrive sharing links.

{% code-block tabs="transcribeUrlTabs" /%}

You'll get back a transcription ID immediately. The file downloads and processes in the background.

Optionally enable speaker diarization with `diarize: true`, and wire up automatic [webhooks](/docs/webhooks) via `webhook_url`. If you know how many speakers are on the recording, pass `speaker_count` along with `diarize` — providing it noticeably improves accuracy versus letting the model auto-detect. Word-level timestamps (`align`) are on by default; pass `align: false` to opt out. When `diarize: true`, alignment is always enabled (it's required for speaker assignment) regardless of what you pass.

**Pass `language` when you know it.** If you omit it, the model auto-detects, which usually works but can misclassify short clips, code-switched audio, or files that start with music. Passing the ISO code (e.g. `"language": "en"`) is both faster and more accurate. See the [language parameter notes](/docs/api-reference#important-notes) for details.
{% /step %}

{% step number=4 title="Get the result" %}
Poll until the status is `completed` or `failed`. Typical transcriptions complete in under a minute.

{% code-block tabs="getResultTabs" /%}

The response includes the full transcript, timestamped utterances, word-level timestamps (alignment is on by default), and speaker labels when diarization is enabled:

```json
{
  "id": "txn-456",
  "status": "completed",
  "audio_duration_seconds": 120,
  "cost_cents": 0.6667,
  "result": {
    "full_transcript": "Hello, thanks for joining the call today...",
    "language": "en",
    "duration_seconds": 120,
    "speakers": ["SPEAKER 1", "SPEAKER 2"],
    "utterances": [
      {
        "start": 0.5,
        "end": 3.2,
        "text": "Hello, thanks for joining the call today.",
        "speaker": "SPEAKER 1",
        "confidence": 0.95,
        "words": [
          { "word": "Hello,", "start": 0.5, "end": 0.9, "confidence": 0.98, "speaker": "SPEAKER 1" },
          { "word": "thanks", "start": 1.0, "end": 1.3, "confidence": 0.97, "speaker": "SPEAKER 1" }
        ]
      }
    ]
  }
}
```
{% /step %}

---

{% callout type="tip" title="Need to upload your own files?" %}
If you don't have a public URL, you can upload files directly using the [file upload flow](/docs/api-reference#upload) in the API Reference.
{% /callout %}

## When something goes wrong

Failures arrive on two different paths, and a client that only handles one of them will silently lose jobs.

**Synchronous** errors come back on the call you just made, as JSON with a stable `code`:

```json
{
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Add funds to continue transcribing.",
    "docs_url": "https://platform.scriptivox.com/docs/api-reference#error-codes"
  }
}
```

The ones worth handling by name on submit are `INVALID_API_KEY` (401 — the key is missing, malformed or revoked), `INSUFFICIENT_BALANCE` and `ZERO_BALANCE` (402 — top up on the [Billing page](/platform/billing)), `VALIDATION_ERROR` (400 — the body is wrong; the message says how), and `RATE_LIMIT_EXCEEDED` (429 — wait for the seconds in `Retry-After`, then retry).

**Asynchronous** errors do not appear on submit at all. `POST /v1/transcribe` returns `status: "created"` before the file has been fetched, so anything about the file itself — an unreachable URL, a permission-gated Drive link, a corrupt container, a codec we cannot decode, a file over 5 GB or longer than 10 hours, or a recording with no audible speech — surfaces later on the poll:

```json
{
  "id": "txn-456",
  "status": "failed",
  "error": {
    "code": "DOWNLOAD_FAILED",
    "message": "The URL could not be fetched (HTTP 403)."
  }
}
```

Treat `status: "failed"` as a first-class outcome next to `"completed"`. Both are terminal; neither will change if you keep polling. Failed and cancelled jobs cost nothing — the reserved balance is released — so a retry after fixing the input is free.

## Retrying safely

Pass `Idempotency-Key` on `POST /v1/transcribe` and `POST /v1/upload`. If the same key arrives again within 24 hours you get the original job back instead of a second one, which makes a network timeout safe to retry. Without it, a retried submit is a second billable job.

## Polling without hammering

Poll no faster than once every few seconds — the rate limit is 100 requests per minute per endpoint, and every response tells you where you stand:

```http
RateLimit-Policy: "endpoint";q=100;w=60, "ip";q=300;w=60
RateLimit: "endpoint";r=94;t=41
```

If you would rather not poll at all, register a `webhook_url` on the submit call and we will POST the finished transcription to you, HMAC-signed. See [Webhooks](/docs/webhooks).

## Supported input

25 container formats are accepted — MP3, WAV, FLAC, M4A, AAC, OGG, Opus, MP4, MOV, MKV, WebM among them — from 1 second to 10 hours long and at most 5 GB. Public URLs work, including Google Drive, Dropbox and OneDrive share links, as long as the link does not require a sign-in. 119 languages are supported; pass `language` whenever you know it.

## Next steps

{% card-grid columns=2 %}
{% nav-card title="API Reference" description="Full endpoint documentation" href="/docs/api-reference" /%}
{% nav-card title="Authentication" description="Keys, headers, rotation and what each 401 means" href="/docs/authentication" /%}
{% nav-card title="Webhooks" description="Real-time completion notifications" href="/docs/webhooks" /%}
{% nav-card title="CLI" description="npx @scriptivox-api/cli for shells and scripts" href="/docs/cli" /%}
{% /card-grid %}

---

<!-- source: https://platform.scriptivox.com/docs/api-reference -->
<!-- updated: 2026-08-25 -->

# API Reference

Complete reference for the Scriptivox transcription API. All endpoints require an API key, passed in one of:

- `Authorization: sk_live_…` (the `Bearer` prefix is accepted but not required)
- `X-Api-Key: sk_live_…`

Header names are case-insensitive. Each account can have at most **5 active API keys** at a time — revoke an unused key in the dashboard before creating a new one if you hit this ceiling.

**Base URL:** `https://api.scriptivox.com/v1`

**Service status:** real-time uptime + incident history at **[status.scriptivox.com](https://status.scriptivox.com)**.

---

## Transcribe

Send audio for transcription. You can either pass a URL (we download it) or upload your own file.

### From a URL

The simplest path — one POST request. We download the file, validate it, and start transcription automatically. Supports Google Drive, Dropbox, and OneDrive sharing links.

{% endpoint
  id="transcribe"
  method="POST"
  path="/v1/transcribe"
  description="Start a transcription from a public URL. The file is downloaded and validated in the background. Poll GET /v1/transcribe/{id} for status updates. Duration and cost are determined after download."
  auth="Authorization: sk_live_YOUR_KEY"
  params="transcribeUrlParams"
  responseExample="transcribeUrlResponse"
  codeExamples="transcribeUrlExamples" /%}

### From a file upload {% #upload %}

Upload your own file when you need full control or don't have a public URL.

{% endpoint
  id="get-upload-url"
  method="POST"
  path="/v1/upload"
  description="Get a presigned URL to upload an audio or video file. The URL expires in 1 hour. Upload your file to the returned URL with a PUT request, then pass the upload_id to POST /v1/transcribe."
  auth="Authorization: sk_live_YOUR_KEY"
  params="uploadParams"
  responseExample="uploadResponse"
  codeExamples="uploadExamples" /%}

{% endpoint
  id="transcribe-upload"
  method="POST"
  path="/v1/transcribe"
  description="Start a transcription from an uploaded file. Pass the upload_id from POST /v1/upload. The file is validated in the background. Poll GET /v1/transcribe/{id} for status updates. Duration and cost are determined after validation."
  auth="Authorization: sk_live_YOUR_KEY"
  params="transcribeUploadParams"
  responseExample="transcribeUploadResponse"
  codeExamples="transcribeUploadExamples" /%}

---

## Get result {% #get-result %}

{% endpoint
  id="get-result-endpoint"
  method="GET"
  path="/v1/transcribe/{id}"
  description="Get the status and result of a transcription. Poll this endpoint until status is completed or failed, or use webhooks for real-time notifications. Pass ?format=srt|vtt|text to export the transcript directly as captions or plain text instead of JSON."
  auth="Authorization: sk_live_YOUR_KEY"
  params="getResultParams"
  responseExample="getResultResponse"
  codeExamples="getResultExamples" /%}

### Status values

The status lifecycle is `created → downloading` (URL flow only) `→ processing → completed | failed`.

| Status | Description |
| --- | --- |
| `created` | Job accepted. Download (URL flow) or validation (upload flow) is about to start. |
| `downloading` | URL flow only — the file is being fetched from the provided URL. |
| `processing` | The file has been validated and is being transcribed on a GPU worker. |
| `completed` | Transcription finished successfully. The `result` object is now populated. |
| `failed` | The job failed. Inspect `error.code` and `error.message`. |

### Caption / text export

`GET /v1/transcribe/{id}?format=srt|vtt|text` returns the transcript in the requested format with the appropriate `Content-Type`:

| `format` | Content-Type | Body |
| --- | --- | --- |
| `json` (default) | `application/json` | Full structured response with `result.utterances[]` |
| `srt` | `text/plain; charset=utf-8` | SubRip Text |
| `vtt` | `text/vtt; charset=utf-8` | WebVTT, with `<v Speaker>` voice tags when speaker labels are shown |
| `text` | `text/plain; charset=utf-8` | Plain text. Diarized jobs get a `Speaker:` prefix per turn with blank lines between turns. |

`srt`, `vtt`, and `text` require `status=completed`; otherwise returns `400 INVALID_REQUEST`. JSON format works at any status.

#### Segmentation controls

The caption formats (`srt`, `vtt`, and `text`) accept the same segmentation knobs as the dashboard's Advanced Export modal — pass them as query params alongside `format`:

| Param | Default | Range | Effect |
| --- | --- | --- | --- |
| `max_words` | 4 | 1–50 | Maximum words per cue. Lower = shorter, faster-changing captions. |
| `max_chars` | 80 | 10–500 | Maximum characters per cue. ~80 is the industry convention (~37 chars × 2 lines). |
| `max_duration` | 10 | 1–60 (seconds) | Maximum seconds a single cue stays on screen. |
| `sentence_aware` | `true` | `true` / `false` | End a cue when a sentence ends (`. ! ?`). Produces more natural breaks. |
| `include_speakers` | `auto` | `true` / `false` / `auto` | Whether to prefix each cue with the speaker label. `auto` includes them only when the job has more than one distinct speaker. |
| `strip_chars` | (empty) | up to 32 chars | Characters to remove from cue text. E.g. `strip_chars=,.` drops all commas and periods. |

Whichever of `max_words`, `max_chars`, `max_duration` is exceeded **first** ends the current cue. `sentence_aware` adds a sentence-ending punctuation rule on top of those.

**Examples:**

```bash
# Short cues (TikTok-style, max 3 words, 2.5s each, no sentence-awareness)
curl "https://api.scriptivox.com/v1/transcribe/{id}?format=srt&max_words=3&max_duration=3&sentence_aware=false" \
  -H "Authorization: sk_live_YOUR_KEY"

# Standard SRT with default settings
curl "https://api.scriptivox.com/v1/transcribe/{id}?format=srt" \
  -H "Authorization: sk_live_YOUR_KEY"

# WebVTT, always show speaker tags, strip filler punctuation
curl "https://api.scriptivox.com/v1/transcribe/{id}?format=vtt&include_speakers=true&strip_chars=,." \
  -H "Authorization: sk_live_YOUR_KEY"

# Plain text without speaker prefixes
curl "https://api.scriptivox.com/v1/transcribe/{id}?format=text&include_speakers=false" \
  -H "Authorization: sk_live_YOUR_KEY"
```

When the job was made with `align: false` (no per-word timestamps), the segmentation falls back to utterance-level — `max_words`/`max_chars`/`max_duration` still cap each cue but cuts can only happen at utterance boundaries. For best segmentation control, keep `align` on (the default).

---

## List transcriptions {% #list %}

{% endpoint
  id="list-endpoint"
  method="GET"
  path="/v1/transcriptions"
  description="List your transcriptions with optional filters and cursor-based pagination. Newest first by default. Soft-deleted transcriptions are excluded."
  auth="Authorization: sk_live_YOUR_KEY"
  params="listTranscriptionsParams"
  responseExample="listTranscriptionsResponse"
  codeExamples="listTranscriptionsExamples" /%}

Each `item` matches the shape of `GET /v1/transcribe/{id}` **except** that the heavy `result` object is omitted — fetch individual transcriptions for the full transcript. Pagination is stable across new inserts: pass the `next_cursor` value from the response into the `cursor` query param to get the next page. `next_cursor` is `null` when there are no more pages.

---

## Cancel transcription {% #cancel %}

{% endpoint
  id="cancel-endpoint"
  method="POST"
  path="/v1/transcribe/{id}/cancel"
  description="Stop an in-flight transcription. Releases the reserved balance and fires the transcription.failed webhook (if configured) with error.code=CANCELLED. Idempotent — calling cancel on an already-cancelled job returns the same response."
  auth="Authorization: sk_live_YOUR_KEY"
  params="cancelTranscriptionParams"
  responseExample="cancelTranscriptionResponse"
  codeExamples="cancelTranscriptionExamples" /%}

Cancel is allowed only while the job is in `created`, `downloading`, `pending`, or `processing` state. Cancelling a `completed` or `failed` job returns `409 CONFLICT`. Cancellation is best-effort against the GPU — the model may still finish briefly after, but its result is discarded and you are not charged.

---

## Delete transcription {% #delete %}

{% endpoint
  id="delete-endpoint"
  method="DELETE"
  path="/v1/transcribe/{id}"
  description="Soft-delete a completed or failed transcription. Removes the stored transcript from our storage. The job record is kept for 7 days for audit, then hard-deleted. In-flight jobs cannot be deleted — cancel them first."
  auth="Authorization: sk_live_YOUR_KEY"
  params="deleteTranscriptionParams"
  responseExample="deleteTranscriptionResponse"
  codeExamples="deleteTranscriptionExamples" /%}

Returns `204 No Content` on success. Idempotent — deleting an already-deleted transcription also returns 204. Trying to delete an in-flight transcription returns `409 CONFLICT` with a message telling you to cancel first.

---

## Balance {% #balance %}

A non-zero balance is required to **start** an upload or submit a transcription — `POST /v1/upload` and `POST /v1/transcribe` return `402 ZERO_BALANCE` when your balance is $0. The exact cost is reserved once the audio duration is known (after download/validation), not on submission.

{% endpoint
  id="balance-endpoint"
  method="GET"
  path="/v1/balance"
  description="Returns your current account balance in cents, the amount reserved for in-progress transcriptions, the amount available for new jobs, and an estimate of remaining hours at the current per-hour price."
  auth="Authorization: sk_live_YOUR_KEY"
  responseExample="balanceResponse"
  codeExamples="balanceExamples" /%}

---

## Error Codes {% #error-codes %}

All errors follow the same format:

```json
{
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable description",
    "docs_url": "https://platform.scriptivox.com/docs/api-reference#error-codes"
  }
}
```

`code` is stable and safe to branch on. `message` is a sanitized, customer-safe string — never a raw stack trace — and its wording may change. `docs_url` points back at this table. Every response also carries a `Link: <https://platform.scriptivox.com/openapi.json>; rel="service-desc"` header pointing at the machine-readable [OpenAPI specification](https://platform.scriptivox.com/openapi.json).

New fields may be added to the error object over time; treat unknown keys as ignorable rather than as a parse failure.

### Synchronous errors — returned immediately on the request

| HTTP | Code | Description |
| --- | --- | --- |
| 400 | INVALID_REQUEST | Malformed request body or missing required fields |
| 400 | INVALID_FILENAME | Filename is missing, too long, contains invalid characters, or has no extension |
| 400 | INVALID_MEDIA_FORMAT | Unsupported file extension at upload time (e.g. `.txt`, `.pdf`). Also returned async when `ffprobe` rejects the actual file contents. |
| 400 | FILE_NOT_UPLOADED | File not found at the upload URL |
| 400 | FILE_TOO_LARGE | File exceeds 5 GB limit |
| 400 | UPLOAD_ALREADY_USED | Upload already used for a transcription |
| 400 | UPLOAD_EXPIRED | Upload URL expired (1 hour TTL) |
| 401 | INVALID_API_KEY | Invalid or missing API key |
| 401 | API_KEY_REVOKED | API key has been revoked |
| 402 | INSUFFICIENT_BALANCE | Not enough balance for this transcription |
| 402 | ZERO_BALANCE | Balance is $0 — deposit required |
| 404 | UPLOAD_NOT_FOUND | Upload ID does not exist |
| 404 | TRANSCRIPTION_NOT_FOUND | Transcription ID does not exist |
| 404 | NOT_FOUND | Path doesn't match any endpoint |
| 403 | FORBIDDEN | Request rejected upstream (e.g. by the network layer) before it reached the API. |
| 413 | PAYLOAD_TOO_LARGE | Request **body** exceeds 100KB. This is the JSON body, not the media file — large files go to the presigned upload URL, which has its own 5 GB limit. |
| 405 | METHOD_NOT_ALLOWED | The path exists but only accepts a different HTTP method. The response includes an `Allow` header naming the accepted method (e.g. `Allow: GET` if you POST to `/v1/balance`). |
| 415 | UNSUPPORTED_MEDIA_TYPE | Request body sent without `Content-Type: application/json` on a POST/PUT/PATCH. The response includes an `Accept-Post: application/json` header. Parameters are allowed (`application/json; charset=utf-8` works). |
| 409 | CONFLICT | Action not allowed in the current state (e.g. delete on an in-flight job, cancel on a completed one) |
| 409 | IDEMPOTENCY_KEY_LOCKED | Another request with the same `Idempotency-Key` is mid-flight. Retry after a few seconds (`Retry-After` header sent). |
| 422 | IDEMPOTENCY_KEY_CONFLICT | `Idempotency-Key` reused with a different request body — see [Idempotency](#idempotency) |
| 429 | RATE_LIMIT_EXCEEDED | Too many requests — see Rate Limits |
| 500 | INTERNAL_ERROR | Server error |

### Asynchronous errors — surface only on `GET /v1/transcribe/{id}`

`POST /v1/transcribe` accepts the job and returns `{"status":"created"}` even if the input will ultimately fail. The errors below only appear on the GET endpoint, with the top-level `status` set to `"failed"` and the failure reason in the `error` object. Your client **must** handle these on the poll path, not on submit.

| Code | When it appears |
| --- | --- |
| URL_NOT_ACCESSIBLE | URL flow only — covers several non-fetchable cases: the URL returned 4xx/5xx; DNS didn't resolve; the connection was refused; the response was an HTML page instead of media (login/preview/folder/expired-link); the share requires sign-in (e.g. OneDrive `1drv.ms/v/c/…` SharePoint Photos shares); or the server returned a 200 with an empty body. The `message` field names the specific cause when detectable. |
| DOWNLOAD_FAILED | URL flow only — the download started but was interrupted. |
| INVALID_MEDIA_FORMAT | The file downloaded but `ffprobe` rejected it: not actually audio/video, no audio track, or **shorter than the 1-second minimum** (message names the measured duration, e.g. `"Audio too short (0.10s). Minimum duration is 1 second."`). |
| DURATION_TOO_LONG | Audio exceeds the 10-hour limit (only known after probing duration). |
| PROCESSING_ERROR | The GPU job failed after all retries. |
| CREATED_TIMEOUT | Job sat in `created` for more than 30 minutes — validation step never started. |
| DOWNLOAD_TIMEOUT | Job sat in `downloading` for more than 15 minutes — file download stalled. |
| PROCESSING_TIMEOUT | Job sat in `processing` for more than 45 minutes — GPU never returned a result. |
| BILLING_ERROR | Internal accounting issue while finalizing the charge — your balance was **not** debited and the transcript was not delivered. Safe to retry. |
| INTERNAL_ERROR | A server-side step (e.g. queueing the job to our processing layer) failed after we accepted the request. Safe to retry. |
| CANCELLED | The transcription was cancelled by the customer via `POST /v1/transcribe/{id}/cancel`. The reserved balance was released; you are not charged. |

Failed transcriptions are free. The reserved balance is released back to your account, so a failure costs $0 regardless of how far the job got.

`error.message` is always a sanitized, customer-safe string — we never forward raw GPU/library stack traces or internal paths. If you need more detail than the message provides for a `PROCESSING_ERROR`, contact support with the transcription ID and we can look up the raw error on our side.

---

## Important notes {% #important-notes %}

### Language parameter behavior

**Recommended: always pass `language` explicitly.** Auto-detection works in most cases but has a small failure rate that's fully avoidable. Passing the actual language gives more accurate transcripts, faster turnaround (the model skips its detection pass), and protects you from the known edge cases below.

When you specify a `language` code, the model is forced to interpret the audio as that language. If the audio is actually in a different language, the model may **translate** rather than transcribe — for example, setting `language: "es"` on English audio can produce a Spanish translation of the speech. Omit `language` (or pass `null`) to let the model auto-detect.

Auto-detect picks a single dominant language for the whole file and is not perfect:

- **Code-switched audio** (e.g. English/Spanish in the same clip) typically gets transcribed as the dominant language, and segments in the other language may be dropped or mistranscribed.
- **Hindi audio is sometimes routed to Urdu** by the detector. If you know the language in advance, pass it explicitly.
- **Short clips** (under 30s) give the detector less signal and are more likely to mis-route.
- **Music or background noise** during the first few seconds can throw the detector off.

If you know the language up front — even probabilistically — pass it. The accuracy cost of being wrong is **roughly the same** as the cost of auto-detect picking the wrong language, but passing it is faster and works on the edge cases above.

### Silence and very short clips

Whisper-based models are prone to a known hallucination on near-silent or sub-second audio, often producing a phantom `"Thank you."` or similar filler. If your pipeline can produce silent or extremely short clips, filter them on your side before submitting.

### Size, duration, and retention limits

- **Max file size:** 5 GB. Anything larger is rejected with `400 FILE_TOO_LARGE`. Very large uploads can also be rejected by the network layer with an HTML `413` page **before** they reach the API — your client should handle non-JSON error bodies gracefully.
- **Max duration:** 10 hours. Files probing longer than this fail with `DURATION_TOO_LONG` (asynchronous; you'll see it on the GET endpoint after probing completes).
- **Min duration:** 1 second. Files shorter than 1 second fail asynchronously with `INVALID_MEDIA_FORMAT` and a message naming the actual measured duration. (Sub-second clips have too little speech signal to transcribe reliably.)
- **Request body Content-Type:** every `POST` / `PUT` / `PATCH` that carries a JSON body must send `Content-Type: application/json` (parameters allowed, e.g. `application/json; charset=utf-8`). Anything else is rejected at the gateway with `415 UNSUPPORTED_MEDIA_TYPE` and an `Accept-Post: application/json` header.
- **Presigned URL TTL:** 1 hour. Uploads must `PUT` to the URL within `expires_in` seconds of receiving it; after that the URL returns `400 UPLOAD_EXPIRED`.
- **Audio retention:** uploaded source audio is retained for **24 hours** after the job ends, then deleted. Transcripts themselves remain available via `GET /v1/transcribe/{id}`.

### Filename rules

The `filename` you pass to `POST /v1/upload` must satisfy all of the following:

- **ASCII only** (rename files with accents, CJK, or emoji before upload — Supabase Storage rejects non-ASCII object keys).
- **No path separators** — `/` and `\` are rejected.
- **No `< > " ' ``** — rejected to prevent injection into dashboards that render filenames unescaped.
- **Must include a supported extension** (e.g. `.mp3`, `.wav`, `.mp4`). See [Supported Formats](#supported-formats) for the full list.
- **255 characters or fewer.**

Violations return `400 INVALID_FILENAME` (or `400 INVALID_MEDIA_FORMAT` if the extension is present but unsupported).

### `audio_duration_seconds` is an integer

`audio_duration_seconds` in `GET /v1/transcribe/{id}` is rounded to the nearest whole second (so an 11.7-second clip returns `11`). `cost_cents`, by contrast, is fractional and exact (e.g. `0.061111` for an 11-second clip at $0.20/hour) — your account balance is debited against the precise value, not a rounded one.

### `speaker_count` is a soft prior, not a hard ceiling

When you set `speaker_count: N` with `diarize: true`, you're giving the diarizer a hint about how many speakers to expect — not a hard cap. The result may include slightly more or fewer speaker labels than `N` (e.g. requesting 5 may produce 6). Values outside `1–50`, or `speaker_count` without `diarize: true`, are rejected with `400 INVALID_REQUEST`.

**Recommended:** whenever you know how many speakers are on the recording, pass `speaker_count`. It noticeably improves diarization accuracy — the model uses it as a prior instead of guessing, which cuts down on over-segmentation (one speaker split across two labels) and under-segmentation (two speakers merged into one).

### Per-word leading whitespace

Word objects in `align: true` output have whitespace pre-stripped (e.g. `{"word":"The"}`, not `{"word":" The"}`). Reconstruct sentence text from `utterance.text` if you need exact spacing.

### `diarize` forces `align`

`diarize` requires word-level alignment to map speakers onto each word, so any request with `diarize: true` is processed as if `align: true` — even when the caller explicitly passes `align: false`. The stored job and webhook payload reflect the effective value (`align: true`), and word objects are present on the result. If you don't want alignment data, leave `diarize` off.

### Idempotency {% #idempotency %}

Both `POST /v1/upload` and `POST /v1/transcribe` accept an optional `Idempotency-Key` HTTP header. When present, we cache the response for 24 hours and replay it byte-for-byte on subsequent requests with the same key and body. This protects you against double-billed transcriptions if your network drops the response and your client retries.

```bash
curl -X POST https://api.scriptivox.com/v1/transcribe \
  -H "Authorization: sk_live_YOUR_KEY" \
  -H "Idempotency-Key: 7c8f5b3a-1234-4d56-90ab-cdef01234567" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/audio.mp3"}'
```

Rules:
- Key is opaque — we don't parse it. Generate one per logical operation (a UUID is conventional). Reuse the same key when retrying after a failure.
- 1–255 printable ASCII characters.
- Same key + same body within 24h → cached response is replayed. The replayed response carries a `Idempotent-Replay: true` header so you can tell it was cached.
- Same key + **different** body → `422 IDEMPOTENCY_KEY_CONFLICT`. This is a bug in your retry logic — use a fresh key for a different operation.
- Same key while a previous request is **still in progress** → `409 IDEMPOTENCY_KEY_LOCKED` with a `Retry-After` header. Sleep briefly and retry; the cached response will be available once the first request finishes.
- No header sent → endpoint behaves normally (no caching).

The replay cache covers 200 success responses only. 4xx and 5xx errors are not cached, so you can retry freely.

### Unknown fields are rejected

Request bodies on `POST /v1/upload` and `POST /v1/transcribe` are strict: any top-level field that isn't in the documented parameter list returns `400 INVALID_REQUEST` with the offending key name. This is to surface typos (`{"dirize": true}`) immediately instead of silently ignoring them. Nested objects (e.g. inside a future `metadata` field) are not currently inspected.

### Response shape changes with `diarize` and `align`

The `GET /v1/transcribe/{id}` example above shows the response with `diarize: true` and `align: true` (the maximal case). When you turn flags off, some `result` fields become null or empty:

| Field | `diarize: false` | `align: false` |
| --- | --- | --- |
| `result.speakers` | `null` | unchanged |
| `result.utterances[].speaker` | `null` | unchanged |
| `result.utterances[].words` | unchanged | `[]` (empty array) |
| `result.utterances[].words[].speaker` | key absent | n/a (no words) |

Top-level metadata (`status`, `cost_cents`, `audio_duration_seconds`, etc.) is identical regardless of flags. `result.utterances[].confidence` and `result.utterances[].words[].confidence` may be `null` — confidence scores depend on the alignment model used for the detected language, and not every language is covered. Always handle `null` defensively (e.g. skip filtering by confidence rather than dropping the word).

Speaker labels are `"SPEAKER 1"`, `"SPEAKER 2"`, … (space, 1-indexed) — not `"SPEAKER_00"`. `source_url` is only present on URL-flow transcriptions; upload-flow jobs omit it.

---

## Rate Limits {% #rate-limits %}

Limits are enforced per API key per endpoint, plus a per-IP cap across all endpoints. Exceeding any limit returns `429 RATE_LIMIT_EXCEEDED` with a `Retry-After` header.

| Scope | Limit | Notes |
| --- | --- | --- |
| `POST /v1/upload` (per key) | 60/min | Presigned URL generation |
| `POST /v1/transcribe` (per key) | 60/min | Job submission |
| `GET /v1/transcribe/{id}` (per key) | 200/min | Higher limit for polling |
| `GET /v1/transcriptions` (per key) | 60/min | List your jobs |
| `POST /v1/transcribe/{id}/cancel` (per key) | 30/min | Cancel in-flight |
| `DELETE /v1/transcribe/{id}` (per key) | 30/min | Soft-delete |
| `GET /v1/balance` (per key) | 100/min | Balance checks |
| Per source IP (across all endpoints) | 300/min | Edge-level cap to prevent abuse |

Limits use a **sliding 60-second window**, not a fixed-window counter — short bursts above the per-minute number are tolerated as long as the rolling 60-second total stays under the limit. Plan against the steady-state number, not the burst.

### Rate limit headers

Rate limits are advertised in two vocabularies on the same response: the
`RateLimit-Policy` / `RateLimit` structured fields from
[draft-ietf-httpapi-ratelimit-headers](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/),
and the older `X-RateLimit-*` spelling that most existing clients read. They
always agree; use whichever your HTTP client makes easier.

**On every response, including `401` and `429`:**

| Header | Description |
| --- | --- |
| `RateLimit-Policy` | The quotas that apply, as an RFC 9651 structured field list. `q` is the quota, `w` the window in seconds. Two policies are always listed: `"endpoint"` (per key, per endpoint) and `"ip"` (per source IP, across everything). |
| `X-RateLimit-Limit` | The `"endpoint"` quota, as a bare integer. |

You do **not** need a working API key to read these. A `401` carries them too,
which means an agent can learn what it is allowed to do before it is allowed to
do anything.

**Once the API key has been validated:**

| Header | Description |
| --- | --- |
| `RateLimit` | Live remaining quota. `r` is the remaining request count, `t` the seconds until the window resets. |
| `X-RateLimit-Remaining` | Requests remaining in the current rolling window. |
| `X-RateLimit-Reset` | Unix timestamp when the window fully resets. |
| `Retry-After` | Seconds to wait before retrying. Only on `429`. |

```http
RateLimit-Policy: "endpoint";q=60;w=60, "ip";q=300;w=60
RateLimit: "endpoint";r=57;t=41
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1787643480
```

{% callout type="info" title="Which 429 you got" %}
A `429` from the per-key limiter carries the live `RateLimit` field. A `429`
from the per-IP cap or from an abuse block is raised before your key is read, so
it carries `RateLimit-Policy` and `Retry-After` but no `r` value — there is no
per-key window to report. Both are safe to retry after `Retry-After` seconds.
{% /callout %}

---

## Supported Formats {% #supported-formats %}

25 container/codec combinations are accepted (10 audio + 15 video). Maximum file size is 5 GB; maximum duration is 10 hours. Unrecognized extensions return `400 INVALID_MEDIA_FORMAT`.

### Audio (10)

| Extension | Format |
| --- | --- |
| .mp3 | MPEG Audio |
| .wav | Waveform Audio |
| .m4a | MPEG-4 Audio |
| .aac | Advanced Audio Coding |
| .ogg | Ogg Vorbis |
| .flac | Free Lossless Audio |
| .opus | Opus |
| .wma | Windows Media Audio |
| .aiff | Audio Interchange |
| .caf | Core Audio Format |

### Video (15)

| Extension | Format |
| --- | --- |
| .mp4 | MPEG-4 Video |
| .mov | QuickTime |
| .avi | Audio Video Interleave |
| .mkv | Matroska Video |
| .webm | WebM |
| .wmv | Windows Media Video |
| .flv | Flash Video |
| .m4v | MPEG-4 Video (iTunes) |
| .3gp | 3GPP |
| .mpeg | MPEG Video |
| .mts | AVCHD |
| .ogv | Ogg Video |
| .ts | MPEG Transport Stream |
| .vob | DVD Video Object |
| .f4v | Flash MP4 Video |

---

## Supported Languages {% #supported-languages %}

119 languages are supported. Pass the ISO 639-1 (or BCP-47 fallback, e.g. `yue`, `kea`) language code below in the `language` parameter.

**We recommend always passing a language explicitly.** Omitting the parameter (or passing `null`) triggers auto-detection, which works for most inputs but has a small chance of picking the wrong language — especially on short clips, code-switched audio, or files that start with music or background noise. See [Language parameter behavior](#important-notes) for details.

Invalid codes return `400 INVALID_REQUEST`.

| Language | Code |
| --- | --- |
| Afrikaans | af |
| Albanian | sq |
| Amharic | am |
| Arabic | ar |
| Armenian | hy |
| Assamese | as |
| Asturian | ast |
| Azerbaijani | az |
| Bashkir | ba |
| Basque | eu |
| Belarusian | be |
| Bengali | bn |
| Bosnian | bs |
| Breton | br |
| Bulgarian | bg |
| Cantonese | yue |
| Cape Verdean Creole | kea |
| Catalan | ca |
| Cebuano | ceb |
| Chichewa | ny |
| Chinese | zh |
| Croatian | hr |
| Czech | cs |
| Danish | da |
| Dutch | nl |
| English | en |
| Estonian | et |
| Faroese | fo |
| Finnish | fi |
| French | fr |
| Fula | ff |
| Galician | gl |
| Georgian | ka |
| German | de |
| Greek | el |
| Gujarati | gu |
| Haitian Creole | ht |
| Hausa | ha |
| Hawaiian | haw |
| Hebrew | he |
| Hindi | hi |
| Hungarian | hu |
| Icelandic | is |
| Igbo | ig |
| Indonesian | id |
| Irish | ga |
| Italian | it |
| Japanese | ja |
| Javanese | jw |
| Kamba | kam |
| Kannada | kn |
| Kazakh | kk |
| Khmer | km |
| Korean | ko |
| Kyrgyz | ky |
| Lao | lo |
| Latin | la |
| Latvian | lv |
| Lingala | ln |
| Lithuanian | lt |
| Luganda | lg |
| Luo | luo |
| Luxembourgish | lb |
| Macedonian | mk |
| Malagasy | mg |
| Malay | ms |
| Malayalam | ml |
| Maltese | mt |
| Maori | mi |
| Marathi | mr |
| Mongolian | mn |
| Myanmar | my |
| Nepali | ne |
| Northern Sotho | nso |
| Norwegian | no |
| Nynorsk | nn |
| Occitan | oc |
| Odia | or |
| Oromo | om |
| Pashto | ps |
| Persian | fa |
| Polish | pl |
| Portuguese | pt |
| Punjabi | pa |
| Romanian | ro |
| Russian | ru |
| Sanskrit | sa |
| Serbian | sr |
| Shona | sn |
| Sindhi | sd |
| Sinhala | si |
| Slovak | sk |
| Slovenian | sl |
| Somali | so |
| Sorani Kurdish | ckb |
| Spanish | es |
| Sundanese | su |
| Swahili | sw |
| Swedish | sv |
| Tagalog | tl |
| Tajik | tg |
| Tamil | ta |
| Tatar | tt |
| Telugu | te |
| Thai | th |
| Tibetan | bo |
| Turkish | tr |
| Turkmen | tk |
| Ukrainian | uk |
| Umbundu | umb |
| Urdu | ur |
| Uzbek | uz |
| Vietnamese | vi |
| Welsh | cy |
| Wolof | wo |
| Xhosa | xh |
| Yiddish | yi |
| Yoruba | yo |
| Zulu | zu |

---

{% card-grid columns=2 %}
{% nav-card title="Webhooks" description="Real-time completion notifications" href="/docs/webhooks" /%}
{% nav-card title="Pricing" description="Pay-as-you-go at $0.20/hour" href="/docs/pricing" /%}
{% /card-grid %}

---

<!-- source: https://platform.scriptivox.com/docs/pricing -->
<!-- updated: 2026-05-14 -->

# Pricing

Simple, transparent pricing. Pay only for what you use.

## $0.20 / hour of audio

Billed per second of audio processed. No rounding up to the nearest minute.

### Everything included

- Speaker diarization
- Word-level timestamps
- Confidence scores (where supported by the alignment model)
- 119 language support
- Automatic language detection
- Webhook notifications

---

## Key details

### Pay-as-you-go

No subscriptions or commitments. Add funds to your balance and use them whenever you need.

### No minimums

No minimum usage requirements. Transcribe one file or a thousand — same rate.

### Balance never expires

Funds in your account never expire. Use them at your own pace, no time limits.

---

## How billing works

When you submit a transcription, we **reserve** the estimated cost from your balance based on the audio duration. Once the transcription completes, we charge the exact cost and release any unused reserve.

Cost is calculated as: `(audio_seconds / 3600) * $0.20`

**Failed transcriptions are free.** If a job fails for any reason, the full reserve is released back to your balance. You are never charged for unsuccessful attempts.

## Example costs

| Audio Duration | Hours | Cost |
| --- | --- | --- |
| 30 seconds | 0.0083h | $0.0017 |
| 5 minutes | 0.0833h | $0.0167 |
| 30 minutes | 0.5h | $0.10 |
| 1 hour | 1h | $0.20 |
| 3 hours | 3h | $0.60 |
| 10 hours | 10h | $2.00 |

## Adding funds

Add funds to your account via the [Billing page](/platform/billing) in your dashboard. Payments are processed securely through Stripe.

Minimum deposit is `$5.00`, which gives you approximately **25 hours** of transcription.

## Rate limits

Rate limits are enforced per API key to ensure fair usage. See the [API Reference](/docs/api-reference#rate-limits) for specific limits per endpoint. You must also have sufficient balance to cover the estimated cost before a transcription can begin. If your balance is insufficient, the request will return a `402` error.

## Common questions

### What formats are supported?

We support 25 audio and video formats including mp3, wav, m4a, mp4, mov, webm, aac, ogg, flac, and opus. Max file size is 5 GB and max audio duration is 10 hours per file.

### Am I charged if a transcription fails?

No. Failed transcriptions are completely free. The reserved amount is returned to your balance immediately.

### How is cost calculated for very short audio?

Cost is calculated per second with no rounding or minimums. A 30-second clip costs $0.001667 (`0.20 * 30 / 3600`).

### Do you offer volume discounts?

Contact us for enterprise pricing if you process more than 1,000 hours per month.

### Can I get a refund?

Unused balance can be refunded within 30 days of deposit. Contact support for refund requests.

---

{% card-grid columns=2 %}
{% nav-card title="Quickstart" description="Get your first transcription running" href="/docs/quickstart" /%}
{% nav-card title="API Reference" description="Full endpoint documentation" href="/docs/api-reference" /%}
{% /card-grid %}

---

<!-- source: https://platform.scriptivox.com/docs/webhooks -->
<!-- updated: 2026-05-18 -->

# Webhooks

Receive real-time notifications when transcriptions complete or fail instead of polling.

---

## Overview

When you provide a `webhook_url` in your transcription request, we'll POST progress and result events to that URL as the job moves through the pipeline. Webhooks are signed with HMAC-SHA256 so you can verify they came from Scriptivox.

Webhooks fire for both upload-based and URL-based transcription flows. The `webhook_url` field is optional — if omitted, no webhooks are sent and you should poll [`GET /v1/transcribe/{id}`](/docs/api-reference) instead.

You can also set a default webhook URL in your [account settings](/platform/settings) that is used for any transcription that does not specify one in the request body.

{% callout type="warning" title="Webhooks are best-effort" %}
Webhook delivery is **fire-and-forget with no retries**. If your endpoint is down or returns a non-2xx response, the event is dropped — the transcription itself still completes normally. Always pair webhooks with polling `GET /v1/transcribe/{id}` as a fallback so you never miss a completion.
{% /callout %}

## Setting up webhooks

Pass a `webhook_url` when starting a transcription:

{% code-block tabs="setupTabs" /%}

## Webhook events

Three events fire per job: `transcription.processing` once the file has been validated and dispatched to a GPU, then exactly one of `transcription.completed` or `transcription.failed` when the job terminates. The `created` and `downloading` states are visible in `GET /v1/transcribe/{id}` polling but do **not** emit webhooks.

### transcription.processing

Sent when the audio file has been downloaded and validated, and the transcription job has been submitted for processing. Includes the detected duration and reserved cost.

```json
{
  "event": "transcription.processing",
  "transcription_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
  "status": "processing",
  "duration_seconds": 120,
  "cost_cents": 0.5
}
```

{% callout type="info" %}
Both URL-based and upload-based transcriptions receive this event after the file has been validated and the job is submitted.
{% /callout %}

### transcription.completed

Sent when a transcription finishes successfully. Includes the full result.

```json
{
  "event": "transcription.completed",
  "transcription_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
  "status": "completed",
  "duration_seconds": 120,
  "cost_cents": 0.5,
  "result": {
    "full_transcript": "Hello, thanks for joining...",
    "language": "en",
    "duration_seconds": 120,
    "speakers": ["SPEAKER 1", "SPEAKER 2"],
    "utterances": [
      {
        "start": 0.5,
        "end": 3.2,
        "text": "Hello, thanks for joining the call today.",
        "speaker": "SPEAKER 1",
        "confidence": 0.95,
        "words": [...]
      }
    ]
  }
}
```

### transcription.failed

Sent any time a transcription ends in the `failed` state. Reserved balance is automatically released so a failed job costs $0.

This event covers every failure surface: URL download/validation errors, insufficient balance, internal queue/server errors, GPU processing failures, and **cleanup-timeout failures** (when a job sits stuck in `created`, `downloading`, or `processing` past its threshold). You will receive at most one `transcription.failed` event per transcription, so a single handler can cover all failure modes — branch on `error.code` (see [API reference: error codes](/docs/api-reference#error-codes)) for code-specific behavior.

`error.message` is a sanitized, customer-safe string; we don't forward raw GPU/library stack traces. For `PROCESSING_ERROR` cases where the message is generic, retrying is usually the right next step.

```json
{
  "event": "transcription.failed",
  "transcription_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
  "status": "failed",
  "error": {
    "code": "PROCESSING_ERROR",
    "message": "Failed to process audio file"
  }
}
```

### Events summary

| Event | When | Flows |
| --- | --- | --- |
| transcription.processing | File validated, job submitted to GPU | Both |
| transcription.completed | Transcription finished successfully | Both |
| transcription.failed | Download, validation, or processing failed | Both |

## Verifying webhook signatures

Every webhook request includes two headers for signature verification:

| Header | Description |
| --- | --- |
| `X-Scriptivox-Signature` | HMAC-SHA256 hex digest of the signed payload |
| `X-Scriptivox-Timestamp` | Unix timestamp (seconds, as a string) of when the webhook was sent |

{% callout type="info" title="Signing formula" %}
1. `signing_secret = SHA256_hex(api_key)` — hex digest of your API key as a UTF-8 string
2. `signed_payload = f"{timestamp}.{body}"` — body is the exact raw JSON request body, byte-for-byte
3. `signature = HMAC_SHA256_hex(signing_secret, signed_payload)` — sent as `X-Scriptivox-Signature`

Reject any request whose `X-Scriptivox-Timestamp` is more than **5 minutes** old to prevent replay attacks.
{% /callout %}

{% code-block tabs="verificationTabs" /%}

## Delivery behavior

Webhooks are delivered on a **best-effort, fire-and-forget** basis:

- **No retries.** Connection failures, timeouts, and non-2xx responses are all final — the event is dropped.
- **One redirect followed.** If your endpoint returns a 3xx response with a `Location` header, the dispatcher re-POSTs the same body and signature/timestamp headers to the resolved URL exactly once. Only `http(s)` destinations are followed; the Location is re-parsed and rejected if it points anywhere else (open-redirect protection).
- **User-Agent** on every request is `scriptivox-webhook/1`.
- **Transcriptions complete normally** regardless of webhook delivery status — webhook health does not affect billing or job state.
- **We monitor our own delivery pipeline.** A heartbeat fires on every successful 2xx response from a customer URL; if no webhook delivery succeeds for several hours, we get alerted internally. This catches systemic delivery problems on our side fast — but it does not retry your specific dropped events, which is why polling is still required.

For reliable delivery, treat webhooks as a latency optimization and poll `GET /v1/transcribe/{id}` as a fallback. Live API uptime is published at **[status.scriptivox.com](https://status.scriptivox.com)**.

## Best practices

- **Return 2xx quickly** — Acknowledge the webhook fast and do any heavy work asynchronously. Slow responses risk timing out, and there are no retries.
- **Always verify signatures** — Check the HMAC-SHA256 signature against the raw body before trusting any payload.
- **Check timestamps** — Reject webhooks with `X-Scriptivox-Timestamp` older than 5 minutes to prevent replay attacks.
- **Be idempotent** — Each `transcription_id` produces one `processing` event followed by one terminal `completed` or `failed`; deduplicate by `(transcription_id, event)` in case a redirected delivery results in a repeat POST.
- **Use HTTPS** — Always use HTTPS for production webhook URLs. HTTP is allowed for local development only.
- **Poll as a fallback** — Since webhooks are never retried, also poll `GET /v1/transcribe/{id}` so a single dropped delivery does not strand a job.

---

{% card-grid columns=2 %}
{% nav-card title="API Reference" description="Full endpoint documentation" href="/docs/api-reference" /%}
{% nav-card title="Pricing" description="Pay-as-you-go at $0.20/hour" href="/docs/pricing" /%}
{% /card-grid %}

---

<!-- source: https://platform.scriptivox.com/docs/use-cases -->
<!-- updated: 2026-08-25 -->

# Use Cases

Practical examples of building with the Scriptivox API.

---

## Folder Watcher {% #folder-watcher %}

A self-contained script that watches a folder for new audio or video files, automatically transcribes them via the Scriptivox API, and saves the results as text files. No manual steps — drop files in and get transcripts out.

### What it does

- Scans `input/` for new audio or video files every 10 seconds (configurable)
- Uploads multiple files in parallel (10 at a time by default, configurable)
- Starts transcription (optionally with speaker diarization and word-level timestamps)
- Receives results via a local [webhook](/docs/webhooks) server
- Saves transcripts as `.txt` files in `output/` with the same base filename
- Moves source files to `done/` on success or `failed/` on failure
- Persists job state to `state.json` — survives script restarts
- Falls back to polling `GET /v1/transcribe/{id}` if a webhook is missed (e.g. after a restart or network blip)
- Client-side rate limiting — self-throttles to stay within your plan's limits (never hits 429)
- Round-robin polling — every pending job gets checked, even with thousands in flight
- Handles 10,000+ files/day with no issues

### Prerequisites

- **Python 3.8+** (or **Node.js 18+** for the JavaScript version)
- A Scriptivox API key with balance — [create one here](/platform/keys)
- [ngrok](https://ngrok.com) (free tier works) to expose your local webhook server

### Setup

{% step number=1 title="Install dependencies" %}

**Python:**

```bash
pip install requests
```

**Node.js:** No dependencies needed — uses built-in `fetch` and `http` (Node 18+).

{% /step %}

{% step number=2 title="Expose your local webhook" %}

The script runs a webhook server on port `8765`. Use ngrok to make it accessible from the internet so Scriptivox can deliver results to it:

```bash
ngrok http 8765
```

Copy the HTTPS forwarding URL (e.g. `https://abc123.ngrok.io`). You'll use it in the next step.

{% /step %}

{% step number=3 title="Run the script" %}

Set your API key and webhook URL, then start:

```bash
export SCRIPTIVOX_API_KEY="sk_live_YOUR_KEY"
export WEBHOOK_URL="https://abc123.ngrok.io/webhook"
python transcribe_watcher.py
```

Or for Node.js:

```bash
SCRIPTIVOX_API_KEY=sk_live_YOUR_KEY \
WEBHOOK_URL=https://abc123.ngrok.io/webhook \
node transcribe_watcher.mjs
```

{% /step %}

{% step number=4 title="Drop files and get transcripts" %}

Put any audio or video file in the `input/` folder. The script picks it up on the next scan, uploads it, and transcribes it. Files are moved automatically once processed:

```
project/
  input/                    <- drop files here
  output/
    meeting.txt             <- transcripts appear here
    podcast.txt
  done/
    meeting.mp3             <- source files after success
    podcast.wav
  failed/
    corrupted.mp3           <- source files after failure
  state.json                <- tracks pending jobs (survives restarts)
  transcribe_watcher.py     <- or transcribe_watcher.mjs for Node
```

{% /step %}

### The script

{% code-block tabs="watcherTabs" /%}

### How it works

1. **Startup** — Creates `input/`, `output/`, `done/`, and `failed/` folders. Loads any pending jobs from `state.json` (so it picks up where it left off after a restart). Starts a webhook server on port 8765 in a background thread. Starts a separate polling thread for webhook fallback.
2. **Scan loop** — Every 10 seconds, scans `input/` for files with [supported extensions](/docs/api-reference#supported-formats). New files are submitted to a concurrent worker pool (10 workers by default) for parallel upload and transcription.
3. **Rate limiting** — Before each API call, the client-side rate limiter checks whether the request fits within the configured limit. If at capacity, it waits automatically. This prevents 429 errors instead of reacting to them. Set the limits slightly below your plan's actual limits to leave headroom.
4. **Upload** — Each worker requests a presigned upload URL via `POST /v1/upload`, then streams the file with a `PUT` request using the exact `Content-Type` returned by the API. Files are streamed from disk (no full-file memory load). If the API returns 429 despite client-side limiting, the script waits for the `Retry-After` duration and retries (up to 5 attempts).
5. **Transcribe** — Starts a transcription job via `POST /v1/transcribe` with the `upload_id` and `webhook_url`. The API returns immediately with `status: "created"` — the file is validated and transcribed in the background. The job is saved to `state.json` so it persists across restarts.
6. **Webhook** — When Scriptivox finishes a transcription, it POSTs the result to your webhook. The handler verifies the [HMAC-SHA256 signature](/docs/webhooks#verifying-webhook-signatures), saves the transcript to `output/<filename>.txt`, and moves the source file to `done/`. All webhook events are logged.
7. **Polling fallback** — A separate thread polls pending jobs every 30 seconds in round-robin order, so every job eventually gets checked — even with thousands in flight. This catches results if a webhook is missed (e.g. after a restart or network blip). Webhooks are best-effort with no retries, so this fallback is important.
8. **Failure** — If a transcription fails, the webhook (or poll) handler logs the error, moves the source file to `failed/`, and removes the job from state. If the upload itself fails (HTTP error, rate limit exhausted), the file is also moved to `failed/`. Either way, the watcher continues processing other files.

### Configuration

| Variable | Default | Description |
| --- | --- | --- |
| SCRIPTIVOX_API_KEY | — | Your API key (required) |
| WEBHOOK_URL | http://localhost:8765/webhook | Public URL for webhook delivery |
| MAX_PARALLEL_UPLOADS | 10 | How many files to upload at the same time |
| SCAN_INTERVAL | 10 | Seconds between folder scans |
| POLL_INTERVAL | 30 | Seconds between polling cycles |
| WEBHOOK_PORT | 8765 | Local port for the webhook server |
| DIARIZE | False | Set to True to enable speaker diarization (auto-detects number of speakers) |
| ALIGN | True | Word-level timestamps (set False to opt out; auto-forced True when DIARIZE is True) |
| INPUT_DIR | ./input | Folder to watch for new files |
| OUTPUT_DIR | ./output | Folder to save transcripts |
| DONE_DIR | ./done | Completed source files moved here |
| FAILED_DIR | ./failed | Failed source files moved here |
| RATE_LIMITS.upload | 50 | Max upload requests/min (keep below your plan's limit) |
| RATE_LIMITS.transcribe | 50 | Max transcribe requests/min (keep below your plan's limit) |
| RATE_LIMITS.poll | 150 | Max polling requests/min (keep below your plan's limit) |

To change the rate limits, concurrency, or directories, edit the constants at the top of the script. Set the rate limits slightly below your actual plan limits to leave headroom.

{% callout type="tip" title="Polling-only mode" %}
The script already polls pending jobs as a webhook fallback. If you'd rather skip webhooks entirely, remove the webhook server, drop `webhook_url` from the transcribe request, and lean on the polling thread alone. See the [Get Result](/docs/api-reference#get-result) endpoint for details.
{% /callout %}

{% callout type="info" title="Filenames must be ASCII" %}
The Scriptivox API rejects filenames with non-ASCII characters (accents, CJK, emoji) and reserved characters (`/`, `\`, `<`, `>`, `"`, `'`, backtick). The watcher will surface these as `INVALID_FILENAME` and move the file to `failed/`. Rename source files to ASCII before dropping them in `input/`. See [filename rules](/docs/api-reference#important-notes) for details.
{% /callout %}

---

{% card-grid columns=2 %}
{% nav-card title="API Reference" description="Full endpoint documentation" href="/docs/api-reference" /%}
{% nav-card title="Webhooks" description="Webhook setup and verification" href="/docs/webhooks" /%}
{% /card-grid %}

---

<!-- source: https://platform.scriptivox.com/docs/authentication -->
<!-- updated: 2026-08-25 -->

# Authentication

Every Scriptivox API request is authenticated with an API key. There is no session login, no OAuth flow, and no cookie — a key is the only credential the API accepts.

**Base URL:** `https://api.scriptivox.com/v1`

---

## Get a key

Create one at [platform.scriptivox.com/keys](/keys). Keys look like `sk_live_` followed by 48 hexadecimal characters.

The full key value is shown **once**, at creation. Store it immediately — the dashboard keeps only a prefix afterwards so you can tell your keys apart, and there is no way to recover a lost key. Create a replacement and revoke the old one instead.

{% callout type="warning" title="An API key is not a web subscription" %}
Scriptivox is two products, billed separately. A subscription on [scriptivox.com](https://www.scriptivox.com) grants nothing to the API, and API usage draws down a prepaid balance you top up at [/billing](/billing) — $0.20 per hour of audio. Even on Pro, `POST /v1/transcribe` returns `402 ZERO_BALANCE` at $0.
{% /callout %}

## Send the key

Two headers are accepted. They are equivalent — pick one:

```http
Authorization: sk_live_a1b2c3...
```

```http
X-Api-Key: sk_live_a1b2c3...
```

A `Bearer ` prefix on `Authorization` is accepted but not required, so `Authorization: Bearer sk_live_…` works too. Header names are case-insensitive.

```bash
curl https://api.scriptivox.com/v1/balance \
  -H "Authorization: sk_live_YOUR_KEY"
```

If both headers are present, `X-Api-Key` wins.

## Limits on keys

Each account may hold **at most 5 active keys**. If you hit the ceiling, revoke an unused key before creating another.

Rate limits are enforced per key per endpoint, plus a per-IP cap across all endpoints. Every response — including the `401` you get with no key at all — carries `RateLimit-Policy`, so you can read the limits before you have a working key. See [Rate Limits](/docs/api-reference#rate-limits).

## Rotating a key

Keys have no expiry, so rotation is something you choose to do rather than something forced on you:

1. Create the new key at [/keys](/keys).
2. Deploy it. Both keys are live and both draw on the same account balance, so there is no cutover window to coordinate.
3. Revoke the old key.

Revocation takes effect immediately. The gateway caches *malformed* keys for five minutes to blunt brute-force attempts, but a well-formed `sk_live_` key that starts returning `401` is never cached — a transient backend blip must not brick a real customer's key for five minutes, so every request re-checks. The cost of that choice is one extra lookup per failure; the benefit is that revoking a leaked key is instant.

## When authentication fails

| Status | Code | What happened |
| --- | --- | --- |
| `401` | `INVALID_API_KEY` | No key, a malformed key, or a key that does not exist. |
| `401` | `API_KEY_REVOKED` | The key was valid and has since been revoked. |
| `402` | `ZERO_BALANCE` | The key is fine. The account balance is $0 — add credit at [/billing](/billing). |
| `429` | `RATE_LIMIT_EXCEEDED` | Valid key, too many requests. Honour `Retry-After`. |

Repeated authentication failures from one IP earn a progressive block — 60 seconds, then 5 minutes, then 30 minutes. The block returns `429 RATE_LIMIT_EXCEEDED` with a `Retry-After` header rather than another `401`, so a client retrying a bad key in a tight loop will start seeing 429s. Fix the key rather than retrying.

Every error body is the same shape, with a stable machine-readable code:

```json
{
  "error": {
    "code": "INVALID_API_KEY",
    "message": "Invalid API key.",
    "docs_url": "https://platform.scriptivox.com/docs/api-reference#error-codes"
  }
}
```

The full list is in [Error Codes](/docs/api-reference#error-codes).

## Keeping a key secret

- **Never ship a key to a browser or a mobile app.** Anything a user can run, a user can read. Call the API from your own server and expose your own endpoint to the client.
- **Never commit one.** Read it from the environment: `SCRIPTIVOX_API_KEY` is the variable name the [CLI](/docs/cli) and the [MCP server](/docs/mcp) both use.
- **A leaked key spends real money.** Revoke it at [/keys](/keys) first, investigate second.

Webhook payloads are signed with HMAC-SHA256 keyed on your API key, so the key doubles as the shared secret for verifying callbacks — see [Webhooks](/docs/webhooks).

---

<!-- source: https://platform.scriptivox.com/docs/versioning -->
<!-- updated: 2026-08-25 -->

# Versioning and deprecation

This page is the promise you can build against: what we are allowed to change without telling you, what we are not, and how you find out before it happens.

---

## How the API is versioned

The version is in the URL path. Every endpoint lives under `/v1`:

```
https://api.scriptivox.com/v1/transcribe
```

There is no version header and no `Accept` negotiation on version. If a request works today at a given `/v1` URL, that URL is the contract.

`info.version` in [openapi.json](/openapi.json) is the version of the *document*, not of the API. It moves whenever the description is corrected. Do not pin behaviour to it — pin to `/v1`.

## What counts as a breaking change

Breaking changes only ever ship behind a new major version — a `/v2` prefix, never a silent change to `/v1`.

**Breaking**, and therefore never done to `/v1`:

- Removing an endpoint, a request parameter, or a response field.
- Renaming any of the above.
- Changing the type of a response field, or making an optional request parameter required.
- Removing a value from an enum you receive (a `status`, an `error.code`).
- Tightening a validation rule so a request that used to succeed now fails.

**Not breaking**, and shipped to `/v1` without notice — write your client so these cannot hurt it:

- Adding a new endpoint.
- Adding a new **optional** request parameter.
- Adding a new field to a response body.
- Adding a new value to an enum you *send* us.
- Adding a new `error.code`, or a new HTTP header.
- Fixing a bug where the documented behaviour and the actual behaviour disagreed.

{% callout type="warning" title="Two client habits that turn a safe change into an outage" %}
**Do not reject unknown fields.** We add response fields without notice. A client that treats an unrecognised key as a parse error will break on a change nobody was required to announce.

**Do not exhaustively switch on `error.code` without a default branch.** New codes are additive. Treat an unrecognised code as "an error I do not specifically handle", never as an unreachable case.

(Note the asymmetry: *we* reject unknown fields on request bodies — `additionalProperties: false` — because a silently ignored typo in your request is worse than a loud rejection.)
{% /callout %}

## Notice before anything is removed

If an endpoint, parameter or field is ever deprecated:

- **Six months minimum** between the deprecation announcement and the removal. No exception for anything except a security defect that cannot be fixed any other way, which will say so explicitly.
- **Twelve months minimum** of `/v1` running alongside `/v2` after a `/v2` exists, so migration is never a same-week emergency.
- The announcement is machine-readable. It is not only a blog post you have to be subscribed to.

## How deprecation is signalled on the wire

A deprecated endpoint answers normally and adds three response headers:

| Header | Meaning |
| --- | --- |
| `Deprecation` | [RFC 9745](https://www.rfc-editor.org/rfc/rfc9745.html). An HTTP date: when the resource became deprecated. |
| `Sunset` | [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594.html). An HTTP date: the earliest date the resource may stop responding. |
| `Link` | `rel="sunset"`, pointing at this page. |

```http
Deprecation: @1794787200
Sunset: Sat, 15 Aug 2026 00:00:00 GMT
Link: <https://platform.scriptivox.com/docs/versioning>; rel="sunset"
```

The response body is unchanged — a deprecation never breaks a working integration on the day it is announced. That is the whole point of the headers: an automated client can notice and open a ticket six months before anything stops working.

{% callout type="info" title="Nothing is deprecated today" %}
No endpoint, parameter or field in `/v1` currently carries a `Deprecation` or `Sunset` header, because nothing is scheduled for removal. The mechanism is in place and documented so it is already there on the day it is first needed — not invented in a hurry alongside the first removal.
{% /callout %}

## What to do if you see one

1. Read the `Link` target for the migration path.
2. Note the `Sunset` date. It is the earliest date the endpoint may stop working, not a date it will be checked again.
3. Migrate. If the `Sunset` date is inconvenient, say so at [support@scriptivox.com](mailto:support@scriptivox.com) *before* it passes — dates are set to be generous, and we would rather move one than break you.

## Watching for changes without polling

- **[openapi.json](/openapi.json)** is regenerated on every deploy and is the most precise description of the current surface. Diffing it in CI is the cheapest change detector there is; a removed `operationId` or a changed `required` list shows up as a diff before it shows up as an incident.
- **Every response** carries `Link: <https://platform.scriptivox.com/openapi.json>; rel="service-desc"` ([RFC 8631](https://www.rfc-editor.org/rfc/rfc8631.html)), so a client can find that document without hard-coding this page.
- **[status.scriptivox.com](https://status.scriptivox.com)** carries incidents and maintenance, which is a different thing from deprecation — availability, not contract.

## Other surfaces

The [CLI](/docs/cli) and the [MCP server](/docs/mcp) follow semver independently of the API version. Their major versions may move while the API stays at `/v1`: a tool renamed in the MCP server is a breaking change to that npm package, not to `/v1`. Deprecated MCP tool names are kept as working aliases and are listed in the [manifest](/.well-known/mcp) with the release that removes them.

---

<!-- source: https://platform.scriptivox.com/docs/cli -->
<!-- updated: 2026-08-25 -->

# CLI

`@scriptivox-api/cli` is the official command-line client for the Scriptivox transcription API. It transcribes recorded audio and video from a shell or a script — 119 languages, speaker diarization, word-level timestamps, and SRT / WebVTT / plain-text export.

It is a peer of the [MCP server](/docs/mcp): same API, different transport. Use the CLI when a human or a shell script is driving; use MCP when a model is.

**npm:** [`@scriptivox-api/cli`](https://www.npmjs.com/package/@scriptivox-api/cli) · **Command:** `scriptivox-api`

---

## Install

```bash
npm install -g @scriptivox-api/cli

# or run it without installing
npx @scriptivox-api/cli --help
```

Node 18 or newer. **Zero runtime dependencies** — nothing is pulled in at install time beyond the package itself.

## Authenticate

Create a key at [/keys](/keys) and add credit at [/billing](/billing).

```bash
export SCRIPTIVOX_API_KEY=sk_live_...
```

Or pass `--api-key` per command. See [Authentication](/docs/authentication) for the header details the CLI handles for you.

{% callout type="warning" title="The API is billed separately from the web app" %}
A subscription on [scriptivox.com](https://www.scriptivox.com) grants no API credit. API usage draws down a prepaid balance at $0.20 per hour of audio. That is why the package is `@scriptivox-api/cli` and the command is `scriptivox-api` rather than `scriptivox` — the name is a reminder of which product you are spending.
{% /callout %}

## Commands

Every command maps one-to-one onto an operation in the [OpenAPI specification](/openapi.json).

```bash
# Transcribe a public URL and wait for the result
scriptivox-api transcribe https://example.com/meeting.mp3 --language en --diarize --wait

# Upload a local file, transcribe it, wait
scriptivox-api transcribe ./interview.m4a --diarize --speakers 2 --wait

# Export captions — stdout is clean, progress goes to stderr
scriptivox-api get 4f3c... --format srt --max-words 3 > interview.srt
scriptivox-api get 4f3c... --format vtt --speakers-in-captions true > interview.vtt

# Inspect and manage jobs
scriptivox-api status 4f3c...
scriptivox-api list --status completed --limit 20 --json
scriptivox-api cancel 4f3c...
scriptivox-api delete 4f3c...

# Check the balance
scriptivox-api balance
```

`scriptivox-api --help` lists every flag.

## Scripting

Structured output goes to **stdout**, progress and diagnostics to **stderr**, so a redirect produces a clean file even while the command is reporting status. `--json` puts JSON on stdout and silences progress entirely.

Exit codes are part of the contract:

| Code | Meaning |
| --- | --- |
| `0` | Success |
| `1` | Usage error — bad arguments, missing API key, unreadable file |
| `2` | API error — the machine-readable error code is printed on stderr |

```bash
if ! scriptivox-api transcribe ./call.mp3 --wait --json > result.json; then
  echo "transcription failed with exit $?" >&2
fi
```

The exit-code contract is guarded by an offline test suite that never touches the network, so a refactor cannot quietly change a code that scripts depend on.

## Things worth knowing

- **`transcribe` returns before the work is done.** Without `--wait` you get a job ID; input problems — unreachable URL, unsupported media, audio too long — surface *later* on `status`, as `status: failed` with an `error.code`. Check the poll path, not just the submit call.
- **Pass `--language` when you know it.** Auto-detection works most of the time but mis-routes short clips, code-switched audio, and files that open with music. It is also faster, because the model skips its detection pass.
- **`--speakers` needs `--diarize`.** It is a prior, not a hard cap: asking for 5 may yield 6.
- **Failed and cancelled jobs are free.** The reserved balance is released.
- **`--idempotency-key` makes retries safe.** The same key with the same body replays the cached response for 24 hours instead of starting a second, separately-billed job.

## Scope

The CLI talks to `https://api.scriptivox.com/v1` and nothing else. There is no session login, no cookie handling, and no access to the web app or the developer dashboard — an `sk_live_…` key is the only credential it understands.

MIT licensed. Bug reports and feature requests: [support@scriptivox.com](mailto:support@scriptivox.com).

---

<!-- source: https://platform.scriptivox.com/docs/mcp -->
<!-- updated: 2026-08-30 -->

# MCP server

Scriptivox ships an official [Model Context Protocol](https://modelcontextprotocol.io) server, so Claude, ChatGPT and any other MCP client can transcribe audio and video as a native tool call instead of you writing an API integration.

There are two ways to run it. They expose the **same tools** and differ only in where the code runs.

| | Hosted | Local (stdio) |
| --- | --- | --- |
| Transport | Streamable HTTP | stdio |
| Where | `https://platform.scriptivox.com/mcp` | `npx @scriptivox/mcp-server` |
| Install | Nothing to install | Node 18+ |
| Local files | No — this server cannot read your disk | Yes, `transcribe_upload` works |
| Best for | Web clients, quick setup, shared configs | Desktop clients with local media |

---

## Hosted (Streamable HTTP)

```json
{
  "mcpServers": {
    "scriptivox": {
      "url": "https://platform.scriptivox.com/mcp",
      "headers": {
        "Authorization": "Bearer sk_live_YOUR_KEY"
      }
    }
  }
}
```

`https://www.scriptivox.com/mcp` resolves to the same endpoint; `platform.` is canonical.

The endpoint is **stateless**: it issues no `Mcp-Session-Id` and every request stands alone, so it behaves identically whether or not the instance handling it saw your `initialize`. It always answers with a single JSON response rather than an SSE stream, which the protocol permits and every conforming client handles.

Protocol revisions accepted: `2025-11-25`, `2025-06-18`, `2025-03-26`. `initialize` echoes yours when we speak it, and otherwise answers with the newest we support so a client that is one revision ahead can step down rather than fail.

## Local (stdio)

```json
{
  "mcpServers": {
    "scriptivox": {
      "command": "npx",
      "args": ["-y", "@scriptivox/mcp-server"],
      "env": { "SCRIPTIVOX_API_KEY": "sk_live_YOUR_KEY" }
    }
  }
}
```

Published as [`@scriptivox/mcp-server`](https://www.npmjs.com/package/@scriptivox/mcp-server) on npm and as `sparkleofficialmain/scriptivox-mcp-server` on Docker Hub. Source: [github.com/SparkleOfficial/scriptivox-mcp-server](https://github.com/SparkleOfficial/scriptivox-mcp-server).

Use this one when the media is on the machine running the client — it is the only version with filesystem access, and therefore the only one where `transcribe_upload` can work.

## Authentication

Get a key at [/keys](/keys); see [Authentication](/docs/authentication) for the details.

The hosted endpoint accepts `Authorization: Bearer sk_live_…`, bare `Authorization: sk_live_…`, or `X-Api-Key: sk_live_…`. Cookies are ignored entirely. Your key is used for exactly one upstream API call per tool invocation and is never logged or stored.

{% callout type="info" title="Four tools work with no key at all" %}
`get_pricing`, `get_supported_languages`, `get_product_info` and `get_api_docs` need no credential, so an agent can find out what Scriptivox costs and what it covers *before* anyone has created an account.

Calling a key-requiring tool without a key does not return HTTP 401 — it returns a normal tool result with `isError: true` and instructions. That is deliberate: a 401 from an MCP endpoint sends clients into OAuth discovery, and there is no authorization server behind it, so you would get a dead end instead of the one sentence you need.
{% /callout %}

## Tools

| Tool | Key? | What it does |
| --- | --- | --- |
| `get_pricing` | no | API rates and plan information. |
| `get_supported_languages` | no | The 119 languages and their codes. |
| `get_product_info` | no | What Scriptivox does, by topic. |
| `get_api_docs` | no | Pointers into this documentation. |
| `check_balance` | yes | Remaining balance and estimated audio hours. |
| `transcribe_url` | yes | Transcribe from a public URL. |
| `transcribe_status` | yes | Poll a job and fetch its transcript. |
| `transcribe_upload` | yes | Transcribe a local file. **Local server only.** |
| `transcribe_cancel` | yes | Stop an in-flight job, release its reservation. |
| `transcribe_delete` | yes | Soft-delete a finished transcription. |
| `list_transcriptions` | yes | List jobs with filters and cursor pagination. |
| `export_transcript` | yes | Export as SRT, WebVTT or plain text. |

`transcription_url` and `transcription_status` are also registered as deprecated aliases of `transcribe_url` and `transcribe_status`, kept so `@scriptivox/mcp-server@1.0.x` configurations keep working. They are removed in 2.0.0.

The authoritative list is the manifest at [/.well-known/mcp](/.well-known/mcp), and a build check asserts that it, the hosted endpoint and the published stdio server all register exactly the same names — a client that reads a tool out of the manifest must never get "unknown tool" back when it calls it.

## Two things to expect

**`transcribe_url` waits, but not forever.** By default it polls until the job finishes. The hosted endpoint stops waiting after about 55 seconds and returns a **non-error** result carrying the `transcription_id` and telling you to call `transcribe_status` — the job itself is unaffected and still running. Pass `await_completed: false` to get the id immediately instead, or a `webhook_url` to be told rather than having to ask. The local stdio server has no such ceiling and waits up to 10 minutes.

**`transcribe_upload` cannot work over the hosted endpoint.** It takes a path on your filesystem, and this server has none of your files. It is still registered — hiding a tool the manifest lists would be its own bug — and returns an error explaining the three ways forward: a public URL with `transcribe_url`, the local stdio server, or the three-step REST upload flow.

## Checking it by hand

```bash
curl -s https://platform.scriptivox.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize",
       "params":{"protocolVersion":"2025-11-25","capabilities":{},
                 "clientInfo":{"name":"curl","version":"1.0"}}}'

curl -s https://platform.scriptivox.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2025-11-25' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
```

`GET` and `DELETE` answer `405` — there is no standalone stream to open and no session to terminate.

## The documentation server

Beyond the product server above, `https://www.scriptivox.com/mcp/docs` speaks the
same Streamable HTTP protocol and registers exactly two tools:

| Tool | What it does |
|---|---|
| `search_docs` | Lexical search across these documentation pages and the agent guides. Returns URLs and summaries, best match first. |
| `get_doc` | Fetches one page as markdown, by slug (`quickstart`, `authentication`, ...). |

Both need **no credential**, touch no account, and cannot change anything. They
are registered on the main `/mcp` endpoint too, so a client already connected
there does not have to open a second transport to look something up. The
separate endpoint exists for a client that wants reference material and nothing
else, and does not want to be handed thirty-nine tools it will never call.

The scoping is a clear contract, not a permission boundary — do not build
anything that treats `/mcp/docs` as a sandbox.

## Tools in the page itself (WebMCP)

The two servers above serve an agent calling Scriptivox from outside. WebMCP is
the other case: an agent driving the browser a person is already signed into. It
needs no credential at all, because the session is right there.

Scriptivox registers its page tools with `document.modelContext.registerTool()`
(`navigator.modelContext` is the deprecated pre-Chrome-150 alias, and is still
accepted as a fallback):

| Tool | What it does |
|---|---|
| `scriptivox_whoami` | Whether anybody is signed in on this page, and as whom. Call it first. |
| `scriptivox_open_signup` | Opens the signup form. It does **not** create an account. |
| `scriptivox_get_plan_status` | The signed-in person's plan and entitlements. |
| `scriptivox_start_plan_checkout` | Returns a Stripe Checkout URL. Charges nothing. |
| `scriptivox_create_api_key` | Mints an API key — the bridge from a web account to the metered API. |

**There is deliberately no transcription tool here.** Web plans include
unlimited transcription and are priced for one human; an agent driving the
browser could otherwise run an archive through a plan sold on the assumption
that a person is clicking. Programmatic transcription belongs on the metered
API, and `scriptivox_create_api_key` is the door to it.

`scriptivox_open_signup` navigates to the form and cannot submit it, which is
the same reason the homepage's declarative `toolname` attributes are on the
question form and not on the signup form.

The homepage also carries a WebMCP-annotated `<form>` (`toolname="ask_scriptivox"`)
that works with no JavaScript at all — it GETs [/ask](/docs/mcp), the NLWeb
endpoint, which renders HTML when the client prefers it and JSON otherwise.

Availability: WebMCP is a W3C draft. It is a secure-context feature and absent
in most browsers today; the page registers what it can and does nothing when the
API is missing.

## Versioning

The MCP server follows semver independently of the API's `/v1` path: a tool renamed in the server is a breaking change to the npm package, not to the REST API. See [Versioning](/docs/versioning) for how deprecations are announced.

---
