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

# Code samples

Two complete programs per language, an asynchronous Text to Speech job from request to saved audio file and an asynchronous Speech to Text job from upload to transcript, plus the response envelope they all read.

Each program on this page runs from top to bottom with one edit: your API
key. The first one turns a sentence into a saved audio file through an
asynchronous Text to Speech job. The second uploads an audio file, waits for
the asynchronous Speech to Text job, and prints the transcript. The
quickstarts explain each call one at a time; this page puts them together. A short third program cleans a recording.

## What you need

- An API key with **Access** to all three services, or with no service
  restriction. See [Get an API key](/docs/developers/get-an-api-key).
- The base URL. The callout at the top of this page
  shows the host these samples call. See
  [Base URL](/docs/developers/base-url).
- Credits on the workspace. Each job is charged before it runs. See
  [Credits](/docs/developers/credits).
- cURL with `jq`, Python 3 with `requests`, or Node 18 or later.
- For the second program, an audio file named `audio.mp3` of up to 10 minutes
  and 150 MB. For WAV or OGG, change `CONTENT_TYPE` to `audio/wav` or
  `audio/ogg`.

## Steps

### Step 1: Read the envelope once

Every response, success or error, is one JSON object with the same four
fields. A successful call returns HTTP `200`, including `POST` calls, and
the result is in `data`:

```json
{
  "success": true,
  "statusCode": 200,
  "errorMessage": null,
  "data": {}
}
```

On an error, `success` is `false`, `data` is `null`, `statusCode` matches
the HTTP status and `errorMessage` holds one string:

```json
{
  "data": null,
  "statusCode": 402,
  "success": false,
  "errorMessage": "Insufficient subscription balance"
}
```

Each program below wraps its calls in one helper that checks `success` and
returns `data`, so the rest of the code reads the result and nothing else.
The programs stop on the first error.
[Retry requests safely](/docs/developers/retries-and-idempotency) shows the
version that backs off and retries.

### Step 2: Text to Speech, from request to audio file

The program lists the voices and takes the first one whose `language` is
`ar`, starts an asynchronous job, polls it every 3 seconds until it is
`completed` or `failed`, then downloads `audioUrl` to `speech.wav`. The
link is signed, so the download sends no `Authorization` header.

:::tabs

```bash title="cURL"
set -eo pipefail
BASE_URL="https://api.echonova.ai"
AUTH="Authorization: Bearer YOUR_API_KEY"
TEXT="مرحبا بكم في إيكونوڤا"

# api METHOD PATH [curl options]: prints .data, or exits on an error envelope
api() {
  local method=$1 path=$2 body
  shift 2
  body=$(curl -s -X "$method" "$BASE_URL$path" -H "$AUTH" "$@")
  if [ "$(echo "$body" | jq -r '.success')" != "true" ]; then
    echo "error $(echo "$body" | jq -r '.statusCode'): $(echo "$body" | jq -r '.errorMessage')" >&2
    exit 1
  fi
  echo "$body" | jq '.data'
}

# 1. Pick an Arabic voice
VOICE_ID=$(api GET /v1/text-to-speech/voices \
  | jq -r '[.items[] | select(.language == "ar")][0].id')

# 2. Start the job
JOB_ID=$(api POST /v1/text-to-speech -H "Content-Type: application/json" \
  -d "$(jq -n --arg text "$TEXT" --arg voiceId "$VOICE_ID" '{text: $text, voiceId: $voiceId}')" \
  | jq -r '.jobId')
echo "job $JOB_ID"

# 3. Poll until it ends
while true; do
  JOB=$(api GET "/v1/text-to-speech/jobs/$JOB_ID")
  STATUS=$(echo "$JOB" | jq -r '.status')
  echo "$STATUS"
  if [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ]; then break; fi
  sleep 3
done
if [ "$STATUS" = "failed" ]; then
  echo "failed: $(echo "$JOB" | jq -r '.error')" >&2
  exit 1
fi

# 4. Download the audio. The link is signed; send no Authorization header.
curl -s -o speech.wav "$(echo "$JOB" | jq -r '.audioUrl')"
echo "saved speech.wav"
echo "$JOB" | jq '{durationSeconds, creditsConsumed}'
```

