Skip to content

Transcribe audio

Core turns a recording into text with an audio model from your organisation's catalogue. There are four ways to use it:

You want Use
The text of a recording in your own application, such as minutes of a meeting POST /audio/transcriptions, which answers the text
The text of a long recording, such as a meeting of an hour a transcription job, which Core splits and transcribes in the background
A model that decides for itself to transcribe a file during a conversation the transcription tool builtin__transcribe_audio__<model>
A spoken message that the model answers as if it were typed an input_audio part in a POST /responses request

All four use the same audio models and cost the same per second of audio.

Before you start

Find an audio model. GET /models?kind=audio lists the audio models your organisation has. Each entry carries its id, where it runs (region, country), its price per minute and allowed: whether your key's model policy admits it right now. false means a transcription with that model is refused. When the list is empty, your organisation has no audio model; ask your operator to add one.

curl -s "$CORE_URL/models?kind=audio" -H "Authorization: Bearer $CORE_KEY"
{
  "items": [
    {
      "id": "whisper-large-v3",
      "object": "model",
      "kind": "audio",
      "allowed": true,
      "tool_name": "builtin__transcribe_audio__whisper-large-v3",
      "region": "eu",
      "country": "fr",
      "price": {
        "input_eur_per_million_tokens": null,
        "output_eur_per_million_tokens": null,
        "eur_per_unit": "0.003",
        "effective_from": "1970-01-01T00:00:00Z",
        "is_tenant_override": false
      }
    }
  ],
  "next_cursor": null
}

The entry is shortened here; the full shape is on Make a model call. For an audio model, price.eur_per_unit is the price of one minute of audio. A model billed in tokens, such as gpt-4o-transcribe, also carries input_eur_per_million_tokens and output_eur_per_million_tokens, which are what it is billed at; its minute is then an estimate (Cost).

Know what your key needs. The transcription route needs no capability: like POST /responses, it is decided by your key's policy over the model. The tool also needs the tooling capability and transcribe_audio among the key's system tools. Naming a file by its id needs the files capability, because the file has to be uploaded first.

Formats and limits

Core reads the format from the recording's own bytes, not from its filename or declared type. It accepts WAV, FLAC, MP3 (also as mpeg and mpga), Ogg (ogg, oga), MP4 (mp4, m4a) and WebM. Anything else is refused before it is sent anywhere. A raw AAC stream without a container is not accepted; put it in an MP4 (m4a) container first. Whether an MP4 or WebM file is transcribed also depends on the model's vendor: if the vendor cannot read it, the call fails with The audio could not be transcribed. Send MP3, WAV, FLAC or Ogg to be safe.

One recording may be at most 25 MiB, the same ceiling as any upload. Your operator can change it for the deployment, and your key's files configuration can lower it; GET /key shows your key's max_bytes. How much time fits in 25 MiB depends on how the recording is encoded:

Recording About
MP3 at 128 kbit/s 27 minutes
MP3 at 64 kbit/s, enough for speech 55 minutes
Ogg Opus at 32 kbit/s 1 hour 49 minutes
WAV, 16 kHz mono, 16-bit 13 minutes
WAV, 44.1 kHz stereo, 16-bit 2.5 minutes

A recording longer than that does not fit in one file: compress it to a speech-friendly format, such as mono MP3 at 64 kbit/s or Ogg Opus, which makes a meeting of an hour or more fit.

One call has its limits too. Some models take only a short recording in one call: Google's chirp-3 a minute, Azure's fast-transcription no more than the call's time budget. And every transcription has a time budget of its own (two minutes unless your operator changed it); a call that runs past it fails with The audio could not be transcribed. A recording past its model's limit is refused with 422 on file before anything is sent. For a recording that one call cannot take, use a transcription job: Core splits it into parts the model takes and transcribes them one after another. Inside a conversation, let the tool run in the background so the turn does not wait for a long recording.

Transcribe a recording

Send the recording as multipart/form-data to POST /audio/transcriptions:

curl -s -X POST "$CORE_URL/audio/transcriptions" \
    -H "Authorization: Bearer $CORE_KEY" \
    -F "file=@meeting.mp3" \
    -F "model=whisper-large-v3" \
    -F "language=nl"
{
  "text": "Goedemorgen allemaal, dan beginnen we met de begroting.",
  "usage": { "type": "duration", "seconds": 1312 }
}

usage.seconds is the length of audio this call was billed for, in whole seconds, rounded up. A model billed in tokens, such as gpt-4o-transcribe, answers OpenAI's token shape instead: "usage": { "type": "tokens", "input_tokens": 14, "output_tokens": 45, "total_tokens": 59 }.

