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

# 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](/docs/developers/get-an-api-key). If the
  key restricts services, it needs **Access** on **Speech to Text**.
- The base URL. See
  [Base URL](/docs/developers/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](/docs/developers/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`.

:::tabs

```bash title="cURL"
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"
```

```python
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())
```

```javascript
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());
```

:::

> [!NOTE]
> 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**

```bash
-F "file=@audio.wav;type=audio/wav"
```

**Less effective**

```bash
-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`:

```json
{
  "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](/docs/developers/stt-formats-and-output#response-fields). `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](/docs/developers/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:

```json
{
  "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`:

:::tabs

```bash title="cURL"
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"
```

```python
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"])
```

```javascript
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:

```json
{
  "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`.

> [!TIP]
> 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](/docs/developers/webhooks-configure) and
> [Webhook events](/docs/developers/webhooks-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.

:::tabs

```bash title="cURL"
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.srt
```

```python
import 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"])
```

```javascript
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:

```json
{
  "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]`:

```text
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](/docs/developers/stt-formats-and-output#download-as-txt-srt-or-json),
and every parameter and error is on [Download a completed STT job](/docs/reference/stt-export-job).

> [!NOTE]
> 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](/docs/developers/credits).
- An asynchronous job that stays `processing` for more than 10 minutes is
  marked `failed` with the error `Job timed out — AI server did not respond`,
  within 15 minutes of the `POST`. The credits are refunded. This timeout
  sends no `job.failed` webhook, 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](/docs/developers/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](/docs/developers/stt-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](/docs/developers/stt-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.

## Related

- [Speech to Text formats and output](/docs/developers/stt-formats-and-output)
- [Transcribe audio synchronously](/docs/reference/stt-transcribe-sync)
- [Transcribe audio file](/docs/reference/stt-transcribe)
- [Get STT job details](/docs/reference/stt-get-job)
- [Download a completed STT job](/docs/reference/stt-export-job)
- [List Speech to Text jobs](/docs/reference/stt-list-jobs)
- [Credits](/docs/developers/credits)
- [Webhook events](/docs/developers/webhooks-events)