```python
import time
import requests

BASE_URL = "https://api.echonova.ai"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}
TEXT = "مرحبا بكم في إيكونوڤا"


def call(method, path, **kwargs):
    # Every response uses the same envelope: raise when success is false, return data.
    response = requests.request(
        method, f"{BASE_URL}{path}", headers=HEADERS, timeout=60, **kwargs
    )
    body = response.json()
    if not body["success"]:
        raise RuntimeError(f'{body["statusCode"]}: {body["errorMessage"]}')
    return body["data"]


# 1. Pick an Arabic voice
voices = call("GET", "/v1/text-to-speech/voices")["items"]
voice_id = next(voice["id"] for voice in voices if voice["language"] == "ar")

# 2. Start the job
job_id = call("POST", "/v1/text-to-speech", json={"text": TEXT, "voiceId": voice_id})["jobId"]
print("job", job_id)

# 3. Poll until it ends
while True:
    job = call("GET", f"/v1/text-to-speech/jobs/{job_id}")
    print(job["status"])
    if job["status"] in ("completed", "failed"):
        break
    time.sleep(3)

if job["status"] == "failed":
    raise SystemExit(f'failed: {job.get("error")}')

# 4. Download the audio. The link is signed; send no Authorization header.
audio = requests.get(job["audioUrl"], timeout=60)
audio.raise_for_status()
with open("speech.wav", "wb") as f:
    f.write(audio.content)
print("saved speech.wav")
print(job["durationSeconds"], "seconds,", job["creditsConsumed"], "credits")
```

```javascript
import { writeFile } from 'node:fs/promises';

const BASE_URL = 'https://api.echonova.ai';
const HEADERS = { Authorization: 'Bearer YOUR_API_KEY' };
const TEXT = 'مرحبا بكم في إيكونوڤا';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Every response uses the same envelope: throw when success is false, return data.
async function call(path, init = {}) {
  const response = await fetch(`${BASE_URL}${path}`, {
    ...init,
    headers: { ...HEADERS, ...(init.headers ?? {}) },
  });
  const body = await response.json();
  if (!body.success) throw new Error(`${body.statusCode}: ${body.errorMessage}`);
  return body.data;
}

// 1. Pick an Arabic voice
const { items: voices } = await call('/v1/text-to-speech/voices');
const voiceId = voices.find((voice) => voice.language === 'ar').id;

// 2. Start the job
const { jobId } = await call('/v1/text-to-speech', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ text: TEXT, voiceId }),
});
console.log('job', jobId);

// 3. Poll until it ends
let job;
for (;;) {
  job = await call(`/v1/text-to-speech/jobs/${jobId}`);
  console.log(job.status);
  if (job.status === 'completed' || job.status === 'failed') break;
  await sleep(3000);
}
if (job.status === 'failed') throw new Error(`failed: ${job.error}`);

// 4. Download the audio. The link is signed; send no Authorization header.
const audio = await fetch(job.audioUrl);
if (!audio.ok) throw new Error(`download failed: ${audio.status}`);
await writeFile('speech.wav', Buffer.from(await audio.arrayBuffer()));
console.log('saved speech.wav');
console.log(job.durationSeconds, 'seconds,', job.creditsConsumed, 'credits');
```

:::

The `POST` returns `{ "jobId", "status" }` with `status` set to
`processing`. The polled job object names its id `id` and adds `text`,
`voiceId`, `createdAt` and `creditsConsumed`; `audioUrl` and
`durationSeconds` appear when the job is `completed`, and `error` when it
is `failed`. The file is WAV. See
[Text to Speech quickstart](/docs/developers/tts-quickstart) for each call
on its own and
[Text to Speech voices and options](/docs/developers/tts-voices-and-options)
for `speed`, `cfg`, `numSteps` and `tashkeel`.

### Step 3: Speech to Text, from upload to transcript

The program sends `audio.mp3` as the `file` part of a `multipart/form-data`
request, polls the job every 5 seconds, and prints `transcription` and
`durationSeconds` when it is `completed`. The `Content-Type` of the `file`
part must start with `audio/`; each sample sets it explicitly.

:::tabs

