Speech to Text
Transcribe and translate audio files using speech models through the platform API.
Overview
The platform provides OpenAI-compatible audio endpoints for turning recordings into text. If you already use the OpenAI SDK for transcription, switching over requires only changing the base_url -- no other code changes needed.
Key features:
- OpenAI SDK compatible -- use the official Python or Node.js SDK, or plain multipart HTTP
- Transcription and translation -- keep the spoken language, or translate speech into English
- Multiple output formats --
json, plaintext,verbose_jsonwith segments, and ready-to-usesrt/vttsubtitles - Streaming -- receive the transcript as it is produced, over Server-Sent Events
- Long recordings -- files of up to 25 MB and 1891 seconds (31 minutes 31 seconds) are handled in a single request
Speech models appear in your model list only where they are deployed. If you do not see one, contact your platform administrator.
Quick Start
Python
from openai import OpenAI
client = OpenAI(
base_url="https://api.bulutistan.ai/v1",
api_key="sk-proj-your-api-key"
)
with open("meeting.mp3", "rb") as audio:
transcript = client.audio.transcriptions.create(
model="your-speech-model",
file=audio,
language="en"
)
print(transcript.text)
Node.js
import OpenAI from 'openai';
import fs from 'fs';
const client = new OpenAI({
apiKey: 'sk-proj-your-api-key',
baseURL: 'https://api.bulutistan.ai/v1'
});
const transcript = await client.audio.transcriptions.create({
model: 'your-speech-model',
file: fs.createReadStream('meeting.mp3'),
language: 'en'
});
console.log(transcript.text);
cURL
curl -X POST https://api.bulutistan.ai/v1/audio/transcriptions \
-H "Authorization: Bearer sk-proj-your-api-key" \
-F "file=@meeting.mp3" \
-F "model=your-speech-model" \
-F "language=en"
API Reference
Endpoints
POST /v1/audio/transcriptions
POST /api/v1/audio/transcriptions
POST /v1/audio/translations
POST /api/v1/audio/translations
Both prefixes are supported for each endpoint. Use /v1/... when setting the SDK base_url to https://api.bulutistan.ai/v1. Use the full /api/v1/... path for direct HTTP requests.
- Transcriptions return the speech in the language it was spoken in. Every speech-to-text model serves this endpoint.
- Translations return English text regardless of the spoken language, and therefore take no
languageparameter. Not every speech model translates. The endpoint is served only by models whose operator has declared translation support; any other speech model is refused with400and the message "Model does not support translation. Use a model with translation capability." Transcription on the same model is unaffected.
Both endpoints accept multipart/form-data only -- send the audio as a file part, and every other parameter as a form field. Do not send a JSON body.
API keys need the inference:audio scope. Keys with full access or a general inference scope already cover it; keys created with a hand-picked scope list need inference:audio selected. See Authentication.
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
file | binary | Yes | The audio file to transcribe. Up to 25 MB and 1891 seconds (31 minutes 31 seconds) -- whichever limit is reached first. |
model | string | Yes | The model identifier of a speech-to-text model. |
language | string | No | Input language as an ISO-639-1 code (for example en, tr, de). Transcriptions only. |
prompt | string | No | Optional text to guide the model's style or help it with names and terminology. |
response_format | string | No | Output format: json (default), text, verbose_json, srt, vtt. Translations support json, text and verbose_json. |
temperature | float | No | Sampling temperature between 0 and 1. Lower values are more deterministic. |
stream | boolean | No | true streams the transcript over Server-Sent Events. Combines with json and text only. |
timestamp_granularities[] | string | No | Only segment is supported, and only together with response_format=verbose_json. Transcriptions only. |
seed | integer | No | Sampling seed, for a repeatable decode. Accepted on both endpoints. |
top_p | float | No | Nucleus sampling. Transcriptions only. |
top_k | integer | No | Top-k sampling. Transcriptions only. |
min_p | float | No | Minimum-probability sampling. Transcriptions only. |
The four sampling fields are forwarded to the model only when you send them; leaving one out is not the same as sending a default, and the platform picks no value on your behalf. They matter far less here than on text generation -- transcription is a task where the confident answer is almost always the right one. Reach for temperature=0 and a fixed seed when you need the same audio to produce the same transcript twice.
/v1/audio/translations takes only seed, because its upstream schema is narrower than the transcription one. top_p, top_k and min_p sent to it are refused rather than ignored.
Accepted upload formats: mp3, mp4, mpeg, mpga, m4a, wav, webm, flac, ogg. An individual model may accept a narrower list; the error message names the formats that model takes.
The field list is closed
Both endpoints accept the fields in the table above and nothing else. Any other multipart part is refused with a 400 naming the field in error.param, rather than being quietly dropped -- a parameter you believed was applied never was.
| Code | What it means |
|---|---|
unknown_parameter | The endpoint does not recognise the field name at all. |
unsupported_parameter | A real engine knob that is outside this platform's contract (for example hotwords, chunking_strategy, repetition_penalty, use_beam_search), or a transcription-only field sent to /v1/audio/translations (language, timestamp_granularities[], top_p, top_k, min_p). |
chunking_strategy is in the second list on purpose: the platform does its own splitting of long recordings, and honouring a caller's chunking plan would contradict the spans the timeline and the billing are built on. See Long Recordings.
Recent OpenAI SDK releases added transcription fields this platform does not implement -- include[], known_speaker_names[] and known_speaker_references[]. Sending one returns 400 unknown_parameter. If an SDK upgrade starts sending a field on your behalf, that is what the error is telling you; pin the SDK version or stop passing the option.
language when you know itIf language is omitted, the model's configured default language is used rather than automatic detection. Passing the language explicitly is the reliable way to transcribe a recording whose language differs from that default.
Audio requests are synchronous. Sending background=true (or the async alias) returns 400 -- submit the request normally and read the response.
Which languages a model handles
A speech model can publish the languages it is configured for. They appear in the model's audio block on GET /v1/models and GET /v1/models/{id}, as two-letter ISO-639-1 codes:
{
"id": "your-speech-model",
"object": "model",
"audio": {
"default_language": "tr",
"supported_languages": ["tr", "en"]
}
}
default_languageis what a request with nolanguagedecodes as.supported_languagesis the operator's declaration of what this deployment is set up for. Read it before hardcoding a language list of your own -- it is per model, and it changes when the operator changes it.
The key is absent on a model that declares no list, which is the ordinary case; treat that as "no list published" rather than as an empty one. The Client Portal Playground turns the language field into a picker of the declared codes where a model publishes them, and leaves it as free text where it does not.
Response Format
json (default)
{
"text": "Thanks everyone for joining the call today.",
"usage": {
"type": "duration",
"seconds": 128
}
}
The usage object reports the measured input duration -- the same measure the request is billed on.
text
Returns the transcript as text/plain, with no JSON envelope:
Thanks everyone for joining the call today.
verbose_json
{
"task": "transcribe",
"language": "en",
"duration": 128.4,
"text": "Thanks everyone for joining the call today.",
"segments": [
{
"id": 0,
"start": 0.0,
"end": 24.2,
"text": "Thanks everyone for joining the call today."
}
]
}
| Field | Type | Description |
|---|---|---|
task | string | transcribe or translate. |
language | string | The language used for the transcription. |
duration | float | Measured input duration in seconds. |
text | string | The full transcript. |
segments | array | Timed segments covering the recording. |
Word-level timestamps are not offered: timestamp_granularities[]=word returns 400.
srt and vtt
Subtitle files rendered from the timed segments, returned as text/plain (srt) and text/vtt (vtt):
curl -X POST https://api.bulutistan.ai/v1/audio/transcriptions \
-H "Authorization: Bearer sk-proj-your-api-key" \
-F "file=@interview.mp3" \
-F "model=your-speech-model" \
-F "language=en" \
-F "response_format=srt" \
-o interview.srt
Cues cover blocks of up to 28 seconds rather than single spoken lines, because the model returns one segment per processed block. Timings line up with the audio, so the files play correctly, but if you need short caption lines you will want to split the cues in your own player or post-processing step.
Streaming
Set stream=true to receive the transcript incrementally over Server-Sent Events. The event shapes are OpenAI-compatible: transcript.text.delta events carry the incremental text, a final transcript.text.done event carries the complete transcript, and the stream closes with data: [DONE].
curl -N -X POST https://api.bulutistan.ai/v1/audio/transcriptions \
-H "Authorization: Bearer sk-proj-your-api-key" \
-H "Accept-Encoding: identity" \
-F "file=@meeting.mp3" \
-F "model=your-speech-model" \
-F "language=en" \
-F "stream=true"
data: {"type":"transcript.text.delta","delta":"Thanks everyone "}
data: {"type":"transcript.text.delta","delta":"for joining the call today."}
data: {"type":"transcript.text.done","text":"Thanks everyone for joining the call today.","usage":{"type":"duration","seconds":128}}
data: [DONE]
done replaces, it does not extendThe two event types carry the transcript in two different ways, and mixing them up duplicates the entire output.
transcript.text.deltacarries an increment. Append everydeltato what you already have.transcript.text.donecarries the complete transcript in itstextfield. It is a replacement, not the final increment.
If you accumulate the deltas and then append done.text to them, you end up with the transcript twice over. Either accumulate the deltas and use done purely as the end-of-stream signal, or ignore the deltas and take done.text as the answer. Do not do both.
Python
from openai import OpenAI
client = OpenAI(
base_url="https://api.bulutistan.ai/v1",
api_key="sk-proj-your-api-key"
)
with open("meeting.mp3", "rb") as audio:
stream = client.audio.transcriptions.create(
model="your-speech-model",
file=audio,
language="en",
response_format="text",
stream=True
)
for event in stream:
if event.type == "transcript.text.delta":
print(event.delta, end="", flush=True)
elif event.type == "transcript.text.done":
print()
Streaming combines with response_format=json and response_format=text only. Asking for verbose_json, srt or vtt together with stream=true returns 400 -- those formats need the full timeline before they can be rendered.
If an error occurs after the stream has started, the connection is not dropped silently: an error event is written and the stream still closes with data: [DONE], so your read loop terminates normally. See the Streaming guide for general SSE handling.
Long Recordings
Recordings longer than about half a minute are split into consecutive blocks on the server, transcribed in order, and merged back into one transcript before the response is returned. The blocks are all the same length -- the recording is divided evenly, not cut into full-size blocks with a short remainder at the end. Consecutive blocks overlap slightly so a word landing on a cut survives, and the duplicate wording is removed during the merge.
For verbose_json, srt and vtt, segment timings are placed on the timeline of the original file: each block's segments are shifted by that block's start offset and then clipped to the block's own non-overlapping display window, so the overlap never produces two cues competing for the same instant. Subtitles stay in sync with the audio you uploaded, from the first cue to the last.
This is transparent to the caller -- one request, one response, one charge for the input duration -- but it is worth knowing about for two reasons:
- Latency grows with duration. Blocks are processed sequentially, so a 20-minute recording takes noticeably longer than a 2-minute one. Use
stream=trueif you want text to appear while the rest is still being processed. - The limit is honest and immediate. The ceiling of 1891 seconds is derived from how many of those blocks one request will process; it is a property of the request shape, not a timeout and not a size limit. A longer file is refused up front with
400 audio_too_long-- before any processing, before any charge -- rather than failing minutes later. Split the recording and send the parts.
Non-Speech Audio
Speech models describe what they hear, and silence or non-speech passages can therefore appear in the transcript as a short bracketed note (for example (Silence)) rather than as an empty string. These annotations are passed through as the model produced them. If your application needs a strictly verbatim transcript, filter bracketed segments on your side, or trim leading and trailing silence before uploading.
Error Handling
| Status | Error code | Cause | Resolution |
|---|---|---|---|
| 400 | Bad Request | Missing file or model, an empty file, an unsupported container format, word timestamp granularity, stream combined with a subtitle format, background=true, or /v1/audio/translations called with a model that does not translate. | Check the parameters against the reference above. |
| 400 | unknown_parameter | The request carries a multipart field the endpoint does not recognise. error.param names it. | Remove it. Only the documented fields are accepted. |
| 400 | unsupported_parameter | A real engine knob outside the platform contract, or a transcriptions-only field sent to /v1/audio/translations. error.param names it. | Remove it. See The field list is closed. |
| 400 | audio_too_long | The recording is longer than 1891 seconds (31 minutes 31 seconds), the most one request transcribes. | Split the recording into shorter parts. |
| 400 | audio_duration_unreadable | The file's duration could not be read -- it may be truncated, empty, or not the format its extension claims. | Re-export the file and try again. |
| 401 | Unauthorized | Missing or invalid API key. | Verify your API key is correct and active. |
| 402 | model_not_priced | The model has no audio pricing configured. | Contact your platform administrator. |
| 403 | Forbidden | The API key is not permitted to call this model, or the model belongs to another organization. | Use a key whose scope covers audio inference and the requested model. |
| 404 | Not Found | The model does not exist. | Check available models in the Client Portal or via the models endpoint. |
| 413 | Payload Too Large | The upload exceeds the 25 MB ceiling. It is enforced while the body streams in, so an oversized file is cut off rather than buffered. | Compress the audio or split the recording. |
| 429 | too_many_requests | Your key's request rate or concurrency limit was reached. | Back off and retry. See the Rate Limiting guide. |
| 429 | model_at_capacity | The model deployment is already transcribing as many requests as it can at once. limit_type is model_concurrent_requests, Retry-After is 10. | Retry after the Retry-After interval. |
| 429 | model_long_audio_at_capacity | The model deployment is at its separate ceiling for long recordings. limit_type is model_concurrent_long_requests, Retry-After is 30. | Retry after the Retry-After interval, or send a shorter file. |
| 502 | transcription_corrupted | The model returned an unusable transcript and the result was discarded. The request is not charged. | Retry. If it persists, the recording may be unusually noisy or near-silent. |
| 503 | billing_unavailable | The metering path is temporarily unreachable, so inference is held back for safety. Retry-After is 10. | Retry. Nothing about your account changed. |
| 503 | audio_processing_unavailable | The audio processing path is temporarily unavailable. Retry-After is 5. | Retry after the Retry-After interval. |
| 503 | model_unavailable | The model is starting, restarting, or not currently serving. Retry-After is 30. | Retry with backoff, or pick another model. |
| 504 | audio_timeout | The request exceeded the processing deadline. error.type is timeout_error. | Send a shorter file. |
The two 429 codes that name a model are about that deployment, not about you: someone else's traffic can trigger them while your own limits are untouched. too_many_requests is the per-tenant one. All three are worth retrying; only the per-tenant one is worth adjusting your own pacing for.
Errors use the standard platform error envelope:
{
"error": {
"message": "The audio file is 2340 seconds long, which exceeds what this endpoint transcribes in a single request. Split the recording and try again.",
"type": "invalid_request_error",
"code": "audio_too_long"
}
}
Python Error Handling
from openai import (
OpenAI,
APIError,
RateLimitError,
AuthenticationError,
BadRequestError,
NotFoundError
)
import time
client = OpenAI(
api_key="sk-proj-your-api-key",
base_url="https://api.bulutistan.ai/v1"
)
def transcribe_with_retry(path, max_retries=3):
for attempt in range(max_retries):
try:
with open(path, "rb") as audio:
return client.audio.transcriptions.create(
model="your-speech-model",
file=audio,
language="en"
)
except AuthenticationError:
print("Invalid API key. Check your credentials.")
raise
except NotFoundError:
print("Model not found. Verify the model identifier.")
raise
except BadRequestError as e:
# Includes audio_too_long; retrying will not help.
print(f"Invalid request: {e.message}")
raise
except RateLimitError:
if attempt == max_retries - 1:
raise
wait = 2 ** attempt
print(f"Rate limited. Retrying in {wait}s...")
time.sleep(wait)
except APIError as e:
if attempt == max_retries - 1:
raise
if e.status_code >= 500:
wait = 2 ** attempt
print(f"Server error. Retrying in {wait}s...")
time.sleep(wait)
else:
raise
transcript = transcribe_with_retry("meeting.mp3")
print(transcript.text)
Playground
Speech models are available in the Client Portal Playground: upload a file or record from your microphone, pick the output format, and read the transcript without writing any code. prompt, temperature and segment timestamps have their own controls there, and a Task selector switches between transcription and translation on models that translate. See the Playground guide.
Rate Limits & Pricing
Audio requests count against the standard per-key request rate limits. See the Rate Limiting guide for the limits and response headers.
How transcription is billed:
- Measured in seconds of input audio. The duration of the file you upload is measured and rounded up to the whole second, once per request. Output length has no effect on the price.
- Communicated per minute. Per-model rates are shown in USD per minute in the Client Portal; the underlying measurement stays per second, so a 90-second recording is billed as one and a half minutes, not two.
- Only successful responses are billed. A request that fails with a server-side error (
5xx), including a discarded transcript, is recorded for your visibility but carries no charge. - Pay as you go. Audio usage is not drawn from plan included-token allowances -- it is billed at your organization's audio rate on top of the plan. See Plans and Usage.
Long recordings are split internally, but you are charged once for the input duration -- the internal split does not multiply the charge.
Trim silence and non-speech padding before uploading. Billing follows the length of the file you send, so a tighter file is both cheaper and faster.