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

# Synchronous and asynchronous calls

Which of the two call styles fits a request, how a job moves through its four statuses, and how to poll for the result.

Text to Speech and Speech to Text each expose the same two call styles. A
synchronous call holds the connection open and returns the result in its
response. An asynchronous call returns a job id at once and does the work in
the background; you read the result from the job endpoint or receive it
through a webhook. This page explains what each style does, when to use it,
and how a job moves from start to finish.

## The two styles

|                          | Synchronous                                                                                                    | Asynchronous                                                                                   |
| ------------------------ | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Text to Speech           | `POST /v1/text-to-speech/sync`                                                                                 | `POST /v1/text-to-speech`                                                                      |
| Speech to Text           | `POST /v1/speech-to-text/sync`                                                                                 | `POST /v1/speech-to-text`                                                                      |
| Returns                  | `jobId` and `status`, with the result: `audioUrl` or `transcription`, `durationSeconds` and `creditsConsumed`. | `jobId` and `status` only.                                                                     |
| Connection               | Open until the result is ready.                                                                                | Closed as soon as the job is accepted.                                                         |
| `status` in the response | `completed`                                                                                                    | `processing`                                                                                   |
| Where the result is      | In the response.                                                                                               | On `GET .../jobs/{jobId}`, or in a `job.completed` webhook.                                    |
| Job events               | None. Synchronous calls never emit a webhook event.                                                            | `job.completed` or `job.failed`.                                                               |
| `tashkeel`               | Applied.                                                                                                       | Accepted, and the text is synthesised as written. Use the synchronous endpoint for diacritics. |

The request body is the same for both Text to Speech endpoints, and the
`multipart/form-data` upload is the same for both Speech to Text endpoints.
The reference pages list every field:
[Generate speech synchronously](/docs/reference/tts-generate-sync),
[Generate speech from text](/docs/reference/tts-generate-async),
[Transcribe audio synchronously](/docs/reference/stt-transcribe-sync) and
[Transcribe audio file](/docs/reference/stt-transcribe).

## What both styles share

- **The charge comes first.** The API prices the request, charges the
  workspace, and only then does the work. If the balance cannot cover it,
  the call is rejected with `402` and `Insufficient subscription balance`
  before anything runs. See [Credits](/docs/developers/credits).
- **Every call creates a job.** A synchronous call creates the job already
  `completed`; an asynchronous call creates it `pending`. Both appear in
  `GET /v1/text-to-speech/jobs` or `GET /v1/speech-to-text/jobs`, newest
  first, and both can be read back from `GET .../jobs/{jobId}`. Any key in the
  workspace with access to that product can read the job.
- **A failure is refunded.** When a synchronous call fails, you get an error
  response and the charge is refunded. When an asynchronous job fails, its
  status becomes `failed`, `error` holds the reason, and the charge is
  refunded.
- **The same envelope.** Every response is
  `{ "success", "statusCode", "errorMessage", "data" }` and every successful
  call, including `POST`, returns HTTP `200`. See
  [Developer API overview](/docs/developers/overview).

## Choosing a style

Use the synchronous endpoint when all of these hold:

- You need the result inside the same request, for example to answer a chat
  message or to play a prompt on a call.
- The input is within the limit: audio of 10 minutes or less for Speech to
  Text, 500 characters for Text to Speech. Both endpoints of each service
  share the limit, so input size never forces the choice.
- Your client can keep an HTTP connection open for as long as the work
  takes. The API sets no timeout of its own on a synchronous call, so set
  your client timeout with the input size in mind.

Use the asynchronous endpoint in every other case:

- Batches. Submit many jobs, then poll them or let webhooks tell you when each
  one is done.
- Clients with a short HTTP timeout, or work you do not want to block on.
- Anything where you want a `job.completed` or `job.failed` event delivered to
  your own endpoint instead of holding a connection.

> [!NOTE]
> Both styles cost the same credits for the same input. The choice is about
> how you receive the result, not about price.

Match the style to who is waiting. A person waiting on a reply wants one
round trip; a batch wants a free connection and a webhook.

