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

# Podcast

> Render source material into a multi-speaker audio episode and download the finished MP3 or MP4.

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

```mermaid
flowchart LR
    A[Submit an episode] --> B[Episode pending]
    B --> C[Poll the episode id]
    C -->|pending or processing| B
    C -->|completed| D[Download audio_url]
    C -->|failed| E[Read error]
```

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

#### Poll

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

#### Download

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

> **Warning**
>
> 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

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

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

> **Warning**
>
> 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

| Method | Path                    | Purpose                                               |
| ------ | ----------------------- | ----------------------------------------------------- |
| `POST` | `/v1/podcast/jobs`      | Submit an episode built from inline text.             |
| `POST` | `/v1/podcast/uploads`   | Get a one-shot signed URL to upload a source PDF to.  |
| `POST` | `/v1/podcast/jobs/pdf`  | Submit 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

```http
POST /v1/podcast/jobs
```

Content type: `application/json`.

| Field           | Type    | Required | Default                    | Bounds                                                                                                                                                                      |
| --------------- | ------- | -------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `content`       | string  | Yes      | —                          | 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.          |
| `briefing`      | string  | Yes      | —                          | Up to 5,000 characters. The angle: what the episode should cover and how it should sound.                                                                                   |
| `speakers`      | array   | Yes      | —                          | 1–4 entries. See [Speakers](#speakers).                                                                                                                                     |
| `num_segments`  | integer | No       | `2`                        | 1–20. Outline segments the episode is split into. The episode never has more than this.                                                                                     |
| `episode_name`  | string  | No       | `episode`                  | Up to 200 characters. Filename stem for the rendered object.                                                                                                                |
| `language`      | string  | No       | none                       | 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). |
| `output_format` | string  | No       | `mp3`                      | `mp3` or `mp4`. See [Output format](#output-format).                                                                                                                        |
| `tts_provider`  | string  | No       | your deployment's default  | Up to 200 characters. `elevenlabs`, `sarvam`, `smallest` or `vakyam`. Chooses which provider voices the episode. See [TTS providers](#tts-providers).                       |
| `tts_model`     | string  | No       | the provider's first model | Up to 200 characters. Must be selectable for `tts_provider`. See [TTS models](#tts-models).                                                                                 |

```bash
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."
      }
    ]
  }'
```

```json
{
  "id": 4821,
  "status": "pending"
}
```

| Field    | Type    | Description                                                              |
| -------- | ------- | ------------------------------------------------------------------------ |
| `id`     | integer | The episode id. Pass it to the poll endpoint.                            |
| `status` | string  | Always `pending` on submit. An episode is never finished at submit time. |

### Request an upload URL

```http
POST /v1/podcast/uploads
```

| Field      | Type   | Required | Default | Bounds                                                                                    |
| ---------- | ------ | -------- | ------- | ----------------------------------------------------------------------------------------- |
| `filename` | string | Yes      | —       | Up to 200 characters. Labels the stored object only; the key itself is minted by the API. |

```bash
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"}'
```

```json
{
  "upload_url": "https://storage.googleapis.com/...&X-Goog-Signature=...",
  "key": "pdf/3f2a9c1b8e4d47a1b0c5d6e7f8091a2b_annual-report.pdf",
  "expires_in": 900,
  "content_type": "application/pdf"
}
```

| Field          | Type    | Description                                                                                                                                                                                                                                                    |
| -------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `upload_url`   | string  | Signed URL that accepts exactly one `PUT`.                                                                                                                                                                                                                     |
| `key`          | string  | The 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_in`   | integer | Seconds the URL stays valid. Always `900`.                                                                                                                                                                                                                     |
| `content_type` | string  | Always `application/pdf`. The `PUT` must send exactly this value.                                                                                                                                                                                              |

### Submit an episode from a PDF

```http
POST /v1/podcast/jobs/pdf
```

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

| Field           | Type    | Required | Default                    | Bounds                                                                                                                                                                                                                                                     |
| --------------- | ------- | -------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `source_key`    | string  | Yes      | —                          | Up to 200 characters. The `key` returned by `POST /v1/podcast/uploads`, unchanged.                                                                                                                                                                         |
| `focus_query`   | string  | Yes      | —                          | 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. |
| `speakers`      | array   | Yes      | —                          | 1–4 entries. Same shape as the text submit.                                                                                                                                                                                                                |
| `briefing`      | string  | No       | `""`                       | Up to 5,000 characters.                                                                                                                                                                                                                                    |
| `num_segments`  | integer | No       | `2`                        | 1–20.                                                                                                                                                                                                                                                      |
| `episode_name`  | string  | No       | `episode`                  | Up to 200 characters.                                                                                                                                                                                                                                      |
| `language`      | string  | No       | none                       | 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).                                                                                |
| `output_format` | string  | No       | `mp3`                      | `mp3` or `mp4`. See [Output format](#output-format).                                                                                                                                                                                                       |
| `tts_provider`  | string  | No       | your deployment's default  | Up to 200 characters. `elevenlabs`, `sarvam`, `smallest` or `vakyam`.                                                                                                                                                                                      |
| `tts_model`     | string  | No       | the provider's first model | Up 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.

```bash
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"}
    ]
  }'
