# Text to speech

Turn text into spoken audio. The endpoint is compatible with OpenAI Audio Speech, so the official OpenAI SDKs work with the base URL `https://kenari.id/v1`. Only text-to-speech models are served here. Sending any other model returns `400`.

`POST /v1/audio/speech`

## Request

The body is JSON.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | Text-to-speech model id, for example `gemini-3-1-flash-tts`. |
| `input` | string | yes | The text to speak. See [Text length limit](#text-length-limit). |
| `voice` | string | no | Voice name. Names differ per model. Leave it out for the model's default voice. See [Choosing a voice](#choosing-a-voice). |
| `response_format` | string | no | `mp3`, `wav`, `pcm`, `opus`, `aac` or `flac`. Not every model produces all of them. Leave it out for the model's default. See [Choosing a format](#choosing-a-format). |
| `speed` | number | no | Speaking speed. Applied only by models that support it. |
| `language` | string | no | Language code, for example `id` or `en`. Detected automatically when left out. |

## Response

The response body is the raw audio, not JSON. The `Content-Type` follows the format you asked for.

| Format | `Content-Type` |
| --- | --- |
| `mp3` | `audio/mpeg` |
| `wav` | `audio/wav` |
| `pcm` | `audio/pcm` |
| `opus` | `audio/ogg` |
| `aac` | `audio/aac` |
| `flac` | `audio/flac` |

## Choosing a voice

Every model has its own voice names, and they are not interchangeable. kenari passes `voice` to the model as you send it. The voices a model offers are listed in the `voices` field of that model in `GET /v1/models`:

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

- The first entry in `voices` is the default, used when you leave `voice` out.
- Names are matched without regard to case.
- A model without a `voices` field has no recorded list. kenari does not check `voice` for it and passes the value to the model.
- A voice outside the list returns `400` with the allowed names, and nothing is charged.

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

## Choosing a format

The formats a model can produce are in the `formats` field of that model in `GET /v1/models`:

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

- The first entry in `formats` is the default, used when you leave `response_format` out.
- Names are matched without regard to case.
- A format outside the list returns `400` with the allowed names, and nothing is charged. kenari never swaps in a different format, because the `Content-Type` would then describe the wrong file.
- A model without a `formats` field accepts `mp3`, `wav` and `pcm`. Any other value gives you `mp3`, and the `Content-Type` is `audio/mpeg`. So `opus`, `aac` and `flac` work only on models that list them in `formats`.

## Text length limit

Synthesis is not streamed, and each provider enforces its own time limit, so text that is too long cannot finish. When a model records a limit, it appears as `max_input_chars` in `GET /v1/models`, and longer text returns `400` before anything is charged. Without that field kenari does not cap the length, but a very long request can still fail on the provider's time limit. Split long text into several requests.

## Examples

### curl

```bash
curl https://kenari.id/v1/audio/speech \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gemini-3-1-flash-tts",
    "input": "Hello, welcome to kenari.",
    "voice": "Kore",
    "response_format": "mp3"
  }' \
  --output speech.mp3
```

### Python

```python
import os

from openai import OpenAI

client = OpenAI(
    base_url="https://kenari.id/v1",
    api_key=os.environ["KENARI_API_KEY"],
)

response = client.audio.speech.create(
    model="gemini-3-1-flash-tts",
    input="Hello, welcome to kenari.",
    voice="Kore",
    response_format="mp3",
)
response.write_to_file("speech.mp3")
```

### JavaScript

```js
import fs from "node:fs";
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://kenari.id/v1",
  apiKey: process.env.KENARI_API_KEY,
});

const response = await client.audio.speech.create({
  model: "gemini-3-1-flash-tts",
  input: "Hello, welcome to kenari.",
  voice: "Kore",
  response_format: "mp3",
});
fs.writeFileSync("speech.mp3", Buffer.from(await response.arrayBuffer()));
```

## Billing

Text to speech is billed per 1,000 characters of `input`, rounded up. The amount is held when the request is accepted and released if no audio is delivered. See [How billing works](/en/docs/billing) and the model's price in [Models and pricing](/en/docs/models).

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | The model is not a text-to-speech model, `input` is empty, `voice` or `response_format` is not available for the model, or `input` is longer than `max_input_chars`. |
| 402 | `insufficient_balance` | The balance does not cover the request. |
| 503 | `all_providers_failed`, `upstream_error` | No provider could produce the audio. Nothing is charged. Retry. |

See [Errors](/en/docs/errors) for every other code.

## See also

- [Models and pricing](/en/docs/models)
- [How billing works](/en/docs/billing)
- [Transcriptions](/en/docs/audio-transcriptions)
- [Errors](/en/docs/errors)
