https://api.echonova.aiDevelopers
Text to Speech quickstart
List the voices, turn one Arabic sentence into audio with the synchronous endpoint, save the file, then run the same request as an asynchronous job and poll it.
In this tutorial you make four calls: list the voices, generate one Arabic sentence with the synchronous endpoint, save the audio file, then run the same sentence as an asynchronous job and poll it until it completes. At the end you have a working request for each path and know what each one returns.
What you need
- An API key. See Get an API key. The key needs Access to Text to Speech, or no service restriction at all.
- The base URL. The callout at the top of this page shows the host these samples call. See Base URL.
- A workspace with credits. Every generation is charged before it runs. See Credits.
- cURL with
jq, Python 3 withrequests, or Node 18 or later. The samples useYOUR_API_KEYwhere the secret goes.
Steps
Step 1: List the voices
Every generation needs a voiceId. This call returns every active voice,
newest first, with no paging and no parameters:
curl https://api.echonova.ai/v1/text-to-speech/voices \
-H "Authorization: Bearer YOUR_API_KEY"import requests
response = requests.get(
"https://api.echonova.ai/v1/text-to-speech/voices",
headers={"Authorization": "Bearer YOUR_API_KEY"},
)
voices = response.json()["data"]["items"]
for voice in voices:
print(voice["id"], voice["language"], voice["displayName"])const response = await fetch('https://api.echonova.ai/v1/text-to-speech/voices', {
headers: { Authorization: 'Bearer YOUR_API_KEY' },
});
const { data } = await response.json();
for (const voice of data.items) {
console.log(voice.id, voice.language, voice.displayName);
}The response is wrapped in the standard envelope. data.items holds the
voices and data.total the count:
{
"data": {
"items": [
{
"id": "66e5ef3a9c1d2b4f8a7e6c01",
"name": "ae_ahmed",
"displayName": "Ahmed",
"language": "ar",
"gender": "Male"
}
],
"total": 1
},
"success": true,
"statusCode": 200,
"errorMessage": null
}Pick a voice whose language is ar and keep its id. The rest of this
tutorial calls it VOICE_ID. The fields are explained on
Text to Speech voices and options.
Step 2: Generate speech synchronously
Send the text and the voice id to POST /v1/text-to-speech/sync. The call
blocks until the audio is ready and returns a link to the file in the same
response. The sample sentence is 41 characters of plain Arabic:
curl -X POST https://api.echonova.ai/v1/text-to-speech/sync \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "الطقس اليوم جميل والسماء صافية في المدينة",
"voiceId": "VOICE_ID"
}'import requests
response = requests.post(
"https://api.echonova.ai/v1/text-to-speech/sync",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={
"text": "الطقس اليوم جميل والسماء صافية في المدينة",
"voiceId": "VOICE_ID",
},
)
result = response.json()["data"]
audio_url = result["audioUrl"]
print(result["status"], result["durationSeconds"], result["creditsConsumed"])const response = await fetch('https://api.echonova.ai/v1/text-to-speech/sync', {
method: 'POST',
headers: {
Authorization: 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: 'الطقس اليوم جميل والسماء صافية في المدينة',
voiceId: 'VOICE_ID',
}),
});
const { data: result } = await response.json();
const audioUrl = result.audioUrl;
console.log(result.status, result.durationSeconds, result.creditsConsumed);The response:
{
"data": {
"jobId": "66e5f0c2a1b2c3d4e5f60718",
"status": "completed",
"audioUrl": "https://storage.example.com/tts/tts-output.wav?X-Amz-Signature=...",
"durationSeconds": 3.4,
"creditsConsumed": 41
},
"success": true,
"statusCode": 200,
"errorMessage": null
}speed, cfg, numSteps and tashkeel were left out, so the defaults
applied. The sentence has 41 characters, spaces included, so it costs 41 credits. See Credits for
the rate.
Step 3: Save the audio
audioUrl is a signed link to a WAV file. Fetch it with a plain GET and no
Authorization header:
# AUDIO_URL is data.audioUrl from Step 2
curl -o speech.wav "$AUDIO_URL"import requests
audio = requests.get(audio_url)
with open("speech.wav", "wb") as f:
f.write(audio.content)import { writeFile } from 'node:fs/promises';
const audio = await fetch(audioUrl);
await writeFile('speech.wav', Buffer.from(await audio.arrayBuffer()));Open speech.wav in any player to hear the sentence.
Treat audioUrl as a download instruction, not as an address to keep.
Recommended
- Download the file when the response arrives, store it on your side, and serve it from there.
Less effective
- Paste
audioUrlinto a page or a message that will be opened later, and find it expired.
The link expires. Download the file when you receive the response. If a
link has expired, call
GET /v1/text-to-speech/jobs/{jobId} with the jobId from the response;
every call returns a fresh link.
Step 4: Run the same request asynchronously and poll
POST /v1/text-to-speech takes the same body but returns at once with a job
id. The audio is produced in the background. Send the request:
curl -X POST https://api.echonova.ai/v1/text-to-speech \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "الطقس اليوم جميل والسماء صافية في المدينة",
"voiceId": "VOICE_ID"
}'import requests
response = requests.post(
"https://api.echonova.ai/v1/text-to-speech",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={
"text": "الطقس اليوم جميل والسماء صافية في المدينة",
"voiceId": "VOICE_ID",
},
)
job_id = response.json()["data"]["jobId"]const response = await fetch('https://api.echonova.ai/v1/text-to-speech', {
method: 'POST',
headers: {
Authorization: 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: 'الطقس اليوم جميل والسماء صافية في المدينة',
voiceId: 'VOICE_ID',
}),
});
const { data: job } = await response.json();
const jobId = job.jobId;The response carries the job id and its status, which is processing once
the job is accepted:
{
"data": {
"jobId": "66e5f0c2a1b2c3d4e5f60718",
"status": "processing"
},
"success": true,
"statusCode": 200,
"errorMessage": null
}Poll GET /v1/text-to-speech/jobs/{jobId} until status is completed or
failed. Each poll counts against the rate limit of 100 requests per 60
seconds per key on that endpoint, so wait a few seconds between calls:
# JOB_ID is data.jobId from the request above
while true; do
STATUS=$(curl -s https://api.echonova.ai/v1/text-to-speech/jobs/$JOB_ID \
-H "Authorization: Bearer YOUR_API_KEY" | jq -r '.data.status')
echo "$STATUS"
if [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ]; then break; fi
sleep 3
done
curl -s https://api.echonova.ai/v1/text-to-speech/jobs/$JOB_ID \
-H "Authorization: Bearer YOUR_API_KEY"import time
import requests
while True:
job = requests.get(
f"https://api.echonova.ai/v1/text-to-speech/jobs/{job_id}",
headers={"Authorization": "Bearer YOUR_API_KEY"},
).json()["data"]
print(job["status"])
if job["status"] in ("completed", "failed"):
break
time.sleep(3)
if job["status"] == "completed":
audio_url = job["audioUrl"]
else:
print(job["error"])const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let job;
while (true) {
const poll = await fetch(`https://api.echonova.ai/v1/text-to-speech/jobs/${jobId}`, {
headers: { Authorization: 'Bearer YOUR_API_KEY' },
});
job = (await poll.json()).data;
console.log(job.status);
if (job.status === 'completed' || job.status === 'failed') break;
await sleep(3000);
}
if (job.status === 'completed') {
const audioUrl = job.audioUrl;
} else {
console.error(job.error);
}A completed job looks like this. Note that the job object names its id id,
while the response that started it named it jobId:
{
"data": {
"id": "66e5f0c2a1b2c3d4e5f60718",
"status": "completed",
"text": "الطقس اليوم جميل والسماء صافية في المدينة",
"voiceId": "66e5ef3a9c1d2b4f8a7e6c01",
"audioUrl": "https://storage.example.com/tts/tts-output.wav?X-Amz-Signature=...",
"durationSeconds": 3.4,
"creditsConsumed": 41,
"createdAt": "2026-09-15T10:21:04.512Z"
},
"success": true,
"statusCode": 200,
"errorMessage": null
}While the job is pending or processing, audioUrl and durationSeconds
are not in the object. When it is failed, error holds the reason and the
credits are refunded to the workspace. A job that stays processing for
longer than 10 minutes is marked failed within 15 minutes of the POST,
so a poll loop always ends. Save the audio exactly as in Step 3.
Instead of polling, register a webhook endpoint and receive job.completed
or job.failed with the same audioUrl. See
Configure a webhook and
Webhook events. Synchronous calls never
emit these events.
What you get
- One audio file per call, reachable through a signed
audioUrlfor a limited time. The file stays attached to the job;GET /v1/text-to-speech/jobs/{jobId}returns a fresh link on every call. - A job for both paths. The synchronous call creates it already
completed; the asynchronous call moves it throughpending,processingand thencompletedorfailed. creditsConsumedon every job: the exact number of credits charged for that text. A failed job is refunded.GET /v1/text-to-speech/jobslists the API jobs of the workspace, newest first, 20 per page by default and 100 at most. Any key in the workspace with Text to Speech access sees them, whichever key created them, and a key without that access gets403.GET /v1/text-to-speech/jobs/{jobId}returns404for a job of another workspace.
Limits
| Limit | Value | Applies to |
|---|---|---|
text length |
1 to 500 characters, after leading and trailing whitespace is trimmed | Both endpoints |
speed |
0.5 to 2, default 1 | Both endpoints |
cfg |
1 to 5, default 2 | Both endpoints |
numSteps |
One of 16, 32, 48, 64, 128, default 48 | Both endpoints |
| Requests | 100 per 60 seconds per key on each endpoint | All calls, including polls |
| Jobs per page | limit 1 to 100, default 20; offset 0 or more, default 0 |
GET /v1/text-to-speech/jobs |
The full option table is on Text to Speech voices and options.
Troubleshooting
400 text must be shorter than or equal to 500 characters
The text field is over the limit. Split the text and make one call per
part, or use the asynchronous endpoint for each part.
400 Invalid identifier
voiceId or jobId is not a valid id. Use the id value exactly as the
voices call or the job response returned it.
404 Voice not found
No active voice has that id. List the voices again; a voice can be withdrawn.
404 TTS Job not found
The job id is unknown, or the job belongs to another workspace or was made in the portal. Any API key in the workspace with Text to Speech access can read its API jobs.
403 This API key does not have access to Text-to-Speech services
The key restricts services and Text to Speech is not among them. Edit the key in the portal and grant Access to Text to Speech.
402 Insufficient subscription balance
The workspace balance cannot cover the charge for this text. Nothing was generated. See Credits.
429 Monthly spending limit exhausted
The key's own credit limit for this billing cycle would be exceeded. Raise or
clear the limit on the key. This is not the request rate limit; that 429
carries a Retry-After header and is explained on
Rate limits.
503 Speech generation is temporarily unavailable. Please try again.
Speech generation was unavailable for the request. The charge is refunded. Retry after a short wait.
The job ends as failed
error on the job holds the reason, or AI server generation failed when
none was given. The credits are refunded. Retry the request; if it fails
again with the same text, shorten it or try another voice.
The job ends as failed with Job timed out
The job stayed processing for more than 10 minutes without a result. It
is marked failed with error set to
Job timed out — AI server did not respond, within 15 minutes of the
POST, and the credits are refunded. Submit the request again.
FAQ
Which endpoint should I use in your live app?
The synchronous one when a person is waiting for the audio, and the asynchronous one with a webhook for batches. See Synchronous and asynchronous calls.
How long is the audio link valid?
It expires, so download the file as soon as you have the link. Every call
to GET /v1/text-to-speech/jobs/{jobId} returns a fresh one.
Can I choose the audio format?
No. The file is WAV.
Do I pay for a failed job?
No. The charge is refunded when the job fails, and creditsConsumed keeps
the amount that was charged and refunded.
Can I send English text?
Use a voice whose language is en, when the voices call lists one. The
voice sets the language; text takes any string up to 500 characters.
Related
- Text to Speech voices and options for every request option and the voice fields.
- Synchronous and asynchronous calls to choose between the two paths.
- Code samples for full working programs.
- Configure a webhook to replace
polling with
job.completedevents. - Credits for what a call costs.
- Rate limits before you send many calls in parallel.
- The reference pages: Generate speech synchronously, Generate speech from text, List available voices, Get TTS job details and List Text to Speech jobs.