Skip to content
tonvio

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.

Download OpenAPI (JSON)

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

  1. 1Create an API key in the web app: API service → API keys. The key is shown once — store it on your server.
  2. 2Pick a voice with GET /voices and copy its id.
  3. 3Create a job with POST /speech: the text, the voice_id and, optionally, the language. You get a job id right away.
  4. 4Call GET /speech/{id}?wait=60 until status is completed, then download audio_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.

HTTP
Authorization: Bearer tvk_your_key_here
# or
X-API-Key: tvk_your_key_here

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

PermissionAllows
speechCreate, cancel and delete jobs (includes speech:read).
speech:readOnly read jobs — for monitoring tools that must not spend money.
voicesBrowse voices.
accountRead balance, limits and usage; manage webhook deliveries.
cloneCreate 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
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"
    },
    …
  ]
}
FieldMeaning
idWhat you pass as voice_id in POST /speech. Use it exactly as returned.
name, description, gender, age, accentWho the voice is. Any of them can be null.
preview_urlA short sample to listen to.
languageThe voice's native language.
languagesLanguages the voice speaks right away.
languages_on_demandLanguages 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.
originvendor — 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.

FieldMeaning
textrequiredWhat 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_idrequiredA voice id from GET /voices, or the voice_id of your own clone.
languageoptionalLanguage 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.speedoptionalSpeaking rate from 0.5 to 2, default 1. The pitch stays the same.
settings.voice.pausesoptionalPauses in milliseconds (0–5000) after a sentence, paragraph, clause or word. Set any of them. A paragraph is separated by an empty line.
idempotency_keyoptionalSame as the Idempotency-Key header (up to 128 characters). Use one or the other.
JSON
{
  "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
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.

FieldMeaning
statusqueued — waiting for a free slot; processing — being voiced; completed, failed, canceled — finished.
progress0–99 while the job runs; 100 only when the audio is ready.
stageWhat 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_secondsEstimated seconds left, when known; otherwise null.
poll_after_secondsHow long to wait before asking again. null once the job is finished.
error_code, error_messageWhy the job failed — see Errors.
JSON
// 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,processing returns all unfinished jobs of the key in one request.
  • No polling at all: set up a webhook and we will call your server.
cURL
# 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 status is completed, audio_url holds a link to the MP3 (44.1 kHz, 192 kbps). duration_seconds is 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 that audio_url is null and audio_expired is true. Download the file and store your own copy.

Characters and billing

  • 1 character of text = 1 credit. Everything in text counts: 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_end in GET /account shows 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_mode in GET /account shows which mode is active (subscription or credits_only); credits.plan_covers_api tells whether this key's jobs are covered by a subscription right now.
  • Without a subscription, characters are reserved when the job is accepted (characters in 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-Key never 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 /account shows what this key can spend right now: credits.api_spendable_chars (null while the subscription covers the API). If the balance is too low, POST /speech returns 402 insufficient_balance with need and available.
  • Reading jobs, voices and the account never costs characters.
cURL
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

LimitValueWhen exceeded
Requests300 per minute per key (short bursts are fine)429 rate_limited — wait Retry-After seconds
Text per joblimits.max_chars_per_request in GET /account400 text_too_long with max
Jobs at the same timeYour plan's slots, shared with the web app; extra jobs wait as queued429 too_many_jobs when the queue is full — wait Retry-After
Daily / monthly charactersOnly 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}/cancel stops a job that is queued or processing. 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_cancellable or 409 finalizing, and the job completes normally — with the audio and the usual charge. 409 job_busy means: 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_cooldown with Retry-After.
cURL
# 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.

EventWhen
speech.completedA job finished; data has audio_url.
speech.failedA job failed; data has error_code and error_message.
clone.completedA voice clone is ready; data has voice_id.
clone.failedA voice clone failed.
HTTP
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 2xx within 10 seconds; do slow work afterwards.
  • If we don't get a 2xx, we retry: up to 5 attempts over about 2.6 hours. Answer 410 Gone to 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.

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

  1. 1Send POST /voice-clones as a form (multipart/form-data) with an Idempotency-Key header (required here): name, consent, optional languages, and the file in audio — mp3, wav, m4a or webm, up to 15 MB. Put the text fields before the file.
  2. 2Check GET /voice-clones/{id} no more often than poll_after_seconds says. Building a voice takes a few minutes.
  3. 3When status is completed, pass its voice_id to POST /speech like any other voice.
cURL
# 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" }'
  • consent is 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_limit of GET /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
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

StatusCodeWhat to do
400invalid_requestThe body is wrong — message says what. Fix and resend.
400text_too_long, text_too_shortShorten the text (max) or add words.
401missing_api_key, invalid_api_key, api_key_expiredCheck the key; create a new one if it was revoked or expired.
402insufficient_balanceTop up characters or get an Unlimited subscription (need, available).
403insufficient_scopeThe key lacks a permission; required names it.
403voice_not_allowedThis voice can't be used — pick another one.
404not_foundNo such job or voice for this key.
409idempotency_mismatch, idempotency_key_consumedUse a new Idempotency-Key for a new request.
429rate_limited, create_busy, too_many_jobs, quota_exceededWait Retry-After (or until resets_at), then retry.
429cancel_cooldownNew jobs are paused after many cancellations of already-voiced work. Wait Retry-After.
504create_outcome_unknownWe 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}.
5xxgeneration_unavailable, service_busy, service_unavailable, delivery_unavailable, internal_errorTemporary. 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`MeaningRetry?
voice_language_mismatchThe 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_availableThe 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_foundThe voice is no longer available.No — choose another voice.
text_rejectedThe text has no speakable words or has characters that can't be read aloud.No — fix the text.
synthesis_failedVoicing failed for a temporary reason.Yes, later.
queue_timeout, execution_timeout, provider_unavailable, voice_unavailableTemporary capacity problem.Yes, later.
assembly_failedThe audio could not be saved.Yes.
output_too_largeThe audio file would be too large.Split the text into several jobs.
generation_failedAnything else.Once; if it repeats, contact support with the job id.

All endpoints

MethodPathWhat it doesPermission
POST/speechCreate a jobspeech
GET/speech/{id}Job status and audio link (?wait= to wait)speech:read
GET/speechJobs of this key, newest first (status, limit, cursor)speech:read
POST/speech/{id}/cancelCancel a jobspeech
DELETE/speech/{id}Delete a job and its audiospeech
GET/voicesVoices (language, gender, q, limit, offset)voices
GET/voices/{id}One voicevoices
GET/voices/{id}/preview.mp3Listen to a voice sample (works without a key)—
GET/accountBalance, limits and usage of this keyaccount
GET/webhooksThis key's webhook settingsaccount
POST/webhooks/testSend a test eventaccount
GET/webhooks/deliveriesDelivery logaccount
POST/webhooks/deliveries/{id}/requeueDeliver againaccount
POST/voice-clonesClone a voice from a recordingclone
GET/voice-clones/{id}Clone statusclone
GET/voice-clonesClones of this keyclone
DELETE/voice-clones/{id}Delete a cloneclone

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 stage or error_code values 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.