> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.formantai.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.formantai.com/_mcp/server.

# Text to Speech

> Voice a batch of texts with ElevenLabs, Sarvam, Smallest or Vakyam and download one audio clip per text.

Use the text-to-speech API to turn up to 50 texts into audio in one call. Every text becomes its own clip, voiced with the provider, model and voice you choose, and each clip comes back as a time-limited download link. It is built for prompts, announcements, IVR menus, notification audio and anything else you want pre-rendered rather than spoken live on a call.

Rendering is asynchronous. A submission returns a batch id immediately, and polling that id is the only way to collect the audio. There is no webhook, no callback, no batch list and no cancel endpoint.

## How it works

```mermaid
flowchart LR
    A[Submit texts] --> B[Batch pending]
    B --> C[Poll the batch id]
    C -->|pending| B
    C -->|completed| D[Download each audio_url]
    C -->|failed| E[Read each clip's error]
```

#### Submit

`POST /v1/tts/batch` with your texts, provider and voice. You get back an `id` and `status: "pending"`, and the exact charge for the batch is held on your balance.

#### Poll

`GET /v1/tts/batch/{id}` until `status` is `completed` or `failed`. A batch of short texts usually finishes in a few seconds.

#### Download

Fetch each clip from its `audio_url`. The links are time-limited; poll again for fresh ones.

> **Warning**
>
> Store the returned `id`, and collect the audio within an hour of submitting. A batch is only reachable by its id, and the id stops resolving one hour after submission.

## Base URLs and authentication

| Region | Base URL                             |
| ------ | ------------------------------------ |
| Global | `https://api.voice.formantai.com`    |
| India  | `https://api.in.voice.formantai.com` |

Use the global production API unless your workspace is provisioned in the India region. A batch id is only resolvable on the deployment it was created on, so poll the same base URL you submitted to.

Every request needs an API key as a bearer token. Keys are created in the dashboard under **Developer -> API Keys** and are shown once.

```http
Authorization: Bearer YOUR_API_KEY
```

A missing header, a scheme other than `Bearer`, an empty token, an unknown key, and a disabled key all return the same `401 authentication_error`. A key that is not bound to a workspace is also refused with `401`.

> **Warning**
>
> Every accepted batch spends text-to-speech credit. Keep keys server-side and rotate one that has been exposed.

## Endpoints

| Method | Path                 | Purpose                                           |
| ------ | -------------------- | ------------------------------------------------- |
| `POST` | `/v1/tts/batch`      | Submit up to 50 texts to voice.                   |
| `GET`  | `/v1/tts/batch/{id}` | Poll a batch and collect one audio link per clip. |

Both are JSON.

### Submit a batch

```http
POST /v1/tts/batch
```

Content type: `application/json`.

