Audio transcription API
Send an audio or video file, get back the text, subtitles and timestamps. The service runs OpenAI's Whisper large-v3 (through faster-whisper) on a server in France: your files are never sent to any third party and are deleted as soon as they are processed.
Free, with a personal token. Tuned for French by default, it also understands about a hundred other languages.
Quickstart
Base URL: https://whisper.mayelk7.fr
# 1. Submit the file: the response contains a task id
curl -H "Authorization: Bearer $WHISPER_TOKEN" \
-F file=@meeting.m4a \
https://whisper.mayelk7.fr/transcriptions
# 2. Poll the task (every 5 to 10 s)
curl -H "Authorization: Bearer $WHISPER_TOKEN" https://whisper.mayelk7.fr/transcriptions/TASK_ID
# 3. Download the text once status is "completed"
curl -H "Authorization: Bearer $WHISPER_TOKEN" https://whisper.mayelk7.fr/transcriptions/TASK_ID/txt
Getting a token
- Fill in the request form: name, email, what you plan to do.
- You get a personal tracking link. Keep it: it is the only way to retrieve your token.
- Requests are reviewed by hand, usually within the day.
- Once approved, open your tracking link and click “Show my token”. It is shown only once: copy it right away.
Tokens start with wsp_. Treat yours like a password: never commit it to a Git repository, never ship it in code that runs in someone else's browser.
Keep it in an environment variable (WHISPER_TOKEN). Lost or leaked? Email mael.kerivel@mayelk7.fr to have it revoked, then request a new one.
Authentication
Every call (except /health) carries this header:
Authorization: Bearer wsp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Without a valid token the response is 401. A token only sees its own tasks: another user's task id returns 404.
The API accepts calls from any origin (CORS is open), but only call it from a browser for your own tools: the token would be visible to anyone opening the page. For a public app, go through your own backend.
How it works
Transcribing takes time: roughly the length of the audio with large-v3, three to four times less with large-v3-turbo.
No HTTP request could stay open that long, so the API is asynchronous:
POST /transcriptionsstores the file and immediately answers202with anid.- The task joins a shared queue: the server processes one transcription at a time, first come, first served.
GET /transcriptions/{id}returns the status, the position in the queue, the progress in %, then the result.- The result stays available for 24 hours, then it is deleted.
API reference
Everything is JSON (UTF-8), except the text exports. Dates are Unix timestamps in seconds (floats), durations are in seconds. An interactive Swagger UI and the OpenAPI specification are available too.
POST/transcriptions
Submits a file. Body as multipart/form-data:
| Field | Type | Description |
|---|---|---|
file | file, required | Audio or video: mp3, m4a, wav, ogg, opus, flac, webm, mp4, mkv… (anything ffmpeg can read). 1 GB and 3 h max. |
language | string, optional | ISO 639-1 code: fr (default), en, es, de… or auto to detect it. Setting it improves accuracy. |
model | string, optional | large-v3 (default, most accurate), large-v3-turbo (3 to 4Ă— faster, nearly as good), medium, small. |
Response 202 Accepted:
{
"id": "3f9c2a1be0d84b6c9f1e7a52d4c0b8e1",
"status": "queued",
"progress": 0,
"filename": "meeting.m4a",
"model": "large-v3",
"language": "fr",
"created_at": 1790355000.12,
"position": 1
}
GET/transcriptions/{id}
Status of the task and, once completed, its result.
{
"id": "3f9c2a1be0d84b6c9f1e7a52d4c0b8e1",
"status": "completed",
"progress": 100,
"filename": "meeting.m4a",
"model": "large-v3",
"language": "fr",
"detected_language": "fr",
"created_at": 1790355000.12,
"started_at": 1790355000.20,
"finished_at": 1790355812.47,
"audio_duration": 845.3,
"processing_time": 812.3,
"text": "Bonjour à tous, on commence la réunion.\nPremier point à l'ordre du jour…",
"segments": [
{ "start": 0.0, "end": 3.52, "text": "Bonjour à tous, on commence la réunion." },
{ "start": 4.1, "end": 7.86, "text": "Premier point à l'ordre du jour…" }
]
}
| Field | Present | Description |
|---|---|---|
status | always | See statuses. |
position | queued | Rank in the queue (1 = next). |
progress | always | Percentage of the audio already processed (0 to 100). |
audio_duration, detected_language | once processing starts | Total length of the audio, language used. |
text | completed | Full transcript, one line per segment. |
segments | completed | List of {start, end, text}, in seconds from the beginning of the file. |
error | failed | Technical message. |
GET/transcriptions/{id}/txt · /srt · /vtt
The result as text/plain: raw text, SRT subtitles or WebVTT (for the HTML5 <track> element).
Returns 409 until the task is completed.
1
00:00:00,000 --> 00:00:03,520
Bonjour à tous, on commence la réunion.
2
00:00:04,100 --> 00:00:07,860
Premier point à l'ordre du jour…
GET/transcriptions
Your tasks from the last 24 hours, without the text, oldest first.
DELETE/transcriptions/{id}
Deletes the task and its result. A queued task is cancelled and its duration is credited back to your quota.
A task being processed cannot be interrupted (409). Responds 204 with no body.
GET/me
Your token and its usage.
{
"name": "Alex",
"prefix": "wsp_a1B2c3",
"created_at": 1790350000.0,
"unlimited": false,
"transcriptions": 12,
"audio_seconds_total": 9321.4,
"daily_quota_seconds": 18000,
"used_today_seconds": 845.3,
"remaining_today_seconds": 17154.7,
"active_tasks": 0,
"max_active_tasks": 3
}
For an unlimited token, unlimited is true and the quota fields are null.
GET/health
No token needed. Tells whether the service is up, which model is loaded and how busy the queue is.
{ "ok": true, "loaded_model": "large-v3", "queued": 2, "processing": true }
Task statuses
| Status | Meaning | Next |
|---|---|---|
queued | Waiting in the queue. | Watch position. |
processing | Being transcribed. | Watch progress. |
completed | Result available for 24 h. | Read text/segments or an export. |
failed | Processing failed (corrupted file…). | Read error. Not counted against the quota. |
Errors
Errors come with an HTTP status code and a JSON body {"detail": "readable message"}.
| Code | Cause | What to do |
|---|---|---|
400 | Invalid parameter (unknown model or language). | Fix the request. |
401 | Missing, invalid or revoked token. | Check the Authorization header. |
404 | Unknown or expired task, or owned by another token. | Results only last 24 h. |
409 | Export requested too early, or deleting a task being processed. | Wait for completed. |
413 | File over 1 GB or over 3 h. | Split it or re-encode it (see best practices). |
415 | Unreadable file or no audio track. | Check the file with ffprobe. |
422 | Missing file field or malformed request. | Send multipart/form-data. |
429 | Daily quota reached, or already 3 active tasks. | Wait for a task to finish, or for tomorrow. |
503 | Queue is full. | Retry after the Retry-After delay. |
Limits and quotas
| Limit | Value |
|---|---|
| File size | 1 GB |
| File duration | 3 h |
| Audio per token per day | 5 h (resets at midnight, Paris time) |
| Queued or processing tasks per token | 3 |
| Total queue | 20 tasks |
| Result retention | 24 h |
| Concurrent transcriptions | 1 (all users together) |
Some tokens are granted custom limits or no limits at all (except the file size); GET /me tells you yours.
Code examples
Shell (curl + jq)
#!/bin/sh
# Usage: WHISPER_TOKEN=wsp_... ./transcribe.sh file.m4a
set -e
API=https://whisper.mayelk7.fr
AUTH="Authorization: Bearer $WHISPER_TOKEN"
ID=$(curl -sf -H "$AUTH" -F "file=@$1" -F model=large-v3 "$API/transcriptions" | jq -r .id)
echo "Task $ID"
while :; do
R=$(curl -sf -H "$AUTH" "$API/transcriptions/$ID")
STATUS=$(echo "$R" | jq -r .status)
echo "$STATUS $(echo "$R" | jq -r '.position // .progress')"
[ "$STATUS" = completed ] && break
[ "$STATUS" = failed ] && { echo "$R" | jq -r .error; exit 1; }
sleep 10
done
curl -sf -H "$AUTH" "$API/transcriptions/$ID/srt" > "${1%.*}.srt"
curl -sf -H "$AUTH" "$API/transcriptions/$ID/txt" > "${1%.*}.txt"
Python (requests)
import os, time, requests
API = "https://whisper.mayelk7.fr"
S = requests.Session()
S.headers["Authorization"] = f"Bearer {os.environ['WHISPER_TOKEN']}"
def transcribe(path, language="fr", model="large-v3"):
with open(path, "rb") as f:
r = S.post(f"{API}/transcriptions", files={"file": f},
data={"language": language, "model": model}, timeout=600)
r.raise_for_status()
task = r.json()
while task["status"] in ("queued", "processing"):
time.sleep(10)
task = S.get(f"{API}/transcriptions/{task['id']}", timeout=30).json()
if task["status"] == "failed":
raise RuntimeError(task["error"])
return task
result = transcribe("meeting.m4a")
print(result["text"])
for s in result["segments"]:
print(f"[{s['start']:7.1f}s] {s['text']}")
JavaScript (Node.js 18+, no dependency)
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
const API = "https://whisper.mayelk7.fr";
const headers = { Authorization: `Bearer ${process.env.WHISPER_TOKEN}` };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function transcribe(path, { language = "fr", model = "large-v3" } = {}) {
const form = new FormData();
form.append("file", new Blob([await readFile(path)]), basename(path));
form.append("language", language);
form.append("model", model);
const r = await fetch(`${API}/transcriptions`, { method: "POST", headers, body: form });
if (!r.ok) throw new Error(`${r.status} ${(await r.json()).detail}`);
let task = await r.json();
while (task.status === "queued" || task.status === "processing") {
await sleep(10_000);
task = await (await fetch(`${API}/transcriptions/${task.id}`, { headers })).json();
}
if (task.status === "failed") throw new Error(task.error);
return task;
}
const { text } = await transcribe("meeting.m4a");
console.log(text);
PHP (cURL)
<?php
$api = 'https://whisper.mayelk7.fr';
$auth = ['Authorization: Bearer ' . getenv('WHISPER_TOKEN')];
function call(string $url, array $headers, ?array $post = null): array {
$c = curl_init($url);
curl_setopt_array($c, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers]);
if ($post !== null) curl_setopt($c, CURLOPT_POSTFIELDS, $post);
$r = json_decode(curl_exec($c), true);
curl_close($c);
return $r;
}
$task = call("$api/transcriptions", $auth, [
'file' => new CURLFile('meeting.m4a'),
'language' => 'fr',
]);
while (in_array($task['status'], ['queued', 'processing'])) {
sleep(10);
$task = call("$api/transcriptions/{$task['id']}", $auth);
}
echo $task['text'] ?? $task['error'];
Best practices
- Poll every 5 to 10 seconds, not more often: the result won't come faster. With a long queue, go up to 30 s.
- Pick
large-v3-turbowhen speed matters: the quality loss in French is minimal. Keeplarge-v3for hard audio (noise, accents, overlapping voices). - Set the language when you know it:
autosometimes guesses wrong on the first seconds. - Send audio only: extracting the soundtrack of a video makes the upload 10 to 100Ă— smaller, with no loss for transcription.
ffmpeg -i video.mp4 -vn -ac 1 -ar 16000 -c:a libopus -b:a 24k audio.ogg - Over 3 h, split the file:
ffmpeg -i long.mp3 -f segment -segment_time 3600 -c copy part_%02d.mp3 - Delete your tasks once you have the result (
DELETE): cleaner for your data. - Silences are skipped automatically (voice activity detection), which also prevents Whisper's “hallucinations” on blank audio.
Your data
- Files are processed on a server in France (OVH, Roubaix), with no third-party service.
- The uploaded file is deleted as soon as the transcription ends; the result after 24 h, or as soon as you delete it.
- Nothing is used to train a model or read by the operator, except for a diagnosis you asked for.
- Details (in French): privacy policy.