```bash title="cURL"
set -eo pipefail
BASE_URL="https://api.echonova.ai"
AUTH="Authorization: Bearer YOUR_API_KEY"
AUDIO_FILE="audio.mp3"
CONTENT_TYPE="audio/mpeg"

# api METHOD PATH [curl options]: prints .data, or exits on an error envelope
api() {
  local method=$1 path=$2 body
  shift 2
  body=$(curl -s -X "$method" "$BASE_URL$path" -H "$AUTH" "$@")
  if [ "$(echo "$body" | jq -r '.success')" != "true" ]; then
    echo "error $(echo "$body" | jq -r '.statusCode'): $(echo "$body" | jq -r '.errorMessage')" >&2
    exit 1
  fi
  echo "$body" | jq '.data'
}

# 1. Upload the file and start the job
JOB_ID=$(api POST /v1/speech-to-text -F "file=@$AUDIO_FILE;type=$CONTENT_TYPE" | jq -r '.jobId')
echo "job $JOB_ID"

# 2. Poll until it ends
while true; do
  JOB=$(api GET "/v1/speech-to-text/jobs/$JOB_ID")
  STATUS=$(echo "$JOB" | jq -r '.status')
  echo "$STATUS"
  if [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ]; then break; fi
  sleep 5
done
if [ "$STATUS" = "failed" ]; then
  echo "failed: $(echo "$JOB" | jq -r '.error')" >&2
  exit 1
fi

# 3. Read the transcript
echo "$JOB" | jq -r '.transcription'
echo "$JOB" | jq '{durationSeconds, creditsConsumed}'
```

```python
import time
import requests

BASE_URL = "https://api.echonova.ai"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}
AUDIO_FILE = "audio.mp3"
CONTENT_TYPE = "audio/mpeg"


def call(method, path, **kwargs):
    # Every response uses the same envelope: raise when success is false, return data.
    response = requests.request(
        method, f"{BASE_URL}{path}", headers=HEADERS, timeout=300, **kwargs
    )
    body = response.json()
    if not body["success"]:
        raise RuntimeError(f'{body["statusCode"]}: {body["errorMessage"]}')
    return body["data"]


# 1. Upload the file and start the job
with open(AUDIO_FILE, "rb") as audio:
    job_id = call(
        "POST",
        "/v1/speech-to-text",
        files={"file": (AUDIO_FILE, audio, CONTENT_TYPE)},
    )["jobId"]
print("job", job_id)

# 2. Poll until it ends
while True:
    job = call("GET", f"/v1/speech-to-text/jobs/{job_id}")
    print(job["status"])
    if job["status"] in ("completed", "failed"):
        break
    time.sleep(5)

if job["status"] == "failed":
    raise SystemExit(f'failed: {job.get("error")}')

# 3. Read the transcript
print(job["transcription"])
print(job["durationSeconds"], "seconds,", job["creditsConsumed"], "credits")
```

```javascript
import { readFile } from 'node:fs/promises';

const BASE_URL = 'https://api.echonova.ai';
const HEADERS = { Authorization: 'Bearer YOUR_API_KEY' };
const AUDIO_FILE = 'audio.mp3';
const CONTENT_TYPE = 'audio/mpeg';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Every response uses the same envelope: throw when success is false, return data.
async function call(path, init = {}) {
  const response = await fetch(`${BASE_URL}${path}`, {
    ...init,
    headers: { ...HEADERS, ...(init.headers ?? {}) },
  });
  const body = await response.json();
  if (!body.success) throw new Error(`${body.statusCode}: ${body.errorMessage}`);
  return body.data;
}

// 1. Upload the file and start the job
const formData = new FormData();
formData.append('file', new Blob([await readFile(AUDIO_FILE)], { type: CONTENT_TYPE }), AUDIO_FILE);
const { jobId } = await call('/v1/speech-to-text', { method: 'POST', body: formData });
console.log('job', jobId);

// 2. Poll until it ends
let job;
for (;;) {
  job = await call(`/v1/speech-to-text/jobs/${jobId}`);
  console.log(job.status);
  if (job.status === 'completed' || job.status === 'failed') break;
  await sleep(5000);
}
if (job.status === 'failed') throw new Error(`failed: ${job.error}`);

// 3. Read the transcript
console.log(job.transcription);
console.log(job.durationSeconds, 'seconds,', job.creditsConsumed, 'credits');
```

