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

# Retry requests safely

Tell a retry that costs nothing from one that creates a second job, keep the job id so you never have to send a request twice, and back off correctly on 429 and 5xx responses.

A `GET` can be repeated as often as you like. A `POST` starts a new job and
charges it every time, and no header or field marks one `POST` as a repeat
of another. This page shows how to build a client that retries without
paying twice: keep the `jobId`, poll instead of re-posting, and back off on
the responses that are worth retrying.

## What you need

- An API key. See [Get an API key](/docs/developers/get-an-api-key).
- The job lifecycle from
  [Synchronous and asynchronous calls](/docs/developers/sync-vs-async).
- Somewhere durable to keep one job id per request your system makes: a
  database row, a queue message or a file. The samples write a file.
- cURL with `jq` for the shell samples.

## Steps

### Step 1: Sort requests by what a retry costs

| Request                                                                                     | Safe to repeat | Why                                                                          |
| ------------------------------------------------------------------------------------------- | -------------- | ---------------------------------------------------------------------------- |
| `GET /v1/text-to-speech/voices`                                                             | Yes            | Reads only. Nothing is charged.                                              |
| `GET /v1/text-to-speech/jobs`, `GET /v1/speech-to-text/jobs`, `GET /v1/sound-cleaning/jobs` | Yes            | Reads only.                                                                  |
| `GET /v1/text-to-speech/jobs/{jobId}`, `GET /v1/speech-to-text/jobs/{jobId}`                | Yes            | Reads only. Each call to a Text to Speech job returns a fresh `audioUrl`.    |
| `POST /v1/text-to-speech`, `POST /v1/text-to-speech/sync`                                   | No             | Every call creates a new job with a new id and charges the text again.       |
| `POST /v1/speech-to-text`, `POST /v1/speech-to-text/sync`                                   | No             | Every call creates a new job with a new id and charges the whole file again. |
| `POST /v1/sound-cleaning`                                                                   | No             | Every call creates a new job with a new id and charges the whole file again. |

Every `POST` creates a new job with a new id and charges it. The text or
the file is not compared with earlier jobs, so two identical requests are
two jobs and two charges.

> [!WARNING]
> A `POST` that is sent twice is charged twice. Neither charge is refunded
> unless its job fails. Never put a `POST` behind an automatic retry that
> cannot tell a lost response from a rejected request.

### Step 2: Persist the jobId before you do anything else

Every Text to Speech and Speech to Text `POST` endpoint returns the job id
in `data.jobId`, asynchronous and synchronous alike. Sound Cleaning returns
it as `data.id`. Write it to durable
storage, keyed by your own request id, before you parse anything else in the
response. From that moment on, everything you need is a `GET`.

Send each `POST` once, and recover from a lost response with a `GET`. A
retry layer that re-sends a `POST` cannot tell a lost response from a
rejected request, and pays for both.

**Recommended**

- One `POST` per request your system makes, its `jobId` saved at once, then
  `GET .../jobs/{jobId}` until the job ends.

**Less effective**

- An HTTP client with automatic retries on `POST`, re-sending the same text
  after every timeout.

```json
{
  "data": {
    "jobId": "66e5f0c2a1b2c3d4e5f60718",
    "status": "processing"
  },
  "success": true,
  "statusCode": 200,
  "errorMessage": null
}
```

### Step 3: Read the error response before you retry

Every error response tells you whether a retry can help. An error response
never leaves you holding a charge: the charge is either never made or
refunded before the error is returned.

