Skip to navigation

Podcast

View as Markdown

Use the podcast API to turn source material — a block of text or an uploaded PDF — into a scripted, multi-speaker audio episode. An LLM writes an outline and a transcript, every turn is synthesized with the voice you assigned to that speaker, and the clips are concatenated into one MP3 at 128 kbps, 44.1 kHz — or, with output_format: "mp4", an MP4 ready for video platforms such as Cloudflare Stream.

Rendering is asynchronous and takes minutes. Every submission returns an episode id immediately, and polling that id is the only way to collect the audio. There is no webhook, no callback, no episode list, and no cancel endpoint.

How it works

pending or processing completed failed Submit an episode Episode pending Poll the episode id Download audio_url Read error
1

Submit

POST /v1/podcast/jobs with your content and speakers, and you get back an id and status: "pending". Store the id: it cannot be recovered later.

2

Poll

GET /v1/podcast/jobs/{id} until status is completed or failed.

3

Download

On completed, fetch the episode — MP3 or MP4 — from audio_url. The link is time-limited; poll again for a fresh one.

Store the returned id before you do anything else. Episodes are not listable through this API, so a lost id means you cannot reach the audio and cannot tell whether the episode rendered.

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. An episode 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 grants access to everything your workspace can do through this API, and every accepted episode spends LLM and TTS credit. Keep keys server-side and rotate one that has been exposed.

Endpoints

MethodPathPurpose
POST/v1/podcast/jobsSubmit an episode built from inline text.
POST/v1/podcast/uploadsGet a one-shot signed URL to upload a source PDF to.
POST/v1/podcast/jobs/pdfSubmit an episode built from an already-uploaded PDF.
GET/v1/podcast/jobs/{id}Poll an episode and collect its audio link.

All four are JSON. There is no multipart endpoint: a PDF is uploaded straight to storage, never through the API.

Submit an episode from text

POST /v1/podcast/jobs

Content type: application/json.

FieldTypeRequiredDefaultBounds
contentstringYes—Up to 200,000 characters. The source material the episode is written from. An empty string is accepted by validation and produces an episode written from nothing.
briefingstringYes—Up to 5,000 characters. The angle: what the episode should cover and how it should sound.
speakersarrayYes—1–4 entries. See Speakers.
num_segmentsintegerNo21–20. Outline segments the episode is split into. The episode never has more than this.
episode_namestringNoepisodeUp to 200 characters. Filename stem for the rendered object.
languagestringNononeUp 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.
output_formatstringNomp3mp3 or mp4. See Output format.
tts_providerstringNoyour deployment’s defaultUp to 200 characters. elevenlabs, sarvam, smallest or vakyam. Chooses which provider voices the episode. See TTS providers.
tts_modelstringNothe provider’s first modelUp to 200 characters. Must be selectable for tts_provider. See TTS models.
curl -X POST "https://api.voice.formantai.com/v1/podcast/jobs" \
-H "Authorization: Bearer $FORMANT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"content": "Quarterly results: revenue grew 18% year on year, driven by ...",
"briefing": "A two-host recap for listeners who do not work in finance. Warm, concrete, no jargon.",
"episode_name": "q3-recap",
"num_segments": 3,
"language": "en-IN",
"speakers": [
{
"name": "Asha",
"voice_id": "priya",
"backstory": "Equity analyst covering consumer tech.",
"persona": "Precise, asks the sharp follow-up."
},
{
"name": "Ravi",
"voice_id": "aditya",
"persona": "Curious generalist, keeps it plain-spoken."
}
]
}'
{
"id": 4821,
"status": "pending"
}
FieldTypeDescription
idintegerThe episode id. Pass it to the poll endpoint.
statusstringAlways pending on submit. An episode is never finished at submit time.

Request an upload URL

POST /v1/podcast/uploads
FieldTypeRequiredDefaultBounds
filenamestringYes—Up to 200 characters. Labels the stored object only; the key itself is minted by the API.
curl -X POST "https://api.voice.formantai.com/v1/podcast/uploads" \
-H "Authorization: Bearer $FORMANT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"filename": "annual-report.pdf"}'
{
"upload_url": "https://storage.googleapis.com/...&X-Goog-Signature=...",
"key": "pdf/3f2a9c1b8e4d47a1b0c5d6e7f8091a2b_annual-report.pdf",
"expires_in": 900,
"content_type": "application/pdf"
}
FieldTypeDescription
upload_urlstringSigned URL that accepts exactly one PUT.
keystringThe object key to pass back as source_key. Minted server-side as pdf/<32 lowercase hex characters>_<filename>; each run of characters outside A-Z a-z 0-9 . _ - collapses to a single _. Pass it back byte for byte — the validator is case-sensitive.
expires_inintegerSeconds the URL stays valid. Always 900.
content_typestringAlways application/pdf. The PUT must send exactly this value.

Submit an episode from a PDF

POST /v1/podcast/jobs/pdf

Content type: application/json. The PDF itself is not in this request — only its key.

