Speech to text
Two endpoints, one recogniser (Whisper large-v3):
| Endpoint | Use it for |
|---|---|
POST /v1/audio/transcriptions |
Plain text, no speaker labels. OpenAI-compatible, so the OpenAI SDKs work unchanged. About twice as fast on short clips: the right choice for voice commands and dictation. |
POST /v1/audio/diarize |
Transcript AND who said what. Interviews, meetings, broadcasts. Long recordings via asynchronous jobs. |
Transcription without speaker labels
POST /v1/audio/transcriptions
| Parameter | Type | Description |
|---|---|---|
file |
file | Audio file. MP3, WAV, FLAC, M4A, OGG. Up to 200 MB. Required |
model |
string | whisper-1 (or whisper-large-v3, whisperx: the same model). Required in the OpenAI SDK. |
language |
string | Language code (e.g., nl, en). Auto-detected if not specified. |
prompt |
string | Vocabulary hint: names and jargon that occur in the audio, comma separated. Max 1000 characters. |
response_format |
string | json (default, {"text": "..."}), text, srt, vtt or verbose_json (segments with timestamps, and the audio duration). |
timestamp_granularities[] |
array | With verbose_json: segment (default) and/or word for word-level timestamps. |
user, session_id |
string | Usage attribution, see Usage attribution. |
Streaming (stream=true) is not supported here; for live audio use live transcription.
import os
from openai import OpenAI
client = OpenAI(base_url="https://router.appelon.ai/v1", api_key=os.environ["APPELON_API_KEY"])
with open("opname.mp3", "rb") as f:
transcript = client.audio.transcriptions.create(model="whisper-1", file=f, language="nl")
print(transcript.text)
Audio without any speech returns an empty transcript ({"text": ""}), not an error. Billing is per minute of audio, at the same rate as diarization.
Transcription with speaker labels
POST /v1/audio/diarize
Request body
| Parameter | Type | Description |
|---|---|---|
model |
string | Transcription model: whisperx (the default when omitted). |
file |
file | Audio file to transcribe. MP3, WAV, FLAC, M4A, OGG. Up to 200 MB here, 500 MB on the jobs endpoint. Required |
language |
string | Language code (e.g., nl, en). Auto-detected if not specified. |
return_speaker_embeddings |
boolean | Set to true to receive a voice embedding per speaker. See Speaker embeddings. Default false. |
num_speakers |
integer | Exact number of speakers, when you know it (an interview: 2). Overrides the min/max pair. 1 to 50. |
min_speakers |
integer | Lower bound for the number of speakers. 1 to 50. |
max_speakers |
integer | Upper bound for the number of speakers. 1 to 50, and at least min_speakers. A bound helps on recordings where voices are similar or people talk over each other. |
initial_prompt |
string | Vocabulary hint: names, places and jargon that occur in the recording, comma separated. Improves the spelling of proper nouns. Max 1000 characters. |
user |
string | Your own end-user identifier, for usage attribution. Max 128 characters. See Usage attribution. |
session_id |
string | Groups requests into one conversation or run. Max 128 characters. See Usage attribution. |
Getting the best transcript
The speaker and vocabulary fields are optional, but on real recordings they make the biggest difference to the result. They cost nothing extra and do not slow the transcription down.
Tell us how many speakers to expect. Without a hint, the number of speakers is estimated from the audio alone. That estimate goes wrong most often on long recordings, similar voices, phone-ins and people talking over each other. You then see two people merged into one SPEAKER_xx, or one person split over several.
- Know the exact number (an interview, a podcast with fixed hosts)? Send
num_speakers. - Know roughly (a radio show with presenters and guests, a meeting)? Send a range with
min_speakersandmax_speakers, for example 2 and 6. Keep the range realistic: a tight range helps more than a wide one. - No idea at all? Leave them out.
Give the recogniser your vocabulary. Names of people, places, programmes and organisations are the words speech recognition gets wrong most often. List the ones you expect in initial_prompt, comma separated, spelled the way you want them in the transcript:
initial_prompt=Jan de Vries, Stadskanaal, Eemsdelta, Provinciale Staten, Waterschap Hunze en Aa's
Only list words that actually occur. The prompt is a hint, not an instruction: do not write sentences or requests in it.
Set the language. language=nl skips auto-detection, which can misfire on a recording that starts with music, jingles or an English quote.
curl -X POST "https://router.appelon.ai/v1/audio/diarize" \
-H "Authorization: Bearer $APPELON_API_KEY" \
-F "file=@uitzending.mp3" \
-F "model=whisperx" \
-F "language=nl" \
-F "min_speakers=2" \
-F "max_speakers=6" \
-F "initial_prompt=Jan de Vries, Stadskanaal, Eemsdelta, Provinciale Staten"
The same fields work on asynchronous jobs.
Example request
The diarization endpoint is not part of the OpenAI SDK, so use a direct HTTP request.
import os
import requests
response = requests.post(
"https://router.appelon.ai/v1/audio/diarize",
headers={"Authorization": f"Bearer {os.environ['APPELON_API_KEY']}"},
files={"file": open("interview.mp3", "rb")},
data={"model": "whisperx", "language": "nl"}
)
result = response.json()
for segment in result["segments"]:
print(segment["speaker"], segment["text"].strip())
Response with speaker labels
WhisperX provides speaker diarization: it identifies different speakers in the audio.
{
"language": "nl",
"segments": [
{
"start": 0.0,
"end": 2.5,
"text": "Welkom bij dit interview.",
"speaker": "SPEAKER_00"
},
{
"start": 2.8,
"end": 5.1,
"text": "Dank je wel voor de uitnodiging.",
"speaker": "SPEAKER_01"
}
]
}
Each segment also has a words list with per-word timestamps, and the response has a flat word_segments list of all words. Note that the response’s top-level text field is not a string: for compatibility it holds the same list as segments. For the plain text, join segments[].text, or use /v1/audio/transcriptions when you do not need speaker labels.
Audio without any speech returns an empty transcript (empty segments), not an error.
Speaker embeddings
Speaker labels are only consistent within one recording: SPEAKER_00 in one file has no relation to SPEAKER_00 in the next. To recognise the same voice across recordings, request speaker embeddings:
response = requests.post(
"https://router.appelon.ai/v1/audio/diarize",
headers={"Authorization": f"Bearer {os.environ['APPELON_API_KEY']}"},
files={"file": open("interview.mp3", "rb")},
data={"model": "whisperx", "language": "nl", "return_speaker_embeddings": "true"}
)
embeddings = response.json()["speaker_embeddings"]
# {"SPEAKER_00": [0.0123, -0.0456, ...], "SPEAKER_01": [...]}
The response gains a speaker_embeddings object with one 256-dimensional vector per detected speaker. Compare vectors with cosine similarity; the same person in two recordings scores high, different people score low. Store the vectors on your side, keyed by the person you identified.
Appelon returns the embeddings and does not store them. Voice embeddings are biometric data under the GDPR (AVG), so storing and matching them is your responsibility as data controller.
Limits and long recordings
/v1/audio/diarize (synchronous) |
/v1/audio/diarize/jobs (asynchronous) |
|
|---|---|---|
| Upload size | up to 200 MB | up to 500 MB |
| Recording length | up to about 160 minutes | up to about 8 hours |
| Processing time | about 6% of the recording (a 60-minute file takes 3 to 4 minutes) | the same, plus any time in the queue |
| Busy periods | the request waits for a free slot; if none frees up in time you get a 503 capacity_error with a Retry-After header |
the job is queued and position tells you how many are ahead |
Which one should I use? The synchronous endpoint keeps your HTTP connection open until the transcript is ready. That is the simplest option for recordings up to about an hour. For anything longer, and whenever you do not want to hold a connection open for minutes (a serverless function with a short timeout, a mobile app, a batch of files), use an asynchronous job.
Asynchronous jobs
A job splits one long request into separate steps: you upload the recording and immediately get a job id back, we transcribe it in the background, and you fetch the transcript when it is ready. Nothing breaks if your connection drops in the meantime, and you can submit several recordings at once.
How a job works
- Submit. You
POSTthe audio file to/v1/audio/diarize/jobs, with the same form fields as the synchronous endpoint. The response comes back within seconds (as soon as the upload is in) with status202 Acceptedand a jobid. - Wait. The job waits in a queue until a transcription slot is free, then runs. You find out it is ready in one of two ways:
- Polling: ask for the job’s status every 10 to 30 seconds with
GET /v1/audio/diarize/jobs/{id}. - Webhook: pass a
webhook_urlwhen you submit, and we POST the result to your server as soon as the job ends. No polling needed.
- Polling: ask for the job’s status every 10 to 30 seconds with
- Collect. When the status is
done, the transcript is in theresultfield of the status document. It has exactly the same format as the response of the synchronous endpoint. - Clean up. We keep the transcript for a limited time (6 hours by default), after which it is deleted automatically. You can delete it earlier with
DELETE.
The uploaded audio itself is deleted as soon as the transcription is finished. After that, and after the transcript has been deleted, only the job’s metadata remains (status, timestamps, length).
Job statuses
| Status | What it means | What to do |
|---|---|---|
queued |
Uploaded and waiting for a free slot. position tells you how many jobs are ahead of yours (0 means yours is next). |
Keep waiting. You can still cancel it with DELETE. |
running |
Being transcribed. This takes about 6% of the recording length: a 2-hour meeting is ready in about 7 minutes. | Keep waiting. A running job can no longer be canceled. |
done |
Finished. The transcript is in result. |
Read result and store it on your side. |
failed |
Something went wrong. The reason is in error. |
See When a job fails. You are not charged. |
canceled |
You canceled it while it was queued. | Nothing. The upload has been deleted. |
A job always moves forward: queued, then running, then one of done or failed. Only a queued job can become canceled.
Example: submit and poll
import os
import time
import requests
API = "https://router.appelon.ai/v1/audio/diarize/jobs"
HEADERS = {"Authorization": f"Bearer {os.environ['APPELON_API_KEY']}"}
# 1. Submit: returns as soon as the upload is in
with open("council-meeting.mp3", "rb") as f:
job = requests.post(
API,
headers=HEADERS,
files={"file": f},
data={"model": "whisperx", "language": "nl", "min_speakers": "3", "max_speakers": "12"},
).json()
print("submitted", job["id"])
# 2. Wait: poll every 15 seconds until the job has ended
while job["status"] in ("queued", "running"):
time.sleep(15)
job = requests.get(f"{API}/{job['id']}", headers=HEADERS).json()
# 3. Collect
if job["status"] == "done":
transcript = job["result"] # same document as the synchronous endpoint
print(" ".join(s["text"].strip() for s in transcript["segments"]))
# 4. Optional: delete the transcript from our side right away
requests.delete(f"{API}/{job['id']}", headers=HEADERS)
else:
print(job["status"], job.get("error"))
Save the job id as soon as you get it (in your database, next to the recording). If your process restarts while it waits, you can pick up where you left off with a GET. Lost the id? GET /v1/audio/diarize/jobs lists your recent jobs.
Request fields
The job endpoint takes every form field of the synchronous endpoint (file, model, language, the speaker counts, initial_prompt, user, session_id), plus two of its own:
| Field | Type | Description |
|---|---|---|
retention_seconds |
integer | How long we keep the transcript after the job is done, in seconds, from 0 to 21600 (6 hours). Default 21600. Choose a short retention if you collect the transcript right away and do not want it to stay on our side. With 0 the transcript is never stored at all and only goes to your webhook_url, which is then required. |
webhook_url |
string | Optional. An https URL on a public host where we POST the result when the job ends. See Webhooks. |
Endpoints
| Method and path | What it does |
|---|---|
POST /v1/audio/diarize/jobs |
Submit a recording. Returns 202 with the status document and a Location header pointing to the job. |
GET /v1/audio/diarize/jobs/{id} |
The job’s status, and the transcript in result once it is done. |
GET /v1/audio/diarize/jobs |
Your jobs of the last 7 days, newest first, at most 100, without transcripts. Useful to find a job id back or to see what is still in progress. |
DELETE /v1/audio/diarize/jobs/{id} |
On a queued job: cancels it and deletes the upload. On a finished job: deletes the transcript right away. Answers 409 on a running job. Calling it twice is harmless. |
Jobs belong to your account: every API key of the account can see and manage them, and any other account gets a 404.
Status document
Every job endpoint (and the webhook) returns the job in this shape:
{
"id": "job_5f1c2a9e0b7d4c3f8e6a1b2c3d4e5f60",
"object": "audio.diarize.job",
"status": "done",
"created_at": "2026-09-22T13:05:00Z",
"started_at": "2026-09-22T13:05:02Z",
"finished_at": "2026-09-22T13:12:40Z",
"retention_seconds": 21600,
"expires_at": "2026-09-22T19:12:40Z",
"audio_seconds": 7415.2,
"filename": "council-meeting.mp3",
"result": {"language": "nl", "text": "...", "segments": [...]}
}
| Field | Description |
|---|---|
id |
job_ followed by 32 hex characters. |
status |
queued, running, done, failed or canceled. See Job statuses. |
position |
Only while queued: how many jobs are ahead of yours. 0 means next. |
created_at, started_at, finished_at |
When the job was submitted, started running and ended. RFC 3339 timestamps in UTC. |
retention_seconds, expires_at |
The retention you asked for, and the moment the transcript will be deleted. After a DELETE, expires_at shows when it was deleted. |
audio_seconds |
The length of the recording in seconds, once done. This is what you are billed for. |
filename |
The file name you uploaded. |
result |
Once done, for as long as the transcript is kept: exactly the document the synchronous endpoint returns (text, segments with speaker labels and word timestamps). Not included in the list. |
error |
{message, type, code}. On a failed job, the reason. On a done job whose transcript has been deleted, code result_expired. |
Webhooks
With a webhook you do not need to poll: when the job ends, we send a POST to your webhook_url with the status document above as the JSON body.
- When: once the job is
done(the body includes the transcript inresult) orfailed(the body includeserror). A job you canceled yourself sends no webhook. - Your server must answer with a 2xx status within 15 seconds. Anything else counts as a failed delivery, including redirects (we do not follow them). We try three times: immediately, after 5 seconds and after 30 seconds. After that we give up.
- Requirements for the URL:
https, on a host that resolves to a public IP address.localhostand private or internal addresses are refused with a400when you submit. For local development, use a tunnel service that gives you a public https URL. - Not signed: the request carries no signature yet, so anyone who knows your URL could send you a fake one. Treat an incoming webhook as a signal that the job has ended, and confirm with a
GETon the job before you act on it. Using a hard-to-guess URL (with a random token in the path) helps too.
Webhook only, nothing stored (retention_seconds=0). For the strictest privacy, submit with retention_seconds=0 and a webhook_url. The transcript is then sent to your webhook and never written to disk on our side, so a GET shows the status but no result. The flip side: if all three delivery attempts fail (your server is down, or answers with an error), the transcript is gone and you have to submit the recording again. Only use this when your receiving endpoint is reliable.
When a job fails
A failed job has status: "failed" and an error with a code:
error.code |
Meaning | What to do |
|---|---|---|
model_unavailable |
The transcription backend was down (for example during a model update). We already retried the job three times, with pauses of 30 and 60 seconds, before giving up. | Submit again a few minutes later. |
upstream_error |
The backend could not process the file, usually because the audio is damaged or not a supported format. The message has the details. |
Check the file (does it play?), convert it to MP3 or WAV and submit again. |
server_error |
Something went wrong on our side. | Submit again. If it keeps failing, contact support@appelon.ai with the job id. |
If we restart our gateway while your job is queued or running, the job is not lost: it goes back into the queue and is completed. You are only charged when a job reaches done.
Response codes
These are the HTTP status codes of the job endpoints themselves (errors in the transcription itself end up in the job’s error, see above):
| Code | Meaning |
|---|---|
200 |
Status, list or delete succeeded. |
202 |
Job accepted. |
400 |
Missing file, invalid speaker parameters, bad webhook_url, retention_seconds outside 0 to 21600, or retention_seconds=0 without a webhook. |
401 |
Missing or invalid API key. |
404 |
Unknown job id, or a job that belongs to another account. |
409 |
DELETE on a job that is already running. |
413 |
Upload over 500 MB. |
429 |
Your account already has 10 open (queued or running) jobs. Wait until some finish, then submit the rest. |
Billing and privacy
Usage is metered once, when the job completes, on the length of the recording and at the same rate as the synchronous endpoint. It shows up in your dashboard like any other transcription.
The uploaded audio and the transcript are stored on our gateway in the Netherlands as encrypted files only, never in a database or a backup. The audio is deleted as soon as the transcription is done. The transcript is deleted when its retention period ends, when you delete it, or is never stored with retention_seconds=0.
Supported formats
- MP3
- WAV
- FLAC
- M4A
- OGG
Language support
WhisperX supports 90+ languages including Dutch, English, German, French, Spanish, and more. Language is auto-detected, but specifying it improves accuracy.
Live transcription
For audio that is happening now (a broadcast, a consult, a meeting) use the realtime endpoint. It speaks the OpenAI Realtime transcription API over a WebSocket, so the OpenAI SDK and most voice frameworks work as they are.
wss://router.appelon.ai/v1/realtime?intent=transcription
Two tiers, one recording:
- Live (
/v1/realtime): text while people speak, tentative text within about a second, committed text 2 to 3 seconds behind the audio. Fast, provisional. - Archive (
/v1/audio/diarize): run the same recording through the batch endpoint afterwards for the best quality and stable speaker labels. Keep the recording on your side; Appelon does not store audio.
Connecting
Servers connect with the API key in the Authorization header. Browsers and apps must never hold the API key: your backend requests a short-lived client secret and passes it to the client.
curl -X POST "https://router.appelon.ai/v1/realtime/client_secrets" \
-H "Authorization: Bearer $APPELON_API_KEY"
# {"value": "ek_...", "expires_at": 1757950000}
The client then connects with ?token=ek_.... A secret is valid for 60 seconds and for one connection.
Sending audio and reading text
import asyncio, base64, os, wave
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=os.environ["APPELON_API_KEY"],
websocket_base_url="wss://router.appelon.ai/v1")
async def main():
wav = wave.open("studio.wav") # 16-bit mono PCM, 16 kHz or 24 kHz
async with client.beta.realtime.connect(model="gpt-4o-transcribe") as conn:
await conn.session.update(session={
"type": "transcription",
"audio": {"input": {
"format": {"type": "audio/pcm", "rate": wav.getframerate()},
"transcription": {"model": "gpt-4o-transcribe", "language": "nl"}}},
})
async def send():
while frames := wav.readframes(wav.getframerate() // 10): # 100 ms
await conn.input_audio_buffer.append(audio=base64.b64encode(frames).decode())
await asyncio.sleep(0.1)
asyncio.create_task(send())
async for ev in conn:
if ev.type == "conversation.item.input_audio_transcription.delta":
print(ev.delta, end="", flush=True)
elif ev.type == "conversation.item.input_audio_transcription.completed":
print("\n[final]", ev.transcript)
asyncio.run(main())
Send 16-bit mono PCM at 16 kHz or 24 kHz in chunks of about 100 ms. Events:
| Event | Meaning |
|---|---|
conversation.item.input_audio_transcription.delta |
Committed text, append-only. |
conversation.item.input_audio_transcription.completed |
The item (a sentence or a speaker turn) is final. |
appelon.transcription.buffer |
Tentative text after the committed text. Show it greyed out; it may change. Not part of the OpenAI API, ignore it if your client does not know it. |
error |
Something ended the session; see below. |
Model names whisper-live, gpt-4o-transcribe and gpt-4o-mini-transcribe all map to the same Dutch-tuned model.
Speaker labels
Add "appelon": {"diarize": true} to the session object to get a speaker field (SPEAKER_00, SPEAKER_01) on every delta and completed event. Live labels are provisional: they are assigned within about a second and are unrelated to the labels the batch endpoint gives the same recording.
Interruptions
A session is never resumed. When something ends it, you get an error event with a code and the socket closes; text already delivered as completed is safe, and reconnecting starts a fresh session.
| Code | Close | What to do |
|---|---|---|
session_idle |
1000 | No audio for 30 s. Reconnect when audio resumes. |
session_expired |
1000 | Sessions last at most 2 hours. Reconnect. |
server_restarting |
1012 | Deploy. Reconnect right away. |
backend_unavailable |
1011 | The GPU is restarting. Reconnect with backoff; run the archive pass on your recording. |
session_limit |
HTTP 503 before connecting | All live slots are in use. Record locally and use the batch endpoint. |
Pricing
Live transcription is metered per minute of audio received, like the batch endpoint. Final pricing for live follows after the pilot.