Podcast
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
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
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.
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
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
Content type: application/json.
Request an upload URL
Submit an episode from a PDF
Content type: application/json. The PDF itself is not in this request — only its key.
There is no content field here: the content is extracted from the PDF by the renderer.
Poll an episode
Polling reads the stored episode record directly, so it keeps working while the renderer is busy or restarting.
While the episode is still rendering:
When it is done:
When it failed:
Source material
An episode is built from exactly one of two sources.
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.
Ask for an upload URL
POST /v1/podcast/uploads with your filename. You get back upload_url, key, expires_in and content_type.
Rules the PUT must follow:
- Method. The URL is signed for
PUTonly. APOSTor aGETto 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 defaultapplication/octet-stream— fails the signature check. - One object. The URL writes the returned
keyand 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 oldkeyis 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.
Rules enforced at submit:
- Between 1 and 4 speakers. Zero, or five or more, is rejected.
- Names must be unique. Two speakers called
Ashareturn400 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_idreturn400 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:
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.
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-INwhen it is absent, Smallest toenand Vakyam tohi-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:
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".
Hindi (116)
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
English (62)
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
Hinglish (English–Hindi code-mixed) (13)
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
Marathi (9)
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
Kannada (4)
Language code: kn-IN.
Female — chaitra_kn_conversation, chaitra_kn_narration, suchitra_kn_narration
Male — chetan_kn_conversation
Punjabi (4)
Language code: pa-IN.
Male — anand_pa_conversation, anand_pa_customer, harpreet_pa_narration, jaspal_pa_banking
Tamil (4)
Language code: ta-IN.
Male — gokul_ta_narration, vetri_ta_ads, vetri_ta_suspense, vijay_ta_narration
Telugu (4)
Language code: te-IN.
Female — kavitha_te_conversation, kavitha_te_narration, pooja_te_conversation
Male — tarun_te_narration
Gujarati (3)
Language code: gu-IN.
Female — pooja_gu_conversational, pooja_gu_customer
Male — bhavik_gu_conversation
Bengali (3)
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.
English (52)
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
Hindi (14)
Female — aarini, inaaya, kareena, meher, mishka, mrunal, rhea, saira, zariya
Male — ahan, aviraj, reyansh, vyom, zoravar
Tamil (12)
Female — malar, nila, tamilselvi
Male — barath, dinesh, ezhil, kavin, mathan, murugan, prabhu, sakthi, tamizh
Kannada (10)
Female — rashmi, sahana, spoorthi, varsha
Male — basava, gowtham, kishore, rakshith, shankar, yogesh
French (9)
Female — amelie, elise, juliette, lucie, manon
Male — louis, maxime, nicolas, raphael
Odia (8)
Female — ankita, sasmita
Male — debasish, pratik, rakesh, sambit, smruti, subrat
Telugu (8)
Female — sravani, swathi
Male — bhaskar, charan, gopal, manohar, naveen, sasank
Dutch (7)
Female — femke, fenna, sanne
Male — bram, daan, lars, stijn
German (7)
Female — hanna, lea, petra
Male — ben, finn, markus, max
Portuguese (7)
Female — catarina, juliana, leticia
Male — bruno, francisco, gustavo, thiago
Punjabi (7)
Female — jasleen, manmeet
Male — amrit, gagandeep, rajdeep, sukhdeep, tejinder
Russian (7)
Female — anastasia, ekaterina, irina, olga
Male — andrei, maksim, nikolai
Swedish (7)
Female — alva, astrid, ebba, saga
Male — anton, erik, nils
Finnish (6)
Female — aino, helmi, venla
Male — matti, mika, timo
Italian (6)
Female — arianna, concetta, silvia
Male — davide, leonardo, luca
Malayalam (6)
Female — lakshmi, parvathy, sreenath
Male — aravindan, unni, vishnu
Bengali (5)
Male — arghya, indranil, rajib, subhro, tanmoy
Chinese (5)
Female — hazel, vivian
Male — dylan, eli, silas
Greek (5)
Female — athina, dimitra, katerina
Male — dimitris, vasilis
Gujarati (5)
Female — krupa, riddhi
Male — jignesh, keval, mit
Marathi (5)
Female — ketaki, manasi, tejaswini
Male — mandar, tushar
Indonesian (4)
Female — nora
Male — bryce, cole, miles
Japanese (4)
Female — aria, daisy, mila
Male — jasper
Norwegian (4)
Female — marit, solveig
Male — espen, kristian
Polish (4)
Female — ewa, joanna
Male — jakub, tomasz
Spanish (4)
Female — claudia, martina
Male — gabriel, mateo
Arabic (2)
Female — layla
Male — adam
Malay (2)
Female — sasha
Male — roman
Turkish (2)
Male — beau, wes
Korean (1)
Female — june
Vietnamese (1)
Male — kai
No recommended language (multilingual) (23)
Female — aashi, candela, carla, irene, ishita, misha, pari, reeva, suri, tanya, valeria, ximena
Male — aayan, ansh, aryan, atharva, dante, gael, ishaan, kian, marco, pablo, shaan
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
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.
Recommended polling
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.
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
Authorizationheader. 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(orvideo/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
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.
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:
A 429 adds one extra key inside the same object:
A 422 comes from request parsing and uses FastAPI’s own shape instead, with a detail array and no type:
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.
