# Transcription

> Turn speech into text — POST /v1/stt for short audio, streamed segments for long recordings, POST /v1/transcriptions for large files.

There are two ways to transcribe, both on the `stt` scope:

| | `POST /v1/stt` | `POST /v1/transcriptions` |
|---|---|---|
| Answer | the text, in the response | a generation id, then poll |
| Input | the audio in the request body | an audio file in your [asset library](https://docs.lumavox.ai/library/assets.md) |
| Size | up to 25 MB | up to the library's audio limit ([Limits](https://docs.lumavox.ai/limits.md)) |
| Streaming | `stream=true`: segments as they are transcribed | no |
| Stored | nothing | the full result, as JSON, in your library |

Short audio — a voice note, a spoken command — goes to `/v1/stt`. Anything long, or anything you want to keep, goes to `/v1/transcriptions`.

## Transcribe in one request

Send the audio as a `file` part with the options as form fields:

```bash title="curl"
curl https://api.lumavox.ai/v1/stt \
  -H "Authorization: Bearer $API_KEY" \
  -F file=@meeting.mp3 \
  -F language=en
```

Or send the audio itself as the body, with the options as query parameters:

```bash title="curl"
curl "https://api.lumavox.ai/v1/stt?language=en&timestamps=true" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: audio/mpeg" \
  --data-binary @meeting.mp3
```

```json title="Response"
{
  "text": "Thanks everyone for joining. Let's start with the roadmap.",
  "language_code": "eng",
  "language_probability": 0.98,
  "duration_seconds": 4.2,
  "model": "scribe_v2",
  "words": null
}
```

**Any audio or video container the provider reads is accepted**, and nothing is converted on the way. The content type must be `audio/*`, `video/*` or `application/octet-stream`; anything else is a 415.

### Options

The same options work as form fields or query parameters.

| Option | Default | What it does |
|---|---|---|
| `model` | `scribe_v2` | The transcription model. `scribe_v2` is the one you can name. |
| `language` | detected | An ISO 639-1 or 639-3 code, such as `en` or `fa`. Set it when you know it: detection is good, and a wrong guess costs the whole transcript. |
| `diarize` | `false` | Label who is speaking. Each word gains a `speaker_id`. |
| `num_speakers` | provider decides | The most speakers to look for, 1 to 32, when `diarize` is on. |
| `tag_audio_events` | `false` | Mark laughter, applause and the like, as entries with `type: audio_event`. |
| `timestamps` | `false` | Return each word with its `start_seconds` and `end_seconds`. |
| `stream` | `false` | Answer as newline-delimited JSON — see [streaming](#streamed-segments). |

**`language_code` in the answer is not what you sent.** It is always ISO 639-3, so `en` comes back as `eng`.

**Word timings are off by default** because the list is far larger than the text — an hour of speech is about ten thousand words. Without them `words` is `null`; with `timestamps=true` it is a list of:

```json title="A word"
{"text": "Thanks", "start_seconds": 0.12, "end_seconds": 0.46, "type": "word", "speaker_id": null}
```

`start_seconds` and `end_seconds` count from the start of the audio. `type` is `word`, the `spacing` between words, or an `audio_event` with `tag_audio_events`; `speaker_id` is set with `diarize`.

### Cost

Charged per minute of audio, on `duration_seconds` — the provider's own measurement, which the response shows you so you can check the charge. A request the provider fails is not charged.

## Streamed segments

With `stream=true` the answer is `application/x-ndjson`: one JSON object per line. The audio is cut at pauses, the pieces are transcribed several at a time — the platform's `speech.stt.stream_concurrency` ([Limits](https://docs.lumavox.ai/limits.md)) — and each piece's text arrives as soon as it is its turn — so a long recording shows its first lines in seconds rather than at the end.

```bash title="curl"
curl "https://api.lumavox.ai/v1/stt?stream=true&language=en" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: audio/wav" \
  --data-binary @lecture.wav \
  --no-buffer
```

```json title="Response (one object per line)"
{"type":"segment","index":0,"duration_seconds":28.4,"reason":"silence"}
{"type":"segment","index":1,"duration_seconds":30.0,"reason":"max"}
{"type":"segment","index":2,"duration_seconds":6.1,"reason":"end"}
{"type":"transcript","index":0,"text":"Good morning. Today we look at…","duration_seconds":28.4,"reason":"silence","language_code":"eng","language_probability":0.99,"words":null}
{"type":"transcript","index":1,"text":"…which is why the second law…","duration_seconds":30.0,"reason":"max","language_code":"eng","language_probability":0.99,"words":null}
{"type":"error","index":2,"code":"internal","message":"That segment could not be transcribed (TimeoutError)."}
{"type":"done","text":"Good morning. Today we look at… …which is why the second law…","segments":3,"failed":1,"duration_seconds":58.4,"language_code":"eng"}
```

The four kinds of line:

| `type` | Fields | Meaning |
|---|---|---|
| `segment` | `index`, `duration_seconds`, `reason` | A piece was cut. `reason` is `silence` (cut at a pause), `max` (cut at 30 seconds, at the quietest moment) or `end` (the last piece). |
| `transcript` | `index`, `text`, `duration_seconds`, `reason`, `language_code`, `language_probability`, `words` | One piece's text. `words` is `null` without `timestamps=true`. |
| `error` | `index`, `code`, `message` | One piece failed; the rest carry on. `code` is an [error code](https://docs.lumavox.ai/errors.md#codes): `provider_failed` when the provider refused it, `internal` otherwise. |
| `done` | `text`, `segments`, `failed`, `duration_seconds`, `language_code` | The end: the whole transcript joined, how many pieces failed, and how long the transcribed pieces were together. |

- **Transcripts arrive in `index` order**, not in the order they finish. A transcript out of order is not a transcript.
- **Every line has every field of its kind**, `null` where there is nothing: `failed` is `0` when no piece failed.
- **The `done` line carries the whole text**, so a client may ignore every line before it.
- **A piece with no speech in it is dropped**, so silence costs nothing.
- **No `done` line means the stream broke.** The status was already 200 by then; treat a stream without `done` as incomplete.

**Streaming needs WAV or raw PCM.** Finding a pause means reading samples, and the API never decodes audio. Send:

- a WAV of 16-bit mono integer PCM, at any rate from 8 to 192 kHz — the rate is read from the header; or
- raw 16-bit little-endian mono PCM, as `audio/pcm`, `audio/l16` or `audio/x-pcm`, which is read as 24 kHz.

Anything else with `stream=true` is a 415 that says what it found. Send it without `stream`, or convert it first.

## Queued transcription

For a long recording, or one you want to keep with its word timings, upload it to your library and queue the transcription.

**1. Upload the file** — ask for an upload URL, `PUT` the file to it, then confirm:

```bash title="curl"
curl https://api.lumavox.ai/v1/assets/upload-url \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"content_type": "audio/mpeg"}'
# → {"asset_id": "ast_…", "url": "https://…", "expires_at": "2026-10-24T09:45:00.000Z"}

curl -X PUT "<url>" -H "Content-Type: audio/mpeg" --data-binary @interview.mp3

curl https://api.lumavox.ai/v1/assets/uploaded \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"asset_id": "ast_…", "content_type": "audio/mpeg", "name": "interview.mp3"}'
```

Wait until `GET /v1/assets/{asset_id}` says `ready`. [Assets](https://docs.lumavox.ai/library/assets.md) has the details.

**2. Queue the transcription:**

```bash title="curl"
curl https://api.lumavox.ai/v1/transcriptions \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"asset_id": "ast_a1B2c3D4e5F6g7H8", "language": "en", "diarize": true, "timestamps": true}'
```

It answers **202** with a generation (`gen_…`). The body takes `asset_id`, `folder_id` (where the result is filed) and the same `model`, `language`, `diarize`, `num_speakers`, `tag_audio_events` and `timestamps` as `/v1/stt`, as JSON.

**3. Poll** `GET /v1/transcriptions/{generation_id}` every couple of seconds until `status` is `succeeded` or `failed`:

```bash title="curl"
curl https://api.lumavox.ai/v1/transcriptions/gen_a1B2c3D4e5F6g7H8 \
  -H "Authorization: Bearer $API_KEY"
```

```json title="Response (abridged)"
{
  "id": "gen_a1B2c3D4e5F6g7H8",
  "status": "succeeded",
  "capability": "stt",
  "transcript": {
    "text": "Thanks for having me. So, where do I start…",
    "language_code": "eng",
    "language_probability": 0.99,
    "duration_seconds": 1834.6,
    "word_count": 5120,
    "asset_id": "ast_q1W2e3R4t5Y6u7I8",
    "content_type": "application/json",
    "size_bytes": 912345,
    "url": "https://…"
  }
}
```

- `transcript.text` is the whole transcript, inline.
- `transcript.url` is a short-lived link to the provider's full response as JSON — word timings and speaker labels included, where you asked for them.
- That JSON is kept in your library as `transcript.asset_id`. Fetch it from there later; the `url` expires.

**The recording stays in your library**, so transcribing it again with other options costs no second upload.

### Cost

**Charged when accepted, as an estimate**: the asset's measured length, or an estimate from its size. When the job finishes, the charge is corrected to the provider's own measurement — `transcript.duration_seconds` is the figure you paid for. A transcription that fails is refunded in full. See [Queued jobs](https://docs.lumavox.ai/jobs.md) and [Credits and usage](https://docs.lumavox.ai/usage.md).

## Errors

The common envelope is described in [Errors and limits](https://docs.lumavox.ai/errors.md). The ones specific to transcription:

| Status | Endpoint | When |
|---|---|---|
| 422 | `/v1/transcriptions` | The asset is not audio, or its upload failed; or `folder_id` is not one of your folders. |
| 400 | `/v1/stt` | The multipart body could not be read. |
| 402 | both | The organisation is out of credits — or, for `/v1/transcriptions`, the job's estimate would take its balance past the grace buffer. Nothing is charged. |
| 404 | `/v1/transcriptions` | The asset or the generation is unknown or not yours. |
| 409 | `/v1/transcriptions` | The asset is still being processed. Poll it until it is `ready`. |
| 413 | `/v1/stt` | The audio is over 25 MB. Use `/v1/transcriptions`, whose limit is the library's for an audio upload (`limits.uploads.audio_bytes` on [Limits](https://docs.lumavox.ai/limits.md)). |
| 415 | `/v1/stt` | Not audio; or, with `stream=true`, not WAV or raw PCM, or a WAV that is not 16-bit mono. |
| 422 | both | An option failed validation, or one the endpoint does not know was sent — or, on `/v1/stt`, no audio was sent, a multipart body had no `file` part, or a WAV header could not be read. `issues` names the field. |
| 429 | both | Over your organisation's rate limit. |
| 502 | `/v1/stt` | The provider failed. Nothing is charged; safe to retry. |
| 503 | both | Transcription, or the storage it reads from, is not set up on this deployment. |

## Related

- [Assets](https://docs.lumavox.ai/library/assets.md) — uploading files.
- [Queued jobs](https://docs.lumavox.ai/jobs.md) — polling, statuses, refunds.
- [The WebSocket](https://docs.lumavox.ai/agents/websocket.md) — transcription inside an agent conversation, including transcribing without answering.
- [Reference: transcription](https://docs.lumavox.ai/reference/transcription.md)