| Field          | Type             | Required | Default                    | Bounds                                                                                                                                                                      |
| -------------- | ---------------- | -------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `texts`        | array of strings | Yes      | —                          | 1–50 entries, each up to 5,000 characters. One clip is rendered per entry, in the same order.                                                                               |
| `tts_provider` | string           | Yes      | —                          | `elevenlabs`, `sarvam`, `smallest` or `vakyam`. See [Providers](#providers).                                                                                                |
| `voice_id`     | string           | Yes      | —                          | Up to 200 characters. A voice of the chosen provider. Every text in the batch uses this voice. See [Voices](#voices).                                                       |
| `tts_model`    | string           | No       | the provider's first model | Up to 200 characters. Must be selectable for `tts_provider`. See [Models](#models).                                                                                         |
| `language`     | string           | No       | the provider's default     | Up to 200 characters. A language name (`Hindi`, `english`) or locale code (`hi-IN`, `en-IN`), any case. An unknown value is rejected with `400`. See [Language](#language). |

```bash
curl -X POST "https://api.in.voice.formantai.com/v1/tts/batch" \
  -H "Authorization: Bearer $FORMANT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tts_provider": "sarvam",
    "voice_id": "priya",
    "language": "hi-IN",
    "texts": [
      "Namaste! Aapka order ship ho gaya hai.",
      "Your order will arrive by Friday.",
      "Dhanyavaad!"
    ]
  }'
```

```json
{
  "id": "01b5ebec0fd34bcba12451ca79f27ab4",
  "status": "pending"
}
```

| Field    | Type   | Description                                                              |
| -------- | ------ | ------------------------------------------------------------------------ |
| `id`     | string | The batch id, 32 lowercase hex characters. Pass it to the poll endpoint. |
| `status` | string | Always `pending` on submit. A batch is never finished at submit time.    |

Everything that can be checked without synthesizing audio is checked at submit, and nothing is billed when it fails: the text lengths, the provider, the model, the language, your workspace's connection to the provider, your rate and your balance. A wrong `voice_id` is the exception — only the provider can tell, so it surfaces per clip when the batch is polled.

### Poll a batch

```http
GET /v1/tts/batch/{id}
```

```bash
curl "https://api.in.voice.formantai.com/v1/tts/batch/01b5ebec0fd34bcba12451ca79f27ab4" \
  -H "Authorization: Bearer $FORMANT_API_KEY"
```

```json
{
  "id": "01b5ebec0fd34bcba12451ca79f27ab4",
  "status": "completed",
  "provider": "sarvam",
  "model": "bulbul:v3",
  "audio_format": "wav",
  "clips": [
    { "index": 0, "audio_url": "https://storage.googleapis.com/...&X-Goog-Signature=...", "error": null },
    { "index": 1, "audio_url": "https://storage.googleapis.com/...&X-Goog-Signature=...", "error": null },
    { "index": 2, "audio_url": "https://storage.googleapis.com/...&X-Goog-Signature=...", "error": null }
  ],
  "audio_url_expires_in": 900
}
```

| Field                  | Type            | Description                                                                                                                      |
| ---------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `id`                   | string          | The batch id.                                                                                                                    |
| `status`               | string          | `pending`, `completed` or `failed`. See [Batch lifecycle](#batch-lifecycle).                                                     |
| `provider`             | string          | The provider that voiced the batch.                                                                                              |
| `model`                | string          | The model actually used — the default when you omitted `tts_model`.                                                              |
| `audio_format`         | string          | Container of every clip in the batch: `mp3` for `elevenlabs`, `wav` for every other provider. See [Audio format](#audio-format). |
| `clips`                | array           | One entry per text, in submission order. Empty while the batch is `pending`.                                                     |
| `clips[].index`        | integer         | Position of the text in your `texts` array, starting at `0`.                                                                     |
| `clips[].audio_url`    | string or null  | Time-limited link to the clip. Set when the clip rendered.                                                                       |
| `clips[].error`        | string or null  | Why the clip did not render. Set when `audio_url` is not.                                                                        |
| `audio_url_expires_in` | integer or null | Seconds every `audio_url` in this response stays valid. `900`, or `null` when no clip rendered.                                  |

A batch belongs to the workspace that submitted it. Polling another workspace's id returns `404`, exactly as an unknown id does.

## Providers

`tts_provider` picks who voices the batch. The connection behind the provider is resolved server-side from your own workspace's connections, so the API takes no connection id and never takes a provider key from you.

| `tts_provider` | Voices                                                                  | Strengths                                                                                               |
| -------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `elevenlabs`   | The voices on the ElevenLabs account behind your workspace's connection | Expressive, natural-sounding voices across many languages; the v4 models are ElevenLabs' newest.        |
| `sarvam`       | 37 `bulbul:v3` speakers and 222 `bulbul:v4-flash` persona voices        | Eleven Indic languages plus Indian English, tuned for Indian names, numbers and code-mixed speech.      |
| `smallest`     | 249 voices                                                              | Broad multilingual coverage — 30+ languages including the Indic set, European languages and East Asian. |
| `vakyam`       | Vakyam's voice roster                                                   | Indic voices from Vakyam's realtime engine; speaks Hindi (`hi-IN`) unless you set `language`.           |

A provider your workspace has no connection to is rejected at submit with `400 invalid_request` (`no <provider> synthesizer connection is available to this tenant`). An unknown provider is rejected with `400` as well. Nothing is billed either way.

## Models

`tts_model` must be selectable for the provider you chose. Omit it and the provider's first model is used.

| Provider     | Selectable `tts_model` values                                                                      | Default                  |
| ------------ | -------------------------------------------------------------------------------------------------- | ------------------------ |
| `elevenlabs` | `eleven_multilingual_v2`, `eleven_flash_v2_5`, `eleven_turbo_v2_5`, `eleven_v4`, `eleven_v4_turbo` | `eleven_multilingual_v2` |
| `sarvam`     | `bulbul:v3`, `bulbul:v4-flash`                                                                     | `bulbul:v3`              |
| `smallest`   | `lightning_v3.1_pro`                                                                               | `lightning_v3.1_pro`     |
| `vakyam`     | `raaga-v1`                                                                                         | `raaga-v1`               |

A model that is not selectable for the chosen provider is rejected at submit with `400 invalid_request`, and the message names the models that are.

## Voices

`voice_id` is whatever the provider calls a voice, passed through unchanged, so it must belong to the provider you selected. One batch uses one voice; to voice texts in different voices, submit a batch per voice.

* **ElevenLabs** — ElevenLabs voice ids such as `21m00Tcm4TlvDq8ikWAM`, from the ElevenLabs account behind your workspace's connection. Every ElevenLabs voice works with every selectable ElevenLabs model.
* **Sarvam** — voices are tied to a model. With `bulbul:v3`, the 37 speakers listed in the podcast [Voice catalogue](/api-reference/podcast#voice-catalogue), such as `priya`, `neha`, `aditya` and `rahul`. With `bulbul:v4-flash`, the 222 persona voices listed there, named `<name>_<language>_<style>`, such as `ritu_hi_customer`. A voice sent with the other model fails every clip.
* **Smallest** — the 249 `lightning_v3.1_pro` voices listed in the podcast [Voice catalogue](/api-reference/podcast#voice-catalogue), such as `mandar`, `jasleen` and `aarini`.
* **Vakyam** — Vakyam voice names, such as `Archana`. Voice names are case-sensitive.

The same lists are in the dashboard under an agent's **Voice** tab, where you can preview a voice before using it.

> **Warning**
>
> A wrong `voice_id` is not caught at submit. Each clip fails when the provider rejects it, with the provider's message in that clip's `error`. When every clip fails, the batch is `failed` and the whole hold is refunded.

## Language

`language` accepts a language name or a locale code, in any case: `Hindi`, `hindi`, `hi` and `hi-IN` all mean Hindi. It is normalized at submit to the locale code the providers expect — English and the Indic languages get the `-IN` region (`hindi` → `hi-IN`, `english` → `en-IN`), and a region you send yourself is kept (`en-US`, `pt-BR`). An unrecognized value is rejected with `400 invalid_request` before anything is rendered or billed.

The code is passed to the provider with every clip. When you omit it, each provider falls back to its own default:

| Provider   | Default when `language` is omitted |
| ---------- | ---------------------------------- |
| `sarvam`   | `en-IN`                            |
| `smallest` | `en`                               |
| `vakyam`   | `hi-IN`                            |

ElevenLabs is not sent a language code; it speaks the language the text is written in.

The text is spoken as written: it is not translated. Write each text in the language you ask for, and pick a voice that covers it — nothing cross-checks the three.

## Audio format

The provider decides the container. `audio_format` in the poll response tells you which one every clip in the batch uses.

| Provider     | `audio_format` | Encoding                   |
| ------------ | -------------- | -------------------------- |
| `elevenlabs` | `mp3`          | MP3, 128 kbps, 44.1 kHz    |
| `sarvam`     | `wav`          | 16-bit PCM, mono, 44.1 kHz |
| `smallest`   | `wav`          | 16-bit PCM, mono, 44.1 kHz |
| `vakyam`     | `wav`          | 16-bit PCM, mono, 24 kHz   |

Clips are stored as `audio/mpeg` or `audio/wav`, so they play directly in a browser or an `<audio>` element. Transcode on your side if you need another format, μ-law or 8 kHz telephony audio.

## Batch lifecycle

```mermaid
flowchart LR
    A[pending] -->|at least one clip rendered| B[completed]
    A -->|no clip rendered| C[failed]
```

| Status      | Terminal | Meaning                                                                                        | What the client should do                                                                   |
| ----------- | -------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `pending`   | No       | Accepted and rendering. `clips` is empty.                                                      | Keep polling.                                                                               |
| `completed` | Yes      | Every clip has finished, and at least one rendered. A clip that did not render has an `error`. | Stop polling. Download each `audio_url`; resubmit only the texts whose clip has an `error`. |
| `failed`    | Yes      | No clip rendered. Each clip's `error` says why.                                                | Stop polling. Fix the submission and submit again. Polling will never change the answer.    |

Clips succeed or fail independently: one rejected text does not throw away the clips that rendered. Up to four clips render at once, so a large batch takes roughly a quarter of the time it would one by one.

> **Warning**
>
> There is no cancel endpoint and no retry. A batch whose rendering is interrupted — a server restart, for instance — stops resolving, and polling it returns `404`. Enforce your own deadline, and contact support with the batch id rather than resubmitting blindly, since its credit hold may still be held.

### Recommended polling

Poll every 2 to 5 seconds. Each clip takes about as long to render as a provider call, so a batch of short texts finishes in seconds and a full batch of long texts in a minute or two.

```python
import os
import time

import requests

BASE_URL = "https://api.in.voice.formantai.com"
HEADERS = {"Authorization": f"Bearer {os.environ['FORMANT_API_KEY']}"}

DEADLINE_SECONDS = 600
POLL_SECONDS = 3


def submit(payload):
    """Submit a batch and return its id."""
    response = requests.post(f"{BASE_URL}/v1/tts/batch", headers=HEADERS, json=payload, timeout=60)
    response.raise_for_status()
    return response.json()["id"]


def wait_for(batch_id):
    """Poll a batch until it reaches a terminal status or the deadline passes."""
    deadline = time.monotonic() + DEADLINE_SECONDS
    while time.monotonic() < deadline:
        response = requests.get(f"{BASE_URL}/v1/tts/batch/{batch_id}", headers=HEADERS, timeout=30)
        if response.status_code == 429:
            time.sleep(int(response.headers.get("Retry-After", POLL_SECONDS)))
            continue
        if response.status_code >= 500:
            time.sleep(POLL_SECONDS)
            continue
        response.raise_for_status()
        batch = response.json()
        if batch["status"] != "pending":
            return batch
        time.sleep(POLL_SECONDS)
    raise TimeoutError(f"batch {batch_id} did not finish within {DEADLINE_SECONDS}s")


texts = ["Namaste! Aapka order ship ho gaya hai.", "Your order will arrive by Friday."]
batch = wait_for(submit({"tts_provider": "sarvam", "voice_id": "priya", "language": "hi-IN", "texts": texts}))

for clip in batch["clips"]:
    if clip["audio_url"]:
        audio = requests.get(clip["audio_url"], timeout=120)  # no auth header
        open(f"clip-{clip['index']}.{batch['audio_format']}", "wb").write(audio.content)
    else:
        print(f"text {clip['index']} failed: {clip['error']}")
```

## The audio URL

Each `audio_url` is a signed link to the clip in private storage, valid for **900 seconds (15 minutes)** from the moment that poll response was built.

* **Fetch it without an `Authorization` header.** The signature is the credential; adding a bearer token is unnecessary and can break the request.
* **It plays inline.** Storage serves range requests, so a player can seek.
* **It is a bearer capability.** Anyone holding the link can download the clip until it expires. Do not paste it into shared logs or tickets.
* **When it expires, poll again.** Every poll of a finished batch mints fresh links, for up to an hour after submission.

Download promptly, or store the bytes yourself. There is no permanent public URL.

## Billing

Text to speech is billed per **1,000 characters** at your workspace's text-to-speech rate, set by FormantAI on your billing account. Characters are counted exactly as sent, spaces and punctuation included, with no rounding up to the next thousand.

* **At submit**, the full charge for every text in the batch is held on your balance: total characters ÷ 1,000 × rate, rounded to two decimal places. If the balance cannot cover it, the submission is refused with `402 insufficient_credits` and nothing is rendered.
* **When the batch finishes**, it is settled against the clips that rendered. A clip that failed is refunded; a `failed` batch is refunded in full.

For example, at ₹30 per 1,000 characters, three texts totalling 58 characters hold and charge ₹1.74.

A workspace with no text-to-speech rate cannot use this API: submissions are refused with `400 invalid_request` (`billing account has no tts_service_rate_per_1k_chars`) rather than voiced for free. Ask support to enable text to speech for your workspace.

Each batch holds its own credit, so batches submitted in parallel each need their own hold to be available.

## Limits

| Limit                                               | Value               | Where it applies                                                              |
| --------------------------------------------------- | ------------------- | ----------------------------------------------------------------------------- |
| `texts`                                             | 1–50 entries        | Per batch. Send several batches for more.                                     |
| Text length                                         | 5,000 characters    | Per entry in `texts`. A longer text is rejected with `400`, naming its index. |
| `voice_id`, `tts_provider`, `tts_model`, `language` | 200 characters each | Per request.                                                                  |
| Clips rendering at once                             | 4                   | Per batch.                                                                    |
| Batch id lifetime                                   | 1 hour              | From submission. Polling after that returns `404`.                            |
| Audio link lifetime                                 | 15 minutes          | From the poll that minted it.                                                 |

A field outside its bounds is rejected with `422` before anything is checked or billed, except an over-long entry in `texts`, which is rejected with `400`.

### Rate limiting

Rate limiting is per workspace, defaulting to 50 requests per minute, and the bucket is shared with every other endpoint on this API — calls, batch calls, conversations, transcription and podcasts draw from the same budget as submitting and polling batches. See [Errors & Rate Limits](/api-reference/errors-rate-limits) for the headers.

## Errors

Every error except `422` uses the standard envelope:

```json
{
  "error": {
    "message": "billing account has no tts_service_rate_per_1k_chars",
    "type": "invalid_request"
  }
}
```

A `422` comes from request parsing and uses FastAPI's own shape instead, with a `detail` array and no `type`:

```json
{
  "detail": [
    {
      "type": "too_short",
      "loc": ["body", "texts"],
      "msg": "List should have at least 1 item after validation, not 0"
    }
  ]
}
```

| HTTP  | Type                   | Message and cause                                                                                                       | Fix                                                                       |
| ----- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `400` | `invalid_request`      | `texts [<index>, ...] exceed 5000 characters` — those entries are too long.                                             | Split them into shorter texts.                                            |
| `400` | `invalid_request`      | `unsupported tts_provider '<value>'` — not a provider this API offers.                                                  | Send `elevenlabs`, `sarvam`, `smallest` or `vakyam`.                      |
| `400` | `invalid_request`      | `no <provider> synthesizer connection is available to this tenant` — your workspace has no connection to that provider. | Pick another provider, or ask support to connect this one.                |
| `400` | `invalid_request`      | `model '<value>' is not selectable for <provider>` — the model is not in that provider's list.                          | Use a model from the [Models](#models) table, or omit the field.          |
| `400` | `invalid_request`      | `unsupported language '<value>'` — not a language name or locale code the API knows.                                    | Send a name such as `Hindi` or a code such as `hi-IN`.                    |
| `400` | `invalid_request`      | `billing account has no tts_service_rate_per_1k_chars` — text to speech is not enabled for your workspace.              | Ask support to set a text-to-speech rate.                                 |
| `400` | `invalid_request`      | `no billing account found for tenant <id>` — your workspace has no billing account.                                     | Contact support.                                                          |
| `401` | `authentication_error` | `invalid or missing api key` — the header is missing or malformed, or the key is unknown or disabled.                   | Send `Authorization: Bearer <key>` with an active key from the dashboard. |
| `402` | `insufficient_credits` | The balance cannot cover the batch's hold.                                                                              | Top up, or submit fewer or shorter texts. Nothing was rendered.           |
| `404` | `not_found`            | `tts batch not found or expired` — the id is unknown, belongs to another workspace, or is over an hour old.             | Check the id and the region, and poll within an hour of submitting.       |
| `422` | none                   | FastAPI `detail` array — a field is missing, the wrong type, or outside its bounds.                                     | Fix the named field. The `loc` array points at it.                        |
| `429` | `rate_limit_exceeded`  | `rate limit exceeded. <N> requests per minute allowed.` — the workspace bucket is empty.                                | Wait for `Retry-After`, then retry. Poll less often.                      |
| `503` | `not_configured`       | `billing is unavailable` — the API cannot reach billing, so it refuses rather than voice for free.                      | Retry with backoff, then contact support.                                 |

Per-clip errors are not HTTP errors: they arrive in a `200` poll response, in `clips[].error`. A provider rejection reads like `sarvam tts failed (400) [speaker=... model=... lang=... text='...']: <provider message>`, naming the voice, model, language and the start of the text. An error on our side reads `internal error`.

Retry `429` after `Retry-After`, and `503` with bounded exponential backoff. Do not retry `400`, `401`, `402`, `404` or `422` until the request itself is fixed.

## Troubleshooting

| Symptom                                                                                        | Likely cause                                                                                                | What to do                                                                                           |
| ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Every clip `failed` with `Speaker '<id>' is not recognized` or `Voice '<id>' is not available` | The `voice_id` does not belong to the chosen provider or model.                                             | Pick a voice from [Voices](#voices) for that provider, and resubmit. The failed clips were refunded. |
| One clip has `... returned no audio`                                                           | That text has nothing speakable in it, such as only punctuation or symbols.                                 | Remove or rewrite the text.                                                                          |
| Audio is in the wrong language, or mispronounced                                               | `language` was omitted and the provider fell back to its default, or the voice does not cover the language. | Set `language`, write the text in that language, and pick a voice that covers it.                    |
| `400 billing account has no tts_service_rate_per_1k_chars`                                     | Text to speech has not been enabled for the workspace.                                                      | Ask support to set a rate.                                                                           |
| `audio_url` returns an expired-signature error                                                 | More than 15 minutes passed since the poll that returned it.                                                | Poll the batch again for fresh links.                                                                |
| `404` on a batch you just submitted                                                            | You are polling a different region than you submitted to, or the batch was interrupted.                     | Poll the same base URL. If it still 404s, contact support with the batch id.                         |
| Frequent `429` while a batch renders                                                           | The poll loop is too tight, and the bucket is shared with every other endpoint.                             | Poll every 2 to 5 seconds, and honour `Retry-After`.                                                 |

## Next steps

#### [Podcast](/api-reference/podcast)

Script and voice a whole multi-speaker episode from source material.

#### [Providers](/platform/providers)

The speech and voice providers behind every clip.

#### [API Overview](/api-reference/overview)

Base URLs, authentication, and the shared conventions of this API.

#### [Errors & Rate Limits](/api-reference/errors-rate-limits)

Review the shared error envelope and retry guidance.