Text to speech API
Tonvio API: natural speech for your product
One HTTP API to turn text into speech: more than 18,000 voices, emotion tags, voice cloning and webhooks. The whole reference is public — read it here, download it as Markdown or OpenAPI, and size up the integration before you sign up.
The API runs on the same engine as the web app: the same voices and generation features, the same queue, the same finished MP3.
Take the whole reference with you: a Markdown file for your team or an AI assistant, or the OpenAPI spec for code generators and HTTP clients.
Turn text into natural speech over HTTP. You send text and a voice, Tonvio returns an MP3. Jobs are asynchronous: create a job, wait for it to finish, download the audio. Everything below works with any HTTP client, and the whole reference is also available as an OpenAPI file and as one Markdown file.
Base URL: https://api.tonvio.net/api/v1
Quickstart
- 1Create an API key in the web app: API service → API keys. The key is shown once — store it on your server.
- 2Pick a voice with
GET /voicesand copy itsid. - 3Create a job with
POST /speech: the text, thevoice_idand, optionally, thelanguage. You get a jobidright away. - 4Call
GET /speech/{id}?wait=60untilstatusiscompleted, then downloadaudio_url.
# 1. Pick a voice
curl "https://api.tonvio.net/api/v1/voices?language=en&limit=3" \
-H "Authorization: Bearer tvk_your_key_here"
# 2. Create a job (save the "id" from the answer)
curl -X POST https://api.tonvio.net/api/v1/speech \
-H "Authorization: Bearer tvk_your_key_here" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7d0c3c1e-5b8a-4f61-9a43-2f1c9e0b6d11" \
-d '{ "text": "Hello! This is my first voiceover.", "voice_id": "voc_2072b6f1a9c4", "language": "en" }'
# → 202 { "id": "JOB_ID", "status": "queued", "characters": 34, "poll_after_seconds": 10 }
# 3. Wait for it: the server holds this request until the job is done (up to 60 s).
# Repeat while "status" is "queued" or "processing".
curl "https://api.tonvio.net/api/v1/speech/JOB_ID?wait=60" \
-H "Authorization: Bearer tvk_your_key_here"
# → { "status": "completed", "progress": 100, "audio_url": "https://cdn.tonvio.net/…" }
# 4. Download the MP3 (the link is valid for about 6 hours)
curl -L -o voiceover.mp3 "AUDIO_URL"Send an Idempotency-Key header with every POST /speech and reuse it when you retry. Then a lost answer or a network error never creates — or charges for — a second job.
Authentication
Send the key in the Authorization: Bearer header or in X-API-Key — both work the same. A key is a password to your balance: keep it on your server and never put it in a website or a mobile app.
Authorization: Bearer tvk_your_key_here
# or
X-API-Key: tvk_your_key_hereEach key has permissions. A new key gets speech, voices and account; voice cloning needs the clone permission, which is granted separately. A request without the needed permission returns 403 insufficient_scope.
| Permission | Allows |
|---|---|
speech | Create, cancel and delete jobs (includes speech:read). |
speech:read | Only read jobs — for monitoring tools that must not spend money. |
voices | Browse voices. |
account | Read balance, limits and usage; manage webhook deliveries. |
clone | Create and delete voice clones. |
Voices
GET /voices lists every voice you can use — more than 18,000 of them. Filter with language (for example en, pt-BR), gender, q (searches the name and description), and page with limit (up to 200, default 50) and offset. The answer carries total.
curl "https://api.tonvio.net/api/v1/voices?language=en&gender=female&limit=2" \
-H "Authorization: Bearer tvk_your_key_here"
{
"total": 4120,
"limit": 2,
"offset": 0,
"voices": [
{
"id": "voc_2072b6f1a9c4",
"name": "Marcus",
"language": "en",
"languages": ["en", "de"],
"languages_on_demand": ["fr", "es"],
"gender": "male",
"age": "middle_aged",
"accent": "american",
"description": "Calm, warm narrator",
"preview_url": "https://cdn.tonvio.net/voice-previews/…/sample.mp3",
"origin": "vendor"
},
…
]
}| Field | Meaning |
|---|---|
id | What you pass as voice_id in POST /speech. Use it exactly as returned. |
name, description, gender, age, accent | Who the voice is. Any of them can be null. |
preview_url | A short sample to listen to. |
language | The voice's native language. |
languages | Languages the voice speaks right away. |
languages_on_demand | Languages the voice can also speak. The first job in such a language waits a few minutes while the voice is prepared (stage awaiting_voice); after that it starts right away. |
origin | vendor — a ready-made catalog voice; recording — a voice cloned from a recording. |
GET /voices/{id} returns one voice. Some other fields in the answer are kept for older integrations and can be ignored.
Create speech
POST /speech accepts the job and answers 202 Accepted at once, with the job id and a Location header pointing to it.
| Field | Meaning | |
|---|---|---|
text | required | What to say. Up to limits.max_chars_per_request characters — see GET /account (depends on your plan). Must contain at least one letter or digit. |
voice_id | required | A voice id from GET /voices, or the voice_id of your own clone. |
language | optional | Language of the text as a BCP 47 code: en, de, pt-BR. If you leave it out, the language is detected from the text. |
settings.voice.speed | optional | Speaking rate from 0.5 to 2, default 1. The pitch stays the same. |
settings.voice.pauses | optional | Pauses in milliseconds (0–5000) after a sentence, paragraph, clause or word. Set any of them. A paragraph is separated by an empty line. |
idempotency_key | optional | Same as the Idempotency-Key header (up to 128 characters). Use one or the other. |
{
"text": "Welcome back. [pause] Today we have big news! [excited] Let's begin.",
"voice_id": "voc_2072b6f1a9c4",
"language": "en",
"settings": {
"voice": {
"speed": 1.1,
"pauses": { "sentence": 350, "paragraph": 800 }
}
}
}Emotions and sounds
Put short English directions in square brackets right into the text: [whisper], [laughing], [sigh], [pause], [excited], [clearing throat], [emphasis], [shouting]. You can also describe the manner in your own words, up to 64 characters: [whisper in small voice]. Brackets that start with a digit or are written in another alphabet — [1], [глава 2] — are read aloud as normal text. Tags are part of the text and count as characters.
The answer
HTTP/1.1 202 Accepted
Location: /api/v1/speech/0b3a7c11-8f2d-4e55-9a10-6c7b8d9e0f12
{
"id": "0b3a7c11-8f2d-4e55-9a10-6c7b8d9e0f12",
"status": "queued",
"characters": 68,
"paid_with": "credits",
"queue_position": 0,
"poll_after_seconds": 10
}characters— how many characters were reserved for this job.queue_position— how many of your jobs are waiting before this one.poll_after_seconds— when to ask about the job for the first time.settings_warnings— appears only if something you sent was not applied, with the field name and the reason. For example, a speed outside 0.5–2.
Older integrations may still send voice_engine, model, settings.stability, settings.settings_preset, settings.delay_between_chunks, settings.voice.chunking or settings.voice.delivery. These fields are accepted for compatibility, have no effect on the sound, and are listed in settings_warnings.
Job status and progress
GET /speech/{id} returns the current state of the job. A job goes queued → processing → completed. It can also end as failed or canceled. Those three states are final.
| Field | Meaning |
|---|---|
status | queued — waiting for a free slot; processing — being voiced; completed, failed, canceled — finished. |
progress | 0–99 while the job runs; 100 only when the audio is ready. |
stage | What is happening now: queued (waiting for the voice engine), cold_start (capacity is starting up), awaiting_voice (the voice is being prepared for a new language), rendering, stitching. Can be null. New values may appear — treat unknown ones as "still working". Decide by status, not by stage. |
eta_seconds | Estimated seconds left, when known; otherwise null. |
poll_after_seconds | How long to wait before asking again. null once the job is finished. |
error_code, error_message | Why the job failed — see Errors. |
// While it is running
{
"id": "0b3a7c11-8f2d-4e55-9a10-6c7b8d9e0f12",
"status": "processing",
"progress": 42,
"stage": "rendering",
"eta_seconds": 35,
"characters": 68,
"credits_charged": null,
"paid_with": "credits",
"audio_url": null,
"poll_after_seconds": 7
}
// When it is ready
{
"id": "0b3a7c11-8f2d-4e55-9a10-6c7b8d9e0f12",
"status": "completed",
"progress": 100,
"characters": 68,
"credits_charged": 68,
"paid_with": "credits",
"language": "en",
"audio_url": "https://cdn.tonvio.net/…",
"audio_expires_at": "2026-11-06T10:12:03.000Z",
"duration_seconds": 5.8,
"poll_after_seconds": null
}
// If it failed (the reserved characters are already back on your balance)
{
"status": "failed",
"error_code": "voice_language_mismatch",
"error_message": "The provider detected the text language as \"fr\", but this voice speaks only: en, de. …",
"error_details": { "text_language": "fr", "voice_languages": ["en", "de"] },
"poll_after_seconds": null
}How to wait
- Easiest:
GET /speech/{id}?wait=60. The server holds the request until the job finishes or 60 seconds pass, then answers with the current state. Repeat until the status is final. - Plain polling: ask again after
poll_after_seconds— never on a fixed short timer. - Many jobs at once:
GET /speech?status=queued,processingreturns all unfinished jobs of the key in one request. - No polling at all: set up a webhook and we will call your server.
# Long polling: the server answers as soon as the job finishes, or after 60 s at most.
curl "https://api.tonvio.net/api/v1/speech/JOB_ID?wait=60" -H "Authorization: Bearer tvk_your_key_here"
# Or list everything still running in one request
curl "https://api.tonvio.net/api/v1/speech?status=queued,processing&limit=100" -H "Authorization: Bearer tvk_your_key_here"A short text is usually ready in seconds. After a quiet period the first job may wait 15–20 minutes while capacity starts (stage cold_start), and a long book takes longer. Don't give up on a job in less than two hours.
Getting the audio
- When
statusiscompleted,audio_urlholds a link to the MP3 (44.1 kHz, 192 kbps).duration_secondsis its length. - The link works for about 6 hours. Every
GET /speech/{id}returns a fresh link, so ask again whenever you need one. - We keep the file for 30 days after the job finishes — the date is in
audio_expires_at. After thataudio_urlisnullandaudio_expiredistrue. Download the file and store your own copy.
Characters and billing
- 1 character of text = 1 credit. Everything in
textcounts: letters, spaces, punctuation, line breaks and tags in brackets. Most emoji count as 2. - While your account has an active Unlimited subscription, the API is covered by it: jobs spend no credits and show
paid_with: subscription. When the subscription ends, the next jobs are paid with credits (paid_with: credits);credits.plan_period_endinGET /accountshows when that happens. - The service can run in credits-only mode: the subscription then does not cover the API and every job is paid with credits.
credits.billing_modeinGET /accountshows which mode is active (subscriptionorcredits_only);credits.plan_covers_apitells whether this key's jobs are covered by a subscription right now. - Without a subscription, characters are reserved when the job is accepted (
charactersin the answer) and charged when it completes (credits_charged). - If a job fails, the whole reservation comes back. If you cancel a job before it finishes, it comes back too.
- A retry with the same
Idempotency-Keynever reserves a second time. A request rejected with an error reserves nothing. - Credits for the API come from purchased character packs. Bonus characters (welcome bonus, promotions) cover the web app only, unless your contract says otherwise.
GET /accountshows what this key can spend right now:credits.api_spendable_chars(nullwhile the subscription covers the API). If the balance is too low,POST /speechreturns402 insufficient_balancewithneedandavailable.- Reading jobs, voices and the account never costs characters.
curl https://api.tonvio.net/api/v1/account -H "Authorization: Bearer tvk_your_key_here"
{
"credits": { "api_spendable_chars": 250000, "plan_unlimited": false, "spend_bucket": "purchased", … },
// With an active Unlimited subscription: "api_spendable_chars": null, "plan_unlimited": true,
// "plan_period_end": "2026-11-08T10:00:00.000Z" — jobs spend no credits until then
// In credits-only mode ("billing_mode": "credits_only") a subscription does not cover the API:
// "plan_covers_api": false and "api_spendable_chars" is always a number
"limits": { "max_chars_per_request": 200000, "active_slots": 3, "rate_per_min": 300, … },
"usage": { "key": { "active_jobs": 1, "queued_jobs": 0, "chars_today": 18340, … } },
…
}Limits
| Limit | Value | When exceeded |
|---|---|---|
| Requests | 300 per minute per key (short bursts are fine) | 429 rate_limited — wait Retry-After seconds |
| Text per job | limits.max_chars_per_request in GET /account | 400 text_too_long with max |
| Jobs at the same time | Your plan's slots, shared with the web app; extra jobs wait as queued | 429 too_many_jobs when the queue is full — wait Retry-After |
| Daily / monthly characters | Only if set for the key (UTC days and months) | 429 quota_exceeded with resets_at |
Answers carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset headers. All numbers that apply to your key are in GET /account.
Safe retries
Networks fail. To make a retry safe, send Idempotency-Key: <any unique string> with POST /speech and send the same key again when you retry.
- Same key, same request → you get the original job back (
200) instead of a new one. Nothing is charged twice. - Same key, different request →
409 idempotency_mismatch. - Same key after you deleted that job →
409 idempotency_key_consumed. Use a new key. - Without a key, every call is a new job. Keys are remembered for 90 days.
Retry only 429 and 5xx answers, after the Retry-After delay. Other 4xx errors will not change on a retry — fix the request first.
Cancel and delete
POST /speech/{id}/cancelstops a job that isqueuedorprocessing. The reserved characters come back.DELETE /speech/{id}deletes the job and its audio, and cancels it first if it is still running.- Once the audio is fully voiced and only saving the file remains, a job can't be stopped: you get
409 not_cancellableor409 finalizing, and the job completes normally — with the audio and the usual charge.409 job_busymeans: try again in a few seconds. - Cancelling does not undo work that was already voiced. If more than a million already-voiced characters are cancelled within 24 hours, new jobs are paused until that window ends:
429 cancel_cooldownwithRetry-After.
# Stop a job that is still queued or running — the reserved characters come back
curl -X POST https://api.tonvio.net/api/v1/speech/JOB_ID/cancel -H "Authorization: Bearer tvk_your_key_here"
# → { "id": "JOB_ID", "status": "canceled" }
# Delete a job and its audio (a running job is canceled first)
curl -X DELETE https://api.tonvio.net/api/v1/speech/JOB_ID -H "Authorization: Bearer tvk_your_key_here"
# → { "id": "JOB_ID", "deleted": true, "canceled": false, "idempotency_key_reusable": false }Webhooks
Instead of asking about a job, let us tell you. Set a webhook for a key in the web app (API service → your key → Webhook): an https address, the events you want, and a signing secret that is shown once.
| Event | When |
|---|---|
speech.completed | A job finished; data has audio_url. |
speech.failed | A job failed; data has error_code and error_message. |
clone.completed | A voice clone is ready; data has voice_id. |
clone.failed | A voice clone failed. |
POST /your-endpoint HTTP/1.1
Host: your-server.example
Content-Type: application/json
User-Agent: tonvio-webhooks/1
X-Tonvio-Event: speech.completed
X-Tonvio-Delivery: 0f9d5f2e-4d1e-4a2b-9a3c-1f2e3d4c5b6a
X-Tonvio-Attempt: 1
X-Tonvio-Timestamp: 1789000000
X-Tonvio-Signature: t=1789000000,v1=6b1c9f0e…
{
"id": "0f9d5f2e-4d1e-4a2b-9a3c-1f2e3d4c5b6a",
"schema_version": 1,
"event": "speech.completed",
"object_id": "0b3a7c11-8f2d-4e55-9a10-6c7b8d9e0f12",
"sent_at": "2026-10-07T10:12:03.114Z",
"data": {
"id": "0b3a7c11-8f2d-4e55-9a10-6c7b8d9e0f12",
"status": "completed",
"characters": 68,
"credits_charged": 68,
"paid_with": "credits",
"audio_url": "https://cdn.tonvio.net/…",
"error_code": null,
"error_message": null
}
}Check the signature
Every delivery is signed: X-Tonvio-Signature: t=<time>,v1=<signature>, where the signature is HMAC-SHA256 of <t>.<raw body> with your secret, in hex. Check the raw bytes (not re-encoded JSON), reject deliveries whose t is more than 5 minutes away from your clock, and compare in constant time.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.TONVIO_WEBHOOK_SECRET; // whsec_… shown once when you set it
const TOLERANCE_SECONDS = 300;
// Verify the RAW bytes. Parsing JSON and serializing it back changes the bytes,
// and the signature will then never match — this is the #1 integration mistake.
function verify(rawBody, header) {
const parts = {};
for (const piece of String(header || "").split(",")) {
const i = piece.indexOf("=");
if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
}
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
// Replay window: the timestamp is part of the signed string, so it cannot be moved.
if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${t}.${rawBody}`, "utf8")
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(parts.v1, "utf8");
// Constant-time compare: === on a hex string leaks the common prefix.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post("/tonvio-hook", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verify(raw, req.get("X-Tonvio-Signature"))) return res.sendStatus(401);
const event = JSON.parse(raw);
// Deduplicate by event.id: a retry repeats the SAME delivery id and the same body.
// Answer 2xx first and do the slow work afterwards — we wait 10 s, then retry.
res.sendStatus(200);
queue.push(event);
});Delivery rules
- Answer with any
2xxwithin 10 seconds; do slow work afterwards. - If we don't get a
2xx, we retry: up to 5 attempts over about 2.6 hours. Answer410 Goneto stop the retries. - A delivery can arrive twice and out of order. Skip ids you have already handled (
X-Tonvio-Delivery). - Canceling a job sends no event — you started the cancellation yourself.
Debugging
POST /webhooks/test sends your endpoint a test event and shows what it answered. GET /webhooks/deliveries lists real deliveries with the exact body that was signed, and POST /webhooks/deliveries/{id}/requeue sends one again.
# Where this key delivers and whether a signing secret is set
curl https://api.tonvio.net/api/v1/webhooks -H "Authorization: Bearer tvk_your_key_here"
# Send yourself a test event; 200 comes back even when your server answered 500 — read result.ok
curl -X POST https://api.tonvio.net/api/v1/webhooks/test -H "Authorization: Bearer tvk_your_key_here"
# → { "result": { "ok": false, "status": 500, "signed": true, "response_snippet": "…", "duration_ms": 214 } }
# The log of real deliveries, with the exact body that was signed
curl "https://api.tonvio.net/api/v1/webhooks/deliveries?state=dead&limit=20" -H "Authorization: Bearer tvk_your_key_here"
# Send one delivery again after you fixed your server
curl -X POST https://api.tonvio.net/api/v1/webhooks/deliveries/DELIVERY_ID/requeue -H "Authorization: Bearer tvk_your_key_here"Voice cloning
Turn a recording of your own voice into a private voice that only you can use. The key needs the clone permission.
- 1Send
POST /voice-clonesas a form (multipart/form-data) with anIdempotency-Keyheader (required here):name,consent, optionallanguages, and the file inaudio— mp3, wav, m4a or webm, up to 15 MB. Put the text fields before the file. - 2Check
GET /voice-clones/{id}no more often thanpoll_after_secondssays. Building a voice takes a few minutes. - 3When
statusiscompleted, pass itsvoice_idtoPOST /speechlike any other voice.
# 1. Order a private clone from your own recording.
# Text fields MUST come before the file, otherwise they never reach the server.
curl -X POST https://api.tonvio.net/api/v1/voice-clones \
-H "Authorization: Bearer tvk_your_key_here" \
-H "Idempotency-Key: 4c2f8e9a-1b7d-4e3a-8c6f-5d9b0a1e2f34" \
-F "name=My narrator" \
-F "consent=I confirm this is my own voice and I agree to have it cloned." \
-F "languages=en" \
-F "[email protected];type=audio/mpeg"
# → 202 { "id": "CLONE_ID", "status": "queued", "visibility": "private", "poll_after_seconds": 15 }
# 2. Check on it no more often than poll_after_seconds says; building takes minutes
curl https://api.tonvio.net/api/v1/voice-clones/CLONE_ID -H "Authorization: Bearer tvk_your_key_here"
# → { "status": "completed", "percent": 100, "voice_id": "VOICE_ID" }
# 3. Use it like any other voice
curl -X POST https://api.tonvio.net/api/v1/speech \
-H "Authorization: Bearer tvk_your_key_here" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9e1d7b3c-2a4f-4c8e-b6d0-3f5a7c9e1b2d" \
-d '{ "text": "Hello, this is my cloned voice.", "voice_id": "VOICE_ID" }'consentis a 20–500 character statement that you have the right to this voice. We store it with the request. Clone only your own voice or a voice you have written permission to use.- How many clones a key may create is in
limits.clone_limitofGET /account. DELETE /voice-clones/{id}deletes a clone you no longer need.
Errors
Every error has the same shape: an HTTP status and a JSON body with a machine-readable error code, plus extra fields for some codes. Decide what to do by the code, not by the text. Every answer has an X-Request-Id header — include it when you contact support.
HTTP/1.1 402 Payment Required
X-Request-Id: req-8f1c2d
{ "error": "insufficient_balance", "feature": "api", "need": 120000, "available": 45000 }
HTTP/1.1 429 Too Many Requests
Retry-After: 10
{ "error": "too_many_jobs", "scope": "account", "limit": 3, "retry_after": 10 }Request errors
| Status | Code | What to do |
|---|---|---|
| 400 | invalid_request | The body is wrong — message says what. Fix and resend. |
| 400 | text_too_long, text_too_short | Shorten the text (max) or add words. |
| 401 | missing_api_key, invalid_api_key, api_key_expired | Check the key; create a new one if it was revoked or expired. |
| 402 | insufficient_balance | Top up characters or get an Unlimited subscription (need, available). |
| 403 | insufficient_scope | The key lacks a permission; required names it. |
| 403 | voice_not_allowed | This voice can't be used — pick another one. |
| 404 | not_found | No such job or voice for this key. |
| 409 | idempotency_mismatch, idempotency_key_consumed | Use a new Idempotency-Key for a new request. |
| 429 | rate_limited, create_busy, too_many_jobs, quota_exceeded | Wait Retry-After (or until resets_at), then retry. |
| 429 | cancel_cooldown | New jobs are paused after many cancellations of already-voiced work. Wait Retry-After. |
| 504 | create_outcome_unknown | We could not confirm whether the job was created; its job_id is in the answer. Retry with the same Idempotency-Key (safe either way) or check GET /speech/{id}. |
| 5xx | generation_unavailable, service_busy, service_unavailable, delivery_unavailable, internal_error | Temporary. Retry after Retry-After with the same Idempotency-Key. |
When a job fails
A job can be accepted and fail later. Then GET /speech/{id} (and the speech.failed webhook) return status: failed, an error_code and an error_message you can show to people. The reserved characters are always returned.
| `error_code` | Meaning | Retry? |
|---|---|---|
voice_language_mismatch | The text is in a language this voice doesn't speak (detected from the text). error_details names both languages. | No — choose a voice that speaks the language. |
voice_language_not_available | The voice can't speak this language right now (or has no version for the requested one). | No — choose another voice (or wait if error_details.variant_building is true). |
voice_not_found | The voice is no longer available. | No — choose another voice. |
text_rejected | The text has no speakable words or has characters that can't be read aloud. | No — fix the text. |
synthesis_failed | Voicing failed for a temporary reason. | Yes, later. |
queue_timeout, execution_timeout, provider_unavailable, voice_unavailable | Temporary capacity problem. | Yes, later. |
assembly_failed | The audio could not be saved. | Yes. |
output_too_large | The audio file would be too large. | Split the text into several jobs. |
generation_failed | Anything else. | Once; if it repeats, contact support with the job id. |
All endpoints
| Method | Path | What it does | Permission |
|---|---|---|---|
POST | /speech | Create a job | speech |
GET | /speech/{id} | Job status and audio link (?wait= to wait) | speech:read |
GET | /speech | Jobs of this key, newest first (status, limit, cursor) | speech:read |
POST | /speech/{id}/cancel | Cancel a job | speech |
DELETE | /speech/{id} | Delete a job and its audio | speech |
GET | /voices | Voices (language, gender, q, limit, offset) | voices |
GET | /voices/{id} | One voice | voices |
GET | /voices/{id}/preview.mp3 | Listen to a voice sample (works without a key) | — |
GET | /account | Balance, limits and usage of this key | account |
GET | /webhooks | This key's webhook settings | account |
POST | /webhooks/test | Send a test event | account |
GET | /webhooks/deliveries | Delivery log | account |
POST | /webhooks/deliveries/{id}/requeue | Deliver again | account |
POST | /voice-clones | Clone a voice from a recording | clone |
GET | /voice-clones/{id} | Clone status | clone |
GET | /voice-clones | Clones of this key | clone |
DELETE | /voice-clones/{id} | Delete a clone | clone |
The OpenAPI file describes every endpoint, field and error code, including the few kept only for older integrations (such as /voices/{id}/warm). Feed it to a code generator, Postman or an AI assistant.
Stability
- Version 1 only grows: we add fields, values and endpoints, and never remove or rename what is documented here.
- So ignore fields you don't know, and treat unknown
stageorerror_codevalues as generic ones. - All dates are ISO 8601 in UTC. Answers are never cached (
Cache-Control: no-store).
Questions developers ask first
Can I read the documentation without an account?
Yes. This page, the Markdown file and the OpenAPI spec are public. An account is needed only to create a key and spend characters.
Is the API synchronous?
No — speech takes from seconds to minutes, so POST /speech returns a job id at once. Then either call GET /speech/{id}?wait=60, which answers as soon as the job is done, or poll after poll_after_seconds, or set a webhook and let us call you.
What does an API call cost?
With an active Unlimited subscription, nothing: the subscription covers the API. Without one, you pay in characters: one character of text is one credit, paid from purchased character packs. Characters are reserved when a job is accepted, charged when it completes and returned in full if it fails or you cancel it. Reading jobs and voices is free. Prices are on the pricing page.
How many voices and languages are there?
More than 18,000 voices. Filter GET /voices by language: each voice lists the languages it speaks right away and the ones it can learn on the first job.
Can I control emotions and pauses?
Yes. Put directions like [whisper], [laughing] or [pause] in square brackets right into the text, and set speaking speed and pauses with settings.voice.
What are the rate limits?
300 requests per minute per key by default, with short bursts allowed. How many jobs run at once and how long one text can be depend on your plan; the exact numbers for your key are in GET /account.
How do I make retries safe?
Send an Idempotency-Key header with every POST /speech and reuse it when you retry. The same key returns the original job instead of creating and charging for a second one.
Can I clone a voice through the API?
Yes, with the clone permission on the key: upload a recording of your own voice, wait a few minutes, then use the new voice_id like any other voice.
How long is the audio kept?
The download link works for about 6 hours, and every status request returns a fresh one. The file itself is kept for 30 days — download it and store your own copy.
Start with one request
Create a key in the web app and your first voiceover is a minute away. With Unlimited the API is covered by your subscription; otherwise you pay only for the characters you voice.
New to Tonvio? Start from the AI text to speech overview.