| Response                                                                                                                                 | Retry                                                                              | Charged                                                                             |
| ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `400` with a validation message, for example `text must be shorter than or equal to 500 characters`                                      | No. Fix the request.                                                               | No. Rejected before anything runs.                                                  |
| `401 Unauthorized`, `403 Subscription expired or inactive`, `403 This API key does not have access to Text-to-Speech services`           | No. Fix the key. See [Authentication](/docs/developers/authentication).            | No.                                                                                 |
| `402 Insufficient subscription balance`                                                                                                  | Not until credits are added.                                                       | No.                                                                                 |
| `404 Voice not found`, `404 TTS Job not found`, `404 STT Job not found`                                                                  | No. The id is wrong or belongs to another workspace.                               | No.                                                                                 |
| `429 Monthly spending limit exhausted`, no `Retry-After` header                                                                          | No. Waiting does not clear it. See [Credits](/docs/developers/credits).            | No.                                                                                 |
| `429 ThrottlerException: Too Many Requests`, with `Retry-After`                                                                          | Yes, after `Retry-After` seconds. See [Rate limits](/docs/developers/rate-limits). | No.                                                                                 |
| `503 Speech generation is temporarily unavailable. Please try again.`, `503 Transcription is temporarily unavailable. Please try again.` | Yes, with backoff.                                                                 | Refunded. An asynchronous `POST` that gets this leaves a `failed` job in your list. |
| Any other `5xx`, for example `503 Database unavailable` or `500 Something went wrong`                                                    | Yes, with backoff.                                                                 | Never made, or refunded.                                                            |

Tell the two `429` responses apart by `errorMessage`, not by the status
code.

### Step 4: When you get no response, look before you re-post

A client timeout, a dropped connection or a crash after the request was
sent leaves you without a response. The job may have been created and
charged, and an asynchronous job keeps running. Do not send the `POST`
again until you have looked.

- If you saved the `jobId`, go back to polling it.
- If the request never got as far as a response with a `jobId`, list your
  recent jobs. They come back newest first, 20 per page by default and 100
  at most.

:::tabs

```bash title="cURL"
curl "https://api.echonova.ai/v1/text-to-speech/jobs?limit=20" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  | jq '.data.items[] | {id, status, text, createdAt}'
```

```python
import requests

items = requests.get(
    "https://api.echonova.ai/v1/text-to-speech/jobs",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    params={"limit": 20},
).json()["data"]["items"]

# TEXT is the text you sent. SENT_AT is the ISO 8601 UTC time you recorded
# just before the POST.
lost = next(
    (job for job in items if job["text"] == TEXT and job["createdAt"] >= SENT_AT),
    None,
)
if lost:
    job_id = lost["id"]  # resume polling with this id; do not POST again
```

```javascript
const response = await fetch('https://api.echonova.ai/v1/text-to-speech/jobs?limit=20', {
  headers: { Authorization: 'Bearer YOUR_API_KEY' },
});
const { data } = await response.json();

// TEXT is the text you sent. SENT_AT is the ISO 8601 UTC time you recorded
// just before the POST.
const lost = data.items.find((job) => job.text === TEXT && job.createdAt >= SENT_AT);
if (lost) {
  const jobId = lost.id; // resume polling with this id; do not POST again
}
```

:::

A Text to Speech job carries the `text` you sent, so you can match on it.
A Speech to Text job carries no reference to the file you uploaded, so match
on `createdAt` alone: a job created after the moment you sent the request,
by this key, is yours. Only when the list shows nothing is it safe to send
the request again.

### Step 5: Poll with a fixed interval and back off on errors

This is the full client for one asynchronous Text to Speech request. It
sends the `POST` exactly once, saves the `jobId`, then polls with `GET`
only. A `429` waits for `Retry-After`; a `5xx` or a connection error backs
off; any other `4xx` stops, because a retry would return the same answer.
The deadline is 15 minutes, which is the longest a job can stay
`processing` before it is marked `failed`.

:::tabs

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

# Exactly one POST. Save the job id before anything else.
RESPONSE=$(curl -s -X POST "$BASE_URL/v1/text-to-speech" -H "$AUTH" \
  -H "Content-Type: application/json" \
  -d '{"text": "مرحبا بكم في إيكونوڤا", "voiceId": "VOICE_ID"}')
if [ "$(echo "$RESPONSE" | jq -r '.success')" != "true" ]; then
  echo "error: $(echo "$RESPONSE" | jq -r '.errorMessage')"; exit 1
fi
echo "$RESPONSE" | jq -r '.data.jobId' > job-id.txt
JOB_ID=$(cat job-id.txt)

