https://api.echonova.aiDevelopers
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.
- The job lifecycle from Synchronous and asynchronous calls.
- 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
jqfor 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.
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
POSTper request your system makes, itsjobIdsaved at once, thenGET .../jobs/{jobId}until the job ends.
Less effective
- An HTTP client with automatic retries on
POST, re-sending the same text after every timeout.
{
"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. | 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. | No. |
429 ThrottlerException: Too Many Requests, with Retry-After |
Yes, after Retry-After seconds. See 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.
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}'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 againconst 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.
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.jsonimport 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"))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 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
completedorfailedwithin 15 minutes of enteringprocessing, so the deadline in Step 5 is never the first thing to fire on a healthy day. - Your logs carry the
jobId. It is theidof the job inGET .../jobs, and thejobIdfield of thejob.completedandjob.failedwebhook events, so a poller and a webhook handler can be reconciled. See Webhook 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.