> ## 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.

# HTTP API basics

> Hosts, authentication, response framing, errors, limits, latency and billing of the Streaming TTS HTTP API.

The HTTP API turns one text into speech in one request: you `POST` the text and read the audio back as the response body, streamed while it is synthesized. It speaks with the same voices and models as the [WebSocket API](https://assemblyai.com/docs/tts/connect-parameters.md), and suits anything that has a whole sentence or paragraph in hand: a voice agent that sends one request per sentence, a server that renders prompts to files, a device that cannot hold a WebSocket open. For text that arrives word by word, and for barge-in on one long-lived connection, use the WebSocket API. This page covers what every route has in common; the pages under it document each route. For a first request with an SDK or curl, start with the [Quickstart](https://assemblyai.com/docs/tts/getting-started/quickstart.md).

| Operation | Method and path | Answers |
| - | - | - |
| [Create speech](https://assemblyai.com/docs/tts/api-reference/http-api/create-speech.md) | `POST /v1/speech` | The audio: raw PCM, G.711, WAV or MP3 |
| [Stream speech with word timings](https://assemblyai.com/docs/tts/api-reference/http-api/stream-speech-events.md) | `POST /v1/speech/events` | Server-sent events with the audio and the time of each word |
| [Create speech (OpenAI compatible)](https://assemblyai.com/docs/tts/api-reference/http-api/create-speech-openai.md) | `POST /v1/openai/audio/speech` | The audio, or OpenAI speech events; see [OpenAI compatibility](https://assemblyai.com/docs/tts/openai-compatibility.md) |
| [List models (OpenAI compatible)](https://assemblyai.com/docs/tts/api-reference/http-api/list-openai-models.md) | `GET /v1/openai/models` | The model names of the OpenAI-compatible route |
| [List models (OpenRouter)](https://assemblyai.com/docs/tts/api-reference/http-api/list-openrouter-models.md) | `GET /v1/openrouter/models` | The same models in OpenRouter's provider document format |

## Hosts and data zones

| Endpoint | Base URL | Where the text and audio are processed |
| - | - | - |
| US data zone | `https://tts.us.assemblyai.com` | `us-east-1` (N. Virginia) |
| EU data zone | `https://tts.eu.assemblyai.com` | `eu-north-1` (Stockholm) |

The routes, fields and limits are the same on both hosts. See [Cloud endpoints and data residency](https://assemblyai.com/docs/tts/endpoints-and-data-zones.md).

## Authentication

Send your API key in the `Authorization` header. The raw key works, and so does `Bearer <key>`, which is what OpenAI SDKs send.

```bash theme={null}
curl https://tts.us.assemblyai.com/v1/speech \
  -H "Authorization: $ASSEMBLYAI_API_KEY" \
  -d '{"text": "Hello from AssemblyAI."}' \
  -o hello.pcm
```

* The credential is read from the `Authorization` header only; send exactly one. Without it the answer is `401 missing_authorization`, also when the query string carries a `token`, which is never read.
* A [temporary token](https://assemblyai.com/docs/tts/authenticate-with-a-temporary-token.md), minted with `product=tts`, works wherever an API key does, in the same header. In a page or an app, use a temporary token rather than an API key. A token stays valid for any number of requests until it expires, so a failed request can be retried with the same token. Mint a new token for each request where you can, and keep `expires_in_seconds` short. Mint the next token while the current reply plays, so that no request waits for a mint.
* A temporary token's `max_session_duration_seconds` caps how long a request with it may run.
* [List models (OpenAI compatible)](https://assemblyai.com/docs/tts/api-reference/http-api/list-openai-models.md) and [List models (OpenRouter)](https://assemblyai.com/docs/tts/api-reference/http-api/list-openrouter-models.md) take no credential; one that is sent is not checked.
* Every `401` carries `WWW-Authenticate: Bearer realm="assemblyai"`.

### Calling from a browser

Every route answers cross-origin requests from any origin (CORS), so a page can call it with `fetch`. Leave the request's credentials mode at its default: the answers allow every origin, so a `fetch` with `credentials: "include"` is blocked. A page may send any request header, `Authorization`, `Content-Type` and `X-Client-Request-Id` included, with `GET`, `HEAD`, `POST` and `OPTIONS`, and may read every response header, the `X-TTS-*` headers included, on errors too. The browser's preflight request takes no credential; its answer is `204` with no body, carries the CORS headers and `X-Request-Id`, and may be cached for two hours. See [Speak a whole text over HTTP from a page](https://assemblyai.com/docs/tts/authenticate-with-a-temporary-token.md#speak-a-whole-text-over-http-from-a-page).

## Requests and responses

* **Body.** One UTF-8 JSON object of at most 131,072 bytes, with or without a `Content-Length` header: a body streamed without one, chunked over HTTP/1.1 or over HTTP/2, is read until it ends. A larger one is refused with `413 request_too_large`, a body that does not arrive within 10 seconds with `408 request_timeout`, and one that is not a JSON object, or that nests about 10,000 levels deep or more, with `400 invalid_message`. The `Authorization` header is checked before the body is read, so a request without a credential is refused without waiting for its body. `Content-Type` is not checked, so `curl -d` works as is.
* **Fields.** `null` counts as leaving a field out. Types are strict: `"sample_rate": 24000.0` and `"sample_rate": "24000"` are refused. Strings other than the text are trimmed. A field given twice is refused with `422 invalid_parameter`: `<field>: must be given at most once (given 2 times)`. Fields are read from the body only: one sent in the query string is refused with `422` (`400` on the OpenAI-compatible route), naming it, and other query parameters are ignored.
* **Voices** are named with `voice_id` on `/v1/speech` and `/v1/speech/events`: an [AssemblyAI voice](https://assemblyai.com/docs/tts/voices.md) by its library slug, such as `aai-jane`, any voice by its ID, or one of your [cloned voices](https://assemblyai.com/docs/tts/cloned-voices.md) by its ID or slug. `voice` is retired on these two routes: a body or a query string that carries it, in any letter case, is refused with `422 invalid_parameter` on `voice`; for a [built-in voice](https://assemblyai.com/docs/tts/voices.md#built-in-voices), the message names the `voice_id` to send instead, such as `voice_id=aai-jane` for `jane`. The OpenAI-compatible route keeps OpenAI's `voice` field; see [Create speech (OpenAI compatible)](https://assemblyai.com/docs/tts/api-reference/http-api/create-speech-openai.md).
* **Unknown fields** are ignored and listed in the `X-TTS-Unknown-Params` header (sorted, comma-separated, at most 16), so a misspelt field shows up instead of being dropped silently.
* **Paths** are exact: a trailing slash, such as `/v1/speech/`, answers `404 not_found`, with no redirect. A method a path does not serve answers `405 method_not_allowed`, with an `Allow` header listing the methods it serves: `OPTIONS, POST` on the speech routes, `GET, HEAD, OPTIONS` on the others. `Accept` is not read.
* **Request ids.** Every response carries `X-Request-Id`, errors and the answer to a browser's CORS preflight included; quote it in support requests. Send your own id in `X-Client-Request-Id` (1 to 512 printable ASCII characters) to have it echoed back and kept with the request's records. An invalid one is dropped on `/v1/speech` and `/v1/speech/events`, and refused with `400 invalid_parameter` on the OpenAI-compatible route.
* **Responses** may gain headers, fields and event types over time. Read the ones you need and ignore the rest.

### The status waits for the first audio

A speech request answers its status line and headers together with the first audio, once synthesis has produced it. Every failure before that point, a bad field, an unknown voice, no capacity, a speech service that does not answer, is an ordinary HTTP error with a JSON body. So you can branch on the status alone: a `200` carries the audio, or an empty body for text with nothing to speak. Synthesis usually produces the first audio within a few hundred milliseconds; a request without audio 20 seconds after it arrived is refused with `503 upstream_unavailable`.

After that point the status cannot change. A request that fails while its audio is streaming, which is rare, is cut: the connection is closed before the end of the body, so your HTTP client reports an error instead of a complete response. A body that ends cleanly holds all the audio. How each client reports a cut:

| Client | A cut response |
| - | - |
| curl | Exit code `18` (HTTP/1.1) or `92` (HTTP/2) |
| Python `requests` | `requests.exceptions.ChunkedEncodingError` while reading the body |
| Python `httpx` | `httpx.RemoteProtocolError` while reading the body |
| Browser `fetch` | The body reader's `read()` rejects with a `TypeError` |

Over HTTP/1.1 a cut is a transfer error: the connection closes before the body's last chunk. Over HTTP/2 the stream is reset. With `httpx`, read the body with `iter_bytes()` and no `chunk_size`: `iter_bytes(chunk_size)` holds bytes back until a whole chunk has arrived and drops them when the cut raises, the final `error` event of the OpenAI speech events included.

[Stream speech with word timings](https://assemblyai.com/docs/tts/api-reference/http-api/stream-speech-events.md) ends such a stream with an `Error` event instead, and the OpenAI speech events send an `error` event before the cut. A buffered request (`"stream": false`) sends nothing until the whole clip is ready, so a failure on our side never cuts it: it answers either the complete audio or an error status. A client that stops reading a buffered body for 30 seconds has the connection closed before the end of the body.

### Response headers

In this table, every response means every answer of the API, the answer to a browser's CORS preflight included.

| Header | On | Meaning |
| - | - | - |
| `X-Request-Id` | Every response | The request's id, a UUID |
| `X-Client-Request-Id` | When the request sent a valid one | Your id, echoed |
| `X-TTS-Region` | Every response | The region that served the request, such as `us-east-1` |
| `X-TTS-Model` | `200` | The name of the model that spoke, such as `assemblyai-tts-1`: the model the request named, else the default model of its language. The same value as `model` in the `Begin` event of `/v1/speech/events` |
| `X-TTS-Voice-Id` | `200` | The voice the request spoke, as `voice_id` names it: the `voice_id` you sent, trimmed, with a UUID in lowercase, or the default voice's library slug, such as `aai-jane`, when you named no voice. The same value as `Begin.configuration.voice_id` |
| `X-TTS-Voice-Expires-At` | `200` that spoke a cloned voice of your account whose consent period has an end | When the voice's consent period ends, as a Unix timestamp in seconds: `voice_expires_at` of the [`Begin` frame](https://assemblyai.com/docs/tts/message-frames#begin). With `extend_biometric_consent`, the expiry after the [renewal](https://assemblyai.com/docs/tts/cloned-voices.md#renew-consent) on a buffered response and on a response without audio; on a streamed response, which is every response of the OpenAI-compatible route, the expiry when the request started |
| `X-TTS-Language`, `X-TTS-Encoding`, `X-TTS-Sample-Rate` | `200` | The values the request ran with: lowercase, encoding aliases in canonical form |
| `X-TTS-Container` | `200`, not on MP3 | `raw` or `wav` |
| `X-TTS-Bit-Rate` | `200`, MP3 only | The MP3 bit rate |
| `X-TTS-Billed-Characters` | `200` | The characters the request bills once its body is delivered. A streamed response sends it with the first audio, so a failure on our side that then cuts the body makes the request free, and a request that reaches its maximum duration bills less; see [Billing](#billing) |
| `X-TTS-Audio-Duration-Ms` | Buffered responses, and responses with no audio | The duration of the audio |
| `X-TTS-Unknown-Params` | When the body had unknown fields | Their names |
| `X-TTS-Voice-Alias`, `X-Generation-Id` | `200` on the OpenAI-compatible route | See [OpenAI compatibility](https://assemblyai.com/docs/tts/openai-compatibility.md) |
| `Server-Timing` | `200` | `open;dur=<ms>, model;dur=<ms>, ttfa;dur=<ms>`: receipt to the start of synthesis, the start of synthesis to the first audio, and receipt to the first byte sent |
| `X-Accel-Buffering` | Streamed responses | `no` |
| `Cache-Control` | Every response | `no-store, no-transform`, except `public, max-age=300` on the models documents |
| `Timing-Allow-Origin`, `X-Content-Type-Options` | Every response | `*` and `nosniff` |
| `Retry-After` | Every `429` and `503` | Whole seconds to wait before retrying |

## Audio formats

| `encoding` | `container` | `Content-Type` | Bytes per second at 24 kHz |
| - | - | - | - |
| `pcm_s16le` (default) | `raw` (default) | `audio/pcm;rate=24000;channels=1` | 48,000 |
| `pcm_s16le` | `wav` | `audio/wav` | 48,000, after a 44-byte header |
| `pcm_mulaw`, `pcm_alaw` | `raw` | `audio/PCMU;rate=24000;channels=1`, `audio/PCMA;rate=24000;channels=1` | 24,000 |
| `pcm_mulaw`, `pcm_alaw` | `wav` | `audio/wav` | 24,000, after a 58-byte header |
| `mp3` | | `audio/mpeg` | `bit_rate / 8`: 8,000 at the default 64 kbps |

The audio is mono. Raw audio is what [Audio formats](https://assemblyai.com/docs/tts/connect-parameters.md#audio-formats) on WebSocket API basics describes: 16-bit little-endian samples, or 8-bit G.711, with no header. Telephony integrations ask for 8 kHz mu-law or A-law:

```bash theme={null}
curl -sS --fail-with-body https://tts.us.assemblyai.com/v1/speech \
  -H "Authorization: $ASSEMBLYAI_API_KEY" \
  -d '{"text": "Your appointment is confirmed.", "encoding": "pcm_mulaw", "sample_rate": 8000}' \
  -o confirm.ulaw
```

* **Whole samples per write, not per read.** The server writes whole samples, about 80 ms of audio at a time, but the network can split a write. Carry an odd byte of 16-bit audio over to the next read, as a client that decodes samples must.
* **WAV.** A streamed WAV file does not know its length when its header is sent, so the header's two size fields hold `0xFFFFFFFF`, which players and decoders read as "until the end of the file". Some tools read the placeholder `fact` sample count of a streamed G.711 file as its length and report a duration of days; ffprobe does, so fix the sizes as below before handing such a file to them. The header is 44 bytes for `pcm_s16le` (format 1) and 58 bytes for `pcm_mulaw` (format 7) and `pcm_alaw` (format 6), which add a `fact` chunk; parse the chunks rather than assuming 44 bytes. To store a streamed WAV file with exact sizes, set bytes 4 to 7 to the file size minus 8 and the data size (bytes 40 to 43, or 54 to 57 for G.711, and the `fact` sample count at 46 to 49) to the file size minus the header size. A buffered WAV file carries its exact sizes.
* **MP3** is constant bit rate, mono, with no ID3 tag, and every frame decodes on its own. Its encoder puts about 1,100 samples of silence before the speech: 46 ms at 24 kHz, 138 ms at 8 kHz. A buffered MP3 file starts with an Xing frame that tells players to skip it; a streamed one cannot, so each streamed MP3 response starts with that silence. For a voice agent, ask for `pcm_s16le`: it starts sooner and decodes for free.

## Errors

Every error of the native routes, `/v1/speech` and `/v1/speech/events`, has this JSON body. This one answers a `sample_rate` of `12345`:

```json theme={null}
{
  "code": "invalid_parameter",
  "message": "sample_rate: sample_rate 12345 not in supported set [8000, 16000, 22050, 24000, 44100, 48000]",
  "param": "sample_rate",
  "request_id": "2f0c1d9e-7b4a-4c3e-9a51-6d8e2b7f4c10",
  "errors": [
    {
      "message": "sample_rate 12345 not in supported set [8000, 16000, 22050, 24000, 44100, 48000]",
      "param": "sample_rate"
    }
  ],
  "retryable": false,
  "retry_after_seconds": null,
  "error": "sample_rate: sample_rate 12345 not in supported set [8000, 16000, 22050, 24000, 44100, 48000]"
}
```

| Field | Meaning |
| - | - |
| `code` | A stable, machine-readable code. Branch on this |
| `message` | What went wrong, written for people. It may be reworded, so log it rather than matching on it |
| `param` | The field the error is about; `null` when it is not about one field |
| `request_id` | The request's id, the same value as the `X-Request-Id` header |
| `errors` | For `invalid_parameter`, one `{message, param}` entry per field that failed, so one answer names every problem; `null` otherwise |
| `retryable` | Whether the same request can succeed if you send it again later |
| `retry_after_seconds` | How long to wait before retrying, also sent as `Retry-After`; `null` when there is no hint |
| `error` | The same text as `message` |

Some errors add fields: `limit` and `limit_window` on `rate_limited` (`limit_window` is `minute`, for the requests per minute), and `limit` on a text or a body over its limit.

The routes under `/v1/openai/` and `/v1/openrouter/` answer errors in the OpenAI shape instead, with the same `code` in it:

```json theme={null}
{
  "error": {
    "message": "model: 'tts-1' is not an AssemblyAI model; use one of GET /v1/openai/models, such as assemblyai-tts-en",
    "type": "invalid_request_error",
    "param": "model",
    "code": "model_not_found",
    "request_id": "93309b7e-9131-474f-99e4-6e0623463374",
    "retryable": false,
    "retry_after_seconds": null
  }
}
```

### Error codes

`code` is the same string on every route, and the same as the WebSocket API's `error_type` where it has one. The OpenAI-compatible route answers some of them with another status, shown with its `type`.

| Code | Status | OpenAI-compatible | `retryable` | Meaning |
| - | - | - | - | - |
| `invalid_message` | `400` | `400` `invalid_request_error` | `false` | The body is empty, not UTF-8, not JSON, not a JSON object, or nested about 10,000 levels deep or more |
| `missing_authorization` | `401` | `401` `authentication_error` | `false` | No credential in the `Authorization` header |
| `unauthorized` | `401` | `401` `authentication_error` | `false` | The credential was rejected: an unknown key, a disabled account or an expired temporary token |
| `insufficient_funds` | `402` | `402` `insufficient_quota` | `false` | The account has no funds for the request |
| `biometric_terms_not_accepted` | `403` | `403` `permission_error` | `false` | Your account has not accepted AssemblyAI's biometric data terms, which a request with one of its own [cloned voices](https://assemblyai.com/docs/tts/cloned-voices.md#biometric-data-terms), or with `extend_biometric_consent`, needs. `param` is `voice_id` (on the OpenAI-compatible route, the field that named the voice), or `extend_biometric_consent` when the request set it. The message names the page where the terms are accepted: `This account has not accepted the biometric data terms. Please visit https://www.assemblyai.com/dashboard/settings/data-controls to review and accept them.` Accept them, then send the request again |
| `not_found` | `404` | `404` `invalid_request_error` | `false` | An unknown path, including a trailing slash |
| `method_not_allowed` | `405` | `405` `invalid_request_error` | `false` | The path does not serve this method; see the `Allow` header |
| `request_timeout` | `408` | `408` `invalid_request_error` | `true` | The body did not arrive within 10 seconds |
| `request_too_large` | `413` | `413` `invalid_request_error` | `false` | The body is over 131,072 bytes |
| `invalid_parameter` | `422` | `400` `invalid_request_error` | `false` | A field is missing, of the wrong type, out of range, given twice or sent in the query string, or fields do not go together; `param` and `errors` say which. On the OpenAI-compatible route, a `voice` string that is not an OpenAI voice name, a voice slug or a voice ID, such as a bare AssemblyAI voice name, is `422` `invalid_request_error` |
| `invalid_request` | `422` | `400` `invalid_request_error` | `false` | A supported language no model of the region speaks (a value outside the supported set is `invalid_parameter`), or a voice the model cannot speak in the language. On the OpenAI-compatible route also text with nothing to speak (`input produced no speech`) |
| `model_not_found` | `422` | `400` `invalid_request_error` | `false` | The model is unknown or not served in this region, such as a `model_id` the region does not know or a `model` on the OpenAI-compatible route that is not an AssemblyAI model name, or no model of the region speaks the language of a request with a cloned voice |
| `voice_not_found` | `422` | `400` `invalid_request_error` | `false` | No voice with this ID or slug that your account can use on this host: it does not exist, was [deleted](https://assemblyai.com/docs/tts/cloned-voices.md#delete-a-voice), is another account's private voice, or belongs to the other data zone. See [Voice cloning](https://assemblyai.com/docs/tts/cloned-voices.md#errors) |
| `voice_expired` | `422` | `400` `invalid_request_error` | `false` | The cloned voice's consent period ended |
| `voice_blocked` | `422` | `400` `invalid_request_error` | `false` | The cloned voice is blocked; contact support |
| `voice_not_paired` | `422` | `400` `invalid_request_error` | `false` | The cloned voice is not ready to speak with the request's model, the model named by `model_id` or the default model of the voice's language, for example while a new voice is still being prepared. The message says what to do; see [The texts of 3014](https://assemblyai.com/docs/tts/error-handling.md#the-texts-of-3014) |
| `model_retired` | `422` | `400` `invalid_request_error` | `false` | The request's model, usually the one named by `model_id`, is retired; the message names its successor |
| `not_owner` | `422` | `400` `invalid_request_error` | `false` | `extend_biometric_consent` on a cloned voice that another account owns: `extend_biometric_consent can only be set by the voice owner; send the request without it to use this voice.` |
| `rate_limited` | `429` | `429` `rate_limit_error` | `true` | The account's requests per minute are used up (`limit_window` `minute`, `Retry-After` 1 to 60) |
| `at_capacity` | `503` | `429` `rate_limit_error` | `true` | No room for the request just now; `Retry-After` 2 to 5, or `1` for an `mp3` request while the server encodes as many MP3 responses as it takes (ask for a pcm encoding to avoid it) |
| `auth_unavailable` | `503` | `503` `service_unavailable_error` | `true` | The credential, or your account's acceptance of the biometric data terms, could not be checked just now; `Retry-After: 1` |
| `voice_service_unavailable` | `503` | `503` `service_unavailable_error` | `true` | The service that routes and looks up voices could not answer just now |
| `upstream_unavailable` | `503` | `503` `service_unavailable_error` | `true` | The speech service failed, or produced no audio within 20 seconds |
| `service_restart` | `503` | `503` `service_unavailable_error` | `true` | The server is restarting; a retry reaches another one |
| `service_unavailable` | `503` | `503` `service_unavailable_error` | `true` | The route is temporarily unavailable; `Retry-After: 30` |
| `internal_error` | `500` | `500` `server_error` | `true` | An unexpected server error. Retry, and contact support with the `request_id` if it persists |
| `session_expired` | After the first audio; `504` on a buffered request | | `true` | The request ran for its maximum duration, 600 seconds |
| `audio_backlog` | After the first audio only | | `false` | The client did not read the audio: 600 seconds of it (for `pcm_s16le` or MP3, about 380 seconds at 44.1 kHz and 349 seconds at 48 kHz) were waiting, one write was blocked for 30 seconds, or, once the whole text was synthesized, audio was still unread when its playing time plus 10 seconds had passed |

The list of codes can grow. Treat an unknown code by its HTTP status and `retryable`.

Retry `429` and `503` after `Retry-After`, and back off with jitter when an error repeats; a retried request starts from scratch, and a request refused with an error status costs nothing. Do not retry a `4xx` whose `retryable` is `false` without changing the request first. A cut response is retried the same way: the audio already received is not resumed, and a cut caused by a server failure is not billed.

### Answers without the JSON body

A few requests are refused before they reach the HTTP API. Those answers carry neither a JSON body nor the `X-Request-Id`, `Retry-After` and CORS headers, so a page sees them as a failed request:

| Status | Body | When |
| - | - | - |
| `400` | HTML | The request carries more than one `Authorization` header. Send exactly one |
| `404` | Plain text that begins `Not found` | A path outside the Streaming TTS routes, such as `/v2/speech` |
| `502`, `503`, `504` | HTML | The service could not be reached. Retry with backoff |

Read the status first, and parse the body as JSON only when its `Content-Type` is `application/json`.

## Limits

| Limit | Value | On breach |
| - | - | - |
| Text | 1 to 5,000 characters, counted as Unicode code points, and not blank | `422 invalid_parameter` on `text`, with `limit` |
| Request body | 131,072 bytes | `413 request_too_large` |
| Body upload | 10 seconds | `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`: the response is cut, or `/v1/speech/events` ends with an `Error` event; a buffered request is answered `504 session_expired` |
| Unread audio | 600 seconds of audio (for `pcm_s16le` or MP3, about 380 seconds at 44.1 kHz and 349 seconds at 48 kHz) waiting for a client that is not reading; one write blocked for 30 seconds; or, once the whole text is synthesized, audio still unread when its playing time plus 10 seconds has passed | `audio_backlog`, as above; a buffered body is cut instead |
| Requests per account | Requests per minute, counted separately from WebSocket sessions; requests running at once are not counted | `429 rate_limited` |

About the requests-per-minute limit:

* It is ten times the account's [WebSocket session limit](https://assemblyai.com/docs/tts/limits.md): unless your account has a limit of its own, 50 requests a minute on a free plan and 1,000 or more on a paid plan. On a paid plan it scales with use as the session limit does: a minute that uses 80% or more of it raises the next by 10%. A voice agent that sends one request per sentence sends about 15 to 20 a minute per conversation.
* It counts requests in fixed one-minute windows; `Retry-After` is the time until the next window starts. Requests refused before the limit is checked, such as one with a bad field, do not count; a request refused after it, such as one for a voice the model does not speak, does.
* Characters are not counted on their own: a request carries at most 5,000.
* The `limit` in a `429` body and in its message is your starting limit; on a paid plan the limit in force can be higher.
* The US and EU hosts count separately.

When the service has no room for a request, the answer is `503 at_capacity` (`429` on the OpenAI-compatible route); retry after `Retry-After`. So on a `429`, branch on the body's `code`: only `rate_limited` means the minute's requests are used up. These are current values and may change. If you need higher limits, contact [support](mailto:support@assemblyai.com).

Reuse connections: with HTTP keep-alive or HTTP/2, every request after the first skips the TCP and TLS handshakes. Give the client a connect timeout and a read timeout, which bounds the wait for the next bytes, but no total timeout: a long text can stream for minutes. With `httpx`, `httpx.Client(http2=True)` (after `pip install 'httpx[http2]'`) sends every request over one HTTP/2 connection, and `iter_bytes()` with no `chunk_size` yields the audio as it arrives.

## Keep latency low

What decides how soon a reply starts to play, in the order it usually matters; [Optimizing accuracy and latency](https://assemblyai.com/docs/tts/getting-started/optimizing-accuracy-and-latency.md) covers each habit in more depth:

* **Stream, and play the first bytes.** Leave `stream` at its default and read the body as it arrives. A streamed response sends its first byte with the first audio, while a buffered request (`"stream": false`) sends its first byte only when the whole clip is ready, so its wait grows with the length of the text.
* **Send each sentence as soon as you have it.** The time to the first audio of a streamed request does not grow with the length of the text. A voice agent that sends the first sentence of a reply as its own request as soon as the LLM has written it starts speaking sooner than one that waits for the whole reply. For text that arrives a few words at a time, the [WebSocket API](https://assemblyai.com/docs/tts/connect-parameters.md) takes it as it is written and starts speaking as soon as it holds enough of the first sentence.
* **Ask for raw audio in the format your player needs.** `pcm_s16le` at your player's sample rate, or 8 kHz `pcm_mulaw` or `pcm_alaw` for a phone line, is ready to play as it arrives; converting to a sample rate other than 24 kHz adds a few milliseconds at most. A streamed MP3 response starts with the encoder's silence, about 46 ms at 24 kHz and 138 ms at 8 kHz, and needs a decoder; see [Audio formats](#audio-formats).
* **Keep the connection open between replies.** Reuse one client, as above, and open its connection before the first reply, for example when a call starts, with any cheap request: `HEAD /v1/openai/models` takes no credential and synthesizes nothing. Each new connection costs the TCP and TLS handshakes, about two network round trips.
* **Use the data zone nearest to the machine that calls the API**, and, when your agent also transcribes speech with AssemblyAI, the same zone for both; a [cloned voice](https://assemblyai.com/docs/tts/cloned-voices.md) is usable only in the zone it was created in. See [Hosts and data zones](#hosts-and-data-zones).
* **Have a temporary token ready.** A page or an app that fetches a token from your server before each request adds that round trip and the mint to every reply; fetch the next one while the current reply plays.
* **Expect the first request with a voice to start a little later.** A voice that has not spoken recently, a cloned voice especially, takes longer to start on its first request than on the requests after it.

`Server-Timing` on every `200` shows the server's part of the wait: `open` for authentication, validation and the voice lookup, `model` for synthesis up to the first audio, and `ttfa` from the request's arrival to the first audio sent. The rest of the time your client measures to the first byte is the network and the connection setup.

## Billing

A request is billed per character of its text, counted as Unicode code points of the text as you sent it. The final count is in `Termination.billed_characters` on `/v1/speech/events`, in `usage.billed_characters` of the OpenAI speech events, and in `X-TTS-Billed-Characters` of a complete (`"stream": false`) response. A streamed response sends `X-TTS-Billed-Characters` with the first audio, before the outcome is known: it is what the request bills once its body is delivered.

A WebSocket session is billed per character the same way, for the text of your `Generate` frames that reaches synthesis; `Termination.total_input_char_length` is that count, see [Termination](https://assemblyai.com/docs/tts/message-frames.md#termination). Rates are on the [pricing page](https://www.assemblyai.com/pricing).

| Outcome | Billed |
| - | - |
| The audio was delivered in full | Every character |
| You closed the connection after the audio started | Every character |
| You stopped reading (`audio_backlog`) after the audio started | Every character |
| The request ran past its maximum duration (`session_expired`) after the audio started | Only the text before the last passage whose audio reached you. A passage is one or more whole sentences, usually up to about 200 characters (a longer sentence is a passage of its own). That passage and the rest of the text, which the error asks you to send in another request, are not billed |
| Any error status, or you closed the connection before the audio started | Nothing |
| A failure on our side after the audio started, which cuts the response | Nothing |
| Text with nothing to speak, such as only emoji | Nothing |

## Related

* [OpenAI compatibility](https://assemblyai.com/docs/tts/openai-compatibility.md): OpenAI SDKs, LiveKit and Pipecat.
* [Error handling](https://assemblyai.com/docs/tts/error-handling.md): the same conditions on the WebSocket API.
