Docs / API
The VoiceAct API
Everything the Studio does with a script, over HTTP: 50 native voices in 10 languages, Voice Design, cloning, your saved voices, timing marks, word timings and subtitles, live audio, and files in MP3, WAV or 8 kHz telephone formats. It also speaks OpenAI's speech format, so existing code can switch with two lines. You send text, get a job back, and download or stream the audio. Base URL: https://api.voiceact.ai/v1
Keys work on every plan, the free one included. API jobs use the same characters and limits as the Studio and show up in your history. The machine-readable description is at /v1/openapi.json.
Quick start
Create a key on your dashboard, then make a short line and wait for it. With wait the answer comes back finished for short texts; without it you get the job at once and poll.
export VOICEACT_API_KEY=va_... # from voiceact.ai/dashboard/api
curl https://api.voiceact.ai/v1/speech \
-H "Authorization: Bearer $VOICEACT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "Welcome to the show.", "voice": "en_grace_soft_narrator", "wait": 30}'The answer is the job. When status is completed, download the file (replace JOB_ID with its id):
curl -L https://api.voiceact.ai/v1/speech/JOB_ID/audio \
-H "Authorization: Bearer $VOICEACT_API_KEY" -o welcome.mp3Python
import os, time, requests
BASE = "https://api.voiceact.ai/v1"
H = {"Authorization": f"Bearer {os.environ['VOICEACT_API_KEY']}"}
job = requests.post(f"{BASE}/speech", headers=H, json={
"text": "Willkommen bei unserem Kundenservice.",
"voice": "de_jonas_newscaster", # German voice, so German by default
"format": "wav",
}).json()
while job["status"] in ("queued", "processing"):
time.sleep(2)
job = requests.get(f"{BASE}/speech/{job['id']}", headers=H).json()
if job["status"] == "failed":
raise RuntimeError(job["error"]["message"]) # failed jobs aren't charged
audio = requests.get(job["audio"]["url"], headers=H)
open("welcome.wav", "wb").write(audio.content)Node.js
import { writeFile } from "node:fs/promises";
const H = { Authorization: `Bearer ${process.env.VOICEACT_API_KEY}` };
const res = await fetch("https://api.voiceact.ai/v1/speech", {
method: "POST",
headers: { ...H, "Content-Type": "application/json" },
body: JSON.stringify({ text: "Your order has shipped.", voice: "en_grace_soft_narrator", wait: 60 }),
});
const job = await res.json();
if (!res.ok) throw new Error(job.error.message);
if (job.status !== "completed") throw new Error(`still ${job.status}: poll GET /v1/speech/${job.id}`);
const audio = await fetch(job.audio.url, { headers: H });
await writeFile("shipped.mp3", Buffer.from(await audio.arrayBuffer()));Authentication
Send your key in the Authorization header: Bearer va_…. A key acts as your account. Keep it on your server: anyone who has it can spend your characters. If one leaks, revoke it on the dashboard; it stops working at once. You can have 10 active keys, so give each server or project its own.
The API doesn't answer browsers on other websites (no CORS), which keeps keys out of web pages. Call it from your backend.
Create speech
POST /v1/speech with a JSON body. Give either voice or voice_description.
| Field | What it does |
|---|---|
text | What to say. Timing marks work as in the Studio (see below). The longest text per request depends on your plan. |
voice | A voice id from GET /v1/voices: a preset like en_grace_soft_narrator, or one of your saved voices. |
voice_description | Voice Design: describe a voice instead, up to 500 characters. Real people can't be imitated. |
language | A name (German), a code (de) or auto to detect it from the text. Default: the voice's own language, else English. |
format | The format the job's audio link points to (default mp3). Every format stays downloadable. |
wait | Wait up to this many seconds (0–60) for the audio. Finished in time: 200 with the finished job. Otherwise 202 and you poll. |
Unknown fields are an error, so a typo in a field name doesn't silently do nothing.
Retries without double charges
Send an Idempotency-Key header (any string up to 100 characters, such as your own order id). If the same key arrives again within 24 hours with the same body, you get the first job back instead of a second one, marked with Idempotent-Replayed: true. The same key with a different body is a 422.
The job
GET /v1/speech/{id} returns the job. status goes from queued to processing to completed or failed. Generation runs at about twice real time once a job starts, so a minute of speech takes roughly 30 seconds.
{
"id": "3f6c1e0a-9b1d-4d2e-8a51-0c2f6d0e7b11",
"object": "speech",
"status": "completed",
"created_at": "2026-10-28T20:14:03.512Z",
"completed_at": "2026-10-28T20:14:06.020Z",
"language": "English",
"voice": { "id": "en_grace_soft_narrator", "type": "preset", "description": null },
"text_length": 20,
"characters": 25,
"duration_seconds": 1.62,
"queue_position": null,
"audio": { "format": "mp3", "url": "https://api.voiceact.ai/v1/speech/3f6c…/audio?format=mp3" },
"words": null,
"words_ready": true,
"speech_check": "passed",
"error": null,
"source": "api"
}characters is what the job costs: an estimate until it finishes, then the amount charged. speech_check comes from our listener: every take is transcribed and compared with your text. flagged means it may contain a misread word; it shows up shortly after completion, and stays null until then. queue_position counts your own jobs ahead of this one.
A flagged job can be made again once for free within 7 days: POST /v1/speech/{id}/regenerate when free_retake_available is true. You get a new job with the same text and voice; its retake_of points to the first one.
GET /v1/speech lists your jobs, newest first, with limit (up to 100), cursor (the next_cursor of the previous page) and source (api, studio or all).
Download audio
GET /v1/speech/{id}/audio?format=… returns the file. Each format is made from the 24 kHz master the first time you ask for it.
| format | File | For |
|---|---|---|
mp3 | MP3, 128 kbps, 24 kHz mono | web, apps, video |
wav | WAV, 16-bit, 24 kHz mono (the master) | editing, highest quality |
wav_16000 | WAV, 16-bit, 16 kHz | wideband telephony, speech tools |
wav_8000 | WAV, 16-bit, 8 kHz | phone systems that want linear PCM (e.g. Asterisk) |
ulaw_8000 | WAV, G.711 µ-law, 8 kHz | telephony in North America and Japan |
alaw_8000 | WAV, G.711 A-law, 8 kHz | telephony in Europe and most other regions |
The 8 and 16 kHz files are filtered before they're downsampled, so nothing above the new limit folds back into the speech band. Every file carries a signed C2PA manifest that marks it as AI-generated, and the 24 kHz files also carry an inaudible watermark. The watermark doesn't survive 8 kHz audio (we measured it), so telephone files rely on the manifest.
Live audio
GET /v1/speech/{id}/stream sends the audio while it's being made. Open it right after creating the job: it replays from the start, so nothing is lost if you connect late. In our tests the first audio arrives about 1.5 seconds after the request, about half a second of which is the model itself. For a finished job you get the stored file in the same format.
# Live audio while it generates: raw 16-bit PCM, 24 kHz mono
curl -sN "https://api.voiceact.ai/v1/speech/JOB_ID/stream" \
-H "Authorization: Bearer $VOICEACT_API_KEY" \
| ffplay -f s16le -ar 24000 -nodisp -autoexit -format picks the samples: pcm_24000 (default), pcm_16000, pcm_8000 (16-bit little-endian), ulaw_8000 or alaw_8000. The response headers X-Audio-Format and X-Sample-Rate repeat what you get.
job = requests.post(f"{BASE}/speech", headers=H, json={
"text": "Thanks for calling. Your balance is twelve euros and forty cents.",
"voice": "en_grace_soft_narrator",
}).json()
# Open the stream right away: it replays from the start, so nothing is missed
with requests.get(f"{BASE}/speech/{job['id']}/stream", headers=H,
params={"format": "ulaw_8000"}, stream=True, timeout=120) as r:
r.raise_for_status() # errors before the first audio are JSON
for chunk in r.iter_content(1600): # 0.2 s of 8 kHz µ-law
send_to_call(chunk) # your telephony codeThe raw stream starts with the first audio, so an error before that (a failed job) is a normal JSON error. If a job hasn't started after 90 seconds you get 503 still_queued; open the stream again. A failure after audio has started ends the transfer early, and your HTTP client reports an incomplete response. Check the job afterwards to be sure it completed.
If you'd rather have explicit events, add transport=sse: Server-Sent Events with base64 audio, keepalive comments while the job waits, and a final end or error event.
event: start
data: {"format":"pcm_24000","sample_rate":24000,"encoding":"pcm_s16le","channels":1}
event: audio
data: {"audio":"<base64 PCM>"}
: keepalive
event: end
data: {"duration_seconds":4.2,"live":true}If the server retries a failed part of a job, the audio you already have is skipped. If the retry would change audio you already received, the stream stops with stream_interrupted instead of repeating it; the job carries on and the file is complete. The stream ends a moment before the job is marked completed, so check the job before downloading the file. Up to 10 streams can be open per account.
Voices
GET /v1/voices lists the presets and your saved voices, with ?language=de to see the voices native to one language. Every voice can speak all 10 languages, but a native voice sounds right in its own: an English voice speaking German keeps an English accent. The ten are English, German, French, Spanish, Italian, Portuguese, Russian, Chinese, Japanese and Korean.
voice_description in a speech request makes a new voice every time. For the same voice every time, design it and keep it, or clone one; voices you saved in the Studio work too. GET /v1/voices/{id} shows one voice, PATCH renames a saved voice ({"name": "…"}) and DELETE removes it, with its recording for a clone. How many voices you can keep depends on your plan.
Design a voice
Describe a voice and get three short takes to choose from, the same as casting in the Studio. Each take is measured against your description (gender, adult, pitch, pace) and the best match is marked. Keep one, and it becomes a saved voice that sounds the same in every job. A design costs a flat 100 characters, refunded if every take fails, and is kept for 24 hours.
# 1. Three short takes of a described voice (100 characters)
curl https://api.voiceact.ai/v1/voices/design \
-H "Authorization: Bearer $VOICEACT_API_KEY" -H "Content-Type: application/json" \
-d '{"description": "warm, low female voice, calm and unhurried", "wait": 30}'
# 2. Listen to takes[].audio_url, then keep the one you like
curl https://api.voiceact.ai/v1/voices/design/DESIGN_ID/save \
-H "Authorization: Bearer $VOICEACT_API_KEY" -H "Content-Type: application/json" \
-d '{"take": 1, "name": "Narrator"}'
# → {"id": "VOICE_ID", "type": "saved", "kind": "designed", ...}: use it as "voice"With text the takes speak the opening of your script, if it's short enough; otherwise a standard sentence in the language. Real people can't be imitated.
Clone a voice
Upload 10 to 60 seconds of one speaker (WAV, MP3, FLAC, OGG or M4A, up to 10 MB), with a transcript if you have one. We check the recording first for noise, echo and a second voice, and the voice is ready when the request returns. Cloning needs a paid plan or any purchase.
Every clone needs the speaker's permission. With each recording you confirm our consent statement: send consent=true and the statement's current consent_version from GET /v1/voices/clone/consent. If we change the statement, the old version stops working (409 consent_outdated) until you've read the new one. We keep a record of each confirmation.
# The statement you confirm, and its version
curl https://api.voiceact.ai/v1/voices/clone/consent -H "Authorization: Bearer $VOICEACT_API_KEY"
curl https://api.voiceact.ai/v1/voices/clone \
-H "Authorization: Bearer $VOICEACT_API_KEY" \
-F file=@speaker.wav \
-F name="Support line voice" \
-F consent=true \
-F consent_version=2026-10-18 \
-F transcript="Thanks for calling. How can I help you today?"Cloning through the API is new. If you get 403 cloning_not_available, clone in the Studio and use the saved voice's id.
OpenAI-compatible
Code written for OpenAI's speech endpoint works with VoiceAct by changing two things: the base URL and the key. POST /v1/audio/speech takes the same body and returns the audio itself.
from openai import OpenAI
client = OpenAI(base_url="https://api.voiceact.ai/v1", api_key=os.environ["VOICEACT_API_KEY"])
with client.audio.speech.with_streaming_response.create(
model="tts-1", # accepted, ignored
voice="nova", # or any VoiceAct voice id
input="Your order has shipped.",
) as response:
response.stream_to_file("shipped.mp3")OpenAI's voice names map to similar English presets: alloy Claire, ash Felix, ballad Omar, coral Sophia, echo Ben, fable Arthur, nova Amara, onyx James, sage Elena, shimmer Grace and verse Kai. Any VoiceAct voice id works as well, and the language is detected from the text (or set it with a language field).
response_format can be mp3, wav or pcm. pcm (24 kHz, 16-bit mono, the same as OpenAI's) streams while it's made; MP3 and WAV come when the job is done, for texts that take up to about 90 seconds. Longer texts: use POST /v1/speech. model is accepted and ignored; speed and instructions aren't supported, and the X-VoiceAct-Ignored header says when one was ignored.
Timing, words and subtitles
Timing marks go in the text: [pause 1.5s] adds silence, and [at 0:12.5] starts the next line at that time in the file. They aren't spoken and aren't billed. The timing guide has the details.
Word timings arrive a few seconds after the audio. Ask for them with GET /v1/speech/{id}?include=words once words_ready is true. Subtitles built from them: GET /v1/speech/{id}/subtitles?format=srt (or vtt).
Errors
Errors share one shape. Quote the request_id (also in the X-Request-Id header) when you write to us.
{
"error": {
"code": "insufficient_characters",
"message": "Not enough characters left: this generation needs 1,240, you have 310.",
"required": 1240,
"available": 310,
"request_id": "req_6a1f0c2e9b8d4f7a1c3e5b20"
}
}| HTTP | code | Meaning |
|---|---|---|
| 401 | missing_api_key, invalid_api_key | No key, a malformed header, or a revoked key |
| 400 | text_too_long | Over your plan's per-request limit (limit says how much) |
| 402 | insufficient_characters | Not enough characters left; top up or wait for the monthly refill |
| 403 | clone_requires_purchase, cloning_not_available, voice_limit_reached, preset_voice | Cloned voices need a paid plan or a purchase; API cloning is off; your plan's saved-voice limit; presets can't be changed |
| 404 | voice_not_found, not_found | No such voice, or no such job on your account |
| 409 | not_ready, voice_not_ready, take_not_ready, job_running, generation_failed, retake_not_available, name_taken, consent_outdated | Try again later, the job failed, or the request conflicts with what's there |
| 422 | invalid_request, nothing_to_speak, voice_description_refused, consent_required, idempotency_key_reused | Something in the request; errors lists each field |
| 429 | rate_limited, too_many_streams | Slow down; Retry-After says for how long |
| 503 / 504 | service_busy, still_queued, api_disabled, still_generating | Busy or paused; try again after Retry-After. 504: the OpenAI-compatible call ran past 90 s, the job continues |
| 500 | internal_error | Our fault; tell us the request_id |
Limits and billing
You pay in characters, from the same balance as the Studio (prices). Chinese, Japanese and Korean characters count three times, timing marks don't count, and a failed job costs nothing. GET /v1/account shows your plan, the characters left and your limits.
| Free | Starter | Creator | Studio | |
|---|---|---|---|---|
| Characters per request | 1,000 | 3,000 | 10,000 | 20,000 |
| Jobs per minute | 5 | 10 | 10 | 10 |
| Jobs running at once | 1 | 2 | 2 | 2 |
More jobs than that wait in your own queue; they aren't rejected. The limits are shared with the Studio. Every account can also make 300 API requests a minute in total, polling included (X-RateLimit-Remaining shows what's left). Free accounts get 3,000 characters a month. When it's very busy, free jobs can be turned away with a 503 while paid jobs always queue.
Need more per minute, bigger jobs, or invoices for a company? Write to support@voiceact.ai.
Your data
Texts and audio are processed in the EU: our servers are in Finland, and the GPUs are in Austria or rented in the EU. We don't train models on your texts or audio. Jobs stay in your history until you delete them, in the Studio or with DELETE /v1/speech/{id}, which removes the job and every file made from it. Details are in the privacy policy.
Not there yet
There are no webhooks yet, so poll or stream. There are no SDKs either: the API is plain HTTP and JSON, and the examples above are the whole client. Live audio goes through the same queue as everything else, so it isn't built for answering a caller within a few hundred milliseconds.
Using the audio in your own product, calls or videos is fine. If you want to build VoiceAct into a product where your customers make speech themselves, that counts as reselling access under our terms: write to us first and we'll sort it out.
API version 1, October 2026.