:::

While the job is `processing`, `durationSeconds` holds an estimate read
from the file and `transcription` is absent. When it is `completed`, both
hold their final values. The job also carries `language` and
`numbersAsDigits`, and, when it was made with `identify_speakers=true` and
has speaker data, timed `segments` and `speakers`; without them those two
are empty lists. To get subtitles, download the job from
`GET /v1/speech-to-text/jobs/{jobId}/export?format=srt`. See
[Speech to Text quickstart](/docs/developers/stt-quickstart) for each call
on its own and
[Speech to Text formats and output](/docs/developers/stt-formats-and-output)
for the accepted inputs.

### Step 4: Sound Cleaning, upload and download

Sound Cleaning is synchronous, so there is no polling. The program uploads
`noisy.wav` (10 minutes or less, 150 MB or less), reads `outputUrl` from the
response and saves the cleaned WAV as `cleaned.wav`. The `Content-Type` of
the `file` part must match the extension.

:::tabs

```bash title="cURL"
set -eo pipefail
BASE_URL="https://api.echonova.ai"
AUTH="Authorization: Bearer YOUR_API_KEY"

RESPONSE=$(curl -sS -X POST "$BASE_URL/v1/sound-cleaning" -H "$AUTH" -F "file=@noisy.wav;type=audio/wav")
if [ "$(echo "$RESPONSE" | jq -r '.success')" != "true" ]; then
  echo "$RESPONSE" | jq -r '"\(.statusCode) \(.errorMessage)"' >&2
  exit 1
fi
curl -sS -o cleaned.wav "$(echo "$RESPONSE" | jq -r '.data.outputUrl')"
echo "$RESPONSE" | jq -r '.data.creditsConsumed'
```

```python
import requests

BASE_URL = "https://api.echonova.ai"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}

with open("noisy.wav", "rb") as audio:
    body = requests.post(
        f"{BASE_URL}/v1/sound-cleaning",
        headers=HEADERS,
        files={"file": ("noisy.wav", audio, "audio/wav")},
    ).json()
if not body["success"]:
    raise SystemExit(f'{body["statusCode"]} {body["errorMessage"]}')

job = body["data"]
with open("cleaned.wav", "wb") as out:
    out.write(requests.get(job["outputUrl"]).content)
print(job["creditsConsumed"], "credits")
```

```javascript
import { readFile, writeFile } from 'node:fs/promises';

const BASE_URL = 'https://api.echonova.ai';

const formData = new FormData();
formData.append('file', new Blob([await readFile('noisy.wav')], { type: 'audio/wav' }), 'noisy.wav');

const body = await fetch(`${BASE_URL}/v1/sound-cleaning`, {
  method: 'POST',
  headers: { Authorization: 'Bearer YOUR_API_KEY' },
  body: formData,
}).then((r) => r.json());
if (!body.success) throw new Error(`${body.statusCode} ${body.errorMessage}`);

const audio = await fetch(body.data.outputUrl).then((r) => r.arrayBuffer());
await writeFile('cleaned.wav', Buffer.from(audio));
console.log(body.data.creditsConsumed, 'credits');
```

:::

See [Sound Cleaning quickstart](/docs/developers/clean-quickstart) for the
tuning fields and the limits.

## What happens next

- Jobs are listed under your key, one list per service: `GET /v1/text-to-speech/jobs`,
  `GET /v1/speech-to-text/jobs` and `GET /v1/sound-cleaning/jobs`, newest first. Each carries the
  `creditsConsumed` it was charged.
- `speech.wav` is on disk. The `audioUrl` it came from expires; read the
  job again for a fresh link if you need the file later.
- The transcript was printed to standard output. Store it yourself; the API
  keeps it on the job, and only there.
- If either job ended `failed`, its `error` was printed and its credits were
  refunded. Run the program again.

## Limits

