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.
Text-to-speech
Section titled “Text-to-speech”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.
Request parameters
Section titled “Request parameters”| Field | Type | Required | Description |
|---|---|---|---|
model | string | yes | TTS model id from the catalog. |
input | string | yes | Text to speak. There is a length limit, see Text length limit. |
voice | string | no | Voice 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_format | string | no | Audio 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. |
speed | number | no | Speech speed. |
language | string | no | Language code, for example id or en. Default is automatic. |
Choosing a voice
Section titled “Choosing a voice”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:
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: ....
Choosing a format
Section titled “Choosing a format”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:
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.
Text length limit
Section titled “Text length limit”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.
Response shape
Section titled “Response shape”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.
Example
Section titled “Example”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.mp3Audio transcription (speech-to-text)
Section titled “Audio transcription (speech-to-text)”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.
Request parameters
Section titled “Request parameters”Requests use multipart/form-data.
| Field | Type | Required | Description |
|---|---|---|---|
file | file | yes | Audio file to transcribe. |
model | string | yes | STT model id from the catalog. |
language | string | no | BCP-47 language code, for example id or en. Auto-detected if omitted. |
prompt | string | no | Instruction text to guide transcription style or spelling for specific terms. |
temperature | number | no | Sampling temperature, 0..1. |
response_format | string | no | Response format: json (default), verbose_json, or text. |
Response shape
Section titled “Response shape”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.
Example
Section titled “Example”curl https://kenari.id/v1/audio/transcriptions \ -H "Authorization: Bearer kn-..." \ -F file=@rekaman.mp3 \ -F model=whisper-1Music generation
Section titled “Music generation”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.
Request parameters
Section titled “Request parameters”| Field | Type | Required | Notes |
|---|---|---|---|
model | string | yes | Music model id from the catalog. |
lyrics | string | conditional | The 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. |
prompt | string | conditional | Style and mood of an instrumental track. Required when instrumental is true. Its length limit is per model, see below. |
instrumental | boolean | no | Produce a track with no vocals. Defaults to false. When true, prompt is required and lyrics is ignored. |
response_format | string | no | Audio 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.
Lyrics and prompt limits
Section titled “Lyrics and prompt limits”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
Section titled “Song length”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.
Writing lyrics
Section titled “Writing lyrics”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 oneI am walking home with a new story
[Chorus]Tonight belongs to usUntil the morning comesGeneration time
Section titled “Generation time”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.
Response shape
Section titled “Response shape”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.
Concurrency limit
Section titled “Concurrency limit”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.
Example
Section titled “Example”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.mp3An instrumental track:
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.mp3Music billing
Section titled “Music billing”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.
Video generation
Section titled “Video generation”kenari provides two video endpoints. Both are asynchronous: the request is accepted immediately, and the result is fetched by polling.
Create a new video
Section titled “Create a new video”POST /v1/videos/generations
| Field | Type | Required | Description |
|---|---|---|---|
model | string | yes | Video model id from the catalog. |
prompt | string | yes | Description of the requested video. |
duration | number | no | Duration in seconds. Default 6, range 1..15, unless the model declares its own duration list. |
resolution | string | no | Resolution tier, for example 720p or 1080p. Defaults to the first tier the model declares. |
Per-model duration and resolution lists
Section titled “Per-model duration and resolution lists”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.
Extend video
Section titled “Extend video”POST /v1/videos/extensions
| Field | Type | Required | Description |
|---|---|---|---|
model | string | yes | Video model id from the catalog. |
video.url | string | yes | URL of the source clip to extend. |
duration | number | no | Additional duration in seconds. Default 6, range 1..15. |
Initial response (both endpoints)
Section titled “Initial response (both endpoints)”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.
Poll video result
Section titled “Poll video result”GET /v1/videos/{id}
Poll until status is no longer rendering. Suggested interval: every few seconds.
| Status | Meaning |
|---|---|
rendering | The job is still running, continue polling. |
done | Finished. Field url contains the clip link. |
failed | Render failed. Cost is refunded automatically. |
expired | Job expired before it was fetched. Cost is refunded automatically. |
Response shape when complete
Section titled “Response shape when complete”{ "id": "vid_abc123", "status": "done", "url": "https://kenari.id/v1/videos/vid_abc123/content"}Downloading the clip
Section titled “Downloading the clip”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.
curl -L https://kenari.id/v1/videos/vid_abc123/content \ -H "Authorization: Bearer kn-..." \ --output clip.mp4The 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.
Polling example
Section titled “Polling example”# Buat videoJOB=$(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 selesaiwhile 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 5doneVideo billing
Section titled “Video billing”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.