Iodine API documentation, every page in one document. Base URL: https://api.iodine.dev. Browse it at https://docs.iodine.dev; Markdown version: https://docs.iodine.dev/llms-full.txt.
Introduction
Lifelike speech from text, streamed back while it's generated. Make your first request in a minute.
Iodine turns text into lifelike speech over a simple HTTP API. Audio streams back while it's being generated, so playback can start almost immediately, and you pay only for the minutes of audio you get.
- Base URL:
https://api.iodine.dev - Authentication: an API key in the
Authorizationheader - Output: 24 kHz mono 16-bit audio, as WAV or raw PCM
- Price: $0.0075 per minute of speech generated, $0.00015 per minute transcribed, from prepaid credit
#Models
| Model | What it's for |
|---|---|
v1 | The production model: fast, natural and steady. Voices ava, katie, sophie. |
v2 | The expressive model: six languages, emotion and delivery cues, and cloned voices. Voices mia, chloe, zander, ethan. |
You choose a model with the model field, or leave it out and Iodine uses the model that has the voice you asked for. See Voices.
#Quickstart
#1. Get an API key
Sign in to the console, add credit under Billing, then create a key under API keys. Keys look like io_live_… and are shown once, so store yours somewhere safe. Keep it on your server: anyone with the key can spend your credit.
export IODINE_API_KEY="io_live_..."#2. Make a request
curl https://api.iodine.dev/v1/tts \
-H "Authorization: Bearer $IODINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "Hello from Iodine.", "voice": "katie"}' \
--output hello.wavThat's it: hello.wav holds the speech.
#Python
import os
import requests
resp = requests.post(
"https://api.iodine.dev/v1/tts",
headers={"Authorization": f"Bearer {os.environ['IODINE_API_KEY']}"},
json={"text": "Hello from Iodine.", "voice": "katie"},
stream=True,
)
resp.raise_for_status()
with open("hello.wav", "wb") as f:
for chunk in resp.iter_content(chunk_size=None):
f.write(chunk)#JavaScript (Node 18+)
import { writeFile } from "node:fs/promises";
const resp = await fetch("https://api.iodine.dev/v1/tts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.IODINE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ text: "Hello from Iodine.", voice: "katie" }),
});
if (!resp.ok) throw new Error((await resp.json()).error.message);
await writeFile("hello.wav", Buffer.from(await resp.arrayBuffer()));#Next steps
- Text to speech: every request option, streaming and the audio formats.
- Emotion and delivery: direct a
v2voice with{happy},{sighs},{whisper}…{/whisper}. - Realtime: a WebSocket for voice agents that speaks text as an LLM writes it.
- Voices: list voices and clone your own.
- Speech to text: transcribe recordings, OpenAI-compatible.
- Errors and limits: what can go wrong and how to handle it.
- Billing and data: credits, pricing and Zero Data Retention.
Text to speech
POST /v1/tts. Send text, get audio back, streamed as it's generated.
POST https://api.iodine.dev/v1/tts
Authorization: Bearer io_live_...
Content-Type: application/json#Request body
| Field | Type | Default | |
|---|---|---|---|
text | string | required | What to say. Up to 5,000 characters. On v2, may include emotion and delivery cues like {happy}. Line breaks, tabs and repeated spaces are tidied up for you. |
voice | string | required | A voice id from GET /v1/voices, or one of your cloned voices (vc_…). |
model | "v1" | "v2" | from the voice | Which model speaks. Left out, Iodine picks the model that has the voice, v1 first. |
format | "wav" | "pcm" | "wav" | WAV with a header, or raw PCM samples. |
stream | boolean | true | Stream audio as it's generated, or send it all at once when it's done. |
temperature | number | the model's | Between 0 and 2. Lower is steadier, higher is more varied. |
language | "en" | "es" | "fr" | "de" | "ja" | "zh" | from the text | v2 only: the language you're speaking, for the most natural accent. Japanese and Chinese are recognised from the text. |
{
"text": "Your order has shipped and should arrive on Thursday.",
"voice": "katie",
"format": "wav",
"stream": true
}#Response
On success the body is audio, with these headers:
| Header | Value |
|---|---|
Content-Type | audio/wav or audio/pcm |
X-Request-Id | The request's id. It also appears in the console's request log, so quote it when you contact us. |
X-Sample-Rate | 24000 |
X-Audio-Encoding | s16le (signed 16-bit, little-endian) |
X-Audio-Channels | 1 |
On failure the body is JSON: {"error": {"code": "...", "message": "..."}}. See Errors and limits.
#Streaming
By default ("stream": true) audio is sent as soon as each piece is generated, so you can start playing it before the whole sentence is done. With "format": "wav" the stream begins with a WAV header whose length fields are set to the maximum, as is usual for streamed WAV. Players and libraries handle this, but if you need exact lengths, either set "stream": false or ask for pcm and add your own header.
With "stream": false the response arrives in one piece once generation finishes, with a Content-Length and, for WAV, an exact header.
#Formats
wav: plays anywhere. 24 kHz, mono, 16-bit PCM inside a WAV container.pcm: raw samples, the same audio without a header. Best for piping into a player, a phone line or your own encoder.
Play raw PCM with ffplay:
curl -s https://api.iodine.dev/v1/tts \
-H "Authorization: Bearer $IODINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "Streaming straight to the speakers.", "voice": "katie", "format": "pcm"}' \
| ffplay -f s16le -ar 24000 -ch_layout mono -nodisp -autoexit -#Playing a stream in Python
import os
import requests
import sounddevice as sd # pip install sounddevice
with requests.post(
"https://api.iodine.dev/v1/tts",
headers={"Authorization": f"Bearer {os.environ['IODINE_API_KEY']}"},
json={"text": "Playback starts before generation ends.", "voice": "katie", "format": "pcm"},
stream=True,
) as resp, sd.RawOutputStream(samplerate=24000, channels=1, dtype="int16") as out:
resp.raise_for_status()
leftover = b""
for chunk in resp.iter_content(chunk_size=4096):
data = leftover + chunk
cut = len(data) - len(data) % 2 # whole 16-bit samples only
out.write(data[:cut])
leftover = data[cut:]#What you're charged
You pay for the audio you receive: its length in minutes times your rate. If you hang up partway through a stream, you pay only for what was sent. Requests that fail on our side cost nothing. See Billing and data.
Emotion and delivery
Direct a v2 voice like an actor. Cues in curly braces set the emotion, add sounds and pauses, and change how words are said.
Write cues in {curly braces} inside your text. They're never spoken: they tell the voice how to say what follows.
{
"text": "{sad} I didn't think you'd come back. {sighs} I'm glad you did. {whisper}Don't tell anyone.{/whisper}",
"voice": "mia",
"model": "v2"
}Cues work on v2. On v1 they're removed before speaking, except {laughs}, which v1 can perform.
#Emotion
Put an emotion at the start of a sentence and it colours what follows. Change it at the next sentence.
| Cue | Sounds like |
|---|---|
{happy} | warm and bright, smiling |
{sad} | heavy, close to tears |
{angry} | frustrated, sharp |
{scared} | nervous, shaking |
{excited} | energetic, can't hold it in |
{calm} | gentle, unhurried |
{nervous} | hesitant, unsure |
{tired} | low energy, drawn out |
{playful} | teasing, light |
{serious} | steady, measured |
{warm} | friendly, close |
{sarcastic} | dry, deadpan |
#Any direction you can describe
You aren't limited to the list. Describe the delivery in plain words, the way you'd brief an actor:
{barely holding it together, forcing a smile} I'm fine. Really.
{low, gravelly, tired but in control} Status report.
{fast, stumbling over the words} I didn't mean to, I swear, it just happened.Short, concrete descriptions work best. Combine ideas with commas.
#Sounds and pauses
Place a sound exactly where it should happen:
I'm fine. {shaky breath} Really. {small laugh} Go on without me.{laughs} {giggles} {sighs} {gasps} {sniffles} {sobs} {coughs} {clears throat} {shaky breath} all work, as does any other short description of a sound. For silence, use {pause} or {long pause}.
#Styles for a few words
Wrap words in a style and close it with a slash. Only the enclosed words change:
It's going to {loud}blow{/loud}. {whisper}Get down.{/whisper}| Style | Effect |
|---|---|
{whisper}…{/whisper} | whispered |
{soft}…{/soft} | quieter, gentler |
{loud}…{/loud} | louder, forceful |
{emphasis}…{/emphasis} | stressed |
{fast}…{/fast} | quicker |
{slow}…{/slow} | slower |
A style you never close applies as a direction for what follows instead, so {whisper} Come closer. is whispered too.
#Languages
v2 voices speak English, Spanish, French, German, Japanese and Chinese: write your text in the language you want. Japanese and Chinese are recognised from the text; for Spanish, French and German, also send "language": "es" (or fr, de) for the most natural accent. Cues themselves can be written in any language.
#Tips
- One emotion per sentence reads most naturally. Change it between sentences, not mid-word.
- Keep to two or three cues in a row; more tends to blur them.
- On the realtime socket, text is spoken a sentence at a time, so keep a style's
{…}and{/…}within one sentence. - Square brackets and angle brackets in your text are read as ordinary text, never as instructions:
[and]become parentheses and<>are dropped.
Realtime
One WebSocket for voice agents. Send text as your LLM writes it; speech starts after the first sentence.
POST /v1/tts needs the whole text up front. A voice agent doesn't have it: the reply arrives from the LLM a few tokens at a time. The realtime endpoint takes text as it's written and speaks each sentence as soon as it's complete, so the caller hears the first words while the rest of the reply is still being generated.
It's a plain WebSocket: connect from your server the same way a LiveKit or Pipecat agent connects to any streaming TTS. No WebRTC is involved.
#Connect
GET wss://api.iodine.dev/v1/tts/realtime?voice=katie
Authorization: Bearer io_live_...| Query parameter | ||
|---|---|---|
voice | required | A voice id, as for POST /v1/tts. |
model | optional | v1 or v2. Left out, Iodine picks the model that has the voice. |
temperature | optional | Between 0 and 2. |
language | optional | v2: en, es, fr, de, ja or zh, as for POST /v1/tts. |
The key goes in the Authorization header, so connect from your backend; browsers can't set headers on a WebSocket, and your key must never reach a browser anyway. A bad key, an unknown voice or an empty balance is refused with a normal HTTP error before the socket opens.
#Messages you send
JSON text messages:
| Message | Effect |
|---|---|
{"type": "text", "text": "Hi there, "} | Add text. Complete sentences are spoken as they form. |
{"type": "text", "text": "…", "flush": true} | Add text and speak everything buffered now. |
{"type": "flush"} | Speak whatever is buffered now, even mid-sentence. Send it when the LLM's turn ends. |
{"type": "cancel"} | Stop speaking at once and drop buffered text. Use it when the user interrupts. |
{"type": "close"} | Speak what's left, send done, then close the socket. |
#Messages you receive
- Binary messages: raw audio, 24 kHz mono signed 16-bit little-endian PCM. Play them in order as they arrive.
- JSON text messages:
| Event | When |
|---|---|
{"type": "ready", "voice": "katie", "model": "v2", "sampleRate": 24000, "encoding": "pcm_s16le", "channels": 1} | Once, when the socket is ready for text. |
{"type": "segment", "id": "…", "chars": 42} | A piece of speech is starting. id is its request id. |
{"type": "segment_done", "id": "…", "audioMs": 2310, "ms": 980} | That piece is finished: audio length and time taken. |
{"type": "cancelled"} | Your cancel took effect. |
{"type": "error", "code": "…", "message": "…"} | Something went wrong, for example insufficient_credits for the next piece. |
{"type": "done"} | After close, once everything has been spoken. |
#How text is split
Text is spoken a sentence at a time. A sentence boundary only ends a piece once it holds at least 12 characters, so a lone "Hi." waits for the next sentence. Text with no sentence boundary is cut at a space once it reaches 300 characters. flush speaks whatever is buffered, however short.
Each piece is billed as one request, by the length of its audio, at the same rate as POST /v1/tts.
#Example (Python)
import asyncio, json, os
import websockets # pip install websockets
async def speak(chunks):
async with websockets.connect(
"wss://api.iodine.dev/v1/tts/realtime?voice=katie",
additional_headers={"Authorization": f"Bearer {os.environ['IODINE_API_KEY']}"},
) as ws:
print(await ws.recv()) # ready
for chunk in chunks: # e.g. tokens from your LLM
await ws.send(json.dumps({"type": "text", "text": chunk}))
await ws.send(json.dumps({"type": "close"}))
with open("reply.pcm", "wb") as f:
async for msg in ws:
if isinstance(msg, bytes):
f.write(msg) # play it, or forward it to your call
elif json.loads(msg)["type"] == "done":
break
asyncio.run(speak(["Sure! ", "Your order shipped this morning ", "and should arrive on Thursday."]))#Limits
- A socket with no message from you for 5 minutes is closed (
idle_timeout). - One message can be up to 256 KB.
- An
errorabout one piece doesn't close the socket; keep sending, orcloseit yourself.
Voices
List the voices you can use, and clone a voice from a short recording.
#List voices
GET https://api.iodine.dev/v1/voices
Authorization: Bearer io_live_...{
"voices": [
{
"id": "vc_4fK9zQ2mXp7LrT1a",
"model": "v2",
"name": "Narrator (Sam)",
"custom": true,
"durationSeconds": 18.4,
"createdAt": "2026-11-09T12:00:00Z"
},
{ "id": "mia", "model": "v2", "languages": ["en", "es", "fr", "de", "ja", "zh"] },
{ "id": "katie", "model": "v1" }
]
}Your account's cloned voices come first, then the shared voices. Each entry says which model speaks it, and v2 voices list the languages they speak. Use the id as voice in a request. Voices are added over time, so read the list rather than hard-coding it; the console's playground lets you hear every voice.
#Voices
| Voice | Model | Character |
|---|---|---|
ava | v1 | Warm, natural and expressive, for conversational agents. |
katie | v1 | Clear and reassuring, for support lines and service. |
sophie | v1 | Bright and energetic, for games and interactive apps. |
mia | v2 | Soft, calm and close, for wellness and coaching. |
chloe | v2 | Quick, chatty and fun, for social apps and entertainment. |
zander | v2 | Low, warm and expressive, for audiobooks and narration. |
ethan | v2 | Gentle and steady, for healthcare and onboarding. |
v2 voices speak English, Spanish, French, German, Japanese and Chinese, and take emotion and delivery cues.
#Cloning
Clone a voice from a 3 to 30 second recording of one person speaking. Cloned voices are spoken by v2, are private to your account (nobody else can see or use them) and are never used for training.
curl https://api.iodine.dev/v1/voices \
-H "Authorization: Bearer $IODINE_API_KEY" \
-F name="Narrator (Sam)" \
-F file=@sam.wav \
-F consent=trueMultipart form fields:
| Field | ||
|---|---|---|
file | required | The recording: WAV, MP3, FLAC or OGG, 3 to 30 seconds, up to 20 MB. 10 to 30 seconds of clear speech with no music or other voices works best. |
name | required | 1 to 64 characters, for your own reference. |
consent | required | true. You confirm that the speaker has given permission for their voice to be cloned. We record which key confirmed it. |
transcript | optional | What's said in the recording, up to 2,000 characters. Left out, we transcribe it for you. An accurate transcript gives a closer clone. |
The response is 201 Created:
{
"voice": {
"id": "vc_4fK9zQ2mXp7LrT1a",
"model": "v2",
"name": "Narrator (Sam)",
"custom": true,
"durationSeconds": 18.4,
"createdAt": "2026-11-09T12:00:00Z",
"transcript": "Hi, I'm Sam, and this is how I sound when I read.",
"transcriptSource": "auto"
}
}transcriptSource is provided, auto (we transcribed it) or none (no transcript could be made; the voice still works, just less closely). Use the new id straight away:
curl https://api.iodine.dev/v1/tts \
-H "Authorization: Bearer $IODINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "Chapter one.", "voice": "vc_4fK9zQ2mXp7LrT1a"}' \
--output chapter-1.wavAn account can keep up to 50 cloned voices.
#Only clone voices you have permission to use
Clone a voice only if you are the speaker, or the speaker has given you permission to clone it and to use it as you intend; you must be able to show that permission if we ask. Don't use any voice to impersonate a real person without their consent, to mislead anyone about who is speaking, or to commit fraud. We may delete a cloned voice, and suspend the account that made it, if it breaks these rules. See the Terms.
#Delete a cloned voice
DELETE https://api.iodine.dev/v1/voices/vc_4fK9zQ2mXp7LrT1a
Authorization: Bearer io_live_...Returns 204 No Content. The recording is deleted and the id stops working at once.
Speech to text
POST /v1/stt. Send a recording, get the words back. Works with OpenAI's SDKs too.
curl https://api.iodine.dev/v1/stt \
-H "Authorization: Bearer $IODINE_API_KEY" \
-F file=@call.mp3 \
-F language=en{
"text": "Hi, I'm calling about my order from last week.",
"language": "en",
"duration": 4.2
}#Request
Multipart form fields:
| Field | ||
|---|---|---|
file | required | The recording: WAV, MP3, FLAC or OGG, up to 25 MB and 10 minutes. Split longer recordings. |
language | optional | The language spoken, as a code like en, es, ja or pt. Default en. Set it for anything that isn't English: the audio is transcribed in this language. |
response_format | optional | json (default) or text for the transcript alone, as plain text. |
The response has an X-Request-Id header, like every request.
#With OpenAI's SDK
/v1/audio/transcriptions is the same endpoint at OpenAI's path, and fields we don't use (model, prompt) are ignored, so existing code needs only a new base URL and key:
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.iodine.dev/v1", api_key=os.environ["IODINE_API_KEY"])
with open("call.mp3", "rb") as f:
result = client.audio.transcriptions.create(model="whisper-1", file=f, language="en")
print(result.text)#Pricing
$0.00015 per minute of audio ($0.009 an hour), measured from the recording you send, to the millionth of a dollar. Requests that fail on our side cost nothing. It draws on the same credit balance as text to speech, and shows in the console's usage as stt.
#Your audio
Recordings are transcribed in memory and never stored, whatever your Zero Data Retention setting; we keep only the request's metadata (length, language, cost). See Billing and data.
Errors and limits
Every error is JSON with a stable code. What each one means and what to do about it.
Errors come back with an HTTP status and a JSON body:
{
"error": {
"code": "unknown_voice",
"message": "Voice `kate` isn't available for model `v1`. See GET /v1/voices."
}
}Branch on code, which stays the same; message is written for people and may change.
#Error codes
| Status | Code | Meaning | What to do |
|---|---|---|---|
| 400 | invalid_request | The body isn't valid JSON or has the wrong shape. | Fix the request; the message says what's wrong. |
| 400 | empty_text | text is empty, or only whitespace. | Send some text. |
| 400 | text_too_long | text is over 5,000 characters. | Split it into several requests, ideally at sentence boundaries. |
| 400 | unknown_voice | No such voice for that model, or a cloned voice from another account. | Check the id with GET /v1/voices. |
| 400 | invalid_temperature | temperature is outside 0 to 2. | Use a value in range, or leave it out. |
| 400 | invalid_language | language isn't one of en, es, fr, de, ja, zh. | Use one of those, or leave it out. |
| 401 | unauthorized | The API key is missing, wrong or revoked. | Send Authorization: Bearer io_live_… with a current key. |
| 402 | insufficient_credits | Your credit balance is used up. | Add credit or turn on auto-recharge in Billing. |
| 404 | not_found | No such endpoint, or a cloned voice that doesn't exist. | Check the path and id. |
| 502 | upstream_error, upstream_unreachable | Generation failed on our side. You aren't charged. | Retry with backoff. |
| 503 | busy | Every slot for that model was taken for 3 seconds. | Retry with backoff. |
| 500 | internal | Something broke on our side. | Retry; if it keeps happening, tell us the X-Request-Id. |
Speech to text adds invalid_response_format and, like cloning, missing_audio, invalid_audio and audio_too_long.
Cloning adds its own codes, all 400: consent_required, invalid_name, invalid_transcript, missing_audio, invalid_audio, audio_too_short, audio_too_long and too_many_voices. Each message says exactly what to fix.
#Retrying
Retry 502, 503 and 500 with exponential backoff and jitter, for example after 0.5 s, 1 s, 2 s and 4 s. Don't retry 4xx errors unchanged: they fail the same way every time.
import random, time, requests
def tts(payload, key, attempts=5):
for i in range(attempts):
r = requests.post("https://api.iodine.dev/v1/tts", json=payload,
headers={"Authorization": f"Bearer {key}"})
if r.status_code not in (500, 502, 503):
r.raise_for_status()
return r.content
time.sleep(0.5 * 2 ** i + random.random() * 0.25)
r.raise_for_status()#Limits
| Text per request | 5,000 characters |
| Waiting for a free slot | up to 3 seconds, then 503 busy |
| Realtime idle timeout | 5 minutes without a message |
| Realtime message size | 256 KB |
| Cloned voice recording | 3 to 30 seconds, up to 20 MB |
| Cloned voices per account | 50 |
| Speech to text upload | 25 MB, 10 minutes |
Need more throughput or higher limits? Email business@iodine.dev.
Billing and data
Prepaid credit, priced per minute of audio. Zero Data Retention is on by default.
#Pricing
$0.0075 per minute of audio, for both models. Speech to text is $0.00015 per minute of recording ($0.009 an hour). You're charged for the length of the audio you receive, to the millionth of a dollar, so a 10-second clip costs $0.00125. There are no plans, seats or minimums.
- Requests that fail on our side cost nothing.
- If you disconnect partway through a stream, you pay only for the audio that was sent.
- Each realtime piece is billed the same way, as its own request.
#Credits
Usage draws down a prepaid balance in US dollars.
- Top up in the console under Billing: $5 to $5,000 at a time, by card, with a receipt for each.
- Auto-recharge tops you up when the balance falls below an amount you choose, using your saved card, so your app never stops.
- At zero, requests fail with
402 insufficient_creditsuntil you add credit. Requests already running finish.
The console shows your balance, every top-up, and your usage and spend by day. Receipts, invoices, your card and your billing details (address, tax ID) are in the billing portal, linked from the Billing page.
High volume or invoicing? We offer custom rates and monthly invoicing with payment terms. Email business@iodine.dev.
#Zero Data Retention
Zero Data Retention (ZDR) is on for every account by default, at no extra cost:
- The text you send is held in memory only while the audio is generated, then discarded.
- Generated audio is streamed to you and never stored. Responses are marked
no-store. - We keep only request metadata (when, which key, model and voice, the character count but not the characters, audio length, time to first byte and status), which powers your usage page and billing.
The account owner can turn ZDR off in the console's settings. While it's off, we keep the text and audio of each completed request and may use them to train and improve our models, and those requests cost 15% less: $0.006375 per minute at the list price. Turning it back on takes effect immediately for new requests, and the owner can delete everything kept from the console. Cloned voices are never used for training, whatever the setting.
The full details are in the Privacy Policy.