**Recommended**

- A chat reply: `POST /v1/text-to-speech/sync`, result in the response.
- Five hundred recordings overnight: `POST /v1/speech-to-text` for each,
  with a webhook on `job.completed` and `job.failed`.

**Less effective**

- A chat reply: `POST /v1/text-to-speech`, then a poll loop that holds the
  user for the same time plus a polling interval.
- Five hundred recordings overnight: `POST /v1/speech-to-text/sync` in
  parallel with a 30-minute client timeout on every connection.

## Job lifecycle

A job has four statuses: `pending`, `processing`, `completed` and `failed`.
An asynchronous job passes through them in this order:

```text
POST accepted
     |
     v
  pending  -->  processing  -->  completed
                    |
                    +--------->  failed
```

| Status       | What it means                                                                                                                                                                                                                      | What the job object holds                                                                                                                                       |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pending`    | The job is charged and created, and is waiting to be accepted for processing. The `POST` returns only after the job leaves this state, so the only place you can see it is a job list fetched while the `POST` is still in flight. | No result fields.                                                                                                                                               |
| `processing` | The job is being worked on.                                                                                                                                                                                                        | Text to Speech: no `audioUrl` and no `durationSeconds`. Speech to Text: `durationSeconds` holds an estimate read from the file, and `transcription` is missing. |
| `completed`  | The result is ready.                                                                                                                                                                                                               | Text to Speech: `audioUrl` and `durationSeconds`. Speech to Text: `transcription` and `durationSeconds` as measured.                                            |
| `failed`     | The job failed, or it timed out. The credits are refunded.                                                                                                                                                                         | `error` with the reason. `creditsConsumed` still shows the amount that was charged and then refunded.                                                           |

Three rules hold for every job:

- **A job ends once.** A job cannot go from `completed` to `failed`, or be
  completed twice.
- **A job cannot stay `processing` forever.** A job that has been
  `processing` for longer than 10 minutes is marked `failed` with `error`
  set to `Job timed out — AI server did not respond`; the status changes
  within 15 minutes of the `POST`. The credits are refunded. This timeout
  sends no `job.failed` webhook, so a poll loop, or a fallback poll after
  15 minutes of silence, is the way to learn about it.
- **A rejection at submission is reported twice.** If the job is refused
  when the `POST` submits it, the job is marked `failed`, the credits are
  refunded, and the `POST` itself returns an error, most often `503` with
  `Speech generation is temporarily unavailable. Please try again.` or
  `Transcription is temporarily unavailable. Please try again.`. The
  `failed` job stays in your job list.

A synchronous call has a shorter story: the job appears as `completed` when
the result is ready, and if the call fails no job is created at all. You
get the error response and the refund.

## Polling a job

Read the job with `GET /v1/text-to-speech/jobs/{jobId}` or
`GET /v1/speech-to-text/jobs/{jobId}`, using the `jobId` from the `POST`
response. Stop when `status` is `completed` or `failed`.

:::tabs

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

```python
import time
import requests

headers = {"Authorization": "Bearer YOUR_API_KEY"}

while True:
    job = requests.get(
        f"https://api.echonova.ai/v1/text-to-speech/jobs/{job_id}",
        headers=headers,
    ).json()["data"]
    if job["status"] in ("completed", "failed"):
        break
    time.sleep(3)

print(job["status"], job.get("audioUrl") or job.get("error"))
```

```javascript
const headers = { Authorization: 'Bearer YOUR_API_KEY' };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

let job;
for (;;) {
  const response = await fetch(`https://api.echonova.ai/v1/text-to-speech/jobs/${jobId}`, { headers });
  job = (await response.json()).data;
  if (job.status === 'completed' || job.status === 'failed') break;
  await sleep(3000);
}

