> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.narilabs.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.narilabs.com/_mcp/server.

# Realtime transcription

Send audio as it becomes available and receive partial transcripts while the speaker talks. At the end of each utterance, receive a final transcript. One connection can transcribe multiple utterances.

For a runnable example, follow the [STT quickstart](/stt-quickstart). Free STT endpoints have [endpoint-specific concurrent session and daily request limits](/rate-limits#free-endpoint-limits).

## Connect and configure

Connect from your backend using your [API key](/authentication):

```text
wss://api.narilabs.com/v1/realtime?intent=transcription
Authorization: Bearer YOUR_NARI_API_KEY
```

Wait for `session.created`, then send your session configuration:

```json
{
  "type": "session.configure",
  "session": {
    "model": "qwen3-asr:free"
  }
}
```

Wait for `session.configured` before sending audio. It returns the applied settings and defaults in `session`, with duration and idle limits in `session.limits`. The configuration is fixed for this connection.

### Session arguments

These fields belong inside `session` in the initial `session.configure`:

| Argument         | Required / default                                      | Purpose                                                                                                              |
| ---------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `model`          | Required                                                | A [public STT model ID](/models-and-pricing#speech-to-text)                                                          |
| `language`       | Optional; omitted or `null` means automatic recognition | Language hint such as `en`, `es`, or `ko`                                                                            |
| `prompt`         | Optional; empty by default                              | Names or terminology to help recognition, applied to every utterance                                                 |
| `turn_detection` | Optional; omitted or `null` means manual commit         | Set `{"type":"server_vad"}` for [automatic turn detection](/transcripts-and-turn-detection#automatic-turn-detection) |

The [API reference](/api-reference/speech-to-text/realtime/realtime-transcription) lists all accepted language codes, VAD fields, and validation limits.

## Send audio

Use **16 kHz, signed 16-bit little-endian PCM, mono**, without a WAV header. Encode each chunk as base64 and send it in an `input_audio_buffer.append` JSON message:

```python
await socket.send(json.dumps({
    "type": "input_audio_buffer.append",
    "audio": base64.b64encode(pcm_chunk).decode("ascii"),
}))
```

For example, 100 ms of audio is 3,200 bytes before base64 encoding. Each chunk must contain whole two-byte samples, and the complete JSON message must fit within 128 KiB.

## Send and receive in Python

This complete example sends audio and reads events concurrently. It keeps receiving after each final result, so automatic boundaries and multiple utterances work on the same connection. At the end of the audio source, it commits remaining audio and waits for all acknowledged utterances to finish.

Use Python 3.11+, set `NARI_API_KEY`, and place the [quickstart sample](/stt-quickstart#2-download-the-sample-audio) `hello.pcm` beside `stt_stream.py`.

```bash
python -m pip install "websockets>=14,<16"
```

Download stt\_stream.py

```python
"""WebSocket transcription example. Requires Python 3.11+ and websockets 14–15."""
import asyncio
import base64
import json
import os

from websockets.asyncio.client import connect

URL = "wss://api.narilabs.com/v1/realtime?intent=transcription"


async def pcm_file(path):
    with open(path, "rb") as audio:
        while chunk := audio.read(3200):  # 100 ms at 16 kHz PCM16 mono
            yield chunk
            await asyncio.sleep(len(chunk) / 32000)


async def transcribe(audio_source, *, turn_detection=None, on_event=print):
    """Source yields PCM bytes, or None to manually commit an utterance."""
    async with connect(
        URL,
        additional_headers={"Authorization": f"Bearer {os.environ['NARI_API_KEY']}"},
    ) as socket:
        async def receive():
            event = json.loads(await socket.recv())
            if event["type"] == "error":
                raise RuntimeError(event["error"])
            return event

        assert (await receive())["type"] == "session.created"
        await socket.send(json.dumps({
            "type": "session.configure",
            "session": {"model": "qwen3-asr:free", "turn_detection": turn_detection},
        }))
        assert (await receive())["type"] == "session.configured"

        async def send_audio():
            commit_number = 0
            async for chunk in audio_source:
                if chunk is None:
                    commit_number += 1
                    event = {"type": "input_audio_buffer.commit",
                             "event_id": f"commit_{commit_number}"}
                else:
                    if not chunk or len(chunk) % 2:
                        raise ValueError("Send nonempty chunks of whole PCM16 samples")
                    event = {"type": "input_audio_buffer.append",
                             "audio": base64.b64encode(chunk).decode("ascii")}
                await socket.send(json.dumps(event))
            # Flush any remaining audio, including when using VAD.
            await socket.send(json.dumps({
                "type": "input_audio_buffer.commit", "event_id": "end_of_input",
            }))

        async def read_results():
            pending = set()
            end_acknowledged = False
            while True:
                event = await receive()
                kind = event["type"]
                if kind == "input_audio_buffer.committed":
                    pending.add(event["item_id"])
                elif kind == "transcript.completed":
                    pending.discard(event["item_id"])
                if kind in ("input_audio_buffer.committed", "input_audio_buffer.commit_empty"):
                    if event.get("client_event_id") == "end_of_input":
                        end_acknowledged = True
                on_event(event)
                if end_acknowledged and not pending:
                    return

        async with asyncio.TaskGroup() as tasks:
            tasks.create_task(send_audio())
            tasks.create_task(read_results())


if __name__ == "__main__":
    asyncio.run(transcribe(pcm_file("hello.pcm")))
```

```bash
python stt_stream.py
```

`pcm_file` paces a recording in 100 ms chunks. For live input, pass an async iterator that yields PCM bytes as they arrive; no extra pacing is needed. Yield `None` when your application wants a manual commit. These are conventions of this example function; the actual WebSocket messages are `append` and `commit`.

The `on_event` callback receives every server event. The [result-handling and VAD examples](/transcripts-and-turn-detection) build on this module. Keep callbacks short so receiving is not blocked.

## Read the results

Partial transcripts may change as more audio arrives, including words returned earlier. After an utterance ends, the server returns a completed transcript that replaces all partial text for that `item_id`.

A partial result:

```json
{
  "type": "transcript.partial",
  "event_id": "event_1",
  "item_id": "utterance_1",
  "transcript": "Hello, welcome",
  "revision": 1
}
```

The completed result for the same `item_id`:

```json
{
  "type": "transcript.completed",
  "event_id": "event_3",
  "item_id": "utterance_1",
  "transcript": "Hello, welcome to Nari Labs.",
  "language": "en",
  "commit_reason": "manual",
  "usage": {
    "input_audio_seconds": 2.48
  }
}
```

| Field                       | Use                                                                                                        |
| --------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `item_id`                   | Associate results with an utterance                                                                        |
| `transcript`                | Replace the current partial text or store the completed text                                               |
| `language`                  | Recognized language code, or `null` if undetermined                                                        |
| `commit_reason`             | What ended the utterance: `manual`, `vad`, or `max_duration`                                               |
| `usage.input_audio_seconds` | Input audio duration assigned to this utterance; [usage rules](/usage-and-billing) explain the measurement |

[Transcripts and turn detection](/transcripts-and-turn-detection) explains how to update text, finalize utterances, and reuse the connection. For timeouts and reconnecting, use [WebSocket failures](/errors#websocket-failures).