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

  1. Fill in the request form: name, email, what you plan to do.
  2. You get a personal tracking link. Keep it: it is the only way to retrieve your token.
  3. Requests are reviewed by hand, usually within the day.
  4. 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:

  1. POST /transcriptions stores the file and immediately answers 202 with an id.
  2. The task joins a shared queue: the server processes one transcription at a time, first come, first served.
  3. GET /transcriptions/{id} returns the status, the position in the queue, the progress in %, then the result.
  4. The result stays available for 24 hours, then it is deleted.
After an idle period the model is unloaded from memory: the next transcription takes a few extra seconds to start.

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:

FieldTypeDescription
filefile, requiredAudio or video: mp3, m4a, wav, ogg, opus, flac, webm, mp4, mkv… (anything ffmpeg can read). 1 GB and 3 h max.
languagestring, optionalISO 639-1 code: fr (default), en, es, de… or auto to detect it. Setting it improves accuracy.
modelstring, optionallarge-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…" }
  ]
}
FieldPresentDescription
statusalwaysSee statuses.
positionqueuedRank in the queue (1 = next).
progressalwaysPercentage of the audio already processed (0 to 100).
audio_duration, detected_languageonce processing startsTotal length of the audio, language used.
textcompletedFull transcript, one line per segment.
segmentscompletedList of {start, end, text}, in seconds from the beginning of the file.
errorfailedTechnical 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

StatusMeaningNext
queuedWaiting in the queue.Watch position.
processingBeing transcribed.Watch progress.
completedResult available for 24 h.Read text/segments or an export.
failedProcessing 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"}.

CodeCauseWhat to do
400Invalid parameter (unknown model or language).Fix the request.
401Missing, invalid or revoked token.Check the Authorization header.
404Unknown or expired task, or owned by another token.Results only last 24 h.
409Export requested too early, or deleting a task being processed.Wait for completed.
413File over 1 GB or over 3 h.Split it or re-encode it (see best practices).
415Unreadable file or no audio track.Check the file with ffprobe.
422Missing file field or malformed request.Send multipart/form-data.
429Daily quota reached, or already 3 active tasks.Wait for a task to finish, or for tomorrow.
503Queue is full.Retry after the Retry-After delay.

Limits and quotas

LimitValue
File size1 GB
File duration3 h
Audio per token per day5 h (resets at midnight, Paris time)
Queued or processing tasks per token3
Total queue20 tasks
Result retention24 h
Concurrent transcriptions1 (all users together)

Some tokens are granted custom limits or no limits at all (except the file size); GET /me tells you yours.

This is a personal server with no GPU, shared by a few people: no availability or delay guarantee. It may be restarted for maintenance, which drops queued tasks and unretrieved results. Keep the source file until you have the result.

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-turbo when speed matters: the quality loss in French is minimal. Keep large-v3 for hard audio (noise, accents, overlapping voices).
  • Set the language when you know it: auto sometimes 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.