> ## Documentation Index
> Fetch the complete documentation index at: https://assemblyai.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Limits

> Text, session, and rate limits on a Streaming TTS session or HTTP request, and how each one reports a breach.

The limits of a WebSocket session come first; those of the [HTTP API](#http-api-limits) follow. Each WebSocket limit below ends the session with an `Error` frame whose `error_code` equals the WebSocket close code that follows, with two exceptions: text intake pacing slows you down without closing anything, and the WebSocket message size is enforced by the WebSocket layer itself with a bare `1009` close. The `3006`, `3010` and `3011` texts quote the number in force, and `Begin.configuration.limits` lists the values of your session; see [Read the configuration back](https://assemblyai.com/docs/tts/connect-parameters.md#read-the-configuration-back).

| Limit | Value | On breach |
| - | - | - |
| Characters per `Generate` | 2000, counted as Unicode code points | Close `3006` |
| Session duration | 3600 seconds by default. `Begin.expires_at` is the exact deadline; a temporary token can shorten it but never extend it | Input stops, audio in flight drains (up to 60 seconds), then close `3008` |
| Idle time | 120 seconds with no frame in either direction. [`inactivity_timeout`](https://assemblyai.com/docs/tts/connect-parameters#inactivity-timeout) shortens it to as little as 5 seconds; `Begin.configuration.inactivity_timeout` shows the value in effect | Close `3006` |
| Text intake pacing | About 100 characters per second sustained, after a 1500-character allowance at the start of the session. Roughly six times the speed of speech | No close. Text is forwarded to synthesis at that rate, so `FlushDone` frames slow down |
| Text backlog | 8000 characters sent but not yet synthesized | Close `3010` |
| Control frames (`Flush`, `KeepAlive`, `Cancel`, empty `Generate`) | 20 per second sustained, with a burst allowance of about 60. `Generate` with text and `Terminate` are not counted | Close `3010` |
| Undelivered audio | 300 seconds of synthesized audio waiting for a client that is not reading, or one write to the socket blocked for 30 seconds | Close `3011`. A client that stopped reading usually sees `1006` instead |
| Queued messages | 10,000 messages sent but not yet handled | Close `3010` |
| Sessions per account | New sessions per minute, as for Streaming Speech-to-Text; sessions open at once are not counted. Your starting limit is your Streaming Speech-to-Text limit, 5 on a free plan and 100 or more on a paid plan, counted separately; on a paid plan it grows with use | Close `3009` at connect, before `Begin` |
| WebSocket message size | 1 MiB per message, counted after its fragments are joined | Close `1009` from the WebSocket layer, no `Error` frame |
| Drain after `Terminate` | 30 seconds of server silence while a `FlushDone` is still owed. The window restarts on every server response, so a long final request is never cut | Close `3005` instead of `Termination` |

## Splitting long text

A single `Generate` carries at most 2000 characters, counted as Unicode code points. One character over and the session closes with `3006`, `error_type` `invalid_message` and `param` `Generate.text`:

```text theme={null}
Generate text is 2001 characters, over the 2000-character limit. Split it across multiple Generate messages (and Flush at sentence boundaries) instead of sending it all at once.
```

The SDKs apply the limit before sending. `generate()` refuses text over it, with `ValueError` in Python and `RangeError` in JavaScript, and `speak()` splits a longer text into pieces of at most 2000 code points at code-point positions, not at word or sentence ends, so split long text at sentence ends yourself before you call `speak()`.

Longer text goes across several `Generate` frames. The server joins them exactly as sent, with no separator, so include the space or newline at each edge yourself. `Generate` boundaries do not affect synthesis, so on their own they can fall anywhere, even in the middle of a sentence. Where you `Flush` is a separate decision: you can send several `Generate` frames before one `Flush`, and that `Flush` concludes everything buffered so far. Place flushes at sentence or clause boundaries so that a request never ends mid-sentence. If you flush after every chunk, as the program below does, that means splitting into sentences.

The program below speaks a passage one sentence per request and keeps a bounded number of flushes in flight, topping up each time a `FlushDone` arrives. It is complete and runs as written.

```python theme={null}
import asyncio
import base64
import json
import os
import re
import struct

import websockets

URL = "wss://tts.us.assemblyai.com/v1/ws?voice_id=49c443a4-8a1d-5f7e-b70b-98bdbd9823c3&language=english"
HEADERS = {"Authorization": os.environ["ASSEMBLYAI_API_KEY"]}

# Flushes sent and not yet answered with FlushDone. Two or three keeps the
# server busy without queuing a deep backlog behind the request being spoken.
MAX_IN_FLIGHT = 3

TEXT = """
The lighthouse at the end of the point was built in 1874, and for a hundred
years a keeper lived in the small stone house beside it. Every evening he
climbed the spiral stairs, trimmed the wick, and wound the clockwork that
turned the lens. On clear nights the beam could be seen from twenty miles out
at sea, and fishing crews set their course by it on the way home.

The last keeper retired in 1978, when the light was automated. The clockwork
was replaced by a motor, the oil lamp by a bulb, and the logbook by a sensor
that reports to the coast guard twice a day. The house stood empty for a
decade before the town bought it and turned it into a small museum.

Visitors today can climb the same stairs and stand beside the lens. The guides
like to point out that the glass has never been replaced. It was cut in Paris,
shipped around the cape, and carried up the cliff by hand, and it still throws
the same beam it did on the first night. Is it worth the climb? Ask anyone who
has been up there at dusk.
"""


def split_sentences(text):
    """Minimal splitter: break after . ! or ? followed by whitespace.

    Each sentence gets its own Flush below, so the whitespace this drops is
    not needed as a separator. A sentence over 2000 characters would still
    need splitting; this passage has none.
    """
    return [s for s in re.split(r"(?<=[.!?])\s+", text.strip()) if s]


def wav_header(n_bytes, rate, channels=1, bits=16):
    byte_rate = rate * channels * bits // 8
    return (
        b"RIFF" + struct.pack("<I", 36 + n_bytes) + b"WAVEfmt "
        + struct.pack("<IHHIIHH", 16, 1, channels, rate, byte_rate,
                      channels * bits // 8, bits)
        + b"data" + struct.pack("<I", n_bytes)
    )


async def read_frame(ws, pcm):
    """Read until a frame that is not Audio. Audio is appended to pcm and an
    Error ends the program; anything else is returned to the caller."""
    while True:
        message = json.loads(await ws.recv())
        kind = message.get("type")
        if kind == "Audio":
            pcm += base64.b64decode(message["audio"])
            continue
        if kind == "Error":
            raise SystemExit(f"{message['error_code']}: {message['error']}")
        if kind == "FlushDone":
            print(f"flush {message['flush_id']}: "
                  f"{message['audio_duration_ms']} ms delivered")
        # FlushDone, WordBoundaries, Termination, or a type you do not handle.
        return message


async def speak(ws, text, pcm):
    """Send text one sentence per request, topping up on FlushDone."""
    in_flight = 0
    for sentence in split_sentences(text):
        while in_flight >= MAX_IN_FLIGHT:
            if (await read_frame(ws, pcm))["type"] == "FlushDone":
                in_flight -= 1
        await ws.send(json.dumps({"type": "Generate", "text": sentence}))
        await ws.send(json.dumps({"type": "Flush"}))
        in_flight += 1
    # The last flushes are still in flight here. Terminate drains them.


async def main():
    pcm = bytearray()
    async with websockets.connect(URL, additional_headers=HEADERS) as ws:
        first = json.loads(await ws.recv())
        if first["type"] == "Error":
            raise SystemExit(f"{first['error_code']}: {first['error']}")
        rate = first["configuration"]["sample_rate"]

        await speak(ws, TEXT, pcm)

        await ws.send(json.dumps({"type": "Terminate"}))
        while True:
            message = await read_frame(ws, pcm)
            if message["type"] == "Termination":
                break

    with open("out.wav", "wb") as f:
        f.write(wav_header(len(pcm), rate))
        f.write(pcm)
    print(f"wrote out.wav: {message['audio_duration_seconds']} s of audio "
          f"from {message['total_input_char_length']} characters")


asyncio.run(main())
```

The output looks like this. The durations vary between runs, because synthesis is not deterministic:

```text theme={null}
flush 0: 7680 ms delivered
flush 1: 5760 ms delivered
flush 2: 6880 ms delivered
flush 3: 4560 ms delivered
flush 4: 7920 ms delivered
flush 5: 5280 ms delivered
flush 6: 4400 ms delivered
flush 7: 3920 ms delivered
flush 8: 7440 ms delivered
flush 9: 1520 ms delivered
flush 10: 2400 ms delivered
wrote out.wav: 57.76 s of audio from 995 characters
```

Three things the program relies on:

* **Flushes are answered in order, so you can keep a few in flight.** Every `Flush` is queued and concluded by its own `FlushDone`, in `flush_id` order. The server works through the queue one request at a time, so each request you queue adds to the wait before its own audio starts; two or three in flight keeps the pipeline full without a long queue.
* **`FlushDone` means delivered, not played.** It arrives once all the audio for that request has been written to your socket, which is well before playback ends because synthesis runs several times faster than real time. Topping up on `FlushDone` paces you against delivery, and delivery is what the limits in the table measure. Pacing playback is your player's job; see [Audio formats](https://assemblyai.com/docs/tts/connect-parameters.md#audio-formats) on WebSocket API basics.
* **`Terminate` flushes for you.** Flushes still in flight drain before `Termination` arrives, and text you sent but never flushed is synthesized and gets its own `FlushDone` first as well. That is why the program terminates without waiting for its last `FlushDone` frames itself.

Flushing per sentence matters when text arrives over time, for example from an LLM: flush each sentence as it completes rather than waiting for the whole reply. When the whole passage is already in hand, a single `Flush` starts audio just as quickly: audio streams as it is synthesized either way, and once enough complete sentences are buffered the server begins speaking before the `Flush` even arrives (see [Generate](https://assemblyai.com/docs/tts/message-frames.md#generate)). What per-sentence flushing buys you in that case is a `FlushDone` boundary per sentence to pace on.

## Pacing

The server accepts text several times faster than it can be spoken: about 100 characters per second sustained, after a 1500-character allowance at the start of the session. Past that it forwards text to synthesis at that rate rather than closing, and you see it as `FlushDone` frames arriving more slowly. Only a client that runs far ahead of what has been synthesized is closed: once more than 8000 characters have been sent but not yet synthesized, the session ends with `3010`.

```json theme={null}
{"type": "Error", "error_code": 3010, "error": "Input text rate exceeded: 8541 characters sent but not yet synthesized (limit 8000). The sustained budget is 100 chars/sec; send text at the pace it is spoken.", "error_type": "input_rate_exceeded", "retryable": false, "param": null, "retry_after_seconds": null}
```

`Flush`, `KeepAlive`, `Cancel` and a `Generate` with empty text are control frames with a budget of their own: 20 per second sustained, with a burst allowance of about 60. A `Generate` with text and `Terminate` are not counted. Exceeding it also closes `3010`, with a different text:

```json theme={null}
{"type": "Error", "error_code": 3010, "error": "Control frame rate exceeded: more than 20 Flush/KeepAlive/Cancel frames or empty Generates per second sustained. Send control frames only as the conversation needs them.", "error_type": "input_rate_exceeded", "retryable": false, "param": null, "retry_after_seconds": null}
```

This is the limit a tight loop hits first. Sending one `Flush` per sentence for a whole document in a single burst spends the burst allowance in milliseconds: a 70-sentence document of under 2000 characters is closed before any audio arrives. The bounded in-flight loop above never gets near either limit, because it can only send as fast as `FlushDone` frames come back. A voice agent rarely meets these limits either, since its text is produced at conversational speed; flush each sentence as it completes and do not send `KeepAlive` while a session is busy.

The same applies in the other direction. If you stop reading from the socket while audio is still being sent, the server ends the session with `3011` once 300 seconds of synthesized audio are waiting for delivery. A client that has stopped reading entirely usually never receives that frame and sees an abnormal `1006` close instead. Read from the socket continuously and buffer audio locally; your player, not the socket, should be what waits.

> [!NOTE]
>
> The values above are the current defaults and may change. The `3010` and
> `3011` Error texts state the limit in force, so log them. Build your retry
> and chunking logic on the close code you receive rather than on a hardcoded
> rate.

## Session lifetime

A session ends at `Begin.expires_at` regardless of activity. That is 3600 seconds after connect by default, and earlier when a [temporary token](https://assemblyai.com/docs/tts/authenticate-with-a-temporary-token.md) caps the session with `max_session_duration_seconds`; a token can shorten the hour but never extend it. `expires_at` is a Unix timestamp in seconds, so treat it as the authoritative deadline. When it passes, the server stops accepting input, flushes any text you have not flushed, delivers the audio still in flight along with its `WordBoundaries`, and then closes with `3008`, at most about a minute later. There is no `Termination` frame on this path; the `Error` frame is the last message:

```text theme={null}
Session reached its maximum duration; open a new connection to continue
```

If you hold a socket open for a long-lived UI, reconnect on `3008` rather than assuming the connection lasts as long as the page. It is routine, not a failure.

Sessions are also counted per account, the way [Streaming Speech-to-Text](https://assemblyai.com/docs/streaming/rate-limits.md) counts them: by new sessions per minute, never by sessions open at once. A voice agent call opens one session and keeps it for the whole call, so your limit is the number of calls that can start in a minute, and calls in progress do not count against it. With a limit of 100, for example, 100 calls can start in one minute and 110 in the next, and all 210 stay connected. Calls in progress follow from your call starts and your call length: at a steady 100 call starts a minute with 4-minute calls, about 400 calls are in progress. They are still subject to the service's capacity (`3005` with `error_type` `at_capacity`) and to the one-hour session lifetime (`3008`), after which the reconnect is a new session.

Your starting limit is your Streaming Speech-to-Text limit (5 on a free plan, 100 or more on a paid plan), counted separately: you can start that many speech-to-text sessions and that many TTS sessions in the same minute. Your limit is shown on the dashboard, and on a paid plan it scales with use as [Rate limits](https://assemblyai.com/docs/streaming/rate-limits.md) describes; a raised starting limit can take one busy minute to apply. The limit counts new sessions in fixed one-minute windows, each running from second `:01` of a minute to second `:00` of the next. It is checked at connect, before `Begin`, and a session over it closes `3009` with a text such as `Too many sessions opened for this account in the last minute (allowance 100 per minute); retry in 17 s.` The number in that text is your starting limit; on a paid plan the limit in force can be higher. The `Error` frame's `retry_after_seconds`, between 1 and 60, is the time until the next window starts, when opening a session works again. Every reconnect is a new session, including those after `1012` and `3008`, and each region counts its own. Our deploys wait for sessions in progress to end; when a server has to stop sooner, it closes its own sessions (`1012`, or `1006` when its machine is replaced), a share of your sessions rather than all of them, so keep some room under your limit for those reconnects. Reuse one socket for many requests instead of opening a session per utterance, and back off before reconnecting.

A session also closes when two minutes pass with no frame in either direction, which releases capacity when a user goes quiet. Any frame starts the two minutes again, yours or the server's, and `KeepAlive` exists to do that while you have nothing to say: send one at least every two minutes to hold an idle socket open. Setting [`inactivity_timeout`](https://assemblyai.com/docs/tts/connect-parameters#inactivity-timeout) closes idle sessions sooner, after as little as 5 seconds, which is worth doing when you open a socket per user and want the capacity back quickly. When the timeout passes the session closes with `3006` and a text that begins `No message received for`. That is not an input error, so reconnect when you next have something to say. `Begin.configuration.inactivity_timeout` echoes the timeout in effect, 120 when you set none.

Separately from all of this, the server sends a WebSocket ping every 60 seconds and closes with `1011` if it is not answered within 60 seconds. Browsers and mainstream libraries answer pings automatically, so only a hand-rolled client needs to care, and `KeepAlive` has nothing to do with it.

## HTTP API limits

The [HTTP API](https://assemblyai.com/docs/tts/api-reference/http-api/overview.md) handles one text per request, so it has no pacing, idle or text backlog limits. A limit reached before the first audio answers an HTTP error; one reached after it cuts the response, or ends `/v1/speech/events` with an `Error` event:

| Limit | Value | On breach |
| - | - | - |
| Text per request | 5,000 characters, counted as Unicode code points | `422 invalid_parameter` |
| Request body | 131,072 bytes; the body must arrive within 10 seconds | `413 request_too_large`, `408 request_timeout` |
| First audio | 20 seconds after the request arrived | `503 upstream_unavailable` |
| Request duration | 600 seconds, or less under a temporary token's `max_session_duration_seconds` | `session_expired` after the first audio; `504 session_expired` for a buffered request |
| Unread audio | 600 seconds of audio waiting for a client that is not reading, or one write blocked for 30 seconds | `audio_backlog` after the first audio |
| Requests per account | Requests per minute: ten times your WebSocket session limit, so 50 on a free plan and 1,000 or more on a paid plan, growing with use. Counted separately from WebSocket sessions, in each region; requests running at once are not counted | `429 rate_limited`, with `Retry-After` |

See [HTTP API basics](https://assemblyai.com/docs/tts/api-reference/http-api/overview.md#limits) for how requests are counted.

## Related

* [Error handling](https://assemblyai.com/docs/tts/error-handling.md): what to do with each close code.
* [HTTP API](https://assemblyai.com/docs/tts/api-reference/http-api/overview.md): one text per request, with its own limits.
* [Message frames](https://assemblyai.com/docs/tts/message-frames.md): the frames these limits apply to.
* [WebSocket API basics](https://assemblyai.com/docs/tts/connect-parameters.md): `inactivity_timeout`, the `Begin` frame that echoes your session's configuration, and scheduling playback of audio that arrives faster than real time ([Audio formats](https://assemblyai.com/docs/tts/connect-parameters.md#audio-formats)).
* [Authenticate with a temporary token](https://assemblyai.com/docs/tts/authenticate-with-a-temporary-token.md): minting temporary tokens, including the session duration cap.
* [Optimizing accuracy and latency](https://assemblyai.com/docs/tts/getting-started/optimizing-accuracy-and-latency.md): holding sessions and connections open so turns skip the setup.
