Text to speech API
Text to speech API: the whole contract, before you sign up
tonvio has a public HTTP API for speech synthesis and voice cloning. This page is the complete reference — endpoints, request fields, limits, error codes and a machine-readable OpenAPI 3.1 spec. Nothing here is behind a login, so an engineer can size up the integration before anyone creates an account.
The API drives the same engine as the web app: the same voices and generation features, the same queue, the same finished MP3.
Base URL
Every path below is relative to this base. The API speaks JSON and nothing else, apart from one multipart endpoint for uploading a voice sample.
https://api.tonvio.net/api/v1Authentication
Send your key as a Bearer token in the Authorization header, or in the X-API-Key header — the two are equivalent. Keys are server-side secrets: anyone holding one can spend your credits, so never ship a key in front-end code. Keys are created in the dashboard and shown in full exactly once.
Authorization: Bearer tvk_your_key_here
# or
X-API-Key: tvk_your_key_hereHow an integration works
The API is asynchronous by design. Synthesis of a long text takes minutes, and an HTTP request held open for minutes dies somewhere in the middle — in a proxy, a load balancer or a CDN. So the server accepts the job, answers immediately, and the client polls.
- 01
Create the job
POST /speech with the text and a voice id. The answer is 202 with a job id and status queued. Credits are reserved at this moment, not spent.
- 02
Poll the status
GET /speech/{id} every 2–3 seconds. progress reaches 100 only together with status completed — while the job is running it is capped at 99, so “100%” never means “nearly there”.
- 03
Download the audio
When status is completed, audio_url holds a link to the MP3. The link is short-lived (about six hours); ask for the job again to get a fresh one.
- 04
Handle the terminal states
failed and canceled are terminal too. On a failure the reservation is released and error_code says whether a retry makes sense. A retry is always a NEW job and a new charge.
# 1. Create a job
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: replace-with-a-unique-request-id" \
-d '{ "text": "Hello from tonvio!", "voice_id": "el_XXXX", "language": "en" }'
# → 202 { "id": "JOB_ID", "status": "queued", "characters": 18, "queue_position": 1 }
# 2. Poll until completed (every 2-3 seconds)
curl https://api.tonvio.net/api/v1/speech/JOB_ID \
-H "Authorization: Bearer tvk_your_key_here"
# → { "status": "completed", "progress": 100, "audio_url": "https://cdn.tonvio.net/..." }
# 3. Or read what this key may actually spend and run into
curl https://api.tonvio.net/api/v1/account \
-H "X-API-Key: tvk_your_key_here"Endpoints
Fourteen endpoints, one envelope. Every request needs a key, and every key carries the set of rights listed in the last column.
| Method | Path | What it does | Scope |
|---|---|---|---|
| POST | /speech | Create a synthesis job (async). | speech |
| GET | /speech/{id} | Job status and, once ready, the audio URL. | speech:read |
| GET | /speech | Jobs of this key, newest first, cursor-paginated. | speech:read |
| POST | /speech/{id}/cancel | Stop a job; what has already been rendered is charged for, the rest comes back. | speech |
| DELETE | /speech/{id} | Delete a job and its audio; stops it first if it is still running. | speech |
| GET | /voices | The voice catalog this key can synthesize with. | voices |
| GET | /voices/{id} | One voice by id. | voices |
| GET | /voices/{id}/warm | Whether a voice is ready for synthesis. | voices |
| POST | /voices/{id}/warm | Warm a voice up before its first paid job. | voices |
| GET | /account | Balance, effective limits and usage of this key. | account |
| POST | /voice-clones | Order a voice clone (async). | clone |
| GET | /voice-clones/{id} | State of a clone request and, once ready, its voice_id. | clone |
| GET | /voice-clones | Clone requests of this key, cursor-paginated. | clone |
| DELETE | /voice-clones/{id} | Drop the request or delete the clone and free the provider slot. | clone |
Key scopes
Every key carries its own set of rights. The factory set is speech, voices and account — it opens everything the API could already do. The clone right is granted separately. Calling an endpoint without the right returns 403 insufficient_scope, and the required field lists the rights any one of which would have worked. A key can also be given an expiry date; past it every request returns 401 api_key_expired. A key can read its own rights and expiry from GET /account.
| Scope | What it opens |
|---|---|
speech | Create and cancel jobs. Also covers speech:read. |
speech:read | Read job state only — the right for a monitoring integration that must not spend money. |
voices | Read the voice catalog. |
account | Read balance, limits and usage. |
clone | Order and delete voice clones. Not part of a key's factory set. |
POST /speech — body
One voice identifier is required: either a catalog voice_id or the same voice's identifier in the external catalog the first provider is built on.
| Field | Meaning | |
|---|---|---|
text | required | The text to voice. Your plan caps characters per request — the figure in force is in GET /account (limits.max_chars_per_request). |
voice_id | one of two | Catalog voice id from GET /voices, or the voice_id of a finished private clone. |
elevenlabs_voice_id | one of two | The voice's identifier in the external catalog the first provider is built on. Providers that do not use that catalog reject it. |
voice_engine | optional | Synthesis engine. With a catalog voice the voice's own default is used; a private clone has no default, so pass it explicitly. Providers without a notion of an engine ignore the field. |
model | optional | Alias of voice_engine. If both are sent they must be identical. |
language | optional | Language of the synthesis, BCP 47 (ru, en, pt-BR). Where a voice is a stack of per-language clones, silence is not “let the engine decide” — it is an implicit choice. The languages a voice actually speaks are in its languages field. |
settings | optional | Tuning: stability (0–1), settings_preset, delay_between_chunks (0–60 s). Unknown keys are rejected rather than silently ignored. |
idempotency_key | optional | Same meaning as the Idempotency-Key header. If both are sent they must match. |
What comes back
202 with the job id, its status, the characters reserved and the queue position. If the text contained markup the target provider does not understand, the answer also carries warnings: every tag that was rewritten or dropped, with a count. A dropped tag is not charged for — this is the one place where the server changes your text, and it always says so.
Credits, rate limits and quotas
The API runs on purchased character credits: 1 character = 1 credit. Subscriptions and bonus credits do not pay for it, and read requests never spend balance. What is actually spendable is in GET /account (credits.api_spendable_chars); running out returns 402 insufficient_balance.
- Request rate: 300 requests per minute per key by default. A key may be given a figure of its own, and the one in force arrives in the RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and RateLimit-Policy response headers. A key builds up an allowance: a short burst goes through, a steady overrun returns 429 rate_limited.
- Concurrency: how many jobs run at once follows the plan and the key's own limits. By default these are the very same execution slots the web app uses — they are shared. Running out returns 429 too_many_jobs, and the scope field says whose ceiling fired: account (shared with the web app) or key (this key's own).
- Volume: a key can be given a daily and a monthly character cap. Both are counted in UTC — the daily one resets at 00:00 UTC, the monthly one on the first of the month. Exceeding either returns 429 quota_exceeded with period, used, limit and resets_at; what is left is in usage.key.
- Every number that applies to a key is reported by GET /account together with limit_sources — a map saying where each one came from: key (set on this key), plan (inherited from the owner's plan) or default (a service value that exists in neither).
Idempotency
Pass idempotency_key in the body or an Idempotency-Key header and a retry becomes safe: the same key with the same parameters returns the original job instead of creating — and charging for — a second one. The same key with different parameters returns 409 idempotency_mismatch; a key whose original result has been deleted returns 409 idempotency_key_consumed. Without a key, every call is a new job. On clone orders the header is mandatory, for a blunt reason: a repeated order costs a slot at the provider, and no retry gives that slot back.
Voice cloning over the API
A clone is ordered asynchronously, just like a synthesis job: POST /voice-clones answers 202 with our request id, and the client polls GET /voice-clones/{id} no more often than poll_after_seconds says. Building a voice takes minutes, not seconds. Once status is completed, voice_id is what you pass to POST /speech.
- Two modes. A private clone is built from your own sample (multipart upload: mp3, wav, m4a or webm, up to 15 MB; the format is checked against the real bytes, not the file name). A language variant of a catalog voice is ordered as JSON — a link to a file is deliberately not accepted.
- A language variant is SHARED: the built voice is visible to every user of the service and cannot be deleted, so the order requires an explicit acknowledge_shared: true. What you actually got is always stated in the visibility field of the response — private or shared.
- consent is required in both forms: a 20–500 character statement of your right to this voice. It is stored with the request, because outside the dashboard it is the only record of who claimed the voice. Clone only your own voice or one you have written permission to use.
- Not every provider can build every kind of clone. Where the notion does not exist, the answer is 501 clone_not_supported_for_provider — returned before the file is uploaded, so nothing is sent and nothing is reserved.
- A private clone is not in the public catalog and has no default engine: when synthesizing with it, pass voice_engine explicitly.
- DELETE /voice-clones/{id} frees the provider slot and is idempotent (204, no body). While the provider still holds the request it returns 409 clone_in_progress; a shared language variant cannot be deleted at all.
# Order a private clone from your own sample.
# The text fields MUST come before the file, or 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: another-unique-request-id" \
-F "name=Narrator" \
-F "consent=I confirm this is my own voice and I may clone it." \
-F "languages=en,de" \
-F "[email protected]"
# → 202 { "id": "CLONE_ID", "status": "queued", "visibility": "private",
# "percent": 0, "poll_after_seconds": 15 }
# Poll until completed, then synthesize with the resulting voice_id.
# A private clone has no default engine, so pass voice_engine explicitly.
curl -X POST https://api.tonvio.net/api/v1/speech \
-H "Authorization: Bearer tvk_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "text": "Hello!", "voice_id": "VOICE_ID", "voice_engine": "ENGINE_ID" }'Errors
Every failure has the same envelope: a machine error code plus whatever fields that code carries, with a matching HTTP status. Branch on the code, never on the wording of message. Keep the X-Request-Id response header for support. The full list of codes per endpoint is in the OpenAPI spec; the ones an integration meets most often are below.
| Code | Meaning |
|---|---|
invalid_request | The body is invalid — see message. |
missing_api_key | No key was sent. |
invalid_api_key | The key is unknown or revoked. |
api_key_expired | The key has passed its expiry date. |
api_key_frozen | All rights were removed from the key. Reversible — ask the service owner. |
insufficient_scope | The key has no right to this endpoint; required lists the rights that would work. |
insufficient_balance | Not enough purchased characters — need says how many the request costs. |
rate_limited | Too many requests for this key. Wait retry_after seconds. |
too_many_jobs | No free execution slot; scope says whose ceiling fired — account or key. |
quota_exceeded | The daily or monthly character volume of this key is used up — see period, used, limit and resets_at. |
voice_not_allowed | That voice cannot be used with this key. |
voice_warming | The voice is warming up — retry shortly. |
voice_language_not_available | The voice does not speak the requested language; available lists the ones it does. |
voice_engine_required | The voice has no default engine — pass voice_engine explicitly. |
text_too_long | The text exceeds the request limit of the plan; max says what it is. |
payload_too_large | The request body exceeds the limit; max_bytes says what it is. |
idempotency_mismatch | This idempotency key was already used with different parameters. |
generation_unavailable | Generation is temporarily unavailable — retry after the indicated delay. |
internal_error | Unexpected server error. Retry later and give support the request_id. |
There is a second family of failures, and it is the one an integration meets most: the request was accepted (202) and the work failed afterwards. Those arrive in error_code of GET /speech/{id} with status failed — queue_timeout, execution_timeout, provider_unavailable, voice_unavailable, synthesis_failed, assembly_failed, output_too_large, or generation_failed for anything else. Reserved credits are refunded in every one of them, and error_message is an English sentence you can show to a person.
Machine-readable spec
The whole contract is published as a single OpenAPI 3.1 document: every endpoint, parameter, response shape, error code and both authentication schemes. It is the same source the reference above is written from.
- Generate a client in your language instead of writing HTTP calls by hand.
- Import it into an HTTP client to get every request pre-filled.
- Hand it to an AI assistant — it is a self-contained description of the service.
Validated against the OpenAPI 3.1 meta-schema on every change.
The spec is a plain JSON file — feed it to a code generator, an HTTP client or an AI assistant and it just works.
Questions engineers ask first
Can I read the documentation without an account?
Yes — this page and the OpenAPI spec are public. An account is needed only to create a key and spend credits, so you can size up the integration, generate a client and review the error contract before anyone signs anything.
Is the API synchronous?
Synthesis of a long text takes minutes, and a request held open that long dies in a proxy or a CDN — so POST /speech returns a job id immediately. You then have three ways to learn the outcome, in ascending order of comfort: poll GET /speech/{id} no more often than its poll_after_seconds says; add ?wait=60 to hold the request open until the job is done (long polling); or set a webhook address on the key and let us call you on speech.completed and speech.failed. Webhooks are signed with HMAC-SHA256 and retried, but they are a signal, not storage — the audio link they carry is short-lived, so fetch a fresh one from GET /speech/{id} if you did not download it at once.
What does an API call cost?
Characters. One character of text equals one credit, taken from purchased credit packs only — subscriptions and bonus credits do not pay for the API. Read requests are free. Credits are reserved when a job is accepted and released if it fails or is cancelled; the price list is on the pricing page.
What are the rate limits?
300 requests per minute per key by default, with an allowance that absorbs short bursts; a key can be given a figure of its own. How many jobs run at once follows the plan and the key's limits, and a key may also carry daily and monthly character caps. The values actually in force are in the RateLimit-* response headers and in GET /account.
Can one key be restricted?
Yes. A key carries a set of rights — for example read-only access to job status for a monitoring service — and can be given its own rate, slots, character caps, allowed providers, clone quota and an expiry date. A key with all rights removed is frozen and answers 403 until the rights come back.
How do I make retries safe?
Send an Idempotency-Key. The same key with the same parameters returns the original job rather than creating a second one, so a network timeout never turns into a double charge. On clone orders the header is mandatory, because a repeated order costs a slot at the provider.
Can I clone a voice through the API?
Yes, with the clone right on the key. Upload a sample and poll the request until it is completed, then use the resulting voice_id like any other voice. Clone only your own voice or one you have written permission to use — every order stores your consent statement.
How long does the audio link live?
About six hours. Download the file when the job completes; if you need the link later, ask for the job again and a fresh one is issued. Finished audio is also kept in your dashboard history.
Start with one request
Create a key in the dashboard and the first call takes a minute. The first voiceover is free, so you can test the whole loop — create, poll, download — before spending anything.
New to tonvio? Start from the AI text to speech overview.