# Poll with GET only. 429: wait Retry-After. 5xx or no response: back off. Other 4xx: stop.
DEADLINE=$(( $(date +%s) + 900 ))
TRANSIENT=0
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
  CODE=$(curl -s -o job.json -D headers.txt -w '%{http_code}' \
    "$BASE_URL/v1/text-to-speech/jobs/$JOB_ID" -H "$AUTH")
  if [ "$CODE" = "200" ]; then
    TRANSIENT=0
    STATUS=$(jq -r '.data.status' job.json)
    echo "$STATUS"
    if [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ]; then break; fi
    sleep 3
  elif [ "$CODE" = "429" ]; then
    WAIT=$(sed -n 's/^[Rr]etry-[Aa]fter: *\([0-9]*\).*/\1/p' headers.txt)
    sleep "${WAIT:-60}"
  elif [ "$CODE" = "000" ] || [ "$CODE" -ge 500 ]; then
    TRANSIENT=$((TRANSIENT + 1))
    if [ "$TRANSIENT" -gt 5 ]; then echo "API unreachable"; exit 1; fi
    sleep $((2 ** (TRANSIENT - 1)))
  else
    echo "error: $(jq -r '.errorMessage' job.json)"; exit 1
  fi
done
jq '.data' job.json
```

```python
import random
import time
import requests

BASE_URL = "https://api.echonova.ai"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}
POLL_SECONDS = 3           # recommendation, not a server value
GIVE_UP_SECONDS = 15 * 60  # a silent job is marked failed within this time
MAX_TRANSIENT = 5          # recommendation


def start_tts_job(text, voice_id):
    # Exactly one POST. If this raises before a response arrives, do not call
    # it again blindly: check the job list first (Step 4).
    response = requests.post(
        f"{BASE_URL}/v1/text-to-speech",
        headers=HEADERS,
        json={"text": text, "voiceId": voice_id},
        timeout=30,
    )
    body = response.json()
    if not body["success"]:
        raise RuntimeError(f'{body["statusCode"]}: {body["errorMessage"]}')
    job_id = body["data"]["jobId"]
    with open("job-id.txt", "w") as f:  # persist before anything else
        f.write(job_id)
    return job_id


def wait_for_job(job_id):
    deadline = time.monotonic() + GIVE_UP_SECONDS
    transient = 0
    while time.monotonic() < deadline:
        try:
            response = requests.get(
                f"{BASE_URL}/v1/text-to-speech/jobs/{job_id}",
                headers=HEADERS,
                timeout=30,
            )
        except requests.RequestException:
            response = None

        if response is not None and response.status_code == 200:
            transient = 0
            job = response.json()["data"]
            if job["status"] in ("completed", "failed"):
                return job
            time.sleep(POLL_SECONDS)
        elif response is not None and response.status_code == 429:
            # Rate limit. Wait exactly what the server asks; a sooner call is rejected.
            time.sleep(int(response.headers.get("Retry-After", "60")))
        elif response is None or response.status_code >= 500:
            transient += 1
            if transient > MAX_TRANSIENT:
                raise RuntimeError("API unreachable")
            time.sleep(2 ** (transient - 1) + random.random())
        else:
            # 400, 401, 403, 404: a retry returns the same answer. Fix the request.
            body = response.json()
            raise RuntimeError(f'{body["statusCode"]}: {body["errorMessage"]}')
    raise TimeoutError(f"job {job_id} still running after {GIVE_UP_SECONDS} s")


