Skip to navigation

Text to Speech

View as Markdown

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

pending completed failed Submit texts Batch pending Poll the batch id Download each audio_url Read each clip's error
1

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.

2

Poll

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

3

Download

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

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

RegionBase URL
Globalhttps://api.voice.formantai.com
Indiahttps://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.

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.

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

Endpoints

MethodPathPurpose
POST/v1/tts/batchSubmit 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

POST /v1/tts/batch

Content type: application/json.

FieldTypeRequiredDefaultBounds
textsarray of stringsYes—1–50 entries, each up to 5,000 characters. One clip is rendered per entry, in the same order.
tts_providerstringYes—elevenlabs, sarvam, smallest or vakyam. See Providers.
voice_idstringYes—Up to 200 characters. A voice of the chosen provider. Every text in the batch uses this voice. See Voices.
tts_modelstringNothe provider’s first modelUp to 200 characters. Must be selectable for tts_provider. See Models.
languagestringNothe provider’s defaultUp 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.
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!"
]
}'
{
"id": "01b5ebec0fd34bcba12451ca79f27ab4",
"status": "pending"
}
FieldTypeDescription
idstringThe batch id, 32 lowercase hex characters. Pass it to the poll endpoint.
statusstringAlways 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

GET /v1/tts/batch/{id}
curl "https://api.in.voice.formantai.com/v1/tts/batch/01b5ebec0fd34bcba12451ca79f27ab4" \
-H "Authorization: Bearer $FORMANT_API_KEY"
{
"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
}
FieldTypeDescription
idstringThe batch id.
statusstringpending, completed or failed. See Batch lifecycle.
providerstringThe provider that voiced the batch.
modelstringThe model actually used — the default when you omitted tts_model.
audio_formatstringContainer of every clip in the batch: mp3 for elevenlabs, wav for every other provider. See Audio format.
clipsarrayOne entry per text, in submission order. Empty while the batch is pending.
clips[].indexintegerPosition of the text in your texts array, starting at 0.
clips[].audio_urlstring or nullTime-limited link to the clip. Set when the clip rendered.
clips[].errorstring or nullWhy the clip did not render. Set when audio_url is not.
audio_url_expires_ininteger or nullSeconds 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_providerVoicesStrengths
elevenlabsThe voices on the ElevenLabs account behind your workspace’s connectionExpressive, natural-sounding voices across many languages; the v4 models are ElevenLabs’ newest.
sarvam37 bulbul:v3 speakers and 222 bulbul:v4-flash persona voicesEleven Indic languages plus Indian English, tuned for Indian names, numbers and code-mixed speech.
smallest249 voicesBroad multilingual coverage — 30+ languages including the Indic set, European languages and East Asian.
vakyamVakyam’s voice rosterIndic 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.

ProviderSelectable tts_model valuesDefault
elevenlabseleven_multilingual_v2, eleven_flash_v2_5, eleven_turbo_v2_5, eleven_v4, eleven_v4_turboeleven_multilingual_v2
sarvambulbul:v3, bulbul:v4-flashbulbul:v3
smallestlightning_v3.1_prolightning_v3.1_pro
vakyamraaga-v1raaga-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, 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, 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.

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:

ProviderDefault when language is omitted
sarvamen-IN
smallesten
vakyamhi-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.

Provideraudio_formatEncoding
elevenlabsmp3MP3, 128 kbps, 44.1 kHz
sarvamwav16-bit PCM, mono, 44.1 kHz
smallestwav16-bit PCM, mono, 44.1 kHz
vakyamwav16-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

at least one clip rendered no clip rendered pending completed failed
StatusTerminalMeaningWhat the client should do
pendingNoAccepted and rendering. clips is empty.Keep polling.
completedYesEvery 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.
failedYesNo 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.

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.

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.

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

LimitValueWhere it applies
texts1–50 entriesPer batch. Send several batches for more.
Text length5,000 charactersPer entry in texts. A longer text is rejected with 400, naming its index.
voice_id, tts_provider, tts_model, language200 characters eachPer request.
Clips rendering at once4Per batch.
Batch id lifetime1 hourFrom submission. Polling after that returns 404.
Audio link lifetime15 minutesFrom 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 for the headers.

Errors

Every error except 422 uses the standard envelope:

{
"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:

{
"detail": [
{
"type": "too_short",
"loc": ["body", "texts"],
"msg": "List should have at least 1 item after validation, not 0"
}
]
}
HTTPTypeMessage and causeFix
400invalid_requesttexts [<index>, ...] exceed 5000 characters — those entries are too long.Split them into shorter texts.
400invalid_requestunsupported tts_provider '<value>' — not a provider this API offers.Send elevenlabs, sarvam, smallest or vakyam.
400invalid_requestno <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.
400invalid_requestmodel '<value>' is not selectable for <provider> — the model is not in that provider’s list.Use a model from the Models table, or omit the field.
400invalid_requestunsupported 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.
400invalid_requestbilling 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.
400invalid_requestno billing account found for tenant <id> — your workspace has no billing account.Contact support.
401authentication_errorinvalid 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.
402insufficient_creditsThe balance cannot cover the batch’s hold.Top up, or submit fewer or shorter texts. Nothing was rendered.
404not_foundtts 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.
422noneFastAPI detail array — a field is missing, the wrong type, or outside its bounds.Fix the named field. The loc array points at it.
429rate_limit_exceededrate limit exceeded. <N> requests per minute allowed. — the workspace bucket is empty.Wait for Retry-After, then retry. Poll less often.
503not_configuredbilling 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

SymptomLikely causeWhat to do
Every clip failed with Speaker '<id>' is not recognized or Voice '<id>' is not availableThe voice_id does not belong to the chosen provider or model.Pick a voice from Voices for that provider, and resubmit. The failed clips were refunded.
One clip has ... returned no audioThat text has nothing speakable in it, such as only punctuation or symbols.Remove or rewrite the text.
Audio is in the wrong language, or mispronouncedlanguage 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_charsText to speech has not been enabled for the workspace.Ask support to set a rate.
audio_url returns an expired-signature errorMore than 15 minutes passed since the poll that returned it.Poll the batch again for fresh links.
404 on a batch you just submittedYou 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 rendersThe 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