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

# Streaming

> Connect to the Murf Speech to Text WebSocket, stream audio, manage turns with Finalize, and handle partial and final transcripts.

Murf Speech to Text uses a single WebSocket connection. You send audio as binary frames and control messages as JSON; the server sends back `partial` transcripts while the caller is speaking, and a `final` transcript when you end the turn.

## Endpoint

> **Warning**
>
> Speech to Text (Beta) is available in the **India** region only. Connect to `wss://in.api.murf.ai/v1/transcription/stream-input`.

```
wss://in.api.murf.ai/v1/transcription/stream-input
```

Pass your API key as the `api_key` query parameter or `api-key` header. See [Authentication](#authentication).

## Quickstart

#### Get an API key

Create a key in the [Murf API dashboard](https://murf.ai/api/dashboard?utm_source=murf_api_docs) and export it:

```bash
export MURF_API_KEY="your_api_key_here"
```

#### Install the packages

The examples use the `websockets` package (version 14 or later). The microphone example also needs `pyaudio`, which depends on PortAudio (`brew install portaudio` on macOS, `sudo apt-get install portaudio19-dev` on Debian/Ubuntu).

```bash
pip install websockets pyaudio
```

#### Stream audio and print transcripts

#### From a WAV file

Streams a 16-bit PCM WAV file at real-time pace and prints each transcript. The WAV sample rate must be one of 8000, 12000, 16000, 24000, 32000, 44100 or 48000 Hz.

```python
import asyncio
import json
import os
import wave

import websockets

API_KEY = os.environ["MURF_API_KEY"]
URL = f"wss://in.api.murf.ai/v1/transcription/stream-input?api_key={API_KEY}&language=hi"
CHUNK_MS = 100  # send 100 ms of audio per frame


async def send_audio(ws, path):
    with wave.open(path, "rb") as wav:
        # Declare the WAV's format. Sample rate must be one of:
        # 8000, 12000, 16000, 24000, 32000, 44100, 48000.
        await ws.send(json.dumps({
            "type": "AudioConfig",
            "codec": "pcm_s16le",
            "sample_rate": wav.getframerate(),
            "channels": wav.getnchannels(),
        }))
        frames_per_chunk = wav.getframerate() * CHUNK_MS // 1000
        while chunk := wav.readframes(frames_per_chunk):
            await ws.send(chunk)
            await asyncio.sleep(CHUNK_MS / 1000)  # pace the file like live audio
    # Flush the remaining speech and close the connection with code 1000.
    await ws.send(json.dumps({"type": "CloseStream"}))


async def receive_results(ws):
    try:
        async for raw in ws:
            msg = json.loads(raw)
            if msg["type"] in ("partial", "final"):
                if msg["transcript"]:
                    print(f'[{msg["type"]}] {msg["transcript"]}')
                if msg["type"] == "final":
                    print("-- end of turn --")
            elif msg["type"] == "QuotaWarning":
                print(f'Low balance: ${msg["remaining_funds"]}')
            elif msg["type"] == "Error":
                print(f'Error: {msg["message"]}')
    except websockets.ConnectionClosedError:
        pass  # closed with an error code; printed below
    print(f"Connection closed with code {ws.close_code}")


async def main(path):
    async with websockets.connect(URL) as ws:
        sender = asyncio.create_task(send_audio(ws, path))
        await receive_results(ws)
        sender.cancel()


if __name__ == "__main__":
    asyncio.run(main("sample.wav"))
```

#### From your microphone

Captures 16 kHz mono audio from your default microphone. Because this is the default input format, no `AudioConfig` message is needed. Press Ctrl+C to stop.

```python
import asyncio
import json
import os

import pyaudio
import websockets

API_KEY = os.environ["MURF_API_KEY"]
URL = f"wss://in.api.murf.ai/v1/transcription/stream-input?api_key={API_KEY}&language=auto"
SAMPLE_RATE = 16000  # the default input format, so no AudioConfig is needed
FRAMES_PER_BUFFER = 320  # 20 ms at 16 kHz


async def main():
    loop = asyncio.get_running_loop()
    audio_queue = asyncio.Queue()

    def on_audio(in_data, frame_count, time_info, status):
        # PyAudio calls this from its own thread; hand the bytes to asyncio.
        loop.call_soon_threadsafe(audio_queue.put_nowait, in_data)
        return (None, pyaudio.paContinue)

    pa = pyaudio.PyAudio()
    mic = pa.open(
        format=pyaudio.paInt16,
        channels=1,
        rate=SAMPLE_RATE,
        input=True,
        frames_per_buffer=FRAMES_PER_BUFFER,
        stream_callback=on_audio,
    )

    async with websockets.connect(URL) as ws:

        async def send_audio():
            while True:
                await ws.send(await audio_queue.get())

        sender = asyncio.create_task(send_audio())
        print("Listening... press Ctrl+C to stop.")
        try:
            async for raw in ws:
                msg = json.loads(raw)
                if msg["type"] in ("partial", "final") and msg["transcript"]:
                    print(msg["transcript"])
                elif msg["type"] == "Error":
                    print(f'Error: {msg["message"]}')
        except websockets.ConnectionClosedError:
            print(f"Connection closed with code {ws.close_code}")
        finally:
            sender.cancel()
            mic.stop_stream()
            mic.close()
            pa.terminate()


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        pass
```

## Session lifecycle

A connection is a **session**. A session can contain many **turns**, for example one per caller utterance in a voice agent.

| Step                      | You send                                    | The server sends                                                                     |
| ------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------ |
| Connect                   | WebSocket upgrade with your API key         | Nothing until audio arrives. An invalid key closes the connection with code `4001`.  |
| Declare format (optional) | One `AudioConfig` message, before any audio | Nothing. An invalid config closes with `4002`.                                       |
| Stream                    | Binary audio frames                         | `partial` messages as the turn grows: `StartOfTurn` first, then `Update`             |
| End a turn                | `{"type": "Finalize"}`                      | The turn's transcript as a `final` message (`EndOfTurn`). The connection stays open. |
| End the session           | `{"type": "CloseStream"}`                   | A `final` message if a turn is still open, then close code `1000`                    |

### Partial and final transcripts

Each message carries the transcript of the current turn in `transcript`. The same text is also in `rawEvent.transcript`.

* **`partial`**: the transcript of the turn so far. Each `partial` contains the whole turn up to that point, so replace the text you are showing instead of appending to it.
* **`final`**: the completed transcript of the turn. Use this text as the caller's utterance, and read `rawEvent.words` if you need word timestamps.

### Ending a turn with `Finalize`

The server does not end a turn on silence. It keeps sending `partial` updates, and the turn ends only when you send `Finalize` or `CloseStream`. In a voice agent, send `Finalize` when your own end-of-turn detection fires: the server transcribes any pending speech immediately and sends a `final` message with `rawEvent.event` set to `EndOfTurn` and `rawEvent.trigger` set to `manual`.

> **Note**
>
> A `final` message is sent only after you send `Finalize` or `CloseStream`. It is not sent automatically when the speaker pauses. If your code waits for `final` to decide that the caller has finished, make sure your code also sends `Finalize`.

If there was no pending speech when you sent `Finalize`, you still receive a `final` message. Its `transcript` is empty.

After `Finalize`, keep sending audio on the same connection to start the next turn. `rawEvent.turn_index` goes up by one, `rawEvent.sequence_id` keeps counting, and `audio_window_start` / `audio_window_end` remain relative to the start of the session, so all turns share one timeline.

### Keeping the connection open

While you are not sending audio, for example while your agent is speaking, send a `KeepAlive` message so the connection is not closed for inactivity:

```json
{ "type": "KeepAlive" }
```

Prefer `KeepAlive` over streaming silence. Silence still counts as audio on the wire.

If the server receives no audio and no `KeepAlive` for too long, it sends an `Error` message and closes the connection with code `4008`. See [Errors](/api/docs/speech-to-text/errors).

## Reading results

A `partial` message:

```json
{
  "type": "partial",
  "transcript": "मेरा नाम मर्फ़ है",
  "rawEvent": {
    "type": "TurnInfo",
    "request_id": "a1b2c3d4-...",
    "event": "Update",
    "turn_index": 0,
    "sequence_id": 4,
    "audio_window_start": 4.20,
    "audio_window_end": 5.25,
    "transcript": "मेरा नाम मर्फ़ है"
  }
}
```

A `final` message has the same shape. `rawEvent.event` is `EndOfTurn`, and `rawEvent` also includes `trigger` and `words`:

```json
{
  "type": "final",
  "transcript": "मेरा नाम मर्फ़ है",
  "rawEvent": {
    "type": "TurnInfo",
    "request_id": "a1b2c3d4-...",
    "event": "EndOfTurn",
    "turn_index": 0,
    "sequence_id": 5,
    "audio_window_start": 4.20,
    "audio_window_end": 5.25,
    "transcript": "मेरा नाम मर्फ़ है",
    "trigger": "manual",
    "words": [
      { "word": "मेरा", "start": 4.30, "end": 4.52 },
      { "word": "नाम", "start": 4.54, "end": 4.71 },
      { "word": "मर्फ़", "start": 4.75, "end": 5.12 },
      { "word": "है", "start": 5.15, "end": 5.25 }
    ]
  }
}
```

| Field                                              | Sent in       | Description                                                               |
| -------------------------------------------------- | ------------- | ------------------------------------------------------------------------- |
| `type`                                             | Every message | `partial` while the turn is in progress, `final` when it ends.            |
| `transcript`                                       | Every message | The transcript of the current turn so far. Same as `rawEvent.transcript`. |
| `rawEvent.event`                                   | Every message | `StartOfTurn`, `Update` or `EndOfTurn`.                                   |
| `rawEvent.turn_index`                              | Every message | Turn number, starting at `0`. Goes up by one after each `EndOfTurn`.      |
| `rawEvent.sequence_id`                             | Every message | Message counter that keeps increasing for the whole session.              |
| `rawEvent.audio_window_start` / `audio_window_end` | Every message | Start and end of the turn, in **seconds from the start of the session**.  |
| `rawEvent.trigger`                                 | `final` only  | `manual` when the turn was ended by `Finalize` or `CloseStream`.          |
| `rawEvent.words`                                   | `final` only  | Start and end of each word, in **seconds from the start of the session**. |

Word times are already on the session timeline, so you don't need to convert them. Quote `rawEvent.request_id` when you contact support.

## Authentication

| Method | Example                                                                               |
| ------ | ------------------------------------------------------------------------------------- |
| Query  | `wss://in.api.murf.ai/v1/transcription/stream-input?api_key=YOUR_API_KEY&language=hi` |
| Header | `api-key: YOUR_API_KEY` on the WebSocket handshake                                    |

Speech to Text does not accept tokens from `GET /v1/auth/token`.

## Query parameters

| Parameter         | Required                                  | Default | Description                                                                                                                                                            |
| ----------------- | ----------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `api_key`         | Yes, unless you send the `api-key` header | —       | Your Murf API key.                                                                                                                                                     |
| `language`        | No                                        | `auto`  | One language value, `auto`, or a comma-separated list of languages to detect from. See [Languages](/api/docs/speech-to-text/languages).                                |
| `keywords`        | No                                        | —       | Comma-separated words or phrases to boost. See [Keyword boosting](/api/docs/speech-to-text/keyword-boosting).                                                          |
| `alpha`           | No                                        | `0.5`   | How strongly to favour `keywords`, from `0.0` to `1.0`.                                                                                                                |
| `text_formatting` | No                                        | `false` | Set to `true` to format numbers and similar terms in Hindi and English (India) transcripts. See [Text formatting](/api/docs/speech-to-text/languages#text-formatting). |

## Coming soon

The following parameters are accepted on the connection URL but have no effect yet. Do not rely on them in production.

| Parameter          | What it will do                                         | What happens today                                                   |
| ------------------ | ------------------------------------------------------- | -------------------------------------------------------------------- |
| `commit_mode=auto` | End a turn automatically when the caller stops speaking | Ignored. A turn ends only when you send `Finalize` or `CloseStream`. |
| `bvc=true`         | Remove background voices before transcription           | Ignored.                                                             |

## Concurrency

| Plan          | Streaming concurrency               |
| ------------- | ----------------------------------- |
| Free tier     | 5                                   |
| Pay as you go | 20 by default; up to 150 on request |
| Enterprise    | Custom (150+)                       |

Each open Speech to Text connection uses one slot until it closes, whether or not you're sending audio. Speech to Text limits are separate from Text to Speech concurrency. If you hit the limit, new connections close with code `4004`. See [Rate Limits & Concurrency](/api/docs/resources/rate-limits) for plan details.

## Best practices

* **Replace `partial` transcripts, don't append them.** Each `partial` contains the whole turn so far. Use the `final` transcript as the completed turn.
* **Send audio in small frames.** Send 20 to 100 ms of audio per frame. Larger frames delay transcription.
* **Stream at real-time pace** when sending pre-recorded audio.
* **Send your telephony codec as-is.** If your provider sends 8 kHz µ-law, declare `mulaw` at `8000` instead of converting the audio yourself. See [Audio formats](/api/docs/speech-to-text/audio-formats).
* **Use `Finalize` at the end of each turn** in voice agents. The server does not end a turn on silence.
* **Keep one connection per call.** Use `Finalize` between turns rather than reconnecting.
* **Handle errors by close code**, not by message text. See [Errors](/api/docs/speech-to-text/errors).
* **Quote `rawEvent.request_id`** when you contact support.

## FAQs

#### Do I get partial (interim) transcripts?

Yes. `partial` messages show the transcript of the current turn while the caller is speaking. Each one replaces the previous one. When the turn ends, you receive a `final` message.

#### When does a turn end?

When you send `Finalize` or `CloseStream`. The server does not end a turn on silence. Automatic end of turn (`commit_mode=auto`) is coming soon.

#### Can I transcribe a recorded file?

Yes, by streaming it over the WebSocket, as in the WAV example above. There is no separate file or batch transcription endpoint.

#### What happens to stereo audio?

Stereo input is mixed down to mono before transcription. Each channel is not transcribed separately.

#### Can it tell different speakers apart?

No. Speaker diarization is not available. Audio with several speakers is transcribed as a single stream.

#### Can I change the language mid-session?

No. Language, keywords and the audio format are set when you connect. Open a new connection to change them.

#### Is my audio stored?

Audio is processed for transcription. See [FAQ](/api/docs/resources/faq) for how Murf handles data security and retention.

#### How am I billed?

\$0.24 per hour of audio, billed per second. Enterprise customers can get custom pricing. When your balance runs low, the server sends one `QuotaWarning` message per session. If credits run out, the connection closes with code `4005`.

## Next steps

#### [Audio formats](/api/docs/speech-to-text/audio-formats)

Telephony codecs, Opus and sample rates.

#### [Keyword boosting](/api/docs/speech-to-text/keyword-boosting)

Improve recognition of names and domain terms.

#### [Errors](/api/docs/speech-to-text/errors)

Close codes and how to handle them.

#### [API reference](/api/docs/api-reference/speech-to-text/stream-input)

Full message and field reference.