job_id = start_tts_job("مرحبا بكم في إيكونوڤا", "VOICE_ID")
job = wait_for_job(job_id)
print(job["status"], job.get("audioUrl") or job.get("error"))
```

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

const BASE_URL = 'https://api.echonova.ai';
const HEADERS = { Authorization: 'Bearer YOUR_API_KEY' };
const POLL_MS = 3000; // recommendation, not a server value
const GIVE_UP_MS = 15 * 60 * 1000; // a silent job is marked failed within this time
const MAX_TRANSIENT = 5; // recommendation
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function startTtsJob(text, voiceId) {
  // Exactly one POST. If this throws before a response arrives, do not call
  // it again blindly: check the job list first (Step 4).
  const response = await fetch(`${BASE_URL}/v1/text-to-speech`, {
    method: 'POST',
    headers: { ...HEADERS, 'Content-Type': 'application/json' },
    body: JSON.stringify({ text, voiceId }),
  });
  const body = await response.json();
  if (!body.success) throw new Error(`${body.statusCode}: ${body.errorMessage}`);
  await writeFile('job-id.txt', body.data.jobId); // persist before anything else
  return body.data.jobId;
}

async function waitForJob(jobId) {
  const deadline = Date.now() + GIVE_UP_MS;
  let transient = 0;
  while (Date.now() < deadline) {
    let response = null;
    try {
      response = await fetch(`${BASE_URL}/v1/text-to-speech/jobs/${jobId}`, { headers: HEADERS });
    } catch {
      // connection error: handled like a 5xx below
    }

    if (response?.status === 200) {
      transient = 0;
      const { data: job } = await response.json();
      if (job.status === 'completed' || job.status === 'failed') return job;
      await sleep(POLL_MS);
    } else if (response?.status === 429) {
      // Rate limit. Wait exactly what the server asks; a sooner call is rejected.
      await sleep(Number(response.headers.get('Retry-After') ?? 60) * 1000);
    } else if (response === null || response.status >= 500) {
      transient += 1;
      if (transient > MAX_TRANSIENT) throw new Error('API unreachable');
      await sleep((2 ** (transient - 1) + Math.random()) * 1000);
    } else {
      // 400, 401, 403, 404: a retry returns the same answer. Fix the request.
      const body = await response.json();
      throw new Error(`${body.statusCode}: ${body.errorMessage}`);
    }
  }
  throw new Error(`job ${jobId} still running after ${GIVE_UP_MS / 1000} s`);
}

const jobId = await startTtsJob('مرحبا بكم في إيكونوڤا', 'VOICE_ID');
const job = await waitForJob(jobId);
console.log(job.status, job.audioUrl ?? job.error);
```

:::

For Speech to Text, change the paths to `/v1/speech-to-text` and
`/v1/speech-to-text/jobs/{jobId}`, send the file as the `file` part of a
`multipart/form-data` request, and read `transcription` instead of
`audioUrl`. The [Code samples](/docs/developers/code-samples) page has the
upload in all three languages.

### Step 6: Pick the numbers

The second column is what the API does; the third is the value to use.

| Situation                       | API behaviour                                                                                                                     | Recommendation                                                                                                               |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| Poll interval                   | Each poll counts against 100 requests per 60 seconds per key on that endpoint. Polling faster does not finish the job sooner.     | 3 seconds for Text to Speech, 5 seconds for Speech to Text. Never under 1 second.                                            |
| `429` on the rate limit         | `Retry-After` gives the seconds until the block ends. Requests sent during the block are rejected and do not shorten it.          | Sleep exactly `Retry-After` seconds. No jitter is needed; the server has already spread you out.                             |
| `5xx` or no response on a `GET` | Reads are free.                                                                                                                   | Wait 1, 2, 4, 8, then 16 seconds, plus up to 1 second of jitter. Give up after 5 attempts and alert.                         |
| `5xx` on a `POST`               | The charge was refunded before the error was returned.                                                                            | Same schedule as a `GET`. On no response at all, do Step 4 before you send again.                                            |
| Give up polling                 | A job that has been `processing` for more than 10 minutes is marked `failed`; the status changes within 15 minutes of the `POST`. | Stop 15 minutes after the `POST` returned, read the job one last time, and treat anything still `processing` as an incident. |
| Parallel requests               | The limit is per key and per endpoint. Two keys have separate counts.                                                             | Give each system its own key, and keep polls from all workers on one key under 100 per 60 seconds in total.                  |

## What happens next

- Each request your system meant to make produces exactly one job and one
  charge, and you hold its `jobId`.
- Every poll loop ends. A job reaches `completed` or `failed` within 15
  minutes of entering `processing`, so the deadline in Step 5 is never the
  first thing to fire on a healthy day.