| Limit                             | Value                                                            | Applies to                  |
| --------------------------------- | ---------------------------------------------------------------- | --------------------------- |
| `text` length                     | 1 to 500 characters                                              | `POST /v1/text-to-speech`   |
| Audio duration                    | 10 minutes                                                       | `POST /v1/speech-to-text`   |
| File size                         | 150 MB                                                           | `POST /v1/speech-to-text`   |
| `Content-Type` of the `file` part | Must start with `audio/`                                         | `POST /v1/speech-to-text`   |
| Time in `processing`              | 10 minutes, then marked `failed` within 15 minutes of the `POST` | Both jobs                   |
| Requests                          | 100 per 60 seconds per key per endpoint                          | Every call, including polls |

## Troubleshooting

### The program prints a status code and a message and stops

The helper raised on an error envelope. The number is `statusCode` and the
text is `errorMessage`. `401 Unauthorized` means the key: check that
`YOUR_API_KEY` was replaced and that the key is enabled.
`402 Insufficient subscription balance` means the workspace cannot cover
the job; nothing ran. `403` with `does not have access to` means the key
restricts services. See [Authentication](/docs/developers/authentication).

### The program prints failed with an error message

The job ended `failed`. The message names the reason; it is
`AI server generation failed` or `AI server transcription failed` when no
reason was given, and `Job timed out — AI server did not respond` when the
job stayed `processing` for more than 10 minutes. The credits were
refunded. Run the program again.

### The voice lookup finds nothing

No active voice has `language` set to `ar`, so `next()` in Python raises
`StopIteration`, `voices.find` in Node returns `undefined`, and the shell
`VOICE_ID` is `null`. List the voices and pick an `id` by hand; see
[List available voices](/docs/reference/tts-list-voices).

### 400 Unsupported file type: application/octet-stream. Please upload an audio file.

The `file` part was sent without an `audio/` content type. Keep the
`CONTENT_TYPE` value in the sample; do not remove the `type=` from the cURL
`-F` argument or the `type` from the Node `Blob`.

### The audio download fails

The signed link has expired. Call `GET /v1/text-to-speech/jobs/{jobId}`
again; every call returns a fresh `audioUrl`. Download it at once.

### 429 ThrottlerException: Too Many Requests

Several copies of the program share one key and their polls add up to more
than 100 requests per 60 seconds on the job endpoint. Wait for `Retry-After`
seconds, then use a longer interval or one key per process. See
[Rate limits](/docs/developers/rate-limits).

## FAQ

### Where are the synchronous versions?

On the quickstarts. `POST /v1/text-to-speech/sync` returns `audioUrl` in
the same response and `POST /v1/speech-to-text/sync` returns
`transcription`, with no polling. See
[Text to Speech quickstart](/docs/developers/tts-quickstart) and
[Speech to Text quickstart](/docs/developers/stt-quickstart), and
[Synchronous and asynchronous calls](/docs/developers/sync-vs-async) to
choose between them.

### Why check success instead of the HTTP status?

Both work. Every error response has `success` set to `false` and a
`statusCode` equal to the HTTP status, so a client can key off either. The
envelope check is one line and gives you `errorMessage` in the same step.

### Can I skip the voices call?

Yes. Set `voiceId` to an `id` you already know. The voice must be active;
an unknown or inactive id returns `404 Voice not found`.

### Can I receive the result without polling?

Yes. Register a webhook for `job.completed` and `job.failed`. The
`job.completed` event carries `audioUrl` or `transcription`, so no further
call is needed. See [Configure a webhook](/docs/developers/webhooks-configure)
and [Webhook events](/docs/developers/webhooks-events).

### Do the programs retry?

No. They stop on the first error. See
[Retry requests safely](/docs/developers/retries-and-idempotency) for the
polling loop that honours `Retry-After` and backs off on `5xx`.

## Related

- [Text to Speech quickstart](/docs/developers/tts-quickstart)
- [Speech to Text quickstart](/docs/developers/stt-quickstart)
- [Synchronous and asynchronous calls](/docs/developers/sync-vs-async)
- [Retry requests safely](/docs/developers/retries-and-idempotency)
- [Generate speech from text](/docs/reference/tts-generate-async)
- [Get TTS job details](/docs/reference/tts-get-job)
- [Transcribe audio file](/docs/reference/stt-transcribe)
- [Get STT job details](/docs/reference/stt-get-job)
- [List available voices](/docs/reference/tts-list-voices)
