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.
  • 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.
  • 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. 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: 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 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.

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.

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.