Skip to content
kenari.

Audio, music and video

kenari provides OpenAI-style audio, music, and video endpoints. Each endpoint only serves models marked for it in the catalog. Calling an audio endpoint with a text model returns status 400.

POST /v1/audio/speech

Send text and a model, and the gateway returns raw audio (not JSON). Cost is calculated per 1.000 input characters, then deducted from the Rupiah balance.

FieldTypeRequiredDescription
modelstringyesTTS model id from the catalog.
inputstringyesText to speak. There is a length limit, see Text length limit.
voicestringnoVoice to use. Voice names differ per model, so there is no single list that applies to all of them. See Choosing a voice. Leave it out to get the model’s default voice.
response_formatstringnoAudio format: mp3, wav, pcm, opus, aac, or flac. Not every model can produce all of them, so see Choosing a format. Leave it out to get the model’s default format.
speednumbernoSpeech speed.
languagestringnoLanguage code, for example id or en. Default is automatic.

Every TTS model has its own voice vocabulary, and the names are not interchangeable: a voice that is legal on one model is rejected by another. kenari passes voice through to the model verbatim.

The list lives on /v1/models, in that model’s voices field:

Terminal window
curl -s https://kenari.id/v1/models \
| jq '.data[] | select(.endpoints | index("audio_speech")) | {id, voices}'

The first element of voices is the default, used when voice is left out. Names are matched case-insensitively.

When a model has no voice list recorded, the voices field is absent. kenari does not check voice for those models, so whatever the model accepts is passed through.

Sending a voice outside the published list returns a 400 naming the legal values, so the request never reaches the model and nothing is charged.

There is no separate field for delivery style. Put the direction in front of the text, for example Read this like a newscaster: ....

kenari can return audio as mp3, wav, pcm, opus, aac, or flac, but a given model may not be able to produce all of them. What a model actually serves lives on /v1/models, in its formats field:

Terminal window
curl -s https://kenari.id/v1/models \
| jq '.data[] | select(.endpoints | index("audio_speech")) | {id, formats}'

The first element of formats is the default, used when response_format is left out. Names are matched case-insensitively.

When a model has no format list recorded, the formats field is absent. For those models mp3, wav, and pcm are all accepted, and any other value is treated as mp3. So opus, aac and flac only work on a model that publishes formats, and asking one of them on a model that does not gets you mp3 with a matching audio/mpeg header.

Sending a format outside the published list returns a 400 naming the legal values, and nothing is charged. kenari does not quietly substitute a different format, because the response Content-Type follows the format you asked for, so returning mp3 bytes for a wav request would mislabel the file you receive.

Synthesis is not streamed, and the model enforces its own time limit, so text that is too long can never finish. A model’s recorded limit lives on /v1/models, in its max_input_chars field. Text past it returns a 400 before anything is charged.

When that field is absent, kenari does not cap the length. A very long request can still fail on the model’s time limit, so split long text into several requests.

The response contains raw audio bytes with Content-Type based on the format (audio/mpeg for mp3, audio/wav for wav, audio/pcm for pcm). There is no JSON wrapper.

Terminal window
curl https://kenari.id/v1/audio/speech \
-H "Authorization: Bearer kn-..." \
-H "Content-Type: application/json" \
-d '{"model":"gemini-3-1-flash-tts","input":"Halo, selamat datang di kenari."}' \
--output speech.mp3

POST /v1/audio/transcriptions

Send an audio file and a model, and the gateway returns a text transcription. Cost is calculated per second of audio duration (rounded up), then deducted from the Rupiah balance. Duration is estimated from the file size when the request arrives and updated with the actual duration reported by the provider.

Requests use multipart/form-data.

FieldTypeRequiredDescription
filefileyesAudio file to transcribe.
modelstringyesSTT model id from the catalog.
languagestringnoBCP-47 language code, for example id or en. Auto-detected if omitted.
promptstringnoInstruction text to guide transcription style or spelling for specific terms.
temperaturenumbernoSampling temperature, 0..1.
response_formatstringnoResponse format: json (default), verbose_json, or text.

Format json (default, or when response_format is omitted):

{ "text": "Halo, ini adalah transkripsi audio." }

Format verbose_json adds task, language, duration, segments, and words alongside text (model-dependent, fields the model does not send are omitted):