Field Meaning
file The recording. Required.
model The id of an audio model from GET /models?kind=audio. Required.
language The recording's language as a two-letter ISO 639-1 code, such as nl or en. Leave it out and the model detects the language itself. Naming it usually gives a better result.
prompt Text that guides the model: names, spellings or the style you want, such as Artific, Kubernetes, Jansen. At most 4,000 characters.
response_format json (the default), text or verbose_json.
temperature A number from 0 to 1. Leave it out unless you have a reason.

text answers the text alone, as text/plain. verbose_json adds task (always transcribe), language (the language you named, or null when you named none) and duration (the seconds of audio the vendor reported, or null for a model billed in tokens). Core does not return segments or words, so srt and vtt, which are built from them, are refused with 422. Other fields of OpenAI's transcription API, such as timestamp_granularities[], are ignored.

With the OpenAI SDK

The route takes the same fields as OpenAI's own transcription API, so the openai library works when you point it at Core:

from openai import OpenAI

client = OpenAI(base_url="https://<your-deployment>/api/v1", api_key="ack_...")

with open("meeting.mp3", "rb") as recording:
    transcript = client.audio.transcriptions.create(
        model="whisper-large-v3",
        file=recording,
        language="nl",
    )

print(transcript.text)

For one person

A request that names a person in X-Core-Subject, or that a person's own token sends, is decided by that person's rules as well as the key's, and the usage row carries the person (Build for people).

Transcribe a long recording

A meeting of an hour is too long for one call. Upload it as a file and create a transcription job: Core splits the recording into parts the model takes, at quiet moments, transcribes them one after another in the background and joins the text. You poll the job until it is done.

1. Upload the recording with the files capability:

curl -s -X POST "$CORE_URL/files" \
    -H "Authorization: Bearer $CORE_KEY" \
    -F "file=@meeting.mp3" \
    -F "purpose=assistants"

The answer's id is the file id, such as file_01J.... The file is at most your key's file ceiling (Formats and limits).

2. Create the job:

curl -s -X POST "$CORE_URL/audio/transcription-jobs" \
    -H "Authorization: Bearer $CORE_KEY" \
    -H "Content-Type: application/json" \
    -d '{"file_id": "file_01J...", "model": "whisper-large-v3", "language": "nl"}'

Core answers 202 with the job and its address in Location:

{
  "id": "atj_01J...",
  "status": "queued",
  "file_id": "file_01J...",
  "model": "whisper-large-v3",
  "language": "nl",
  "chunks_done": 0,
  "chunks_total": null,
  "error": null,
  "result": null,
  "created_at": "2026-10-17T09:00:00Z",
  "ended_at": null
}

The body takes file_id and model, both required, and language, prompt and temperature as the route does; the prompt is sent with every part. The model, the file, your key's policy and your monthly cap are checked before the job is created, so a job that is created can run: the cap is asked with what the whole recording will cost.

3. Poll the job with GET /audio/transcription-jobs/{id} until status is completed, failed or cancelled. While it runs, status is in_progress, chunks_done counts the parts already transcribed and chunks_total how many the recording takes. A part of five minutes usually takes well under a minute, so poll every ten seconds or so.

4. Read the transcript. A completed job carries result:

{
  "task": "transcribe",
  "language": "nl",
  "duration": 3612.4,
  "text": "Goedemorgen allemaal, dan beginnen we met de begroting. ...",
  "segments": [
    { "start": 0.0, "end": 298.7, "text": "Goedemorgen allemaal, dan beginnen we met de begroting." },
    { "start": 298.7, "end": 596.1, "text": "Het tweede punt is de planning voor het voorjaar." }
  ],
  "usage": { "type": "duration", "seconds": 3624 }
}

text is every part's text joined by a space. segments has one entry per part, with its start and end in seconds of the original recording, so you can find a passage again in the audio. There is no srt or vtt: a part of several minutes is no subtitle.

GET /audio/transcription-jobs lists your key's jobs, newest first, by cursor. A key sees only the jobs it created, and a subject token only its own person's. POST /audio/transcription-jobs/{id}/cancel stops a job: the parts already transcribed stay billed, and no further part is sent.

When a job fails, error says why with a fixed code and message:

code What happened
file_unavailable The file was deleted, or your key can no longer read it.
recording_unreadable, split_timeout Core could not decode the recording, or decoding took too long. Convert it to MP3 or FLAC and try again.
recording_too_long The file decodes longer than its size can hold at the lowest bit rate, which no real recording does. The parts before that point stay billed. Convert it and try again.
transcription_refused, transcription_failed The vendor refused or failed one part. The parts before it stay billed.
usage_cap_exceeded Your monthly cap was reached during the job. The parts before it stay billed.
model_not_available, model_not_allowed The model was withdrawn, or your key's policy no longer admits it.
key_revoked, key_disabled, service_account_paused, tenant_unavailable The key that created the job is revoked, expired or disabled, the service account it works for is paused, or your organisation is not available.
reaped_too_often The job stopped its worker every time it ran. Ask your operator.

Let the model transcribe a file

Offer the transcription tool when the model should decide for itself whether a recording needs to be read, for example an assistant that processes what a user uploaded.

  1. Switch the tool on for your key. Your key's tooling configuration must list transcribe_audio under system_tools. In the console that is Transcribe audio among the system tools on the key's page (Keys). Without it, offering the tool is refused with 400.
  2. Upload the recording with POST /files (Files) and keep its id. An artifact your organisation holds can be named the same way.
  3. Offer the tool and tell the model which file to read:
curl -s -X POST "$CORE_URL/responses" \
    -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
    -d '{
      "model": "<a chat model id>",
      "tools": [{"type": "tool", "name": "builtin__transcribe_audio__whisper-large-v3"}],
      "input": "Summarise the recording src_3Wq8... in five bullets. It is in Dutch."
    }'

There is one tool per audio model, named after the model's id. The name is in tool_name on GET /models?kind=audio, so you do not have to build it yourself. How the tool works in detail, with its arguments and fixed answers, is on Tools through the API.

The model passes file_id and, optionally, language and prompt. It receives the transcript as the tool's result. The model never sends the recording itself, and Core never fetches a recording from a web address.

Run it in the background

A recording can be transcribed as a task, so the conversation goes on while it runs and the transcript arrives on the thread when it is ready. The tool still takes only what one call takes; a recording past its model's limit needs a transcription job. Your organisation allows that per tool, as for an image tool (Run a tool in the background).

Send a spoken message

A user message in POST /responses can carry a recording as an input_audio part. Core transcribes it before the model is called, and the model reads the text as if the user had typed it. Use this for a voice message from an app or a phone.

Send the recording inline, base64-encoded with its format:

{
  "model": "<a chat model id>",
  "input": [
    {
      "role": "user",
      "content": [
        { "type": "input_audio", "input_audio": { "data": "<base64>", "format": "mp3" } }
      ]
    }
  ]
}

or name a file you uploaded before:

{ "type": "input_audio", "file_id": "src_3Wq8..." }

Give exactly one of the two. format is accepted for compatibility; Core reads the format from the bytes, as on the route. A message can mix input_audio with input_text parts: each recording becomes text at the place where it stood, and the parts are joined as usual. Only a user message may carry input_audio.

Choose the audio model, or let Core choose. Add "model" to the part to name the audio model that transcribes that recording, an id from GET /models?kind=audio:

{ "type": "input_audio", "file_id": "src_3Wq8...", "model": "whisper-large-v3" }