```

```json
{
  "id": 4822,
  "status": "pending"
}
```

### Poll an episode

```http
GET /v1/podcast/jobs/{id}
```

| Path parameter | Type    | Required | Description                                                                                                          |
| -------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------- |
| `id`           | integer | Yes      | The `id` returned by either submit endpoint. Ids are scoped to your workspace; another workspace's id returns `404`. |

```bash
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:

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

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

```json
{
  "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
}
```

| Field                  | Type           | Description                                                                                                                                                      |
| ---------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                   | integer        | The episode id.                                                                                                                                                  |
| `status`               | string         | One of `pending`, `processing`, `completed`, `failed`. See [Job lifecycle](#job-lifecycle).                                                                      |
| `episode_name`         | string         | The filename stem you submitted.                                                                                                                                 |
| `error`                | string         | Why 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_seconds`     | number or null | Measured 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_url`            | string         | Time-limited link to the episode, in the `output_format` you asked for. `null` until the episode completes.                                                      |
| `audio_url_expires_in` | integer        | Seconds `audio_url` stays valid. `900` when a link is present, otherwise `null`.                                                                                 |

## Source material

An episode is built from exactly one of two sources.

| Source      | Endpoint                                                    | What you send                    | What the script is written from          |
| ----------- | ----------------------------------------------------------- | -------------------------------- | ---------------------------------------- |
| Inline text | `POST /v1/podcast/jobs`                                     | `content` plus `briefing`        | Your text, verbatim.                     |
| PDF         | `POST /v1/podcast/uploads` then `POST /v1/podcast/jobs/pdf` | An object key plus `focus_query` | Text 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.

#### Ask for an upload URL

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

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

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

```bash
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.

> **Note**
>
> 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.

> **Warning**
>
> 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.

| Field       | Type   | Required | Bounds                 | What it does                                                                      |
| ----------- | ------ | -------- | ---------------------- | --------------------------------------------------------------------------------- |
| `name`      | string | Yes      | Up to 200 characters   | Display name in the transcript and the label the renderer maps back to a voice.   |
| `voice_id`  | string | Yes      | Up to 200 characters   | The voice this speaker is synthesized with, passed to the TTS provider unchanged. |
| `backstory` | string | No       | Up to 2,000 characters | Background and expertise. Shapes what this speaker knows and brings up.           |
| `persona`   | string | No       | Up to 2,000 characters | Speaking 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".

> **Note**
>
> 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](#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`.

> **Warning**
>
> 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_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`.           |

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.

| 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`               |

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.

> **Note**
>
> 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:

| Value           | File                                                                                                        | Use it for                                                              |
| --------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `mp3` (default) | Audio only: MP3, 128 kbps, 44.1 kHz, mono.                                                                  | Podcast feeds, audio players.                                           |
| `mp4`           | The 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.

> **Warning**
>
> 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`

> **Note**
>
> 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`

> **Note**
>
> 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

```mermaid
flowchart LR
    A[pending] -->|renderer picks it up| B[processing]
    B -->|audio uploaded| C[completed]
    B -->|render error| D[failed]
    A -->|rejected before rendering| D
```

| Status       | Terminal | Meaning                                                                                                                           | What the client should do                                                                               |
| ------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `pending`    | No       | Accepted and queued. Nothing has been generated yet.                                                                              | Keep polling with backoff.                                                                              |
| `processing` | No       | The 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.                                                                              |
| `completed`  | Yes      | The episode is rendered and stored.                                                                                               | Stop polling. Download `audio_url`, and note `duration_seconds`.                                        |
| `failed`     | Yes      | The episode could not be rendered. `error` says why.                                                                              | Stop polling. Read `error`, fix the submission, and submit again. Polling will never change the answer. |

> **Warning**
>
> 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.

> **Note**
>
> 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.

```python
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"
```

> **Tip**
>
> 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

| Limit                                                             | Value                  | Where it applies                                                                                                                                                                                |
| ----------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Request body                                                      | 32 MB                  | Any 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 PDF                                                        | 10 MB                  | Checked when the object is read, at submit — not at upload. An oversized PDF uploads fine and then fails the submit.                                                                            |
| `content`                                                         | 200,000 characters     | `POST /v1/podcast/jobs`.                                                                                                                                                                        |
| `briefing`                                                        | 5,000 characters       | Both submit endpoints.                                                                                                                                                                          |
| `focus_query`                                                     | 5,000 characters       | `POST /v1/podcast/jobs/pdf`. A blank value is rejected later, as `400`, not by the bound.                                                                                                       |
| `speakers`                                                        | 1–4 entries            | Both submit endpoints, with unique names and unique voice ids.                                                                                                                                  |
| `num_segments`                                                    | 1–20                   | Both submit endpoints.                                                                                                                                                                          |
| `episode_name`, `language`, `tts_model`, `source_key`, `filename` | 200 characters each    | Wherever they appear.                                                                                                                                                                           |
| `name`, `voice_id`                                                | 200 characters each    | Per speaker.                                                                                                                                                                                    |
| `backstory`, `persona`                                            | 2,000 characters each  | Per speaker.                                                                                                                                                                                    |
| Submit timeout                                                    | 180 seconds by default | How 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.

```http
X-RateLimit-Limit: 50
X-RateLimit-Remaining: 48
X-RateLimit-Reset: 2026-03-18T20:54:00.917640+00:00
```

| Header                  | Description                                                                                                                                                             |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-RateLimit-Limit`     | Requests per minute allowed for your workspace.                                                                                                                         |
| `X-RateLimit-Remaining` | Requests left in the bucket right now. `0` when throttled.                                                                                                              |
| `X-RateLimit-Reset`     | ISO-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-After`           | Seconds 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.

> **Note**
>
> 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:

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

A `429` adds one extra key inside the same object:

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

```json
{
  "detail": [
    {
      "input": 25,
      "loc": ["body", "num_segments"],
      "msg": "Input should be less than or equal to 20",
      "type": "less_than_equal"
    }
  ]
}
```

| HTTP  | Type                   | Message and cause                                                                                                                  | Fix                                                                                                 |
| ----- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `400` | `invalid_request`      | `each speaker must use a distinct voice_id` — two speakers share a voice.                                                          | Give every speaker a different `voice_id`.                                                          |
| `400` | `invalid_request`      | `speaker names must be unique` — two speakers share a name.                                                                        | Rename one of them.                                                                                 |
| `400` | `invalid_request`      | `tts_model '<value>' is not selectable for <provider>` — the model is not in that provider's list.                                 | Use a model from the [TTS models](#tts-models) table, or omit the field.                            |
| `400` | `invalid_request`      | `unsupported 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](#language).                   |
| `400` | `invalid_request`      | `source_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.                                                 |
| `400` | `invalid_request`      | `uploaded PDF is empty` — the stored object has zero bytes.                                                                        | Re-upload the file and confirm the `PUT` returned a success status.                                 |
| `400` | `invalid_request`      | `focus_query is required` — it was blank or whitespace.                                                                            | Send a real focus query.                                                                            |
| `400` | `invalid_request`      | `PDF exceeds the 10MB limit` — the uploaded object is over the cap.                                                                | Split the document, or extract the relevant pages, and upload again.                                |
| `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 episode's credit hold.                                                                                | Top up the account, then resubmit. Nothing was rendered.                                            |
| `404` | `not_found`            | `podcast 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.                                 |
| `404` | `not_found`            | `no 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. |
| `413` | `request_too_large`    | `request 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.                           |
| `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. Increase your poll interval.                                    |
| `500` | `internal_error`       | `internal 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.

> **Warning**
>
> 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

| Symptom                                                                    | Likely cause                                                                                                                   | What to do                                                                                                                     |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| Episode goes `processing` then `failed` with a provider message in `error` | A `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 document`           | The 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_url`                                       | The 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 submit                         | The `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 error                             | More 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 expected                            | Length 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 mispronounced                           | `language` 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 loud                       | Only the ElevenLabs v4 models interpret audio tags, and the transcript is written without them.                                | Move the direction into `persona` or `backstory`.                                                                              |
| Episode never leaves `pending`                                             | Nothing 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 `processing`                                          | The 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 renders                                   | The 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

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

The speech, voice, and model providers behind every episode.

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

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

#### [Transcription](/api-reference/transcription)

Go the other way: turn recorded audio into a transcript.

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

Review the shared error envelope and retry guidance.