{
"text": "Hello, this is an audio transcription.",
"task": "transcribe",
"language": "en",
"duration": 8.5,
"segments": [
{
"id": 0, "seek": 0, "start": 0.0, "end": 3.2,
"text": "Hello, this is an audio transcription.",
"tokens": [50364, 1234],
"temperature": 0.0, "avg_logprob": -0.28,
"compression_ratio": 1.23, "no_speech_prob": 0.008
}
]
}

kenari rebuilds this response from the field list above rather than relaying the provider’s body, so fields outside that list are not passed through.

Format text returns the raw text string without a JSON wrapper.

Formats srt and vtt are not supported in v1 yet and will use the default json response.

Terminal window
curl https://kenari.id/v1/audio/transcriptions \
-H "Authorization: Bearer kn-..." \
-F file=@rekaman.mp3 \
-F model=whisper-1

POST /v1/music/generations

Send lyrics or a description and a music model, and the gateway returns one complete song in a JSON envelope, with the audio encoded as base64. Cost is a flat rate per song, deducted from the Rupiah balance once the song is produced.

This endpoint only serves models marked as music models in the catalog. Calling it with any other model returns 400.

FieldTypeRequiredNotes
modelstringyesMusic model id from the catalog.
lyricsstringconditionalThe words to be sung. Required for a sung track, that is when instrumental is false or absent. Its length limit is per model, see below.
promptstringconditionalStyle and mood of an instrumental track. Required when instrumental is true. Its length limit is per model, see below.
instrumentalbooleannoProduce a track with no vocals. Defaults to false. When true, prompt is required and lyrics is ignored.
response_formatstringnoAudio format. Only mp3 is available for music, and it is already the default. Sending wav or pcm returns 400 rather than mp3 bytes under the wrong Content-Type.

The lengths of lyrics and prompt are counted in Unicode characters, not bytes, so accented text is not cut short earlier than it looks.

The limits differ per model, so read them from /v1/models rather than hardcoding them. A music model whose limits are recorded sends max_lyrics_chars and max_prompt_chars on its model entry. A request past either one returns a 400 before anything is charged.

When neither field is present, kenari applies its built-in defaults: 3,500 characters for lyrics and 2,000 for prompt. An absent field means no per-model figure has been recorded, not that the field is unlimited.

Song length is a property of the model and cannot be requested through the API. There is no duration parameter on the request, and no way to ask for a shorter or a longer song.

A model whose length is recorded sends max_duration_secs on its entry in /v1/models, in SECONDS. That number is informational: no request is rejected because of it, and real songs usually run shorter than it.

lyrics is the words that get sung, not a description of the song. Writing “a relaxed pop song about Jakarta” makes the model sing that sentence back. To describe a track instead, use instrumental with prompt.

Section markers help the model structure the song:

[Verse]
City lights come on one by one
I am walking home with a new story
[Chorus]
Tonight belongs to us
Until the morning comes

Most songs take two to three minutes, and a longer one takes longer still. The gateway holds the connection open for the whole run and gives a generation up to 15 minutes before it gives up, so set your client timeout generously: five minutes is a sensible floor, and a client that cuts off at three will occasionally kill a song that was about to arrive. Read the full body before processing it.

The response is JSON, with the song in data[0].b64_json and the format in data[0].format:

{
"data": [
{ "b64_json": "<base64 of the mp3 bytes>", "format": "mp3" }
]
}

format is always mp3, and the data array always has exactly one element. The audio is not a download link, decode the base64 and write it to a file.

The body may arrive preceded by ASCII spaces. The gateway writes one space every 20 seconds while the generation runs to hold the connection open, then sends the JSON frame. Every JSON parser skips leading whitespace, so parse the body as JSON and never treat it as a raw blob.

A failure that lands inside the grace period keeps its real HTTP status, 400, 402, 429 or 503. A failure that lands past it comes back as HTTP 200 carrying the standard OpenAI error object, for example {"error":{"code":"...","message":"...","param":null,"type":"..."}}. Always parse the JSON and check for an error field before decoding the audio.

One account may have at most two generations running at the same time. A third request is answered 429 with a Retry-After header, and nothing is deducted.