That model transcribes it or the request is refused: an id that is not one of your organisation's audio models is 400 on input, and a model your key's policy does not admit is 403. Core never swaps in another model. Leave model out and Core takes, among the audio models your key's policy admits for this turn, your operator's preferred one (Scaleway's Whisper, whisper-large-v3, unless your operator changed it), else the first hosted in the EU, else the first in your organisation's catalogue. The turn's hosting.region, if you set one, narrows what your key admits. When your key admits none of them, the request is refused with 403; when your organisation has no audio model at all, it is refused with 400 on input.

The recording is transcribed only once the turn has been accepted. Core first checks the model, the policy, your monthly cap and the conversation. A request that is refused there costs no transcription. A request with background: true has its recording transcribed before Core answers that the run was accepted.

The conversation stores the transcript, not the recording. The stored message holds the text the recording became. A recording sent inline is not stored anywhere. A recording named by file_id stays the file it already was.

A router does not hear the recording. If your request names a model router, the router chooses the model before the recording is transcribed. It sees the message without its recordings, so a message that is only a recording looks empty to the router.

Limits. Each recording follows the 25 MiB ceiling above. An inline recording is also part of the request body, which may be at most 8 MiB. Because base64 makes data about a third larger, about 6 MiB of audio fits inline: roughly six minutes of MP3 at 128 kbit/s. Upload a longer recording and send its file_id.

Where input_audio is refused:

  • POST /threads and POST /threads/{thread_id}/items refuse an item with input_audio with 422 on items, because those routes do not call a model and nothing would transcribe it. Send the message through POST /responses instead.
  • A schedule's stored request may name a recording only by file_id. An inline recording is refused with 422 on request.input, because the schedule would otherwise keep the recording for every time it fires.

Errors

Status Message What to do
413 The recording is larger than your key's file ceiling. Compress or split it (Formats and limits).
422 on model The model is not an audio model this organisation has. Choose an id from GET /models?kind=audio. A chat model and a model of another organisation answer the same way.
422 on file The file is not a recording Core can transcribe. Accepted: ... Convert the recording to one of the formats listed.
422 on file The recording is longer than this model transcribes: at most ... Upload it as a file and POST /audio/transcription-jobs, which splits it into parts this model takes. The recording is past what this model takes in one call. Use a transcription job.
403 The audio model is not allowed for this key. Your key's policy does not admit this model. Ask your administrator.
403 The audio model is not hosted in a region this key allows. The model runs outside your key's regions. Use another audio model, or ask your administrator.
403 The audio model is not hosted in the region this turn requires. Only for input_audio: no audio model runs in the turn's hosting.region.
429 Your key or organisation has reached its monthly cap, or this recording would take it past the cap (Your monthly cap).
422 The transcription model refused this audio. The vendor would not transcribe this recording. Sending it again does not help.
503 The audio could not be transcribed. The vendor failed, timed out or could not read the format. Try again later, or try a shorter recording or another format.

For input_audio the same sentences arrive in the error object of POST /responses, which answers 400 where the route answers 422, with param set to input where the problem is the recording. Two more apply there: a recording over the ceiling is An input_audio recording exceeds the size this caller may upload., and a file_id your key cannot read is A named input_audio file could not be used. Core never retries a transcription by itself, because the vendor may already have transcribed and billed it.

Cost

An audio model is priced per minute of audio; the price is on the model in GET /models?kind=audio and on Models in the console. Every transcription the vendor answered writes one usage row of kind audio_transcription, with the whole seconds of audio as its quantity, rounded up, and the model (Usage through the API). The cost is the seconds times the price per minute, divided by sixty: 90 seconds at EUR 0.003 per minute cost EUR 0.0045. A refused or failed transcription costs nothing.

Some models are billed in tokens, the way their vendor invoices them: OpenAI's gpt-4o-transcribe and gpt-4o-mini-transcribe. Their price also names a rate per million input tokens (the recording's and your prompt's) and per million output tokens, and their row carries those tokens with a quantity of zero, so the cost is the tokens at those rates. Their price per minute is then an estimate, used only for the monthly cap below.

Several recordings in one request are several rows. A transcription made by the tool also writes the ordinary tool_invocation row of a tool call. A transcription job writes one row per part, each rounded up on its own, so a job of twelve parts may bill a few seconds more than the recording is long, and a model's minimum per call (Groq bills at least ten seconds) applies to every part. A job's result.usage is the sum of its rows.

The monthly cap is checked before the call, with what the recording will cost. Core reads how long the recording is from its own headers, before any vendor hears it, and refuses the call with 429 when that many seconds would take your key or organisation past its cap. A recording whose headers do not say how long it is (a WebM recorded live in a browser, for example) is counted as long as its size allows at 2 kbit/s, below the lowest speech codecs these formats carry, so it may be refused where a recording that states its length would not. A transcription job is weighed this way once when it is created, for the whole recording, and again before every part, against what the parts before it already cost. What you are billed afterwards is the seconds the vendor reports, not the estimate, or for a model billed in tokens, its tokens. An audio model priced at zero is never refused.

Privacy and where audio goes

The recording is sent to the model's vendor as it is. Hiding personal data works on text, and there is no text until the vendor has heard the audio. So on a key with pseudonymisation the voices and every name spoken in the recording still reach the vendor. What protects a recording is where it goes: your key's policy decides which audio models it may use and in which regions, before any byte is sent (Regions). Check region and country on GET /models?kind=audio before you send something sensitive.

The transcript is text like any other. The route returns it to you as the vendor heard it. When the transcript enters a conversation, through the tool or input_audio, a key that hides personal data hides it from the chat model as it hides your own messages.

Traces hold neither the audio nor the text. A transcription appears in your traces with the model, the size of the recording and the seconds billed. The recording and the words it contained are never stored there.