> ## 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 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`.
* 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.
* 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. 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 thousands of levels deep, 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.
* **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 it is never cut: it answers either the complete audio or an error status.

### 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; 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 thousands of levels deep |
| `feature_not_enabled` | `400` | `403` `permission_error` | `false` | Cloned voices are not available on this host (`the voice catalog is not enabled in this region, so voice_id names only a preloaded AssemblyAI voice here, such as aai-jane`, or `model_id is not enabled in this region` for a request that sent `model_id` without `voice_id`) |
| `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, an expired temporary token, one minted for another product, or one already used where tokens are single-use (`temporary token already used`) |
| `insufficient_funds` | `402` | `402` `insufficient_quota` | `false` | The account has no funds for the request |
| `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 [cloned voice](https://assemblyai.com/docs/tts/cloned-voices.md#errors) with this ID or slug that your account can use on this host: it does not exist, was deleted, is another account's private voice, or belongs to the other data zone |
| `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 paired with the request's model, or that pairing is still in progress: the model named by `model_id`, or the default model of the voice's language. 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.` |
| `biometric_terms_not_accepted` | `403` | `403` `permission_error` | `false` | Your account has not accepted the biometric data terms a cloned voice needs; accept them on the dashboard's data controls page |
| `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 could not be checked just now |
| `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 switched off; `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 were waiting, or one write was blocked for 30 seconds |

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, and the load balancer refused it. Send exactly one; such a request that reaches the HTTP API is answered `401 missing_authorization` |
| `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 waiting for a client that is not reading, or one write blocked for 30 seconds | `audio_backlog`, as above |
| 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.

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