# API endpoints

> Every route with its parameters, responses and status codes. Create jobs, follow them, upload files, ask questions, read your account and your keys.

Page: https://scribiz.com/docs/api/endpoints

Base URL `https://scribiz.com/api`. Every path below is relative to it: `POST /v1/context` is `POST https://scribiz.com/api/v1/context`. Send `Authorization: Bearer <key>` on every call except `/health`, `/v1/status`, `/v1/openapi.json` and the anonymous web tool. For the shape of errors, see [Errors and limits](https://scribiz.com/docs/api/errors.md).

## Jobs

### `POST /v1/context`

Create a job that builds the Context of a link or an upload. The call checks the request, resolves the link, quotes the cost and checks your plan, then queues the job. Nothing is queued if any step refuses.

The body is JSON, up to 64 KB, and takes only the fields below. Send exactly one of `url` and `upload_id`.

| Field | Type | Description |
| --- | --- | --- |
| `url` | string | A public video or audio link, up to 2,048 characters. |
| `upload_id` | string | An id from `PUT /v1/uploads`. |
| `mode` | string | `auto` (default), `captions`, `audio`, `visual` or `full`. `listen`, `watch` and `both` are aliases for `audio`, `visual` and `full`. See [Overview](https://scribiz.com/docs/overview.md#modes). |
| `language` | string or array | A BCP-47 language hint, such as `en` or `pt-BR`. Up to 5 in an array. |
| `options` | object | Optional settings, below. |
| `turnstile_token` | string | A Cloudflare Turnstile token. Anonymous Listen and Watch need one when the server has Turnstile on. |

| `options` field | Type | Description |
| --- | --- | --- |
| `detail` | `transcript`, `brief`, `full` | How much to write besides the transcript. Default `full`. |
| `diarize` | boolean or `"auto"` | Separate the speakers. Default `"auto"`. |
| `proofread` | boolean, `"terms"`, `"full"` or `"auto"` | How much to correct. |
| `include_words` | boolean | Keep the word list in the result. Accounts only. |
| `quality` | `standard` or `high` | Accounts only. |
| `visual` | object | `fps` from 0.1 to 2 and `max_scenes` from 1 to 200. Accounts only. |
| `vocabulary` | array of strings | Up to 50 names and terms, 100 characters each. Accounts only. |

An anonymous request that sets an accounts-only option is `403` with `account_required`.

| Header | Description |
| --- | --- |
| `Authorization` | `Bearer <key>` |
| `Idempotency-Key` | Optional. A repeated request with the same key and body returns the same job. |

```bash
curl https://scribiz.com/api/v1/context \
  -H "Authorization: Bearer $SCRIBIZ_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \
  -d '{"url": "https://www.youtube.com/watch?v=jNQXAC9IVRw", "mode": "auto"}'
```

```http title="202 Accepted"
HTTP/1.1 202 Accepted
Location: /v1/jobs/job_2DUafpGltJTqVKlAYlIREm
Content-Type: application/json
```

```json title="Body"
{
  "estimate": {
    "cached": false,
    "charges_as": "captions",
    "media_seconds": 19,
    "minutes": 0.031667,
    "tiers": ["captions", "synthesis"]
  },
  "events_url": "/api/v1/jobs/job_2DUafpGltJTqVKlAYlIREm/events",
  "job_id": "job_2DUafpGltJTqVKlAYlIREm",
  "mode": "auto",
  "queue_position": 1,
  "result_url": "/api/v1/jobs/job_2DUafpGltJTqVKlAYlIREm",
  "status": "queued"
}
```

| Field | Description |
| --- | --- |
| `job_id` | The job id. |
| `status` | `queued`. |
| `mode` | The mode you asked for. |
| `estimate` | The quote. `minutes` is what the job charges if it runs as quoted, and 0 when `cached` is true. `charges_as` is `captions`, `audio`, `visual` or `full`. `media_seconds` is the length of the source. `tiers` lists the steps that will run. |
| `events_url`, `result_url` | Paths on the host you called, under the prefix you used. Called at `https://scribiz.com/api/v1/context`, they start with `/api/v1`: join them to `https://scribiz.com`, not to the base. |
| `queue_position` | How many jobs are ahead. |
| `degraded` | Present when Auto was switched to captions because the free Listen and Watch budget is spent: `{ "from": "auto", "to": "captions", "reason": "busy" }`. |
| `idempotent_replay` | `true` when this is the job of an earlier request with the same `Idempotency-Key`. The response also has the header `Idempotent-Replayed: true`. |
| `upload_id` | Only from `POST /v1/transcribe`. |

A cached result still goes through a job and finishes almost at once. A request that could not be accepted fails before a job exists:

| Status | Code | When |
| --- | --- | --- |
| 400 | `invalid_request` | The body is not valid. The message says what is wrong. |
| 401 | `invalid_api_key`, `unauthorized` | The key is not valid |
| 402 | `quota_exceeded` | Not enough minutes. Carries `minutesNeeded` and `minutesRemaining`. |
| 403 | `account_required`, `turnstile_required`, `turnstile_failed`, `insufficient_scope` | The caller may not do this |
| 404 | `upload_not_found` | The upload id is not yours or does not exist |
| 409 | `upload_in_use` | The upload already belongs to another job |
| 410 | `upload_expired` | The upload is more than an hour old |
| 413 | `video_too_long` | The video is over your plan's limit |
| 415 | `unsupported_media_type` | The upload cannot be read |
| 422 | `url_unsupported`, `source_unavailable`, `source_live`, `source_auth_required` and the other source errors | The link cannot be read. Carries `engineCode` and a `hint`. |
| 429 | `anonymous_limit`, `too_many_in_flight`, `rate_limited` | A limit. Honor `Retry-After`. |
| 451 | `blocked_content` | The video was removed after a takedown request |
| 503 | `busy`, `not_configured` | The queue is full, the disk is low, or Listen and Watch are paused for anonymous use |

A link that needs a login, such as an Instagram post, is refused here with `source_auth_required`, not reported later on the job. See [Errors and limits](https://scribiz.com/docs/api/errors.md).

### `GET /v1/jobs/:id`

The state of a job. When it succeeded, `result` is the Context.

```json title="200 OK, while running"
{
  "attempt": 1,
  "cache_hit": null,
  "cancel_requested": false,
  "created_at": 1791094927185,
  "error": null,
  "estimate": { "cached": false, "charges_as": "audio", "media_seconds": 12.120726, "minutes": 0.202012, "tiers": ["audio", "synthesis"] },
  "events_url": "/api/v1/jobs/job_7b9rgMRwWjy1mIoS64A8CU/events",
  "expires_at": 1793686927185,
  "finished_at": null,
  "input": { "kind": "upload", "upload_id": "upl_1MOSdD3kCZtK72deTv7Dw6" },
  "job_id": "job_7b9rgMRwWjy1mIoS64A8CU",
  "minutes_charged": null,
  "mode": "audio",
  "progress": 0.88,
  "result": null,
  "result_url": "/api/v1/jobs/job_7b9rgMRwWjy1mIoS64A8CU",
  "stage": "synthesize",
  "started_at": 1791094927185,
  "status": "running"
}
```

| Field | Type | Description |
| --- | --- | --- |
| `job_id` | string | The job id. |
| `status` | string | `queued`, `running`, `succeeded`, `failed` or `canceled`. |
| `mode` | string | The mode as requested. |
| `input` | object | `{ "kind": "url", "url": ... }` or `{ "kind": "upload", "upload_id": ... }`. A link is shown without credentials or a signed query. |
| `stage` | string or null | The current step: `resolve`, `download`, `extract`, `transcribe`, `visual`, `synthesize` and the others listed in [The JSON protocol](https://scribiz.com/docs/cli/json-protocol.md#lines-you-will-see). |
| `progress` | number or null | 0 to 1, never going backwards. A rough guide, not a promise. |
| `estimate` | object | The quote from creation. |
| `cache_hit` | boolean or null | Whether the job was served from stored results. Null until it ends. |
| `minutes_charged` | number or null | Minutes charged. Null until the job ends, and null for a job that failed or was canceled. |
| `degraded` | object | As on creation, when present. |
| `result` | Context or null | The [Context](https://scribiz.com/docs/api/context-object.md), when `status` is `succeeded`. |
| `error` | object or null | The engine's error, when `status` is `failed` or `canceled`. See [The error object](https://scribiz.com/docs/api/errors.md#the-error-object). |
| `attempt` | number | How many times the job was started. A job interrupted by a restart runs again, up to three times. |
| `cancel_requested` | boolean | Whether `DELETE` was called while it ran. |
| `created_at`, `started_at`, `finished_at`, `expires_at` | number or null | Milliseconds since the Unix epoch. Results are deleted at `expires_at`: 24 hours after creation for anonymous jobs, 30 days for accounts. |
| `events_url`, `result_url` | string | Where to stream and where to read. |

A job that belongs to another account is `404` with `not_found`, the same as one that does not exist. An expired job is `410` with `job_expired`. A recording with no speech, such as music, is a normal `succeeded` job with `result.transcript` set to `null` and the warning `NO_SPEECH_DETECTED`.

### `GET /v1/jobs/:id/events`

A stream of server-sent events for one job, for a live progress display.

```bash
curl -N https://scribiz.com/api/v1/jobs/job_2DUafpGltJTqVKlAYlIREm/events \
  -H "Authorization: Bearer $SCRIBIZ_API_KEY"
```

- Each event is three lines: `id: <number>`, `event: <type>` and `data: <JSON>`. The data is one object. These are the same objects as in [The JSON protocol](https://scribiz.com/docs/cli/json-protocol.md): `start`, `plan`, `stage`, `progress`, `chunk`, `retry`, `warning`, `partial`, `usage`, `cache`, `done` and `error`. The API adds `queued`, `started` (once per attempt) and `requeued`.
- The stream starts with `retry: 3000`, and then replays every stored event.
- Reconnect with a `Last-Event-ID` header, or `?last_event_id=`, and the stream replays what you missed, then continues.
- A `: ping` line, a comment, is sent every 15 seconds.
- The stream ends after exactly one `done` or one `error`. A canceled job ends with an `error` whose code is `CANCELLED`.
- `progress` and `partial` events are thinned to about one every 400 milliseconds per step. A job keeps at most 2,000 events.
- An owner can hold 8 streams open at once. More is `429` with `too_many_streams`.
- A `partial` event says how much of the video the transcript covers so far, so a page can show text while the rest is still running.

A stream that drops is not a failed job. Read `GET /v1/jobs/:id` to find out.

### `DELETE /v1/jobs/:id`

Cancel a running job, or delete the stored result of a finished one. Only the account or anonymous caller that created the job can do it. Anyone else gets `403` with `not_owner`.

```bash
curl -X DELETE https://scribiz.com/api/v1/jobs/job_0fVFR5QoOu2P2t3QkDkj2H \
  -H "Authorization: Bearer $SCRIBIZ_API_KEY"
```

- A **queued** job is canceled at once and answers `200`.
- A **running** job is aborted. The call waits up to 10 seconds and answers `200` with the final job, whose `status` is `canceled` and whose `error.code` is `CANCELLED`. If it is still winding down, the answer is `202`. Nothing is charged for a canceled job.
- A **finished** job has its result erased and answers `200` with `{ "job_id": "...", "status": "deleted" }`. A later `GET` answers `404`.

## Uploads

### `PUT /v1/uploads`

Upload a media file to use in a job. The request body is the file itself, not JSON and not multipart. It takes audio and video files.

| Header | Description |
| --- | --- |
| `Content-Type` | The type of the file, for example `audio/mpeg`, `audio/mp4`, `audio/wav` or `video/mp4`. |
| `Content-Length` | Required. The body is streamed and capped at 100 MB. |
| `X-File-Name` | The original file name, URL-encoded. |

```json title="201 Created"
{
  "bytes": 73264,
  "duration_seconds": 12.120726,
  "expires_at": 1791097083297,
  "file_name": "speech.mp3",
  "has_audio": true,
  "has_video": false,
  "mime": "audio/mpeg",
  "upload_id": "upl_4dEjVvJnlzplZvFcA2PULr"
}
```

`ffprobe` must name a real media container. Scribiz decides that from the file, not from the `Content-Type` or the extension. A playlist or a text file is refused with `415`. An upload is kept for one hour. Pass its id to `POST /v1/context` in that time. The file belongs to one job: it is removed when the job succeeds, and kept for a retry when it fails.

| Status | Code | When |
| --- | --- | --- |
| 400 | `length_mismatch` | The body was not as long as `Content-Length` said |
| 411 | `length_required` | No `Content-Length` |
| 413 | `body_too_large` | The file is over 100 MB. Checked before the body is read. |
| 415 | `unsupported_media` | Not a media file Scribiz can read |
| 429 | `too_many_in_flight`, `too_many_uploads` | Too many uploads at once, or waiting to be used (anonymous 1 at a time and 3 waiting, accounts 2 and 10) |
| 503 | `busy` | The disk is low |

### `POST /v1/transcribe`

A shortcut that uploads a file and creates a job in one request. It takes a `multipart/form-data` body with the file in `file`, and optional `mode`, `language`, `options` (a JSON string) and `turnstile_token`. It has the same 100 MB cap, and answers like `POST /v1/context` plus `upload_id`. If the job is refused after the file arrived, the error carries `upload_id`, so you can call `POST /v1/context` without sending the file again.

```bash
curl https://scribiz.com/api/v1/transcribe \
  -H "Authorization: Bearer $SCRIBIZ_API_KEY" \
  -F "file=@talk.mp3" \
  -F "mode=audio"
```

The server buffers a multipart body in memory, about three times the size of the file, and parses one at a time. Prefer `PUT /v1/uploads`.

## Questions

### `POST /v1/ask`

Ask a question about a Context you already have. It costs 0.1 minutes.

| Field | Type | Description |
| --- | --- | --- |
| `job_id` | string | A job of yours that has succeeded. Give this or `context`. |
| `context` | object | A Context, at most 1 MB. Give this or `job_id`. |
| `question` | string | 1 to 8,000 characters. |

```json title="200 OK"
{
  "answer": "The speaker mentions that the cool thing about elephants is that they have really, really long trunks, and adds that that is pretty much all there is to say about them.",
  "citations": [
    { "start": 5.318, "end": 14.367, "text": "the cool thing about these guys is that they have really... really really long trunks and that's cool" },
    { "start": 16.881, "end": 18.881, "text": "and that's pretty much all there is to say" }
  ],
  "minutes_charged": 0.1
}
```

`citations` point at real transcript segments. This route never starts the on-screen layer: for a question about the picture on a Context that has none, `notes` says so. A job that has not succeeded is `409` with `job_not_ready`. Anonymous callers get 5 questions a day, then `429` with `anonymous_limit`.

## Account

### `GET /v1/me`

The account behind the key, its plan and its minutes. Without a credential it describes the anonymous limits instead.

```json title="200 OK, with a key"
{
  "anonymous": false,
  "auth": { "keyId": "key_GDAeFMIBQ2lh", "scopes": "all", "via": "api_key" },
  "entitlement": {
    "lifetimeMac": false,
    "minutesIncluded": 30,
    "minutesTopup": 0,
    "period": { "end": 1793491200000, "key": "2026-10-01", "start": 1790812800000 },
    "plan": "free",
    "planUntil": null,
    "source": "none"
  },
  "limits": {
    "apiKeys": { "active": 1, "max": 10 },
    "maxConcurrentJobs": 2,
    "maxVideoSeconds": 7200,
    "minutesPerPeriod": 30,
    "proxy": { "maxInflightCalls": 4, "requestsPerMinute": 60, "uploadBytesPerDay": 1073741824 }
  },
  "plan": "free",
  "usage": {
    "audioSeconds": 0,
    "costMicros": 0,
    "includedRemaining": 30,
    "minutesRemaining": 30,
    "minutesTopup": 0,
    "minutesUsed": 0,
    "period": "2026-10-01",
    "periodEnd": 1793491200000,
    "periodStart": 1790812800000
  },
  "user": { "email": "you@example.com", "id": "xEPzDpPvurJRuQneA7TXzN9ldow7Xo25", "name": "" }
}
```

| Field | Description |
| --- | --- |
| `plan` | `free`, `pro` or `lifetime`, or `anonymous` |
| `auth` | How the call was authenticated: `via` is `api_key` for a key, `bearer` for a session token or `session` for a browser session, with the `scopes` and the `keyId` of a key |
| `entitlement.minutesIncluded` | Minutes the plan gives for the current period |
| `entitlement.minutesTopup` | Top-up minutes. They do not expire. |
| `entitlement.period` | The current period. Free and Pro minutes reset when it ends. |
| `entitlement.lifetimeMac` | Whether the account has the Mac Lifetime license |
| `usage.minutesUsed` | Minutes used in the current period |
| `usage.minutesRemaining` | Included minutes left plus top-up minutes |
| `limits` | The limits of the plan: video length, running jobs, keys |

For an anonymous caller, `usage` has `day`, `minutesUsed`, `minutesRemaining`, `captionJobsUsed`, `captionJobsRemaining` and `resetsAt`, and `limits` has `minutesPerDay`, `captionJobsPerDay`, `asksPerDay`, `maxVideoSeconds`, `maxConcurrentJobs` and `resultTtlHours`.

## Keys

### `GET /v1/keys`

List your keys. The key itself is never returned again, only its prefix and its last four characters. It works with a key that has the `all` scope. See [API authentication](https://scribiz.com/docs/api/authentication.md#what-a-key-looks-like) for the response.

### `POST /v1/keys`

Create a key. It needs a signed-in session, which the dashboard has. A key cannot call it: that is `403` with `session_required`. The response is the only time you see the key.

| Field | Type | Description |
| --- | --- | --- |
| `name` | string | A label, such as the service that will use the key. |
| `scopes` | string | `all`, `read` or `mcp`. Default `all`. |
| `expiresInDays` | number | Optional. The key stops working after this many days. |

```json title="201 Created"
{
  "createdAt": 1791093434663,
  "createdVia": "dashboard",
  "deviceLabel": null,
  "expiresAt": null,
  "id": "key_T09PW9orWbQL",
  "key": "sbz_live_your_new_key_here",
  "lastUsedAt": null,
  "last4": "UYZC",
  "name": "mcp agent",
  "prefix": "sbz_live_",
  "scopes": "mcp"
}
```

An account can hold up to 10 active keys. The next is `409` with `key_limit_reached`.

### `DELETE /v1/keys/:id`

Revoke a key. It needs a signed-in session. The key stops working at once, and everywhere within about a minute. A key that is not yours is `404`.

## Other routes

### `GET /v1/status`

How the service is doing, for a status page. No key needed. The answer is cached for 10 seconds.

```json title="200 OK"
{
  "free_tier": { "listen_watch": "open" },
  "ok": true,
  "queue": { "capacity": 2, "queued": 0, "running": 0 },
  "sources": [
    { "avg_seconds": 8.2, "blocked": 0, "failed": 0, "jobs": 1, "source": "youtube", "succeeded": 1, "success_rate": 1 }
  ],
  "status": "operational",
  "time": 1791093526634,
  "window_hours": 24
}
```

`sources` covers the last 24 hours. A failure that is the caller's, such as a private video or no minutes, does not count against a source. `free_tier.listen_watch` is `paused` when Listen and Watch are switched off for anonymous callers. `status` is `degraded` then.

### `GET /v1/openapi.json`

The OpenAPI 3.1 description of the API. No key needed.

### `GET /health`

Returns `{"ok":true}` when the service is up. It reports nothing else.

### `POST /v1/billing/checkout`

Starts a purchase and answers where to send the buyer: `{ "url", "sessionId", "expiresAt", "amount", "currency", "earlyBird" }`. `url` is a Stripe payment page, and `amount` (in cents, before tax) is what that page charges. Send `plan` (`pro`, `mac-lifetime` or `topup`), with `interval` for Pro and `pack` for a top-up, and never a price. It needs a signed-in session, not an API key. While nothing is on sale it answers `501` with the code `billing_not_configured`.

### `DELETE /v1/account`

Delete your account and everything it owns: results, keys and files at Google. It needs a signed-in session.

### `/mcp`

The remote MCP server. It speaks the Streamable HTTP transport and takes the same API keys. It also answers without a key, inside a small daily allowance. See [MCP](https://scribiz.com/docs/mcp.md).

## Routes that are not part of the API

These exist for Scribiz's own apps. Do not build on them. They can change without notice.

- `POST /v1/cli/exchange` swaps a browser approval for an API key. It is the last step of command-line sign-in (`scribiz login`), and the key it returns is the one the CLI saves.
- `/v1/gemini/*` is a metered, allow-listed route that the CLI uses when it is signed in to a Scribiz account. Usage is measured from what the model reports, not from anything the caller says.
- `https://scribiz.com/api/auth/*` is sign-in for the website and the device flow. The code you confirm is entered at `https://scribiz.com/login/device`.

---

Checked against the Scribiz build on 2026-10-04.