FieldTypeRequiredDefaultBounds
source_keystringYes—Up to 200 characters. The key returned by POST /v1/podcast/uploads, unchanged.
focus_querystringYes—Up to 5,000 characters. What the episode should focus on inside the document. A blank or whitespace-only value is rejected with 400 invalid_request (focus_query is required), not 422, and only after the PDF has been downloaded and size-checked.
speakersarrayYes—1–4 entries. Same shape as the text submit.
briefingstringNo""Up to 5,000 characters.
num_segmentsintegerNo21–20.
episode_namestringNoepisodeUp to 200 characters.
languagestringNononeUp 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.
output_formatstringNomp3mp3 or mp4. See Output format.
tts_providerstringNoyour deployment’s defaultUp to 200 characters. elevenlabs, sarvam, smallest or vakyam.
tts_modelstringNothe provider’s first modelUp to 200 characters. Must be selectable for tts_provider.

There is no content field here: the content is extracted from the PDF by the renderer.

curl -X POST "https://api.voice.formantai.com/v1/podcast/jobs/pdf" \
-H "Authorization: Bearer $FORMANT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source_key": "pdf/3f2a9c1b8e4d47a1b0c5d6e7f8091a2b_annual-report.pdf",
"focus_query": "Cover the outlook section and the risks the auditors flagged.",
"briefing": "Two analysts talking it through, sceptical but fair.",
"episode_name": "annual-report-brief",
"speakers": [
{"name": "Asha", "voice_id": "priya"},
{"name": "Ravi", "voice_id": "aditya"}
]
}'
{
"id": 4822,
"status": "pending"
}

Poll an episode

GET /v1/podcast/jobs/{id}
Path parameterTypeRequiredDescription
idintegerYesThe id returned by either submit endpoint. Ids are scoped to your workspace; another workspace’s id returns 404.
curl "https://api.voice.formantai.com/v1/podcast/jobs/4821" \
-H "Authorization: Bearer $FORMANT_API_KEY"

Polling reads the stored episode record directly, so it keeps working while the renderer is busy or restarting.

While the episode is still rendering:

{
"id": 4821,
"status": "processing",
"episode_name": "q3-recap",
"error": null,
"duration_seconds": null,
"audio_url": null,
"audio_url_expires_in": null
}

When it is done:

{
"id": 4821,
"status": "completed",
"episode_name": "q3-recap",
"error": null,
"duration_seconds": 784.32,
"audio_url": "https://storage.googleapis.com/...&X-Goog-Signature=...",
"audio_url_expires_in": 900
}

When it failed:

{
"id": 4821,
"status": "failed",
"episode_name": "q3-recap",
"error": "sarvam tts failed (400) [speaker=emily model=bulbul:v3 lang=en-IN ...]",
"duration_seconds": null,
"audio_url": null,
"audio_url_expires_in": null
}
FieldTypeDescription
idintegerThe episode id.
statusstringOne of pending, processing, completed, failed. See Job lifecycle.
episode_namestringThe filename stem you submitted.
errorstringWhy the render failed, taken from the renderer. null unless status is failed. It is a raw internal message, so log it rather than showing it to end users.
duration_secondsnumber or nullMeasured length of the rendered episode, to two decimals. Populated when the episode completes, or null if the rendered file’s length could not be read.
audio_urlstringTime-limited link to the episode, in the output_format you asked for. null until the episode completes.
audio_url_expires_inintegerSeconds audio_url stays valid. 900 when a link is present, otherwise null.

Source material

An episode is built from exactly one of two sources.

SourceEndpointWhat you sendWhat the script is written from
Inline textPOST /v1/podcast/jobscontent plus briefingYour text, verbatim.
PDFPOST /v1/podcast/uploads then POST /v1/podcast/jobs/pdfAn object key plus focus_queryText the renderer extracts from the PDF.

Inline text

Put the whole source in content — up to 200,000 characters — and describe the angle in briefing. Nothing else is fetched: the episode is written only from what you send.

The PDF upload flow

The PDF goes straight to object storage and is submitted by key, so the file never travels through the API.

1

Ask for an upload URL

POST /v1/podcast/uploads with your filename. You get back upload_url, key, expires_in and content_type.

2

PUT the file

Send the raw PDF bytes to upload_url with PUT and Content-Type: application/pdf. No Authorization header: the URL carries its own signature.

3

Submit the key

POST /v1/podcast/jobs/pdf with that key as source_key, plus focus_query and your speakers. From here the episode polls like any other.

