https://api.echonova.aiDevelopers
Speech to Text formats and output
What the upload endpoints accept, which checks run before a job starts, every field the responses carry, speaker identification, the TXT, SRT and JSON downloads, and what durationSeconds means.
This page is the lookup table for the two Speech to Text upload endpoints,
POST /v1/speech-to-text/sync and POST /v1/speech-to-text: what a request
must satisfy, which audio decodes, every field the responses carry, and
what the transcript does and does not include. It also covers speaker
identification and GET /v1/speech-to-text/jobs/{jobId}/export, which
downloads a finished job as TXT, SRT subtitles or JSON.
Accepted input
Both endpoints take one multipart/form-data part named file. The API runs
these checks, in this order, before any credits are charged or any
transcription starts:
| Check | Rule | Rejection | Status |
|---|---|---|---|
| Size | 150 MB or less | File too large |
413 |
| Part present | A part named file exists |
File is required |
400 |
| Content type | The part's Content-Type starts with audio/, compared case-insensitively |
Unsupported file type: <type>. Please upload an audio file. |
400 |
| Duration | 10 minutes or less, on both endpoints | Audio exceeds the 10-minute limit. |
400 |
The size check runs while the body is being received, so an oversized upload is cut off before the other checks run. The duration check reads the file's metadata; when the duration cannot be read, the check passes and the file is accepted.
The <type> in the content-type rejection is whatever your client put on the
part. application/octet-stream means the client guessed a generic type;
text/plain means the part carried no Content-Type header at all. Set the
type on the part yourself:
curl -X POST "https://api.echonova.ai/v1/speech-to-text/sync" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@audio.wav;type=audio/wav"import requests
with open("audio.wav", "rb") as audio:
response = requests.post(
"https://api.echonova.ai/v1/speech-to-text/sync",
headers={"Authorization": "Bearer YOUR_API_KEY"},
files={"file": ("audio.wav", audio, "audio/wav")},
)
print(response.json())import { readFile } from 'node:fs/promises';
const formData = new FormData();
formData.append('file', new Blob([await readFile('audio.wav')], { type: 'audio/wav' }), 'audio.wav');
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());| Container | Content-Type to send |
|---|---|
| WAV | audio/wav |
| MP3 | audio/mpeg |
| OGG | audio/ogg |
Which audio decodes
The checks above read the type label, not the bytes. Decoding happens after them: the audio is resampled to 16 kHz mono, and the channels of a stereo file are averaged rather than one being kept.
WAV, FLAC, OGG and MP3 decode. Before you rely on M4A, MP4, 3GP, WebM or any other container, send a short test file. A file that cannot be decoded passes every check above and fails afterwards, in one of two ways:
| Endpoint | What you see | Credits |
|---|---|---|
POST /v1/speech-to-text/sync |
503 with Transcription is temporarily unavailable. Please try again. The reason is not included. |
Refunded |
POST /v1/speech-to-text |
The job ends as failed and its error field holds the reason, which starts with Audio could not be decoded. Supported: WAV, FLAC, OGG/Opus, MP3…. A job.failed webhook is sent. |
Refunded |
Audio shorter than 50 ms is rejected with a message that starts with
Audio is too short followed by the measured duration. This surfaces the
same two ways.
To learn why a file fails, send it to POST /v1/speech-to-text: the job's
error field carries the reason, and the synchronous endpoint does not.
Language
Both upload endpoints take an optional language form field. Send auto
(default), ar or en. The value is trimmed and read without regard to case,
so AR and en are accepted. Any other value is a 400 with the message
language must be one of: auto, ar, en. Other extra form fields are ignored.
| Value | Use for |
|---|---|
auto |
Letting the service decide. That is Arabic today, which also handles mixed Arabic/English and clean English speech. |
ar |
Arabic and mixed Arabic/English speech. |
en |
Speech that is all English. Arabic sent as en comes back garbled. |
Speaker identification
Both upload endpoints take an optional identify_speakers form field. Send
true to learn who speaks when: the finished job then also carries the
transcript as timed segments, each tagged with a speaker, and a
speakers list with each speaker's talk time. The default is false. The
value is trimmed and read without regard to case; true, false, 1 and
0 are accepted, and an empty value counts as false. Any other value is a
400 with the message identify_speakers must be true or false. Asking for
speakers does not change the charge.
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"import requests
with open("call.mp3", "rb") as audio:
response = requests.post(
"https://api.echonova.ai/v1/speech-to-text",
headers={"Authorization": "Bearer YOUR_API_KEY"},
files={"file": ("call.mp3", audio, "audio/mpeg")},
data={"language": "ar", "identify_speakers": "true"},
)
print(response.json())import { readFile } from 'node:fs/promises';
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 response = await fetch('https://api.echonova.ai/v1/speech-to-text', {
method: 'POST',
headers: { Authorization: 'Bearer YOUR_API_KEY' },
body: formData,
});
console.log(await response.json());Speakers are named spk0, spk1 and so on, numbered in the order they
first speak. The same id is used on every segment and in speakers, so you
can count, colour or label the speakers yourself. The
download turns the ids into Speaker 1,
Speaker 2 and so on.
A segment:
| Field | Type | Value |
|---|---|---|
start |
number | Where the piece starts, in seconds from the start of the audio. |
end |
number | Where the piece ends, in seconds from the start of the audio. |
text |
string | What was said in this piece. Numbers are written as in transcription. |
speaker |
string or null |
The id of the speaker, such as spk0. null when this piece could not be given a speaker. |
Segments are sorted by start. A speaker:
| Field | Type | Value |
|---|---|---|
id |
string | The speaker id the segments use. |
talkTime |
number | How long this speaker talks, in seconds. |
identifySpeakers on the job records what you asked for, not what came
back. Speaker identification may not be available to you yet. Until it is,
a job made with identify_speakers=true completes with its full transcript,
and segments and speakers are empty. A recording whose speakers could
not be told apart comes back the same way. To know whether a job has
speaker data, check that segments is not empty, not identifySpeakers.
Jobs created before the field existed read false, [] and [].
The job.completed webhook carries the transcription, not the segments
or the speakers. Get the job, or download it, to read them.
Response fields
Every response is wrapped in the standard envelope; the fields below are
inside data. A successful POST returns HTTP 200.
Synchronous transcription
POST /v1/speech-to-text/sync returns the finished job:
| Field | Type | Value |
|---|---|---|
jobId |
string | The id of the job the call created. |
status |
string | completed. |
transcription |
string | The transcript. |
durationSeconds |
number or null |
Length of the audio in seconds, with decimals. |
creditsConsumed |
number | Credits charged for the call. |
language |
string | The language the transcription was requested with. |
numbersAsDigits |
boolean | Whether spoken numbers were written as digits. |
identifySpeakers |
boolean | Whether the call was made with identify_speakers=true. |
segments |
array | The transcript in timed pieces, each with its speaker. Empty unless the job has speaker data. See Speaker identification. |
speakers |
array | The speakers heard, in order of first appearance. Empty unless the job has speaker data. |
{
"jobId": "66e3f2a1c9d4b8a1f0e12345",
"status": "completed",
"transcription": "مرحبا بكم في إيكونوڤا",
"durationSeconds": 15.51,
"creditsConsumed": 26,
"language": "ar",
"numbersAsDigits": true,
"identifySpeakers": false,
"segments": [],
"speakers": []
}Asynchronous transcription
POST /v1/speech-to-text returns only the job id and its state:
| Field | Type | Value |
|---|---|---|
jobId |
string | The id to poll. |
status |
string | processing once the file is accepted. |
Job
GET /v1/speech-to-text/jobs/{jobId} returns one job, and
GET /v1/speech-to-text/jobs returns items, a list of these, plus total,
the number of API jobs in the workspace. Lists are sorted newest first and
paged with offset (0 or more, default 0) and limit (1 to 100, default
20).
| Field | Type | Value |
|---|---|---|
id |
string | The job id. |
status |
string | pending, processing, completed or failed. |
transcription |
string | The transcript. Present only once the job is completed; absent before that and on a failed job. |
durationSeconds |
number or null |
See below. |
creditsConsumed |
number | Credits charged when the job was created. Unchanged by a refund; a failed job keeps the number it was charged. |
error |
string | Present only on a failed job. It holds the reason the job failed; a job that stayed processing for more than 10 minutes carries Job timed out — AI server did not respond. |
language |
string | The language the transcription was requested with; auto for jobs created before the field existed. |
numbersAsDigits |
boolean | Whether spoken numbers were written as digits; true for jobs created before the field existed. |
identifySpeakers |
boolean | Whether the job was made with identify_speakers=true; false for jobs created before the field existed. |
segments |
array | The transcript in timed pieces, each with its speaker. Empty unless the job has speaker data, and always empty before the job is completed. |
speakers |
array | The speakers heard, in order of first appearance. Empty unless the job has speaker data. |
createdAt |
string | ISO 8601 timestamp of when the job was created. |
{
"id": "66e3f2a1c9d4b8a1f0e12346",
"status": "completed",
"transcription": "السلام عليكم، كيف حالك؟ بخير، الحمد لله.",
"durationSeconds": 6.4,
"creditsConsumed": 11,
"language": "ar",
"numbersAsDigits": true,
"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 }
],
"createdAt": "2026-09-15T10:21:04.512Z"
}Any API key in the workspace with Speech to Text access can list, get and
delete its jobs, even jobs made by a key that was later deleted. The job
endpoint returns 404 with STT Job not found for a job of another workspace
or one made in the portal.
What durationSeconds is
durationSeconds is the length of the audio in seconds. Where the number
comes from depends on the endpoint and the job's state:
| Where | Source of the value |
|---|---|
| Synchronous response | The length measured while transcribing. If none was measured, the duration read from the file's metadata, or the size-based estimate below, rounded to a whole second. |
Asynchronous job while pending or processing |
An estimate read from the file's metadata before the job started. When the metadata carries no duration, the file size in bytes divided by 16 000, rounded up. |
Asynchronous job once completed |
The length measured while transcribing, replacing the estimate. |
Credits are charged per second of audio, 100 credits a minute. The charge is computed from the
metadata duration, or the size-based estimate, before the job starts, and it
is rounded to a whole credit, at least 1. The measured
durationSeconds is usually the same length, but creditsConsumed is always
the number actually charged; use it, not durationSeconds, when you
reconcile spend. The rate is on the
Credits page.
Write files with a proper header so the duration can be read. A file with no readable duration is priced from its size, and that charge is not corrected afterwards.
Recommended
audio/wav,audio/mpegoraudio/oggfiles written by an encoder, with a complete header and a known duration.
Less effective
- A raw stream captured from a socket and uploaded as
audio/wavwith no header, priced at one second per 16 000 bytes whatever its real length.
What the transcript includes
The transcript is one string, and the duration is one number. A job made
with identify_speakers=true that has speaker data also carries the
transcript as timed segments with a speaker on each; see
Speaker identification. No response carries word
timestamps or confidence scores. Only the language you requested comes back,
as language on the synchronous response and on a job. If you need
timestamps for a job without speaker data, split the audio on your side
before you upload it and keep the offsets.
Download as TXT, SRT or JSON
GET /v1/speech-to-text/jobs/{jobId}/export returns a completed job as a
file. Unlike the other endpoints, a success is the file itself, not the
{ data, ... } envelope. An error is still the usual error object, so check
the status before you save the body. The endpoint reference is
Download a completed STT job.
| Query parameter | Required | Values |
|---|---|---|
format |
Yes | txt, srt or json. |
lang |
No | en (default) or ar, the language of the speaker labels: Speaker 1 or المتحدث 1. It does not translate the transcript. |
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 requests
response = requests.get(
"https://api.echonova.ai/v1/speech-to-text/jobs/JOB_ID/export",
headers={"Authorization": "Bearer YOUR_API_KEY"},
params={"format": "srt", "lang": "en"},
)
response.raise_for_status()
with open("call.srt", "wb") as out:
out.write(response.content)import { writeFile } from 'node:fs/promises';
const response = await fetch('https://api.echonova.ai/v1/speech-to-text/jobs/JOB_ID/export?format=srt&lang=en', {
headers: { Authorization: 'Bearer YOUR_API_KEY' },
});
if (!response.ok) throw new Error((await response.json()).errorMessage);
await writeFile('call.srt', await response.text());format |
Content-Type |
Body |
|---|---|---|
txt |
text/plain; charset=utf-8 |
Exactly the job's transcription. |
srt |
application/x-subrip; charset=utf-8 |
SubRip subtitles, built as described below. |
json |
application/json; charset=utf-8 |
The transcript, the speakers and the segments, shown below. |
Every download carries Content-Disposition: attachment and a file name
made from the uploaded file's name, with the audio extension swapped for the
format: call.mp3 downloads as call.srt. A name that is not plain ASCII,
such as an Arabic one, also comes as an RFC 5987 filename* in UTF-8, next
to an ASCII filename for older clients. Browser code that calls the API
from another origin can read the header.
Content-Disposition: attachment; filename="transcript.srt"; filename*=UTF-8''%D9%85%D9%83%D8%A7%D9%84%D9%85%D8%A9.srtAny key in the workspace with Speech to Text access can download its API
jobs, as with GET /v1/speech-to-text/jobs/{jobId}. Only a completed job
has a file; wait for job.completed or poll the job first.
SRT
The cues are built from the job's segments:
- Each cue lasts at most 7 seconds and holds at most two lines of at most 42 characters. Characters are counted as a reader counts them, not as bytes, so Arabic gets the same room as English.
- A longer segment is split at sentence and clause punctuation first
(
.!?؟،,;؛…:), then between words. A word is never split, and the text is never changed except for spaces and line breaks. - The cues of one segment share its time in proportion to their length in characters. There are no word timings, so cue times inside a segment are estimates: close enough to read along, not exact to the frame. The cues start and end where the segment does and never overlap.
- When a segment has a speaker, each of its cues starts with the speaker's
label in brackets:
[Speaker 1]withlang=en,[المتحدث 1]withlang=ar. Speakers are numbered 1, 2, 3 in the order they first speak. The label counts toward the line length. - A job with no segments, because speakers were not asked for or not
identified, downloads as one cue from
00:00:00,000to itsdurationSeconds, holding the whole transcript. - The file is UTF-8 with
\nline endings,HH:MM:SS,mmmtimestamps and a blank line between cues.
Three segments, the last one 12.7 seconds long, download with lang=en as:
1
00:00:00,380 --> 00:00:04,120
[Speaker 1] السلام عليكم، كيف حالك؟
2
00:00:04,300 --> 00:00:06,400
[Speaker 2] بخير، الحمد لله.
3
00:00:06,900 --> 00:00:11,425
[Speaker 2] عندي سؤال عن الفاتورة الأخيرة،
4
00:00:11,425 --> 00:00:15,659
[Speaker 2] المبلغ أعلى من الشهر الماضي.
5
00:00:15,659 --> 00:00:19,600
[Speaker 2] هل يمكنك أن تشرح لي السبب؟JSON
The JSON download keeps the segments as they are on the job, not split into cues, and adds the labels:
| Field | Type | Value |
|---|---|---|
id |
string | The job id. |
fileName |
string | The uploaded file's name. |
durationSeconds |
number or null |
As on the job. |
language |
string or null |
The language the transcription was requested with. |
text |
string | The transcript, the same as transcription. |
speakers |
array | id, label and talkTime of each speaker, numbered as in the SRT. Empty when the job has no speaker data. |
segments |
array | start, end, speaker, speakerLabel and text of each segment. speaker and speakerLabel are null when unknown. |
{
"id": "66e3f2a1c9d4b8a1f0e12346",
"fileName": "call.mp3",
"durationSeconds": 19.6,
"language": "ar",
"text": "السلام عليكم، كيف حالك؟ بخير، الحمد لله. عندي سؤال عن الفاتورة الأخيرة، المبلغ أعلى من الشهر الماضي. هل يمكنك أن تشرح لي السبب؟",
"speakers": [
{
"id": "spk0",
"label": "Speaker 1",
"talkTime": 3.74
},
{
"id": "spk1",
"label": "Speaker 2",
"talkTime": 14.8
}
],
"segments": [
{
"start": 0.38,
"end": 4.12,
"speaker": "spk0",
"speakerLabel": "Speaker 1",
"text": "السلام عليكم، كيف حالك؟"
},
{
"start": 4.3,
"end": 6.4,
"speaker": "spk1",
"speakerLabel": "Speaker 2",
"text": "بخير، الحمد لله."
},
{
"start": 6.9,
"end": 19.6,
"speaker": "spk1",
"speakerLabel": "Speaker 2",
"text": "عندي سؤال عن الفاتورة الأخيرة، المبلغ أعلى من الشهر الماضي. هل يمكنك أن تشرح لي السبب؟"
}
]
}Download errors
| Status | errorMessage |
When |
|---|---|---|
400 |
format must be one of the following values: txt, srt, json |
format is missing or holds another value, such as vtt. |
400 |
lang must be one of the following values: en, ar |
lang holds something other than en or ar. |
400 |
Job is not completed |
The job is pending, processing or failed. |
400 |
Invalid identifier |
jobId is not 24 hexadecimal characters. |
400 |
property <name> should not exist |
The query carries a parameter other than format and lang. |
404 |
STT Job not found |
The job id is unknown, or the job belongs to another workspace or was made in the portal. |
The authentication and rate-limit errors of every endpoint apply too; they are on Errors. Downloading is not charged.
Numbers
Spoken Arabic numbers are written as digits by default: a phone number said
as صفر واحد واحد خمسة comes back as 0115, and مية وخمسين جنيه as
150 جنيه. Words that are also ordinary Arabic stay words, so واحد صاحبي
("a friend of mine") is not turned into 1.
To keep the numbers exactly as they were said, send numbers_as_digits=false
with the upload. English transcripts are never converted. The job reports the
setting it was made with as numbersAsDigits.
FAQ
Why did a bad file give me 503 on one endpoint and a failed job on the other?
Both endpoints refund the charge. The synchronous endpoint reports every
transcription failure as 503 without a reason; the asynchronous job
carries the reason in error. Use POST /v1/speech-to-text when you need
to know why.
Is a rejected upload charged?
No. Every check in the table above runs before any credits are charged.
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.
Can I get timestamps or speaker labels?
Send identify_speakers=true with the upload. A job with speaker data
carries segments, each with a start, an end and a speaker, and
GET /v1/speech-to-text/jobs/{jobId}/export?format=srt turns them into
subtitles with speaker labels. There are no word timestamps.
Why are segments and speakers empty when I sent identify_speakers=true?
Speaker identification may not be available to you yet, or the speakers in the recording could not be told apart. The transcript is complete either way, and the SRT download holds one cue for the whole file.
Can I download WebVTT subtitles?
No. The download formats are txt, srt and json; format=vtt is a
400.
Why is durationSeconds different from what I was charged for?
The charge is computed before the job starts, from the file's metadata or
its size. durationSeconds on a completed job is the length measured
during transcription. Reconcile spend on creditsConsumed.