Skip to main content
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, 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.

Hosts and data zones

The routes, fields and limits are the same on both hosts. See Cloud endpoints and data residency.

Authentication

Send your API key in the Authorization header. The raw key works, and so does Bearer <key>, which is what OpenAI SDKs send.
  • 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, 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) and List models (OpenRouter) 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.

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 by its library slug, such as aai-jane, any voice by its ID, or one of your cloned voices 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, 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).
  • 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: 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 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.

Audio formats

The audio is mono. Raw audio is what 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:
  • 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:
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:

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. 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: Read the status first, and parse the body as JSON only when its Content-Type is application/json.

Limits

About the requests-per-minute limit:
  • It is ten times the account’s WebSocket session limit: 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. 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 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 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.
  • 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 is usable only in the zone it was created in. See 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. Rates are on the pricing page.