Terminal window
curl https://kenari.id/v1/music/generations \
-H "Authorization: Bearer kn-..." \
-H "Content-Type: application/json" \
-d '{
"model": "music-1.5",
"lyrics": "[Verse]\nCity lights come on one by one\n\n[Chorus]\nTonight belongs to us"
}' \
--max-time 180 \
| jq -r '.data[0].b64_json' | base64 -d > song.mp3

An instrumental track:

Terminal window
curl https://kenari.id/v1/music/generations \
-H "Authorization: Bearer kn-..." \
-H "Content-Type: application/json" \
-d '{"model":"music-1.5","instrumental":true,"prompt":"relaxed lo-fi, soft piano, night rain"}' \
--max-time 180 \
| jq -r '.data[0].b64_json' | base64 -d > instrumental.mp3

Music is billed a flat rate per song, whatever the resulting duration. The balance is held when the request is accepted and only deducted once the audio is produced. If generation fails, the hold is released and the balance is untouched. Each model’s per-song price is in the catalog.


kenari provides two video endpoints. Both are asynchronous: the request is accepted immediately, and the result is fetched by polling.

POST /v1/videos/generations

FieldTypeRequiredDescription
modelstringyesVideo model id from the catalog.
promptstringyesDescription of the requested video.
durationnumbernoDuration in seconds. Default 6, range 1..15, unless the model declares its own duration list.
resolutionstringnoResolution tier, for example 720p or 1080p. Defaults to the first tier the model declares.

Some video models accept only certain durations, for example 4, 6, 8 and 10 seconds. Such a model ignores the 1..15 range above and uses its own list. The default is the first entry.

Resolution works the same way. A model that sells several resolution tiers charges a different per-second rate for each, and the first tier is the default. A model with a single rate accepts any resolution and ignores it, because there is no other tier to pick.

Both lists are shown per model in the catalog. A value outside the list is refused before any balance is deducted, so a wrong request never costs anything.

POST /v1/videos/extensions

FieldTypeRequiredDescription
modelstringyesVideo model id from the catalog.
video.urlstringyesURL of the source clip to extend.
durationnumbernoAdditional duration in seconds. Default 6, range 1..15.

Both endpoints immediately return a job object:

{
"id": "vid_abc123",
"object": "video.job",
"status": "rendering",
"model": "kling-v2"
}

The clip is not available yet. Use id to poll.


GET /v1/videos/{id}

Poll until status is no longer rendering. Suggested interval: every few seconds.

StatusMeaning
renderingThe job is still running, continue polling.
doneFinished. Field url contains the clip link.
failedRender failed. Cost is refunded automatically.
expiredJob expired before it was fetched. Cost is refunded automatically.
{
"id": "vid_abc123",
"status": "done",
"url": "https://kenari.id/v1/videos/vid_abc123/content"
}

GET /v1/videos/{id}/content

The link in url is served by kenari, not by the render engine. Fetch it with the same API key. Only the account that owns the job can read it.

Terminal window
curl -L https://kenari.id/v1/videos/vid_abc123/content \
-H "Authorization: Bearer kn-..." \
--output clip.mp4

The response is raw video bytes with a Content-Type matching the clip format (video/mp4, video/webm, or video/quicktime). While the job is still rendering this endpoint answers 400.

Terminal window
# Buat video
JOB=$(curl -s https://kenari.id/v1/videos/generations \
-H "Authorization: Bearer kn-..." \
-H "Content-Type: application/json" \
-d '{"model":"kling-v2","prompt":"Seekor elang terbang di atas hutan hujan","duration":6}' \
| jq -r '.id')
# Poll sampai selesai
while true; do
RESULT=$(curl -s "https://kenari.id/v1/videos/$JOB" \
-H "Authorization: Bearer kn-...")
STATUS=$(echo "$RESULT" | jq -r '.status')
[ "$STATUS" != "rendering" ] && echo "$RESULT" && break
sleep 5
done

Video is billed per second of requested duration, deducted when the job is accepted. If render failed or expired, cost is refunded automatically to the balance. See Billing for balance and deduction details.

When a model sells several resolution tiers, the per-second rate follows the tier you asked for, not the cheapest one. A job costs the duration times that tier’s rate. Asking for a tier the model does not sell is refused before any deduction, never quietly downgraded to a cheaper tier.