Base URL https://api.echonova.ai

Developers

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 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.

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.
{
  "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}'

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.json

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 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.

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.