> For the full page index, fetch https://echonova.ai/llms.txt

# 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](/docs/developers/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](/docs/developers/base-url).
- A workspace with credits. Every generation is charged before it runs. See
  [Credits](/docs/developers/credits).
- cURL with `jq`, Python 3 with `requests`, or Node 18 or later. The samples
  use `YOUR_API_KEY` where 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:

:::tabs

```bash title="cURL"
curl https://api.echonova.ai/v1/text-to-speech/voices \
  -H "Authorization: Bearer YOUR_API_KEY"
```

```python
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"])
```

```javascript
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:

```json
{
  "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](/docs/developers/tts-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:

:::tabs

```bash title="cURL"
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"
  }'
```

```python
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"])
```

```javascript
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:

```json
{
  "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](/docs/developers/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:

:::tabs

```bash title="cURL"
# AUDIO_URL is data.audioUrl from Step 2
curl -o speech.wav "$AUDIO_URL"
```

```python
import requests

audio = requests.get(audio_url)
with open("speech.wav", "wb") as f:
    f.write(audio.content)
```

```javascript
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 `audioUrl` into a page or a message that will be opened later, and
  find it expired.

> [!NOTE]
> 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:

:::tabs

```bash title="cURL"
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"
  }'
```

```python
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"]
```

```javascript
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:

```json
{
  "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:

:::tabs

```bash title="cURL"
# 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"
```

```python
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"])
```

```javascript
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`:

```json
{
  "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.

> [!TIP]
> Instead of polling, register a webhook endpoint and receive `job.completed`
> or `job.failed` with the same `audioUrl`. See
> [Configure a webhook](/docs/developers/webhooks-configure) and
> [Webhook events](/docs/developers/webhooks-events). Synchronous calls never
> emit these events.

## What you get

- One audio file per call, reachable through a signed `audioUrl` for 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 through `pending`, `processing`
  and then `completed` or `failed`.
- `creditsConsumed` on every job: the exact number of credits charged for that
  text. A failed job is refunded.
- `GET /v1/text-to-speech/jobs` lists 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 gets `403`. `GET /v1/text-to-speech/jobs/{jobId}`
  returns `404` for 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](/docs/developers/tts-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](/docs/developers/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](/docs/developers/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](/docs/developers/sync-vs-async).

### 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](/docs/developers/tts-voices-and-options)
  for every request option and the voice fields.
- [Synchronous and asynchronous calls](/docs/developers/sync-vs-async) to
  choose between the two paths.
- [Code samples](/docs/developers/code-samples) for full working programs.
- [Configure a webhook](/docs/developers/webhooks-configure) to replace
  polling with `job.completed` events.
- [Credits](/docs/developers/credits) for what a call costs.
- [Rate limits](/docs/developers/rate-limits) before you send many calls in
  parallel.
- The reference pages:
  [Generate speech synchronously](/docs/reference/tts-generate-sync),
  [Generate speech from text](/docs/reference/tts-generate-async),
  [List available voices](/docs/reference/tts-list-voices),
  [Get TTS job details](/docs/reference/tts-get-job) and
  [List Text to Speech jobs](/docs/reference/tts-list-jobs).