- Your logs carry the `jobId`. It is the `id` of the job in
  `GET .../jobs`, and the `jobId` field of the `job.completed` and
  `job.failed` webhook events, so a poller and a webhook handler can be
  reconciled. See [Webhook events](/docs/developers/webhooks-events).

## Limits

| Limit                | Value                                                            | Applies to                                                             |
| -------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Requests             | 100 per 60 seconds per key per endpoint                          | Every endpoint, including polls                                        |
| `Retry-After`        | Seconds until the block ends                                     | The rate-limit `429` only; the spending-limit `429` has no such header |
| Time in `processing` | 10 minutes, then marked `failed` within 15 minutes of the `POST` | Asynchronous jobs                                                      |
| Jobs per list page   | `limit` 1 to 100, default 20; `offset` 0 or more, default 0      | `GET /v1/text-to-speech/jobs` and `GET /v1/speech-to-text/jobs`        |

## Troubleshooting

### The same text produced two jobs and two charges

Your client sent the `POST` twice. A retry layer, an HTTP library with
automatic retries on `POST`, or a worker restarted mid-request are the usual
causes. Turn off automatic retries for `POST` and follow Step 4 when a
request gets no response. Both jobs are real: the second charge is not
refunded unless that job fails.

### 429 ThrottlerException: Too Many Requests while polling

The poll loop is too tight, or several workers poll with the same key. The
response carries `Retry-After`; wait that many seconds. Then raise the poll
interval or split the workers across keys.

### 429 Monthly spending limit exhausted

The key's own credit limit for this billing cycle would be crossed. This is
not transient and carries no `Retry-After`. Raise or clear the limit under
**Edit** in the portal, or wait for the next cycle.

### The connection dropped before the response arrived

The job may exist. If you have the `jobId`, poll it. If you do not, list
your recent jobs as in Step 4 and match on `text` and `createdAt` for Text
to Speech, or on `createdAt` for Speech to Text, before you send the request
again.

### 404 TTS Job not found when polling a saved jobId

The id belongs to another workspace, was made in the portal, or is not a job
id at all. A malformed id returns `400 Invalid identifier` instead. Check
which workspace the worker's key belongs to.

### 503 Speech generation is temporarily unavailable. Please try again.

Speech generation was unavailable for that attempt. The charge is refunded.
Retry with the backoff from Step 6. If it was an asynchronous `POST`, the
job list also shows a `failed` job for this attempt; ignore it.

## FAQ

### Is there an idempotency key?

No request header or body field marks a `POST` as a repeat of an earlier
one. Keep the `jobId` from the first response and use `GET` from then on.

### Does a job survive my client crashing?

Yes. Once the `POST` has been accepted, the job runs to `completed` or
`failed` whether or not anyone is polling it, and it stays in your job list.
Recover it as in Step 4.

### Can I cancel a job?

No. A job that has started runs to its end and is charged unless it fails.

### Does polling cost credits?

No. Only the five `POST` endpoints charge. The only cost of a `GET` is the
request itself against the rate limit.

### Should I retry a POST that returned 503?

Yes. The charge for that attempt was refunded before the `503` was sent, so
a new `POST` is a fresh, single charge. Retry with backoff; do not retry
without a delay.

### How long should a poll loop run?

Fifteen minutes is enough. A job that stays `processing` for more than 10
minutes is marked `failed`, and the status changes within 15 minutes of the
`POST`.

## Related

- [Synchronous and asynchronous calls](/docs/developers/sync-vs-async)
- [Code samples](/docs/developers/code-samples)
- [Rate limits](/docs/developers/rate-limits)
- [Credits](/docs/developers/credits)
- [Delivery, retries and failures](/docs/developers/webhooks-retries)
- [List Text to Speech jobs](/docs/reference/tts-list-jobs)
- [List Speech to Text jobs](/docs/reference/stt-list-jobs)
- [Get TTS job details](/docs/reference/tts-get-job)
- [Get STT job details](/docs/reference/stt-get-job)