UPLOAD=$(curl -s -X POST "https://api.voice.formantai.com/v1/podcast/uploads" \
-H "Authorization: Bearer $FORMANT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"filename": "annual-report.pdf"}')
UPLOAD_URL=$(echo "$UPLOAD" | jq -r .upload_url)
KEY=$(echo "$UPLOAD" | jq -r .key)
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/pdf" \
--upload-file annual-report.pdf
curl -X POST "https://api.voice.formantai.com/v1/podcast/jobs/pdf" \
-H "Authorization: Bearer $FORMANT_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"source_key\": \"$KEY\",
\"focus_query\": \"Cover the outlook section and the risks the auditors flagged.\",
\"speakers\": [{\"name\": \"Asha\", \"voice_id\": \"anushka\"}]}"

Rules the PUT must follow:

  • Method. The URL is signed for PUT only. A POST or a GET to it is rejected by storage.
  • Content type. application/pdf, exactly. The content type is part of what the URL is signed over, so any other value — including a client’s default application/octet-stream — fails the signature check.
  • One object. The URL writes the returned key and nothing else. You cannot aim it at another object, and you cannot choose the key yourself.
  • Validity. 15 minutes (expires_in: 900), counted from the moment the URL was issued. When it expires, request a new one; the old key is then useless because nothing was written to it.
  • Body. Raw PDF bytes, not multipart and not base64.

Storage returns its own XML error body on a failed PUT, not the FormantAI error envelope. A 403 there almost always means the URL expired or the Content-Type did not match.

At submit, only the key is passed back. It is validated against the exact shape the upload endpoint mints, pdf/<32 hex characters>_<safe filename>, and anything else is rejected with 400 invalid_request — you cannot point source_key at an arbitrary object. A well-formed key with no object behind it returns 404 not_found.

The uploaded PDF is read once, when the episode is submitted. That is where the 10 MB ceiling is enforced, because a signed upload cannot cap what a client sends: an oversized file uploads successfully and is then refused at submit. The renderer reads the document in two passes — one over the whole file for context, one for the material your focus_query asks about — and the combined result becomes the episode’s content.

An upload you never submit is left in storage, and so is the source of a failed episode. There is no delete endpoint, so do not upload documents you would not want retained.

Speakers

speakers describes the cast. Order is the order the LLM is told about them; it does not force who speaks first.

FieldTypeRequiredBoundsWhat it does
namestringYesUp to 200 charactersDisplay name in the transcript and the label the renderer maps back to a voice.
voice_idstringYesUp to 200 charactersThe voice this speaker is synthesized with, passed to the TTS provider unchanged.
backstorystringNoUp to 2,000 charactersBackground and expertise. Shapes what this speaker knows and brings up.
personastringNoUp to 2,000 charactersSpeaking style and personality. Shapes how they talk.

Rules enforced at submit:

  • Between 1 and 4 speakers. Zero, or five or more, is rejected.
  • Names must be unique. Two speakers called Asha return 400 invalid_request, because the renderer maps a transcript line back to a voice by name.
  • Voice ids must be unique. Two speakers sharing a voice_id return 400 invalid_request — an episode where two people sound identical is assumed to be a mistake.

backstory and persona change the script, never the audio directly. They are the right place for direction such as “sceptical, interrupts to ask for numbers”.

Audio-tag markup is not supported. The transcript is written as plain spoken dialogue with no bracketed cues such as [excited], whichever model voices it — only the ElevenLabs v4 models interpret audio tags, and every other model would read a cue out loud as words. Keep delivery notes in persona and backstory.

Voice ids

voice_id is whatever the provider calls a voice: an ElevenLabs voice id, a Sarvam speaker name, a Smallest voice id, a Vakyam voice name. It is passed through unchanged, so it must belong to the provider you selected with tts_provider — a Sarvam speaker will not work on a Smallest episode. Every valid value is listed in the Voice catalogue.

The same lists are in the dashboard under an agent’s Voice tab, where you can preview a voice before using it, and programmatically from the dashboard API as GET /synthesizer-voices.

A wrong voice_id is not caught at submit. The provider only rejects it while the audio is being synthesized, which is after the outline and transcript have been written and paid for, so the episode comes back status: "failed" with the provider’s message in error. Confirm a voice id against the catalogue before putting it in an API call.

TTS providers

tts_provider picks who voices the episode. Four providers are available:

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.

The connection behind the provider is resolved server-side from your own workspace’s connections, so the API takes no connection id. Omit tts_provider and the episode uses your deployment’s default provider.

An unknown value is rejected at submit with 400 invalid_request naming the ones that are valid. Nothing is rendered and nothing is billed.

TTS models

tts_model must be selectable for the provider you chose. Omit it and the first model in the provider’s row 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

Set tts_model to pick a model other than the default — for example bulbul:v4-flash or eleven_v4. 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. Nothing is rendered and nothing is billed.

A provider can also reject a voice that is valid for a different model of its own. Sarvam gates speakers by model: bulbul:v3 takes short names such as priya, while bulbul:v4-flash takes only its persona voices such as ritu_hi_customer. That mismatch surfaces mid-render as a failed episode rather than at submit.

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

It does two things:

  • The script. When set, the outline and transcript are written in that language. When omitted, they follow the language of your content and briefing.
  • The audio. The normalized code is passed to the TTS provider with every turn. Sarvam falls back to en-IN when it is absent, Smallest to en and Vakyam to hi-IN. ElevenLabs is not sent a language code; it speaks the language the text is written in.

Give each speaker a voice that covers the language: nothing cross-checks the two, and a mismatch shows up as mispronounced audio or a provider error mid-render.

Output format

output_format chooses the container of the finished episode:

ValueFileUse it for
mp3 (default)Audio only: MP3, 128 kbps, 44.1 kHz, mono.Podcast feeds, audio players.
mp4The same audio as AAC (192 kbps), plus a still black 1280×720 H.264 video track, cut to the audio’s length.Platforms that only ingest video, such as Cloudflare Stream or YouTube.

The two carry the same episode and are billed the same: duration_seconds is the audio length either way.

Voice catalogue

Every voice_id accepted by the podcast API, per provider. A voice belongs to one provider only — pass it with the matching tts_provider.

Custom and cloned voices

voice_id is not an open field. Every provider rejects an id that is not in its own catalogue, at the first synthesis call:

  • ElevenLabs voices belong to the ElevenLabs account behind your workspace’s connection. A voice from another account — including a clone on your own ElevenLabs account — is not visible to it, and the episode fails.
  • Sarvam has a fixed roster and no cloning. An unknown speaker returns Speaker '<id>' is not recognized, and the episode fails.
  • Smallest supports voice cloning, but a cloned voice belongs to the Smallest account it was created on. Rendering uses the API key on your workspace’s synthesizer connection, so a voice cloned on your own Smallest account is not visible to it and returns Voice '<id>' is not available on the lightning_v3.1_pro model.

To use a cloned voice, the clone has to live on the account behind the connection your workspace renders with. That means bringing your own ElevenLabs or Smallest connection, with your own API key, rather than the shared platform one — talk to support to have one attached to your workspace. Once it is, your cloned voices appear alongside the catalogue below and can be passed as voice_id like any other.

A rejected voice is not caught at submit. The outline and transcript are written and billed first, and the failure surfaces only when synthesis starts, as status: "failed" with the provider’s message in error.

ElevenLabs voices

ElevenLabs voice ids are 20-character ids such as 21m00Tcm4TlvDq8ikWAM. Which ones work depends on the ElevenLabs account behind your workspace’s connection: its premade voices plus any voices added to that account. Look them up in the dashboard under an agent’s Voice tab, or with GET /synthesizer-voices. Every ElevenLabs voice works with every selectable ElevenLabs model.

Sarvam voices (bulbul:v3)

37 speakers. Each one covers all eleven supported languages: bn-IN, en-IN, gu-IN, hi-IN, kn-IN, ml-IN, mr-IN, od-IN, pa-IN, ta-IN, te-IN. Set which one is spoken with language.

Female (14) — ritu, priya, neha, pooja, simran, kavya, ishita, shreya, roopa, tanya, shruti, suhani, kavitha, rupali

Male (23) — aditya, ashutosh, rahul, rohan, amit, dev, ratan, varun, manan, sumit, kabir, aayan, shubh, advait, anand, tarun, sunny, mani, gokul, vijay, mohit, rehan, soham

These are bulbul:v3 speakers. Sarvam gates speakers by model, so a name from an older Bulbul release is rejected mid-render rather than at submit.

Sarvam voices (bulbul:v4-flash)

222 persona voices, named <name>_<language>_<style> — for example ritu_hi_customer is Ritu, speaking Hindi, in a customer-support style. Unlike the bulbul:v3 speakers, each persona speaks the language in its name; Hinglish personas (enhi) cover both English and Hindi. Pass the whole name as voice_id, with tts_model: "bulbul:v4-flash".

Language code: hi-IN.

Female — kavya_hi_conversational, aparna_hi_customer, aparna_hi_kyc, chhavi_hi_kids, ishita_hi_ads, ishita_hi_edtech, ishita_hi_banking, ishita_hi_ads_informal, ishita_hi_devotional, ishita_hi_numbers, ishita_hi_social, priya_hi_recovery, ritu_hi_customer_utility, ritu_hi_kids, ritu_hi_conversation, ritu_hi_customer, ritu_hi_edtech, ritu_hi_ads_formal, ritu_hi_banking, ritu_hi_ads_informal, ritu_hi_insurance, ritu_hi_edtech_bot, ritu_hi_medical, ritu_hi_sales, ritu_hi_reels, ritu_hi_social, ritu_hi_customer_warm, ritu_hi_social_lively, roopa_hi_companion, roopa_hi_narration, roopa_hi_recovery, roopa_hi_market, roopa_hi_conversational, sanchita_hi_assistant, sanchita_hi_edtech, sanchita_hi_banking, sanchita_hi_feedback, sanchita_hi_ads_formal, sanchita_hi_ads_informal, sanchita_hi_interview, sanchita_hi_romantic, sanchita_hi_market, sanchita_hi_social, sanchita_hi_kyc, sarika_hi_conversation, shalini_hi_companion, shalini_hi_social, shreya_hi_conversational, shreya_hi_news, shruti_hi_edtech, simran_hi_assistant, simran_hi_narration, simran_hi_automobile, simran_hi_conversation, simran_hi_news_breaking, simran_hi_social_energetic, simran_hi_social_excited, simran_hi_latenight, simran_hi_news, simran_hi_recovery, simran_hi_sales, suchitra_hi_ecomm, suhani_hi_social, chaitra_hi_customer, shilpa_hi_narration, tanya_hi_narration, aarti_hi_customer, suman_hi_companion, vandana_hi_ecomm

Male — aayan_hi_conversational, amit_hi_conversational, ashutosh_hi_conversational, kabir_hi_conversational, manan_hi_conversational, rahul_hi_conversational, sumit_hi_conversational, aditya_hi_conversational, aditya_hi_sales, anand_hi_documentary, anand_hi_news, ashok_hi_character, ashok_hi_news, kunal_hi_kids, mahesh_hi_documentary, mani_hi_devotional, mani_hi_conversational, mohit_hi_conversational, nachiket_hi_devotional, ratan_hi_latenight, ratan_hi_customer_expressive, ratan_hi_documentary, ratan_hi_devotional, ratan_hi_recovery, ratan_hi_social, ratan_hi_sports, ratan_hi_latenight_warm, rehan_hi_social, shubh_hi_customer, shubh_hi_ecomm, shubh_hi_stories_mixed, shubh_hi_devotional, shubh_hi_ads, shubh_hi_recovery, shubh_hi_stories_dramatic, sunny_hi_ads, sunny_hi_reels, tarun_hi_conversational, tarun_hi_sales, advait_hi_character, aryaman_hi_ads, chirag_hi_social, girish_hi_devotional, mukul_hi_ads, mukul_hi_suspense, vaibhav_hi_social, vipul_hi_social

Language code: en-IN.

Female — aditi_en_stories, aparna_en_companion, aparna_en_edtech, chandrika_en_stories, ishita_en_customer, ishita_en_medical, ishita_en_numbers, ishita_en_social, ishita_en_stories, neha_en_customer, neha_en_latenight, nupur_en_kids, ritu_en_edtech, ritu_en_latenight, ritu_en_medical, ritu_en_reels, roopa_en_conversational, sanchita_en_companion, sanchita_en_insurance, sanchita_en_recovery, sanchita_en_market, sanchita_en_social, shabana_en_edtech, shalini_en_companion, shalini_en_customer, simran_en_narration, simran_en_automobile, simran_en_conversation, simran_en_customer, simran_en_edtech, simran_en_edtech_bot, simran_en_sales, simran_en_recovery, simran_en_ads, simran_en_therapist, zarina_en_conversation, amelia_en_conversational, sophia_en_conversational, payal_en_edtech

Male — ashwin_en_sports, ashwin_en_sports_energetic, dev_en_recovery, dev_en_conversational, deven_en_conversation, kalpit_en_edtech, nachiket_en_ads, ojas_en_social, rohan_en_recovery, rustom_en_suspense, shubh_en_narration, shubh_en_numbers, shubh_en_ads, shubh_en_recovery, shubh_en_audiobook, shubh_en_narration_gentle, shubh_en_sports, sunny_en_social, varun_en_ads, varun_en_suspense, girish_en_documentary, girish_en_devotional, sarang_en_narration

Language code: en-IN, hi-IN.

Female — ishita_enhi_companion, ishita_enhi_customer, ishita_enhi_customer_expressive, sanchita_enhi_companion, shalini_enhi_companion, shalini_enhi_customer, simran_enhi_companion, simran_enhi_customer, simran_enhi_banking_expressive

Male — shubh_enhi_companion, shubh_enhi_ads, shubh_enhi_banking, sunny_enhi_customer

Language code: mr-IN.

Female — ishita_mr_conversational, mrunal_mr_narration, neha_mr_narration, ritu_mr_insurance, ritu_mr_narration, rupali_mr_stories

Male — nilesh_mr_conversation, soham_mr_narration, mukul_mr_stories

Language code: kn-IN.

Female — chaitra_kn_conversation, chaitra_kn_narration, suchitra_kn_narration

Male — chetan_kn_conversation

Language code: pa-IN.

Male — anand_pa_conversation, anand_pa_customer, harpreet_pa_narration, jaspal_pa_banking

Language code: ta-IN.

Male — gokul_ta_narration, vetri_ta_ads, vetri_ta_suspense, vijay_ta_narration

Language code: te-IN.

Female — kavitha_te_conversation, kavitha_te_narration, pooja_te_conversation

Male — tarun_te_narration

Language code: gu-IN.

Female — pooja_gu_conversational, pooja_gu_customer

Male — bhavik_gu_conversation

Language code: bn-IN.

Female — roopa_bn_conversational

Male — bappa_bn_conversation, bimal_bn_suspense

These voices work only with bulbul:v4-flash, and the bulbul:v3 speakers above work only with bulbul:v3. A voice sent with the other model fails the episode mid-render.

Smallest voices (lightning_v3.1_pro)

249 voices. Most are multilingual; they are grouped below by the language Smallest recommends them for, which is the one they sound most natural in.

Female — amelia, arabella, autumn, bethany, chelsea, cressida, ellie, elowen, fiona, jenna, kaitlyn, kate, kelsey, kennedy, leah, molly, ottilie, reagan, ruby, savannah, seraphina, sierra, skylar, sophie, tabitha, willow, zoe

Male — asher, austin, benedict, blake, brooks, callum, caspian, colton, cormac, everett, finley, henry, hunter, jack, leo, luke, maverick, nolan, owen, rupert, sam, spencer, wesley, winston

Unspecified — kimberly

Female — aarini, inaaya, kareena, meher, mishka, mrunal, rhea, saira, zariya

Male — ahan, aviraj, reyansh, vyom, zoravar

Female — malar, nila, tamilselvi

Male — barath, dinesh, ezhil, kavin, mathan, murugan, prabhu, sakthi, tamizh

Female — rashmi, sahana, spoorthi, varsha

Male — basava, gowtham, kishore, rakshith, shankar, yogesh

Female — amelie, elise, juliette, lucie, manon

Male — louis, maxime, nicolas, raphael

Female — ankita, sasmita

Male — debasish, pratik, rakesh, sambit, smruti, subrat

Female — sravani, swathi

Male — bhaskar, charan, gopal, manohar, naveen, sasank

Female — femke, fenna, sanne

Male — bram, daan, lars, stijn

Female — hanna, lea, petra

Male — ben, finn, markus, max

Female — catarina, juliana, leticia

Male — bruno, francisco, gustavo, thiago

Female — jasleen, manmeet

Male — amrit, gagandeep, rajdeep, sukhdeep, tejinder

Female — anastasia, ekaterina, irina, olga

Male — andrei, maksim, nikolai

Female — alva, astrid, ebba, saga

Male — anton, erik, nils

Female — aino, helmi, venla

Male — matti, mika, timo

Female — arianna, concetta, silvia

Male — davide, leonardo, luca

Female — lakshmi, parvathy, sreenath

Male — aravindan, unni, vishnu

Male — arghya, indranil, rajib, subhro, tanmoy

Female — hazel, vivian

Male — dylan, eli, silas

Female — athina, dimitra, katerina

Male — dimitris, vasilis

Female — krupa, riddhi

Male — jignesh, keval, mit

Female — ketaki, manasi, tejaswini

Male — mandar, tushar

Female — nora

Male — bryce, cole, miles

Female — aria, daisy, mila

Male — jasper

Female — marit, solveig

Male — espen, kristian

Female — ewa, joanna

Male — jakub, tomasz

Female — claudia, martina

Male — gabriel, mateo

Female — layla

Male — adam

Female — sasha

Male — roman

Male — beau, wes

Female — june

Male — kai

Vakyam voices (raaga-v1)

Vakyam voice names, such as Archana, passed through unchanged. Names are case-sensitive, and an unknown name fails the episode mid-render, like any other provider. Vakyam renders at 24 kHz; the episode is still encoded at 44.1 kHz like every other.

Job lifecycle

renderer picks it up audio uploaded render error rejected before rendering pending processing completed failed
StatusTerminalMeaningWhat the client should do
pendingNoAccepted and queued. Nothing has been generated yet.Keep polling with backoff.
processingNoThe outline and transcript are being written, or the turns are being synthesized. This is where most of the wall-clock time goes.Keep polling with backoff.
completedYesThe episode is rendered and stored.Stop polling. Download audio_url, and note duration_seconds.
failedYesThe episode could not be rendered. error says why.Stop polling. Read error, fix the submission, and submit again. Polling will never change the answer.

There is no server-side deadline, no cancel endpoint and no retry. Rendering runs as a background task inside the renderer process, and only that task moves an episode off a non-terminal status. An episode that is never picked up stays pending indefinitely, and one whose render is interrupted — a renderer restart, for instance — stays processing indefinitely. Neither self-heals, so your client must impose its own wall-clock budget on both.

Two different failures look alike and are not. An HTTP 4xx or 5xx means the request failed and nothing was rendered. Most rejections happen before an episode row exists; 402 insufficient_credits is the exception — the episode is created first and then marked failed when the credit hold cannot be taken, so it can be polled afterwards. A 200 with status: "failed" means the episode was accepted, started, and could not be finished — the credit hold for it is released in full.

Poll every 5 to 10 seconds. A render writes an outline, writes a transcript segment by segment, synthesizes every turn, and concatenates the clips, so minutes are normal and seconds are not.

import os
import time
import requests
BASE_URL = "https://api.voice.formantai.com"
HEADERS = {"Authorization": f"Bearer {os.environ['FORMANT_API_KEY']}"}
DEADLINE_SECONDS = 1800
POLL_SECONDS = 10
TERMINAL = {"completed", "failed"}
def submit(payload):
"""Submit an episode and return its id."""
response = requests.post(
f"{BASE_URL}/v1/podcast/jobs", headers=HEADERS, json=payload, timeout=200
)
response.raise_for_status()
return response.json()["id"]
def wait_for(episode_id):
"""Poll an episode 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/podcast/jobs/{episode_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()
job = response.json()
if job["status"] in TERMINAL:
return job
time.sleep(POLL_SECONDS)
raise TimeoutError(f"episode {episode_id} did not finish within {DEADLINE_SECONDS}s")
job = wait_for(submit({
"content": open("q3-notes.txt").read(),
"briefing": "A two-host recap for listeners who do not work in finance.",
"episode_name": "q3-recap",
"num_segments": 3,
"speakers": [
{"name": "Asha", "voice_id": "priya", "persona": "Precise, asks the sharp follow-up."},
{"name": "Ravi", "voice_id": "aditya", "persona": "Curious generalist."},
],
}))
if job["status"] == "failed":
raise RuntimeError(job["error"])
audio = requests.get(job["audio_url"], timeout=120) # no auth header
open(f"{job['episode_name']}.mp3", "wb").write(audio.content) # .mp4 if you asked for output_format "mp4"

Set the client-side timeout on the submit call above the server’s own submit ceiling — 180 seconds by default, and deployment-configurable — so your client does not give up on a request the server is still handling.

The audio URL

audio_url is a signed link to the episode 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. The object is stored as audio/mpeg (or video/mp4) with no attachment disposition, so it works directly in a browser or an <audio>/<video> element, and storage serves range requests, so a player can seek.
  • It is a bearer capability. Anyone holding the link can download the episode until it expires. Do not paste it into shared logs or tickets.
  • When it expires, poll again. GET /v1/podcast/jobs/{id} mints a fresh link on every call for a completed episode. The audio itself is not deleted, only the link expires.

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

Limits

LimitValueWhere it applies
Request body32 MBAny request to this API, checked against the declared Content-Length before the body is read. Exceeding it returns 413 request_too_large. A request that declares no length is not checked.
Source PDF10 MBChecked when the object is read, at submit — not at upload. An oversized PDF uploads fine and then fails the submit.
content200,000 charactersPOST /v1/podcast/jobs.
briefing5,000 charactersBoth submit endpoints.
focus_query5,000 charactersPOST /v1/podcast/jobs/pdf. A blank value is rejected later, as 400, not by the bound.
speakers1–4 entriesBoth submit endpoints, with unique names and unique voice ids.
num_segments1–20Both submit endpoints.
episode_name, language, tts_model, source_key, filename200 characters eachWherever they appear.
name, voice_id200 characters eachPer speaker.
backstory, persona2,000 characters eachPer speaker.
Submit timeout180 seconds by defaultHow long a submit waits for the renderer to accept the episode. Deployment-configurable, so treat it as a floor rather than a fixed value. Exceeding it returns 500 internal_error.

A field outside its bounds is rejected with 422 — after the 32 MB body check (413) and the API-key check (401), which both run before the body is parsed, and before anything reaches the renderer.

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 and transcription draw from the same budget as submitting and polling episodes. It is a token bucket that refills continuously.

X-RateLimit-Limit: 50
X-RateLimit-Remaining: 48
X-RateLimit-Reset: 2026-03-18T20:54:00.917640+00:00
HeaderDescription
X-RateLimit-LimitRequests per minute allowed for your workspace.
X-RateLimit-RemainingRequests left in the bucket right now. 0 when throttled.
X-RateLimit-ResetISO-8601 UTC instant at which the bucket is full again. It normally carries fractional seconds, so parse it with a general ISO-8601 parser and tolerate an empty value.
Retry-AfterSeconds to wait. Sent only with a 429.

The headers are absent on responses rejected before the key is checked, such as 401 and 413, so treat them as optional when parsing.

Rate limiting caps requests, not render cost. Nothing throttles how much LLM and TTS spend a workspace can queue within its per-minute budget. The workspace concurrent-call limit applies to calls, not to podcasts, so nothing caps how many episodes are rendering at once either.

Episode length

Episode length is not proportional to num_segments. The renderer targets a fixed window of roughly 12 to 15 minutes of spoken dialogue — about 1,800 to 2,250 words at 150 words per minute — and divides that budget across the segments you asked for. The episode never has more segments than you asked for: if the outline comes back with extra, they are folded into the last one. Each segment also has a floor of 200 words, so above about 9 segments the floor takes over and the episode does get longer.

num_segments therefore controls structure, not duration: more segments means more, shorter topic blocks. The true length of what was rendered comes back as duration_seconds.

Billing

Submitting an episode places a credit hold of 26 minutes × your per-minute podcast rate — ₹156 at ₹6 per minute. If the balance cannot cover the hold, the submission is refused with 402 insufficient_credits and nothing is rendered.

When the episode finishes, the hold is settled against the audio actually produced, billed per started minute — a 61-second episode bills as two minutes. A normal 12–15 minute episode is refunded the rest of the hold. An episode longer than the hold is debited the extra only down to a zero balance; a podcast never takes your balance negative. A failed episode settles at zero, so the whole hold comes back.

Each episode holds its own credit, so episodes submitted in parallel need the hold for each one: three at ₹6 per minute need ₹468 available, and a submit that the remaining balance cannot cover gets 402 while the others render.

Errors

Every error except 422 uses the standard envelope:

{
"error": {
"message": "each speaker must use a distinct voice_id",
"type": "invalid_request"
}
}

A 429 adds one extra key inside the same object:

{
"error": {
"message": "rate limit exceeded. 50 requests per minute allowed.",
"retry_after_seconds": 2,
"type": "rate_limit_exceeded"
}
}

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

{
"detail": [
{
"input": 25,
"loc": ["body", "num_segments"],
"msg": "Input should be less than or equal to 20",
"type": "less_than_equal"
}
]
}
HTTPTypeMessage and causeFix
400invalid_requesteach speaker must use a distinct voice_id — two speakers share a voice.Give every speaker a different voice_id.
400invalid_requestspeaker names must be unique — two speakers share a name.Rename one of them.
400invalid_requesttts_model '<value>' is not selectable for <provider> — the model is not in that provider’s list.Use a model from the TTS models table, or omit the field.
400invalid_requestunsupported language '<value>' — not a language name or locale code the renderer knows.Send a name such as Hindi or a code such as hi-IN. See Language.
400invalid_requestsource_pdf_key is not an uploaded PDF key — source_key is not a key minted by /v1/podcast/uploads.Pass the key from the upload response, unchanged.
400invalid_requestuploaded PDF is empty — the stored object has zero bytes.Re-upload the file and confirm the PUT returned a success status.
400invalid_requestfocus_query is required — it was blank or whitespace.Send a real focus query.
400invalid_requestPDF exceeds the 10MB limit — the uploaded object is over the cap.Split the document, or extract the relevant pages, and upload again.
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 episode’s credit hold.Top up the account, then resubmit. Nothing was rendered.
404not_foundpodcast not found — the episode id is unknown or belongs to another workspace.Check the id, and that you are polling the region you submitted to.
404not_foundno uploaded PDF found for that key — the key is well formed but nothing is stored at it.Re-run the upload. A PUT that failed, or a URL that expired before you used it, leaves no object.
413request_too_largerequest body exceeds the 32MB limit — the declared body is over the cap.Shorten content, or move the source into a PDF and use the upload flow.
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. Increase your poll interval.
500internal_errorinternal server error — the renderer was unreachable, returned a server error, or did not accept the episode within 180 seconds.Retry once with backoff, then contact support with the time of the request.

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

A 500 on submit does not guarantee that no episode was created. The renderer may have accepted it after the submit call gave up. Because episodes are not listable, a blind retry can render and bill the same episode twice — prefer polling for a few minutes before resubmitting if the episode is expensive.

Troubleshooting

SymptomLikely causeWhat to do
Episode goes processing then failed with a provider message in errorA voice_id the provider does not recognise, or a voice that is not valid for the chosen model. Neither is checked at submit.Confirm the id against the dashboard voice picker and resubmit. The script was already generated, so this costs a full render.
failed with llm returned no usable content from the documentThe extraction passes returned almost nothing — usually a scanned or image-only PDF.Upload a text-based PDF, running OCR on the source first if you have to.
403 from the PUT to upload_urlThe URL expired, or the Content-Type was not exactly application/pdf.Request a new upload URL and send the header verbatim. Signed URLs last 15 minutes.
404 no uploaded PDF found for that key on submitThe PUT did not actually succeed, or you submitted a key from an earlier upload that was never written.Check the PUT response status, then resubmit with the key from that upload.
audio_url returns an expired-signature errorMore than 15 minutes passed since the poll that returned it.Poll the episode again for a fresh link. The audio is still there.
Episode is much longer or shorter than expectedLength targets a fixed 12–15 minute window split across segments, with a 200-word floor per segment.Leave num_segments low for a normal-length episode. Read duration_seconds for the real length.
Audio is in the wrong language, or mispronouncedlanguage was omitted and the content is in another language, or the voice does not cover the language.Set language and pick a voice that covers it. Both have to agree; nothing cross-checks them.
Bracketed cues such as [excited] are read out loudOnly the ElevenLabs v4 models interpret audio tags, and the transcript is written without them.Move the direction into persona or backstory.
Episode never leaves pendingNothing has picked it up yet, and there is no server-side deadline.Enforce your own deadline, then contact support with the episode id rather than resubmitting blindly.
Episode never leaves processingThe render task was interrupted, usually by a renderer restart. Nothing resumes or fails it.Same: enforce your own deadline, then submit again. The stuck row stays processing forever.
Frequent 429 while one episode rendersThe poll loop is too tight, and the bucket is shared with every other endpoint.Poll every 5 to 10 seconds, and honour Retry-After.

Next steps