# Voibe Transcription API

> Send a recording, get back a speaker-labelled transcript and a summary. Create the job, upload the audio, then poll for the result or let a webhook bring it to you.

Base URL: https://api.getvoibe.com/v1  
Auth: Bearer token in the Authorization header  
Billing: per second of audio, charged only when a job reaches DONE  
MCP server (for AI apps): https://api.getvoibe.com/mcp, see https://platform.getvoibe.com/mcp.md  
Human version of this page: https://platform.getvoibe.com/docs

Two ways to use Voibe:

- REST API: Build transcription into your product. Three endpoints. https://platform.getvoibe.com/docs.md
- MCP for AI apps: Use it from Claude, Cursor, Codex or ChatGPT. Connect once, then ask. https://platform.getvoibe.com/mcp.md

## Authentication

Every request carries your API key as a Bearer token. Create and delete keys on the [API keys](https://platform.getvoibe.com/keys) page.

Keys start with `vb_live_` and are shown once at creation. Deleting a key stops it on the next request. Requests without a valid key return `401` and are never billed.

```http
Authorization: Bearer $VOIBE_KEY
```

## Quickstart

Three requests to get the complete transcript and summary.

### 01 Create the job

The body is JSON. You get back a `job_id` and an `upload_url` to send the audio to. Nothing is billed until the job finishes.

```bash
curl -s -X POST "https://api.getvoibe.com/v1/transcripts" \
  -H "Authorization: Bearer $VOIBE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"webhook_url": "https://you.dev/ready"}'   # optional
```

Response 201:

```json
{
  "job_id": "a523721c-…",
  "status": "QUEUED",
  "upload_url": "https://…/audio?X-Amz-Signature=…"
}
```

### 02 Upload the audio

Send the raw file to `upload_url` with `PUT`. The URL goes straight to storage. The header must be `Content-Type: application/octet-stream`. Transcription starts the moment the upload lands. The URL works for 5 hours and takes one file of up to 200 MB. One job transcribes one file. A second upload to the same URL is ignored.

```bash
curl -s -X PUT "$UPLOAD_URL" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @meeting.mp3
```

### 03 Get the result

While working, `status` is `QUEUED` or `PROCESSING`. When it is `DONE` you get the transcript, the plain text, the summary and a short title. On `FAILED`, `error` says why. Failed jobs are never charged.

```bash
curl -s "https://api.getvoibe.com/v1/transcripts/$JOB_ID" \
  -H "Authorization: Bearer $VOIBE_KEY"
```

Response 200:

```json
{
  "job_id": "a523721c-…",
  "status": "DONE",
  "title": "Pricing page review",
  "audio_duration_seconds": 205.27,
  "seconds_charged": 206,
  "processing_seconds": 61.4,
  "diarize": true,
  "prompt": null,
  "transcript": [
    { "speaker": "speaker_0", "start": 2.7,  "end": 47.1,
      "text": "Today, as the agenda states, we start with pricing." },
    { "speaker": "speaker_1", "start": 47.4, "end": 61.0,
      "text": "Tom here. Copy is done. I need a review before Thursday." }
  ],
  "transcript_text": "speaker_0: Today, as the agenda states…\nspeaker_1: Tom here. Copy is done…",
  "summary": { "text": "Pricing page ships Friday. Tom owns the copy and needs a review by Thursday." },
  "error": null,
  "code": null,
  "created_at": "2026-08-27T09:12:03+00:00",
  "started_at": "2026-08-27T09:13:10+00:00",
  "completed_at": "2026-08-27T09:14:11+00:00",
  "content_expires_at": "2026-08-28T09:14:11+00:00",
  "content_expired": false
}
```

### 04 List your jobs

Newest first, each with its full body. Page with `limit` (default 50, max 200) and `offset`. `total` is the count of all your jobs.

```bash
curl -s "https://api.getvoibe.com/v1/transcripts?limit=50&offset=0" \
  -H "Authorization: Bearer $VOIBE_KEY"
```

```json
{
  "transcripts": [ { "job_id": "…", "status": "DONE", "title": "…", "…": "…" } ],
  "total": 42
}
```

## Options

Four optional fields on the create call. Leave any of them out for the default. A field outside these four is ignored and named back in the response's warnings list, so a typo never fails silently.

| Field | Type | Does |
| --- | --- | --- |
| `diarize` | boolean | Speaker labels, off by default. Send `true` (a JSON boolean) to label who spoke. Labelling is the slow part of transcription, so switch it on when speakers matter. |
| `prompt` | string | How the summary should read: focus, tone, length, format. It changes the summary. Max 2,000 characters. |
| `webhook_url` | string | Where the finished result is `POST`ed. A public `http` or `https` address. Localhost and private networks are refused with `400`. |
| `notify_email` | boolean | Send `true` to get an email at your account address when the job finishes or fails. The mail always goes to the address you signed up with. |

```json
{
  "diarize": true,
  "prompt": "summarise as bullet points, focus on decisions",
  "webhook_url": "https://you.dev/ready",
  "notify_email": true
}
```

## Endpoints

Three endpoints. Every request is scoped to the account that owns the key, so you only see your own jobs.

| Verb | Path | Does |
| --- | --- | --- |
| POST | `/transcripts` | Create a job, get an upload URL |
| GET | `/transcripts/{job_id}` | Fetch status, transcript, summary |
| GET | `/transcripts` | List your jobs, newest first (`limit` ≤ 200, `offset`) |

## The job

The GET, the list and the webhook all carry this body.

| Field | Meaning |
| --- | --- |
| `job_id` | The job's id, a UUID. |
| `status` | `QUEUED`, `PROCESSING`, `DONE` or `FAILED`. |
| `title` | A short name for the recording, written by the summary model. `null` when it gave none. |
| `audio_duration_seconds` | The length of the audio, as measured by the speech model. |
| `seconds_charged` | What the job cost, in seconds. 0 until the job is charged. A failed job stays at 0. |
| `processing_seconds` | How long the transcription took. |
| `diarize` | Whether speaker labels were requested. |
| `prompt` | The summary prompt you sent, or `null`. |
| `transcript` | Lines in time order: `speaker`, `text`, `start` and `end` in seconds. Speakers are labelled `speaker_0`, `speaker_1` and so on, consistent within one job. `null` when a line could not be attributed. |
| `transcript_text` | The same transcript as one string, one `speaker: text` line per turn. Plain lines when `diarize` is off. |
| `summary.text` | The summary, in markdown. It names people when the recording does, and otherwise calls them Speaker 0, Speaker 1 and so on. |
| `error` | Why the job failed, in plain words. `null` otherwise. |
| `code` | The failure's stable machine word, e.g. `insufficient_credit`. Match your logic on this: it never changes, the `error` wording may. `null` otherwise. |
| `created_at, started_at, completed_at` | ISO 8601 timestamps. `null` until the step happens. |
| `content_expires_at` | When the transcript and the summary are deleted (see Limits below). `null` until the job is `DONE`. |
| `content_expired` | `true` once the transcript and the summary are gone. `transcript`, `transcript_text` and `summary` read `null` from then on. The rest of the job stays: title, duration, charge and dates. |

## Errors

Every failure returns the HTTP status plus JSON with a plain `error` message and a stable `code` word. Match your logic on the status and the `code` word. The wording may change, the code never does.

| Status | Meaning | Detail |
| --- | --- | --- |
| 400 | Bad request | A wrong value in a known field (invalid_field), or a field the API does not accept (unknown_field) |
| 401 | Invalid key | Missing, wrong, or deleted key (unauthorized) |
| 402 | No balance | The balance is empty: the job is refused before any upload (insufficient_credit). Buy minutes, then retry |
| 402 | No minutes left | The balance is 0, so no job is created. Buy minutes, then retry |
| 404 | Not found | No such job, or it belongs to another account (not_found) |
| 429 | Rate limited | More jobs than the per-minute or per-day limit allows (rate_limited). Wait, then retry |

```json
{
  "error": "what went wrong, in plain words",
  "code": "a_stable_machine_word"
}
```

### Failures that arrive on the job

Some problems only show after the audio arrives. The job ends `FAILED` with the reason in `error` and its stable word in `code`, and nothing is charged.

- **insufficient_credit.** The balance is shorter than this audio. Top up and try again.
- **upload_expired.** No file arrived in time after creating the job.
- **file_too_large.** The upload is over the size cap; it is deleted and the job fails.
- **audio_unreadable.** The file is not audio in any format the models or ffmpeg can read.
- **processing_timeout.** Processing took too long and was stopped. Create a new job.
- **service_unavailable.** Every transcription backend was busy or unreachable. Try again shortly.
- **processing_failed.** Anything else. The full reason is in our logs; write to support with the job_id.

```json
{
  "job_id": "a523721c-…",
  "status": "FAILED",
  "seconds_charged": 0,
  "transcript": null,
  "summary": null,
  "error": "Your balance is shorter than this audio. Top up and try again. You were not charged.",
  "code": "insufficient_credit"
}
```

## Billing and limits

- **Per second of audio.** Rounded up to the whole second. A 3 minute 24 second file costs 3 minutes 24 seconds.
- **Charged only on DONE.** Queued, processing, and failed jobs cost nothing. Creating a job is free.
- **Minutes never expire.** Buy a pack once and use it whenever you need it.
- **Your jobs are yours.** Every read is scoped to your account.
- **Transcripts and summaries are kept for 24 hours.** The clock starts when the job finishes. Store the result before then.
- **Formats.** mp3, wav, m4a and mp4, flac, ogg, webm go straight in. Other audio and video files are converted first when the audio can be read. The format is read from the file's content.
- **Languages.** The speech and summary models are multilingual, so the recording does not have to be in English.
- **Models.** Speech to text runs on Whisper large-v3-turbo, speaker separation on pyannote community-1, and the summary on gpt-oss-120b.

| Limit | Value |
| --- | --- |
| File size | 200 MB per file |
| Upload URL | Valid for 5 hours, one file |
| Job with no audio | Fails after 24 hours |
| Rate limit | 50 jobs a minute, 1,000 a day, per account |
| Result storage | Transcript and summary are deleted 24 hours after DONE. Fetch and store the result on your side |
| Speakers | No cap. Labelled speaker_0, speaker_1 and so on |
| Summary prompt | 2,000 characters |
| List page | 200 jobs |
| Free trial | 15 minutes on every new account |

## Webhook (optional)

Pass a `webhook_url` when you create the job and we `POST` the result there, so you do not need to poll. The body is an envelope: `event` names what happened, and `data` is the same job body `GET /transcripts/{job_id}` returns.

Answer with any `2xx` within 15 seconds. If we get anything else, we try once more about 2 minutes later, then stop. The result stays on the job, so fetch it with `GET` if the webhook did not reach you.

Every delivery carries an `X-Voibe-Signature` header. To verify it, generate your signing secret on the [API keys](https://platform.getvoibe.com/keys) page (the Webhook secret section). With a secret set, the header becomes `t=<unix seconds>,v1=<hex>` where `v1` is HMAC-SHA256 of `"<t>.<raw body>"` with your secret. Compare it to your own computation and reject the call when it differs, or when `t` is older than a few minutes (that blocks replays).

Rotating the secret replaces it instantly; deleting it returns you to unsigned-for-you deliveries. Without a secret, treat the webhook as a notification and confirm the result with `GET` before acting on it.

```json
{
  "event": "transcript.completed",
  "data": {
    "job_id": "a523721c-…",
    "status": "DONE",
    "transcript_text": "…",
    "summary": { "text": "…" },
    "error": null,
    "code": null
  }
}
```

```python
import hashlib, hmac, time

def verify(header, body_bytes, secret):
    t, v1 = (p.split("=", 1)[1] for p in header.split(","))
    if abs(time.time() - int(t)) > 300:
        return False                       # too old, possible replay
    digest = hmac.new(secret.encode(),
                      f"{t}.".encode() + body_bytes,
                      hashlib.sha256).hexdigest()
    return hmac.compare_digest(digest, v1)
```

| Event | Sent when |
| --- | --- |
| `transcript.completed` | The job reached DONE |
| `transcript.failed` | The job reached FAILED; data.error says why |

Your data: Audio is deleted once the transcript is saved, and anything left over is removed within a day. Transcripts and summaries are deleted 24 hours after the job finishes. Nothing you send is used to train models.