console.log(job.status, job.audioUrl ?? job.error);
```

:::

The job object names its id `id`, while the `POST` that started it returned
`jobId`. The fields:

| Field             | Text to Speech job | Speech to Text job                    |
| ----------------- | ------------------ | ------------------------------------- |
| `id`              | Always             | Always                                |
| `status`          | Always             | Always                                |
| `createdAt`       | Always             | Always                                |
| `creditsConsumed` | Always             | Always                                |
| `text`            | Always             | Not present                           |
| `voiceId`         | Always             | Not present                           |
| `durationSeconds` | When `completed`   | Always; an estimate until `completed` |
| `audioUrl`        | When `completed`   | Not present                           |
| `transcription`   | Not present        | When `completed`                      |
| `error`           | When `failed`      | When `failed`                         |
| `language`        | Not present        | Always                                |
| `numbersAsDigits` | Not present        | Always                                |

`audioUrl` is a signed link to a WAV file, and it expires. Every `GET` of
the job returns a fresh link, so poll again rather than storing the URL.
Fetch the file with a plain `GET` and no `Authorization` header.

Two things to keep in mind while polling:

- Every poll counts against the rate limit of 100 requests per 60 seconds
  per key on that endpoint. Wait a few seconds between polls. See
  [Rate limits](/docs/developers/rate-limits).
- Give the loop a deadline. Every job reaches `completed` or `failed`
  within 15 minutes of entering `processing`.

The full pattern, with backoff on `429` and `5xx`, is on
[Retry requests safely](/docs/developers/retries-and-idempotency).

## Webhooks instead of polling

When an asynchronous job ends, the API sends `job.completed` or `job.failed`
to every enabled webhook in the workspace that subscribes to that event.
`job.completed` carries the result itself, `audioUrl` for Text to Speech and
`transcription` for Speech to Text, so you do not need to call the job
endpoint afterwards. `job.failed` carries `error`. Synchronous calls never
emit either event, and neither does the 10-minute timeout. See
[Configure a webhook](/docs/developers/webhooks-configure) and
[Webhook events](/docs/developers/webhooks-events).

## Limits

| Limit                | Value                                                            | Applies to                                                   |
| -------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------ |
| `text` length        | 1 to 500 characters                                              | `POST /v1/text-to-speech` and `POST /v1/text-to-speech/sync` |
| Audio duration       | 10 minutes                                                       | `POST /v1/speech-to-text/sync` and `POST /v1/speech-to-text` |
| File size            | 150 MB                                                           | Both Speech to Text upload endpoints                         |
| Time in `processing` | 10 minutes, then marked `failed` within 15 minutes of the `POST` | Asynchronous jobs                                            |
| Requests             | 100 per 60 seconds per key per endpoint                          | Every endpoint, including polls                              |

## FAQ

### Does the asynchronous endpoint accept longer input?

No. Both Text to Speech endpoints take up to 500 characters, and both
Speech to Text endpoints take up to 10 minutes of audio.

### Can I get diacritics on an asynchronous job?

No. `tashkeel` takes effect on `POST /v1/text-to-speech/sync` only. The
asynchronous endpoint accepts the field and synthesises the text as
written.

### Does a synchronous call show up in my job list?

Yes, already `completed`, with the same `creditsConsumed`, `audioUrl` or
`transcription` as the response.

### Why did my job end as failed with no webhook?

It timed out after more than 10 minutes in `processing`, and the timeout
sends no event. Poll the job when 15 minutes have passed without one.

### Is the result kept after the job ends?

Yes. `GET .../jobs/{jobId}` returns the transcript, or a fresh `audioUrl`,
until you delete the job with `DELETE .../jobs/{jobId}` or close your account.
Any key in the workspace with access to that product can delete its jobs. A job of another workspace answers `404`.

## Related

- [Text to Speech quickstart](/docs/developers/tts-quickstart)
- [Speech to Text quickstart](/docs/developers/stt-quickstart)
- [Retry requests safely](/docs/developers/retries-and-idempotency)
- [Code samples](/docs/developers/code-samples)
- [Configure a webhook](/docs/developers/webhooks-configure)
- [Webhook events](/docs/developers/webhooks-events)
- [Get TTS job details](/docs/reference/tts-get-job)
- [Get STT job details](/docs/reference/stt-get-job)
- [Credits](/docs/developers/credits)
- [Rate limits](/docs/developers/rate-limits)
