https://api.echonova.aiDevelopers
Speech to Text quickstart
Transcribe a short audio file in one call, read the transcript and its duration, run a long file as an asynchronous job and poll it, then download it as subtitles with speaker labels.
Speech to Text turns an audio file into a transcript. This tutorial makes one synchronous call for a short file, reads the two fields you will use most, runs a long file as an asynchronous job that you poll until it finishes, and then downloads a job as subtitles that say who is speaking.
What you need
- An API key. See Get an API key. If the key restricts services, it needs Access on Speech to Text.
- The base URL. See Base URL. The callout at the top of this page shows the host these samples call.
- Credits on the workspace. Each call is charged before it runs. See Credits.
- An audio file of 10 minutes or less and 150 MB or smaller. Both the synchronous and the asynchronous endpoint take the same limits.
Steps
Step 1: Transcribe a short file synchronously
Send the file as the file part of a multipart/form-data request to
POST https://api.echonova.ai/v1/speech-to-text/sync. The call waits for the
transcript and returns it in the response.
Add the optional language field: auto (default), ar or en.
curl -X POST "https://api.echonova.ai/v1/speech-to-text/sync" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@audio.mp3;type=audio/mpeg" \
-F "language=ar"import requests
with open("audio.mp3", "rb") as audio:
response = requests.post(
"https://api.echonova.ai/v1/speech-to-text/sync",
headers={"Authorization": "Bearer YOUR_API_KEY"},
files={"file": ("audio.mp3", audio, "audio/mpeg")},
data={"language": "ar"},
)
print(response.json())import { readFile } from 'node:fs/promises';
const formData = new FormData();
formData.append('file', new Blob([await readFile('audio.mp3')], { type: 'audio/mpeg' }), 'audio.mp3');
formData.append('language', 'ar');
const response = await fetch('https://api.echonova.ai/v1/speech-to-text/sync', {
method: 'POST',
headers: { Authorization: 'Bearer YOUR_API_KEY' },
body: formData,
});
console.log(await response.json());The API checks the Content-Type of the file part, not the file
extension, and it must start with audio/. Set it explicitly as the samples
do. Without it, cURL sends application/octet-stream, Python requests
sends no type at all, which the API reads as text/plain, and a Node
Blob with no type is sent as application/octet-stream. All three are
rejected with 400. Use audio/wav for WAV, audio/mpeg for MP3 and
audio/ogg for OGG.
Name the type on the part, not on the request. The file extension is not read.
Recommended
-F "file=@audio.wav;type=audio/wav"Less effective
-F "file=@audio.wav" -H "Content-Type: audio/wav"Step 2: Read the transcript and the duration
A successful call returns HTTP 200 and the standard envelope. The result
is in data:
{
"success": true,
"statusCode": 200,
"errorMessage": null,
"data": {
"jobId": "66e3f2a1c9d4b8a1f0e12345",
"status": "completed",
"transcription": "مرحبا بكم في إيكونوڤا",
"durationSeconds": 15.51,
"creditsConsumed": 26,
"language": "ar"
}
}| Field | Type | What it holds |
|---|---|---|
jobId |
string | The id of the job the call created. It is already completed, and you can read it again from GET /v1/speech-to-text/jobs/{jobId}. |
status |
string | Always completed on a successful synchronous call. |
transcription |
string | The transcript. |
durationSeconds |
number or null |
The length of the audio as measured, in seconds, with decimals. |
creditsConsumed |
number | The credits charged for this call, a whole number. |
language |
string | The language the transcription was requested with. |
numbersAsDigits |
boolean | Whether spoken numbers came back as digits. It is true unless you sent numbers_as_digits=false. |
identifySpeakers |
boolean | Whether you sent identify_speakers=true. See Step 4. |
segments |
array | The transcript in timed pieces with a speaker on each. Empty unless the job has speaker data. |
speakers |
array | The speakers heard and their talk time. Empty unless the job has speaker data. |
The sample leaves out numbersAsDigits, identifySpeakers, segments and speakers, which the response and the job also carry; see Speech to Text formats and output. transcription is the text you store or show. durationSeconds tells you how
much audio was processed. Credits are charged per second of audio, 100 credits a minute, so durationSeconds and creditsConsumed move together; the rates are on the Credits page.
Step 3: Transcribe a file asynchronously
When you do not want to hold a connection open, or want a webhook when the
job ends, send it to POST https://api.echonova.ai/v1/speech-to-text
instead. The request is the same multipart/form-data upload, with the same optional language field; the limit is
10 minutes of audio, as on the synchronous endpoint. The call returns as soon as the file is accepted:
{
"success": true,
"statusCode": 200,
"errorMessage": null,
"data": {
"jobId": "66e3f2a1c9d4b8a1f0e12346",
"status": "processing"
}
}Then poll GET https://api.echonova.ai/v1/speech-to-text/jobs/{jobId} until
status is completed or failed:
curl -X POST "https://api.echonova.ai/v1/speech-to-text" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@audio.mp3;type=audio/mpeg" \
-F "language=ar"
# Repeat until data.status is "completed" or "failed"
curl "https://api.echonova.ai/v1/speech-to-text/jobs/JOB_ID" \
-H "Authorization: Bearer YOUR_API_KEY"import time
import requests
headers = {"Authorization": "Bearer YOUR_API_KEY"}
with open("audio.mp3", "rb") as audio:
started = requests.post(
"https://api.echonova.ai/v1/speech-to-text",
headers=headers,
files={"file": ("audio.mp3", audio, "audio/mpeg")},
data={"language": "ar"},
).json()
job_id = started["data"]["jobId"]
while True:
job = requests.get(
f"https://api.echonova.ai/v1/speech-to-text/jobs/{job_id}",
headers=headers,
).json()["data"]
if job["status"] in ("completed", "failed"):
break
time.sleep(5)
if job["status"] == "completed":
print(job["transcription"], job["durationSeconds"])
else:
print("failed:", job["error"])import { readFile } from 'node:fs/promises';
const headers = { Authorization: 'Bearer YOUR_API_KEY' };
const formData = new FormData();
formData.append('file', new Blob([await readFile('audio.mp3')], { type: 'audio/mpeg' }), 'audio.mp3');
formData.append('language', 'ar');
const started = await fetch('https://api.echonova.ai/v1/speech-to-text', {
method: 'POST',
headers,
body: formData,
}).then((r) => r.json());
const jobId = started.data.jobId;
let job;
while (true) {
job = await fetch(`https://api.echonova.ai/v1/speech-to-text/jobs/${jobId}`, {
headers,
})
.then((r) => r.json())
.then((body) => body.data);
if (job.status === 'completed' || job.status === 'failed') break;
await new Promise((resolve) => setTimeout(resolve, 5000));
}
if (job.status === 'completed') {
console.log(job.transcription, job.durationSeconds);
} else {
console.log('failed:', job.error);
}A finished job looks like this:
{
"success": true,
"statusCode": 200,
"errorMessage": null,
"data": {
"id": "66e3f2a1c9d4b8a1f0e12346",
"status": "completed",
"transcription": "مرحبا بكم في إيكونوڤا",
"durationSeconds": 312.4,
"creditsConsumed": 521,
"language": "ar",
"createdAt": "2026-09-15T10:21:04.512Z"
}
}While the job is processing, transcription is not present and
durationSeconds holds an estimate read from the file. When the job
completes, transcription appears and durationSeconds is replaced by the
measured length. When it fails, error appears with the reason and the
credits are refunded. Until they are set, transcription and error are
absent from the object, not null.
Instead of polling, register a webhook endpoint in the portal. The
job.completed event carries the transcription and durationSeconds, so
you do not need a second call. It does not carry segments or speakers;
get the job for those. See
Configure a webhook and
Webhook events.
Step 4: Identify speakers and download subtitles
Add identify_speakers=true to the upload from Step 3. When the job is
completed, download it from
GET https://api.echonova.ai/v1/speech-to-text/jobs/{jobId}/export with
format=srt. The download is the file itself, not the JSON envelope.
curl -X POST "https://api.echonova.ai/v1/speech-to-text" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@call.mp3;type=audio/mpeg" \
-F "language=ar" \
-F "identify_speakers=true"
# Once GET /v1/speech-to-text/jobs/JOB_ID shows "completed"
curl "https://api.echonova.ai/v1/speech-to-text/jobs/JOB_ID/export?format=srt&lang=en" \
-H "Authorization: Bearer YOUR_API_KEY" \
-o call.srtimport time
import requests
headers = {"Authorization": "Bearer YOUR_API_KEY"}
with open("call.mp3", "rb") as audio:
started = requests.post(
"https://api.echonova.ai/v1/speech-to-text",
headers=headers,
files={"file": ("call.mp3", audio, "audio/mpeg")},
data={"language": "ar", "identify_speakers": "true"},
).json()
job_id = started["data"]["jobId"]
while True:
job = requests.get(
f"https://api.echonova.ai/v1/speech-to-text/jobs/{job_id}",
headers=headers,
).json()["data"]
if job["status"] in ("completed", "failed"):
break
time.sleep(5)
if job["status"] == "completed":
subtitles = requests.get(
f"https://api.echonova.ai/v1/speech-to-text/jobs/{job_id}/export",
headers=headers,
params={"format": "srt", "lang": "en"},
)
subtitles.raise_for_status()
with open("call.srt", "wb") as out:
out.write(subtitles.content)
print(len(job["speakers"]), "speakers")
else:
print("failed:", job["error"])import { readFile, writeFile } from 'node:fs/promises';
const headers = { Authorization: 'Bearer YOUR_API_KEY' };
const formData = new FormData();
formData.append('file', new Blob([await readFile('call.mp3')], { type: 'audio/mpeg' }), 'call.mp3');
formData.append('language', 'ar');
formData.append('identify_speakers', 'true');
const started = await fetch('https://api.echonova.ai/v1/speech-to-text', {
method: 'POST',
headers,
body: formData,
}).then((r) => r.json());
const jobId = started.data.jobId;
let job;
while (true) {
job = await fetch(`https://api.echonova.ai/v1/speech-to-text/jobs/${jobId}`, {
headers,
})
.then((r) => r.json())
.then((body) => body.data);
if (job.status === 'completed' || job.status === 'failed') break;
await new Promise((resolve) => setTimeout(resolve, 5000));
}
if (job.status === 'completed') {
const subtitles = await fetch(`https://api.echonova.ai/v1/speech-to-text/jobs/${jobId}/export?format=srt&lang=en`, { headers });
if (!subtitles.ok) throw new Error((await subtitles.json()).errorMessage);
await writeFile('call.srt', await subtitles.text());
console.log(job.speakers.length, 'speakers');
} else {
console.log('failed:', job.error);
}The completed job lists the speakers and the transcript in timed segments.
Speakers are spk0, spk1 and so on, in the order they first speak:
{
"identifySpeakers": true,
"segments": [
{ "start": 0.38, "end": 4.12, "text": "السلام عليكم، كيف حالك؟", "speaker": "spk0" },
{ "start": 4.3, "end": 6.4, "text": "بخير، الحمد لله.", "speaker": "spk1" }
],
"speakers": [
{ "id": "spk0", "talkTime": 3.74 },
{ "id": "spk1", "talkTime": 2.1 }
]
}The SRT file labels each cue with its speaker. lang=ar writes
[المتحدث 1] instead of [Speaker 1]:
1
00:00:00,380 --> 00:00:04,120
[Speaker 1] السلام عليكم، كيف حالك؟
2
00:00:04,300 --> 00:00:06,400
[Speaker 2] بخير، الحمد لله.Send format=txt for the transcript alone, or format=json for the
transcript, the labelled speakers and the segments in one file. Cue rules,
file names and the JSON shape are on
Speech to Text formats and output,
and every parameter and error is on Download a completed STT job.
Speaker identification may not be available to you yet. Until it is, a job
made with identify_speakers=true still completes with its transcript,
and segments and speakers are empty. The SRT download then holds one
cue for the whole file. Check that segments is not empty before you rely
on speaker labels.
What happens next
- Every call, synchronous or asynchronous, creates a job. List the API jobs of the
workspace at
GET /v1/speech-to-text/jobs, newest first, 20 per page by default. Any key in the workspace with Speech to Text access sees them, whichever key created them. - Credits are charged to the workspace before transcription starts and refunded if the job fails. The key's own usage counter is updated after the call. See Credits.
- An asynchronous job that stays
processingfor more than 10 minutes is markedfailedwith the errorJob timed out — AI server did not respond, within 15 minutes of thePOST. The credits are refunded. This timeout sends nojob.failedwebhook, so a client that listens for webhooks should also poll a job that has been silent for 15 minutes.
Limits
| Limit | Value | Applies to |
|---|---|---|
| Audio duration | 10 minutes | Both upload endpoints |
| File size | 150 MB | Both upload endpoints |
Content-Type of the file part |
Must start with audio/ |
Both upload endpoints |
limit query parameter |
1 to 100, default 20 | GET /v1/speech-to-text/jobs |
offset query parameter |
0 or more, default 0 | GET /v1/speech-to-text/jobs |
| Requests per key | See Rate limits | Every endpoint |
format query parameter |
txt, srt or json |
GET /v1/speech-to-text/jobs/{jobId}/export |
| Subtitle cue | At most 7 seconds and two lines of 42 characters | SRT download |
Troubleshooting
400 Unsupported file type: application/octet-stream. Please upload an audio file.
The file part was sent without an audio/ content type. The type named in
the message is what your client sent; text/plain means it sent none. Set
the part's type as shown in Step 1.
400 language must be one of: auto, ar, en
The language field held something else. The value is trimmed and read
without regard to case, so AR and en are accepted. Send auto, ar or
en, or leave the field out.
400 File is required
The request had no part named file. Check the field name and that the
request is multipart/form-data.
400 Audio exceeds the 10-minute limit.
The file is longer than 10 minutes. Both upload endpoints give the same message; split longer recordings.
413 File too large
The upload is over 150 MB. The request is cut off before the file is read. Re-encode the audio at a lower bitrate or as mono.
503 Transcription is temporarily unavailable. Please try again.
Transcription was unavailable, or the file could not be decoded; the
synchronous endpoint reports both with this one message. The charge is
refunded. Retry once. If the same file fails every time, send it to
POST /v1/speech-to-text: that job ends as failed with the reason in its
error field. The containers that decode are listed on
Speech to Text formats and output.
400 identify_speakers must be true or false
The identify_speakers field held something other than true, false,
1 or 0. Send true or false, or leave the field out.
400 Job is not completed
The export was asked for a job that is pending, processing or failed.
Wait for job.completed, or poll the job until status is completed, then
download it. A failed job has no transcript to download.
404 STT Job not found
The jobId does not exist, or it belongs to another workspace or was made in
the portal. A malformed id returns
400 Invalid identifier.
403 This API key does not have access to Speech-to-Text services
The key grants Access to at least one service, and Speech to Text is not among them. Edit the key in the portal or use another key.
402 Insufficient subscription balance
The workspace cannot cover the charge for this file. Nothing ran. Add credits or use a shorter file.
FAQ
Which language is the transcript in?
Send language: auto (default) lets the service decide. That is Arabic today, which also handles mixed Arabic/English and clean English speech. Use ar for Arabic and mixed speech. Use en only for speech that is all English. Arabic sent as en comes back garbled. Any other value is a 400.
Do I get word timestamps or speaker labels?
Speaker labels, yes: send identify_speakers=true and the job carries timed
segments with a speaker on each, as in Step 4. Word timestamps, no. See
Speech to Text formats and output
for the full list of fields and what is not returned.
Can I transcribe a 30-minute recording?
Not in one call. Both endpoints reject anything over 10 minutes before transcription starts, so split the recording and send each part.
Is the synchronous call also a job?
Yes. It creates a job in the completed state and returns its jobId. The
job appears in GET /v1/speech-to-text/jobs with the same
transcription, durationSeconds and creditsConsumed.
Can I upload the same file twice?
Yes. Each upload is a new job and a new charge, whatever the file.