# kenari docs

> Create a key, send your first request with a free model, then call kenari from code.



---

# Quickstart

Send your first request to kenari with a free model. You need a kenari account and either a terminal or a Python or JavaScript project.

## 1. Create a key

Sign in to the [dashboard](/login), open **API Keys**, and select **Create API Key**. Add a label if you want, then select **Create Key**. Copy the key right away, because kenari shows it only once.

## 2. Set your key

Keep the key in an environment variable. The rest of the docs read it from `KENARI_API_KEY`.

```bash
export KENARI_API_KEY=kn-...
```

## 3. Send a request

This request calls a `:free` model, so it costs nothing and works with a zero balance.

```bash
curl https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "step-3-7-flash:free",
    "messages": [{"role": "user", "content": "Hello!"}]
  }'
```

A successful call returns HTTP 200 and a JSON body. The answer is in `choices[0].message.content`.

```json
{
  "id": "...",
  "object": "chat.completion",
  "model": "step-3-7-flash:free",
  "choices": [
    {
      "index": 0,
      "message": { "role": "assistant", "content": "Hello! How can I help you today?" },
      "finish_reason": "stop"
    }
  ],
  "usage": { "prompt_tokens": 9, "completion_tokens": 10, "total_tokens": 19 }
}
```

The free models change over time. List the current ones with the public models endpoint (the command needs `jq`):

```bash
curl https://kenari.id/v1/models | jq -r '.data[] | select(.pricing.free) | .id'
```

## 4. Call from code

The OpenAI SDKs work with kenari. Point them at `https://kenari.id/v1` and keep the rest of your code.

```bash
pip install openai
```

```python
import os
from openai import OpenAI

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

response = client.chat.completions.create(
    model="step-3-7-flash:free",
    messages=[{"role": "user", "content": "Hello!"}],
)
print(response.choices[0].message.content)
```

```bash
npm install openai
```

```js
import OpenAI from "openai";

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

const response = await client.chat.completions.create({
  model: "step-3-7-flash:free",
  messages: [{ role: "user", content: "Hello!" }],
});
console.log(response.choices[0].message.content);
```

The Anthropic SDKs work too, with the base URL `https://kenari.id` and no `/v1`. See [Messages](/en/docs/messages).

## 5. Add balance for paid models

Free models have per-minute and daily limits. To call paid models, open **Balance** and top up with QRIS from Rp 1,000. See [How billing works](/en/docs/billing).

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| `401` with a plain-text body | The key is missing, mistyped, cut off when copied, or revoked | Check `echo $KENARI_API_KEY`. If the key is lost, create a new one under **API Keys**. |
| `400` with `model_not_found` | The model id is wrong | Copy an id from [Models and pricing](/en/docs/models). |
| `402` with `insufficient_balance` | A paid model was called with too little balance | Switch to a `:free` model, or top up. |
| `405` | The base URL has the wrong path, such as `/v1/v1` or a missing `/v1` | Use `https://kenari.id/v1` for OpenAI clients and `https://kenari.id` for Anthropic clients. |
| `429` | A rate limit was reached. It can be a free-model limit, your account limit or a provider limit | Read the `code` in the error, and wait for the `Retry-After` seconds if the response has that header. See [Rate limits](/en/docs/rate-limits). |

## See also

- [Authentication and API keys](/en/docs/authentication): send, restrict, share and revoke keys.
- [Chat completions](/en/docs/chat): every request field and the response shape.
- [Models and pricing](/en/docs/models): the model list and prices.
- [Claude Code](/en/docs/claude-code), [Codex](/en/docs/codex) and the [kenari CLI](/en/docs/cli): use kenari from a coding agent.
- [Errors](/en/docs/errors): every error code and what to do about it.


---

# Authentication and API keys

Every `/v1` endpoint except `GET /v1/models` needs an API key. A key starts with `kn-`, and one key works for every model and every API format.

## Create a key

Open **API Keys** in the dashboard and select **Create API Key**. Add a **Label (optional)**, such as the name of the app that will use the key, so you can tell keys apart and revoke one without touching the others.

kenari shows the key once, right after you create it. It cannot be recovered later. If you lose a key, select **Rotate key** on its row to get a new one, or create a new key and revoke the old one.

## Send the key

Send the key in one of two headers. Both work on every `/v1` endpoint.

| Header | Used by |
| --- | --- |
| `Authorization: Bearer kn-...` | OpenAI SDKs and most HTTP clients |
| `x-api-key: kn-...` | Anthropic SDKs and Claude Code |

If a request has both headers, `Authorization` wins. Send the bare key in `x-api-key`, without a `Bearer` prefix.

```bash
curl https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "step-3-7-flash:free",
    "messages": [{"role": "user", "content": "Hello!"}]
  }'
```

## Sign in from a terminal

The [kenari CLI](/en/docs/cli) can create and store a key for you. Install it with `npm install -g @kenarihq/cli`, then run `kenari login`. It opens your browser to approve the sign-in, then saves a new key, so you never copy it by hand. On a server with no browser, create a key in the dashboard and run `kenari login --api-key`. It prompts for the key, or reads it from stdin when you pipe it in. Never pass the key as an argument. The guide above has the details.

## Call from a browser

The `/v1` API can be called straight from browser JavaScript, so a `fetch` from a web page works without a proxy of your own.

```js
const response = await fetch("https://kenari.id/v1/chat/completions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${apiKey}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: "step-3-7-flash:free",
    messages: [{ role: "user", content: "Hello!" }],
  }),
});
const data = await response.json();
console.log(data.choices[0].message.content);
```

`/v1` answers requests from any origin and allows `GET`, `POST` and `OPTIONS`. It accepts whatever request headers the browser asks for in its preflight, so `Authorization`, `Content-Type`, `x-api-key` and the Anthropic headers all pass. Cookies are not used: `/v1` reads only the `Authorization` or `x-api-key` header. Scripts can read the `x-request-id` and `request-id` response headers. The dashboard API under `/api` stays closed to cross-origin requests.

<aside class="warn"><strong>Warning</strong> Anyone who opens the browser's developer tools can read a key that a web page sends. Use this for local tools, personal projects and prototypes. A production web app should keep the key on a server and call kenari from there, or use a restricted shared API key.</aside>

## Public endpoints

`GET /v1/models` is the only `/v1` endpoint that works without a key. These endpoints are public:

| Endpoint | Purpose |
| --- | --- |
| `GET /v1/models` | The model list, including `?modality=` filters. See [Models and pricing](/en/docs/models). |
| `GET /api/public/pricing` | Current prices and free-tier limits |
| `GET /openapi.json` | The OpenAPI description |

A request without a key, or with a bad one, gets a `401` with a plain-text body instead of JSON. See [Errors](/en/docs/errors#plain-text-responses) for the messages.

## Manage and revoke keys

**API Keys** lists every key with its status, **Active** or **Revoked**. Select **Revoke key** to disable a key for good. A revoked key cannot be turned back on, and the next request that uses it gets a `401`. If you revoke the key that `kenari login` saved, its [per-tool keys](/en/docs/account-keys) are revoked with it. **Rotate key** creates a new key and revokes the old one in one step, so update your app to the new key right after.

kenari checks the account on every request. If an account is suspended, every key on it stops working at once, and creating a new key does not help.

## Restrict a key

Open **Advanced settings (expiry, model scope, capabilities, spend cap)** in the create dialog to narrow what a key can do.

| Setting | What it does |
| --- | --- |
| **Expiry** | The key stops working on that date. Choose **Never expires**, a number of days, or **Custom**. |
| **Model scope** | **All models**, or **Specific models**. A request for any other model fails with `400` and `bad_request`, and the message lists the allowed models. |
| **Paid capabilities** | Which paid features the key can use: **Document reading**, **Web search and page fetching**, and **X search**. |
| **Spend cap, Rp (optional)** | The most the key can spend, counted at catalog prices and not against your balance. When it is reached, requests fail with `402` and `insufficient_balance`. |

The capability list works as follows. A key with no model restriction can use every paid capability. A key with a model scope can use only the capabilities you tick, and a request for another one fails with `400` and `bad_request`, with no charge. **Document reading** gates OCR only, meaning the `ocr` engine and `/v1/ocr`. A restricted key can still attach documents that kenari reads the default way, because those are ordinary input tokens of a model it may use. See [Read documents](/en/docs/documents) and [Web and X search](/en/docs/web-search).

A **Token cap (optional)**, the total of prompt and completion tokens, is set when you create a shared API key.

A key with a model scope, a spend cap or a token cap cannot use named routes. A key with a spend cap also cannot use BYOK models that you address as `byok/...`, and the request fails with `403` and `byok_spend_capped_key`. A spend cap counts the Rupiah kenari records, and BYOK usage records Rp 0, so the cap would never be reached. To limit BYOK usage on a key, use a token cap. See [Providers](/en/docs/byok).

A restricted key also reads only its own usage from `GET /v1/usage`, and `GET /v1/account/balance` and `GET /v1/account/quota` refuse it with `403` and `restricted_key_not_allowed`. See [Account overview](/en/docs/account).

## Shared API keys

A shared API key lets other people call kenari without an account of their own. Open **Share**, find **Share an API key**, and select **Create a new share**. Choose the **Shared models**, an **Expiry (1-90 days)**, and optionally a spend cap, a token cap, a note, and the paid capabilities. Then select **Create Share**. kenari creates a public link and a key. The key is shown once, like any other key.

A shared key spends from your balance or plan, only on the models you chose, and stops on the expiry date or when a cap is reached. When your own balance cannot cover a request, the holder gets a `402` and `insufficient_balance` that points at you, the owner. Revoke a share from the same page with **Revoke**, and the link and key stop working at once.

A shared key is a limited grant, so it cannot do these things:

- Read your account data. `GET /v1/account/balance`, `GET /v1/account/quota` and the [Usage](/en/docs/usage) endpoints refuse a shared key with `403` and `shared_key_not_allowed`, and the `kenari_balance`, `kenari_usage` and `kenari_quota` tools of the [MCP server](/en/docs/mcp) refuse it too. See [Account quota](/en/docs/account-quota).
- Reuse a document reading that another key paid for. The request fails with `400` and the document is not read. Uploading the same document again does not get around this, because the stored reading stays tied to the key that paid for it. See [Read documents](/en/docs/documents).
- Use named routes.

Shared keys also have their own pacing. See [Rate limits](/en/docs/rate-limits).

## See also

- [Quickstart](/en/docs/quickstart)
- [Providers](/en/docs/byok)
- [Rate limits](/en/docs/rate-limits)
- [Errors](/en/docs/errors)


---

# Function calling

Function calling lets a model ask your code to do something it cannot do itself, such as look up an order or read a database. You describe your functions. The model picks one and returns its arguments. You run the function, then send the result back so the model can write the final answer. It works on [Chat completions](/en/docs/chat), [Messages](/en/docs/messages) and [Responses](/en/docs/responses).

## Pick a model that supports tools

A model supports function calling when `tool_call` is `true` in `GET /v1/models`. The call is public, so you can filter it without a key:

```bash
curl -s https://kenari.id/v1/models | jq -r '.data[] | select(.tool_call == true) | .id'
```

kenari does not block a model that lacks the flag, but such a model may ignore your tools. The examples on this page use `step-3-7-flash:free`, which supports tools and costs nothing to call.

## The loop with Chat completions

A tool call is a short conversation: your request, the model's call, your result, the model's answer.

1. Send the user message and a `tools` array.
2. The model returns an assistant message with `tool_calls` and `finish_reason: "tool_calls"`.
3. Run each function. Append the assistant message, then one `tool` message per call, each with the matching `tool_call_id`.
4. Send the whole conversation again. The model answers in plain text.

Each tool has a `name`, a `description` that tells the model when to use it, and a JSON Schema for `parameters`. This is the model's reply to step 2:

```json
{
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": null,
        "tool_calls": [
          {
            "id": "call_abc123",
            "type": "function",
            "function": {
              "name": "get_weather",
              "arguments": "{\"city\": \"Jakarta\"}"
            }
          }
        ]
      },
      "finish_reason": "tool_calls"
    }
  ]
}
```

`arguments` is a string that contains JSON, not an object. Parse it yourself. The complete loop with the OpenAI SDK:

```python
import json
import os

from openai import OpenAI

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

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Get the current weather for a city.",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string", "description": "City name, for example Jakarta"}
                },
                "required": ["city"],
            },
        },
    }
]


def get_weather(city: str) -> dict:
    return {"city": city, "temperature_c": 31, "conditions": "cloudy"}


messages = [{"role": "user", "content": "What is the weather in Jakarta?"}]

for _ in range(5):  # stop a runaway loop
    response = client.chat.completions.create(
        model="step-3-7-flash:free",
        messages=messages,
        tools=tools,
    )
    message = response.choices[0].message
    if not message.tool_calls:
        print(message.content)
        break

    messages.append(
        {
            "role": "assistant",
            "content": message.content,
            "tool_calls": [call.model_dump() for call in message.tool_calls],
        }
    )
    for call in message.tool_calls:
        arguments = json.loads(call.function.arguments)
        result = get_weather(**arguments)
        messages.append(
            {
                "role": "tool",
                "tool_call_id": call.id,
                "content": json.dumps(result),
            }
        )
else:
    print("Stopped after 5 rounds: the model is still asking for tool calls.")
```

The `tool` message `content` is a string. Send JSON as text, or a plain error message if the function failed, so the model can recover.

## The loop with Messages

On `/v1/messages` a tool is `{name, description, input_schema}`. The model returns `tool_use` blocks and `stop_reason: "tool_use"`. You answer with `tool_result` blocks in the next `user` message.

Point the Anthropic SDK at `https://kenari.id`, without `/v1`. The SDK adds the path itself.

```python
import json
import os

import anthropic

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

tools = [
    {
        "name": "get_weather",
        "description": "Get the current weather for a city.",
        "input_schema": {
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
        },
    }
]

messages = [{"role": "user", "content": "What is the weather in Jakarta?"}]

for _ in range(5):
    response = client.messages.create(
        model="step-3-7-flash:free",
        max_tokens=2048,
        tools=tools,
        messages=messages,
    )
    if response.stop_reason != "tool_use":
        print("".join(block.text for block in response.content if block.type == "text"))
        break

    messages.append({"role": "assistant", "content": response.content})
    results = []
    for block in response.content:
        if block.type == "tool_use":
            output = {"city": block.input["city"], "temperature_c": 31}
            results.append(
                {
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": json.dumps(output),
                }
            )
    messages.append({"role": "user", "content": results})
else:
    print("Stopped after 5 rounds: the model is still asking for tool calls.")
```

On `/v1/responses` the model returns `function_call` items and you reply with `function_call_output` items that carry the same `call_id`. Tools are declared flat, as `{"type": "function", "name": ..., "parameters": ...}`. See [Responses](/en/docs/responses).

## Control when the model calls a tool

`tool_choice` decides whether and which tool the model calls. Without it the model decides.

| Chat completions | Messages | Effect |
| --- | --- | --- |
| `"auto"` | `{"type": "auto"}` | The model decides. |
| `"required"` | `{"type": "any"}` | The model must call at least one tool. |
| `{"type": "function", "function": {"name": "get_weather"}}` | `{"type": "tool", "name": "get_weather"}` | The model must call that tool. |
| `"none"` | `{"type": "none"}` | The model answers without calling a tool. |

A forced choice without a name is rejected with `400`. Unknown shapes are handled per API format. Chat completions treats a shape it does not know, such as `allowed_tools`, as `"auto"`. Messages and Responses reject an unknown `tool_choice` type with `400`.

## Parallel tool calls

A model can return several calls in one reply: several entries in `tool_calls`, or several `tool_use` blocks. Run all of them, then answer every call. On Chat completions that is one `tool` message per `tool_call_id`. On Messages it is every `tool_result` block in a single `user` message.

kenari forwards a `parallel_tool_calls` field only to models served through an OpenAI-style API and drops it for the rest. It is also dropped whenever the request lists a `kenari:` server tool. Do not rely on it to prevent parallel calls. Write your loop so it handles one call or many.

## Streaming tool calls

With `"stream": true`, a call arrives in pieces that you join before parsing. See [Streaming](/en/docs/streaming) for the fields and events of each API format.

## Tools that kenari runs for you

A tool with the type `kenari:<name>`, for example `{"type": "kenari:web_search"}`, runs on kenari. You do not write the loop: the model calls it, kenari runs it and continues the answer. You can mix server tools with your own functions in one `tools` array. See [Server tools](/en/docs/server-tools).

## Limits and pitfalls

- Send the same `tools` array on every request of the loop, including the one that returns the final answer.
- Define every tool that your code runs as a `function` tool. Apart from `kenari:` server tools, a tool type that has no function definition is not run by kenari, so do not depend on it.
- The model can produce invalid JSON in `arguments`, or a value outside your schema. Validate before you run anything with side effects.
- A request that declares tools, or a conversation that already holds tool calls and results, is only sent to providers that can carry them, so your tools and results are not silently dropped.

## See also

- [Server tools](/en/docs/server-tools)
- [Structured output](/en/docs/structured-output)
- [Streaming](/en/docs/streaming)
- [Models and pricing](/en/docs/models)


---

# Structured output

Structured output means the model answers with JSON your code can parse, instead of free text. All three chat APIs accept it: `response_format` on [Chat completions](/en/docs/chat), `text.format` on [Responses](/en/docs/responses), and `output_config.format` on [Messages](/en/docs/messages). kenari asks every model for JSON, but it does not check the result, so validate every reply in your own code.

## Ask for JSON

`{"type": "json_object"}` asks for a single valid JSON object. kenari also adds an instruction to answer with JSON to the system prompt, so you do not have to.

```bash
curl https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "step-3-7-flash:free",
    "response_format": {"type": "json_object"},
    "messages": [
      {"role": "system", "content": "Reply with a JSON object and nothing else."},
      {"role": "user", "content": "Extract vendor and total: Invoice from Toko Maju, total 417000 rupiah."}
    ]
  }'
```

## Ask for a schema

`{"type": "json_schema"}` asks for JSON that matches a JSON Schema. Give the schema a `name` and put the schema itself under `schema`.

```json
{
  "model": "step-3-7-flash:free",
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "name": "invoice",
      "schema": {
        "type": "object",
        "properties": {
          "vendor": {"type": "string"},
          "total": {"type": "integer"}
        },
        "required": ["vendor", "total"]
      }
    }
  },
  "messages": [
    {"role": "user", "content": "Extract the invoice fields: Invoice from Toko Maju, total 417000 rupiah."}
  ]
}
```

## On Responses and Messages

On [Responses](/en/docs/responses), put the format in `text.format`. The schema fields sit on the format object itself, not under `json_schema`:

```json
{
  "model": "step-3-7-flash:free",
  "input": "Extract the invoice fields: Invoice from Toko Maju, total 417000 rupiah.",
  "text": {
    "format": {
      "type": "json_schema",
      "name": "invoice",
      "schema": {
        "type": "object",
        "properties": {
          "vendor": {"type": "string"},
          "total": {"type": "integer"}
        },
        "required": ["vendor", "total"]
      }
    }
  }
}
```

`{"type": "json_object"}` works there too. The response echoes the format in its `text` field, and any other `text.format` type returns `400`.

On [Messages](/en/docs/messages), put the schema in `output_config.format`. The older top-level `output_format` is accepted with the same meaning.

```json
{
  "model": "step-3-7-flash:free",
  "max_tokens": 1024,
  "output_config": {
    "format": {
      "type": "json_schema",
      "schema": {
        "type": "object",
        "properties": {
          "vendor": {"type": "string"},
          "total": {"type": "integer"}
        },
        "required": ["vendor", "total"],
        "additionalProperties": false
      }
    }
  },
  "messages": [
    {"role": "user", "content": "Extract the invoice fields: Invoice from Toko Maju, total 417000 rupiah."}
  ]
}
```

## What kenari does with the format

On all three APIs, kenari does two things with a structured-output request:

- It adds a short instruction to the system prompt that asks for JSON, with your schema when you sent one. Every model gets it, so a model answers in JSON even when its provider ignores the format. The instruction is billed as input tokens, like the rest of the prompt.
- It passes the format on in the provider's own field where that provider is known to accept it. There the provider can enforce the schema. Elsewhere the model follows the instruction alone.

`GET /v1/models` does not say which models enforce a schema, and that can change, so plan to validate and retry on every model. If a provider rejects the format, you can get a `400`. A reply can still be invalid JSON, or JSON wrapped in a code fence.

## The reliable pattern: validate and retry

Request JSON, validate the reply against your schema, and send the validation error back when it fails. This works on every model.

```python
import os

from openai import OpenAI
from pydantic import BaseModel, ValidationError


class Invoice(BaseModel):
    vendor: str
    total: int


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


def extract_invoice(text: str, attempts: int = 3) -> Invoice:
    messages = [
        {"role": "system", "content": "Reply with one JSON object and nothing else."},
        {"role": "user", "content": f"Extract vendor and total from: {text}"},
    ]
    for _ in range(attempts):
        response = client.chat.completions.create(
            model="step-3-7-flash:free",
            messages=messages,
            response_format={
                "type": "json_schema",
                "json_schema": {"name": "invoice", "schema": Invoice.model_json_schema()},
            },
        )
        reply = response.choices[0].message.content or ""
        try:
            return Invoice.model_validate_json(reply)
        except ValidationError as error:
            messages += [
                {"role": "assistant", "content": reply},
                {"role": "user", "content": f"That reply was invalid: {error}. Reply with corrected JSON only."},
            ]
    raise RuntimeError("no valid JSON after retries")


print(extract_invoice("Invoice from Toko Maju, total 417000 rupiah."))
```

Asking for a format adds no separate fee. The instruction kenari adds is billed as input tokens. Each retry is a new request, billed as usual on paid models.

## Structured output with a tool

Forcing a tool call requests arguments shaped by a schema, and works on any model with `tool_call` set to `true`. Define one tool whose `input_schema` is your schema, force it with `tool_choice`, and read the arguments from the call. On Messages:

```python
import os

import anthropic

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

response = client.messages.create(
    model="step-3-7-flash:free",
    max_tokens=1024,
    tools=[
        {
            "name": "record_invoice",
            "description": "Record the fields of an invoice.",
            "input_schema": {
                "type": "object",
                "properties": {
                    "vendor": {"type": "string"},
                    "total": {"type": "integer"},
                },
                "required": ["vendor", "total"],
            },
        }
    ],
    tool_choice={"type": "tool", "name": "record_invoice"},
    messages=[{"role": "user", "content": "Invoice from Toko Maju, total 417000 rupiah."}],
)

invoice = next(block.input for block in response.content if block.type == "tool_use")
print(invoice)
```

`block.input` is already parsed into an object. You do not run the tool or send a result back. The same pattern works on Chat completions with a function tool and `tool_choice: {"type": "function", "function": {"name": "record_invoice"}}`, where the JSON is in `tool_calls[0].function.arguments`. Validate it all the same, and handle a reply that has no tool call. See [Function calling](/en/docs/function-calling).

## Limits and pitfalls

- A reply cut off by `max_tokens` is invalid JSON. On Chat completions, check that `finish_reason` is not `length`. On Responses, check that `status` is not `incomplete`. On Messages, check that `stop_reason` is not `max_tokens`. Reasoning models spend part of `max_tokens` on thinking, so leave room. See [Reasoning](/en/docs/reasoning).
- Do not rely on schema keywords such as `minimum` or `pattern` being enforced. Models differ, so check them in your own code.

## See also

- [Function calling](/en/docs/function-calling)
- [Chat completions](/en/docs/chat)
- [Responses](/en/docs/responses)
- [Messages](/en/docs/messages)
- [Reasoning](/en/docs/reasoning)


---

# Images and files as input

Some models read images directly. You attach the image to a message, and the model answers about it in the same request. Documents work on any model: kenari reads a PDF, text file or image file first and gives the model its text and page images. This page covers images on all three chat formats and documents on Chat completions and Messages. [Read documents](/en/docs/documents) has the limits and the engines.

## Check what a model can read

`GET /v1/models` lists what each model accepts under `modalities.input`. The call is public, so you can filter it without a key:

```bash
curl -s https://kenari.id/v1/models \
  | jq -r '.data[] | select((.modalities.input // []) | index("image")) | .id'
```

Replace `"image"` with `"pdf"` to find models that read PDFs themselves.

| Value in `modalities.input` | The model reads |
| --- | --- |
| `image` | Images. |
| `pdf` | PDF files, by the model itself. |
| `file` | Any document. Every chat model lists it, because kenari reads attached documents for any model. |

The list can also contain `audio` and `video`, which this page does not cover. The examples below use `step-3-7-flash:free`, which reads images and costs nothing to call.

## Send an image with Chat completions

Add an `image_url` part next to your text. The `url` is either a data URL or a web address.

```python
import base64
import os

from openai import OpenAI

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

with open("receipt.jpg", "rb") as file:
    image = base64.b64encode(file.read()).decode()

response = client.chat.completions.create(
    model="step-3-7-flash:free",
    messages=[
        {
            "role": "user",
            "content": [
                {"type": "text", "text": "What is the total on this receipt?"},
                {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image}"}},
            ],
        }
    ],
)
print(response.choices[0].message.content)
```

A web address works the same way: `{"type": "image_url", "image_url": {"url": "https://example.com/receipt.jpg"}}`.

kenari does not download the image. With a web address, the model's provider fetches it, and not every provider does. When a provider cannot, the request fails with `400` and the message `remote image URL not supported by upstream`. A data URL works on every model that reads images, so use one when you need a request to work everywhere.

## Send an image with Messages

On `/v1/messages` an image is a block with a `source` of type `base64` or `url`. Point the Anthropic SDK at `https://kenari.id`, without `/v1`.

```python
import base64
import os

import anthropic

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

with open("receipt.jpg", "rb") as file:
    image = base64.b64encode(file.read()).decode()

message = client.messages.create(
    model="step-3-7-flash:free",
    max_tokens=1024,
    messages=[
        {
            "role": "user",
            "content": [
                {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": image}},
                {"type": "text", "text": "What is the total on this receipt?"},
            ],
        }
    ],
)
print(next(block.text for block in message.content if block.type == "text"))
```

A `url` source is `{"type": "url", "url": "https://example.com/receipt.jpg"}`, with the same caveat about web addresses as above. Any other source type, such as an uploaded file id, is ignored without an error, so the model answers without the image.

## Send an image with Responses

On `/v1/responses` an image is an `input_image` part whose `image_url` is a string, either a data URL or a web address:

```json
{
  "model": "step-3-7-flash:free",
  "input": [
    {
      "role": "user",
      "content": [
        {"type": "input_text", "text": "What is the total on this receipt?"},
        {"type": "input_image", "image_url": "data:image/jpeg;base64,/9j/4AAQ..."}
      ]
    }
  ]
}
```

## Send a PDF or other document

On Chat completions a document is a `file` part. `file_data` is a data URL. This works on any model: kenari reads the document before routing, so the model does not need to read files itself.

```python
import base64
import os

from openai import OpenAI

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

with open("invoice.pdf", "rb") as file:
    document = base64.b64encode(file.read()).decode()

response = client.chat.completions.create(
    model="step-3-7-flash:free",
    messages=[
        {
            "role": "user",
            "content": [
                {"type": "text", "text": "What is the invoice number?"},
                {
                    "type": "file",
                    "file": {
                        "filename": "invoice.pdf",
                        "file_data": f"data:application/pdf;base64,{document}",
                    },
                },
            ],
        }
    ],
)
print(response.choices[0].message.content)
```

kenari sends the model the text of every page that has a text layer and an image of every page that does not. You can attach several documents to one request. Accepted types are PDF, `text/plain`, `text/markdown`, `text/csv`, `text/html`, `application/json` and images. The limits are about 15 MB and 100 pages per document, and 20 pages without a text layer per request. A document that makes the request too long for the model's context is refused with `400` and no charge, and the message gives the token counts. See [Read documents](/en/docs/documents) for the limits and the engines.

Send images as `image_url` parts when you can. An image in a `file` part is sent to the model as an image, but with the `native` engine it is refused with `400`. A `file` part without a `file` object is refused too.

Two other formats behave differently:

- `/v1/responses` does not accept `input_file` and answers `400`.
- On `/v1/messages`, a `document` block with a base64 source is read the same way as a `file` part, on any model. A `document` block with a `url` source reaches only models served through an Anthropic-style API, and for other models it is dropped without an error. Send the PDF as base64.

### Let the model read the file itself

With the `native` engine, the file goes to the model untouched. This needs a model that reads files itself. `file` in `modalities.input` does not tell you that, because every chat model lists it, and `pdf` marks a model that reads PDFs itself. kenari checks the model's own capability:

- A model that reads PDFs takes PDF files.
- A model that reads any document takes any document.
- Any other model: the request is refused with `400` before anything is charged, because the document would otherwise reach a model that cannot read it.

```json
"plugins": [{"id": "file-parser", "pdf": {"engine": "native"}}]
```

A refusal reads like this:

```json
{
  "error": {
    "message": "model 'step-3-7-flash:free' does not read application/pdf file attachments. Send the file to a model that reads files, or add the file-parser plugin to read it with OCR (billed per page from your balance)",
    "type": "bad_request",
    "param": null,
    "code": "bad_request"
  }
}
```

The paid `ocr` engine reads a scan or handwriting to text with OCR. It is billed per page from your balance and never covered by a plan. See [Read documents](/en/docs/documents).

## Billing

Images and files that the model reads are billed as Input tokens of that model, at the model's normal rate. The token count depends on the model, so read the usage in the response: `usage.prompt_tokens` on Chat completions, `usage.input_tokens` on Messages and Responses. On Messages, add the cache fields to get the whole prompt, as described in [Prompt caching](/en/docs/prompt-caching). A subscription plan that covers the model covers these tokens too, and that includes documents that kenari reads for you. Only the `ocr` engine is billed per page instead. See [How billing works](/en/docs/billing).

## Limits and pitfalls

- The whole request body is limited to 32 MiB. A larger request is refused with `413`. Base64 adds about a third to the size of a file.
- kenari does not resize images or count them. Limits on image size and image count come from the model and its provider.
- kenari does not check an image against `modalities.input`. A model that does not list `image` can fail with an error or answer without looking at the image, so pick a model from the list.
- A conversation resends its images on every request, and each request is billed for them again. Drop images from the history once the model has used them.

## See also

- [Read documents](/en/docs/documents)
- [Models and pricing](/en/docs/models)
- [Chat completions](/en/docs/chat)
- [How billing works](/en/docs/billing)


---

# Read documents

Attach a PDF to an ordinary chat request, on any model. kenari reads the document before the request is routed and gives the model what it contains: the text of every page that has a text layer, and an image of every page that does not, such as a scan or a page that is only a figure. Page order is kept. No plugin is needed.

This costs nothing extra. What the model receives is billed as its normal input tokens, under whatever pays for the request, so a subscription plan that covers the model covers the document too.

For documents that need real OCR, such as handwriting or poor scans, name the `ocr` engine. That reading is billed per page from your balance and is never covered by a plan. `POST /v1/ocr` does the same reading with no model in the loop.

## Ask a question about a document

Add a `file` content part to a user message. `file_data` is a data URL in the form `data:<type>;base64,<contents>`. The data URL must name the media type. A data URL without one, such as `data:;base64,...`, is refused. A plain http URL is not fetched, so send the contents yourself. A request can carry several documents.

The shell builds the request body, because curl cannot read a file into JSON by itself. This needs `jq`. Replace `invoice.pdf` with your own file.

```bash
{ printf 'data:application/pdf;base64,'; base64 < invoice.pdf | tr -d '\n'; } > invoice.dataurl

jq -n --rawfile data invoice.dataurl '{
  model: "step-3-7-flash:free",
  messages: [{
    role: "user",
    content: [
      {type: "text", text: "What is the total on this invoice?"},
      {type: "file", file: {filename: "invoice.pdf", file_data: $data}}
    ]
  }]
}' | curl https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d @-
```

The model in the example is free, so the example runs with no balance. On `POST /v1/messages`, a `document` block with a base64 PDF source is read the same way. Responses requests do not take documents. See [Images and files as input](/en/docs/vision).

## Accepted types

- `application/pdf`: pages with a text layer go as text, pages without one go as images.
- `text/plain`, `text/markdown`, `text/csv`, `text/html` and `application/json`: sent as text.
- Images such as `image/png`, `image/jpeg` and `image/webp`: sent as an image.

Any other type is refused with `400` before anything is charged.

## Limits

- About 15 MB per document and 100 pages per PDF.
- Up to 20 pages without a text layer per request. Each one is sent as an image, roughly 1,000 to 1,500 input tokens. A longer scan is refused with `400`: split it, or use the `ocr` engine.
- A document that makes the request too long for the model's context is refused with `400` and no charge. The message gives the estimated token count and the model's context size, so you can pick a larger model or send fewer pages.
- A damaged or password-protected PDF is refused with `400`.

## Choose an engine

The `file-parser` plugin is optional. Name an engine only when you want something other than the default.

```json
"plugins": [{"id": "file-parser", "pdf": {"engine": "ocr"}}]
```

| `engine` | What happens | Cost |
| --- | --- | --- |
| none or `auto` | Text pages as text, other pages as images. Several documents per request | Model input tokens only |
| `pdf-text` (alias `cloudflare-ai`) | Text layer only. A PDF with pages that have no text is refused | Model input tokens only |
| `native` | The file goes to the model untouched, for a model that reads files itself | Model input tokens only |
| `ocr` (alias `mistral-ocr`) | Paid OCR, then the text goes to the model. One document per request | Per page from your balance, plus model tokens |

With `native`, a model that does not read that kind of file itself refuses the request with `400` and no charge, and images must be sent as `image_url` parts. Any other engine name is refused with `400`.

The sections from here to Keys with restrictions describe the `ocr` engine.

## Read the OCR result

With the `ocr` engine, the response carries the extracted text next to the model's answer, in `message.annotations`. The request is the same as above plus `"plugins": [{"id": "file-parser", "pdf": {"engine": "ocr"}}]`.

```json
{
  "choices": [{
    "message": {
      "role": "assistant",
      "content": "The total is Rp 417,000.",
      "annotations": [{
        "type": "file",
        "file": {
          "hash": "sha256:a3b6919a...",
          "name": "invoice.pdf",
          "content": [{"type": "text", "text": "# INVOICE\n..."}],
          "confidence": 0.9892,
          "low_confidence": false,
          "reuse_id": "ocr_8f286cec-075b-4083-a462-fc89170feb4f"
        }
      }]
    }
  }]
}
```

`content` holds the extracted text, `hash` identifies it, and `reuse_id` is present when the reading was stored for reuse. Annotations are added to the non-streaming response only. With `stream: true` the document is still read and billed, but the text and the `reuse_id` are not returned, so use a non-streaming request when you need them.

`confidence` is the mean per-page score from the reading engine. It is `null` when the engine gives no score. `low_confidence` is `true` when a score is below kenari's threshold, and a missing score does not always set it. Treat it as a warning, not a guarantee. Correctly and incorrectly read documents score in overlapping ranges, and the engine tends to fill an unreadable spot with a plausible value instead of leaving it blank. Have a person check any figure you read from a scan, whatever `low_confidence` says.

## Reuse an OCR reading without another page charge

Send the `reuse_id` back in the plugin on the next turn, with no `file` part:

```json
{
  "model": "step-3-7-flash:free",
  "messages": [{"role": "user", "content": "Who is the seller?"}],
  "plugins": [{"id": "file-parser", "pdf": {"engine": "ocr", "reuse_id": "ocr_8f286cec-075b-4083-a462-fc89170feb4f"}}]
}
```

Substitute the `reuse_id` that your own response returned.

That turn does not read the document again and adds no page charge. A request carries a `file` or a `reuse_id`, never both. A `reuse_id` works only for the account that received it, and it works on both endpoints: one from a chat turn can be used on `/v1/ocr`, and the other way round. If the chat model fails after the document was read, the page charge stays. Sending the same document again on the same account is not billed twice, as long as the first reading was stored, which means its response carried a `reuse_id`.

## Cost

The default reading, `pdf-text` and `native` have no charge of their own. The document's text and page images are the model's input tokens, billed like any other input, and a plan that covers the model covers them.

The `ocr` engine bills per page read, from your balance, and an image counts as one page. Plans never cover it, even when a plan covers the model that answers. If your balance cannot cover the document, kenari refuses the request with `402` and `insufficient_balance` before it reads anything, and nothing is charged. One OCR request produces two rows in usage, one for the reading and one for the model's answer. See [How billing works](/en/docs/billing) for balance and price dimensions, and [Errors](/en/docs/errors) for the error.

## Keys with restrictions

This section applies to the `ocr` engine and `/v1/ocr`. A key restricted to certain models can use OCR only if you ticked **Document reading** under **Paid capabilities** when you created it. The default reading only uses model tokens, so a key that may use a model may also attach documents to it. A shared API key can reuse only readings that the same key made, so a holder of a shared key cannot read back documents from your other keys. See [Authentication and API keys](/en/docs/authentication) for key restrictions and sharing.

## Reading without a model

`POST /v1/ocr` returns the extracted text and its metadata, with no model generating a reply, so you pay the per-page fee and no token charge. See [OCR](/en/docs/ocr) for the request and response.

## See also

- [OCR](/en/docs/ocr)
- [Images and files as input](/en/docs/vision)
- [How billing works](/en/docs/billing)
- [Authentication and API keys](/en/docs/authentication)


---

# Streaming

Streaming returns an answer piece by piece while the model writes it, so your app can show text right away instead of waiting for the whole reply. Use it for chat interfaces, for long answers, and for anything that reasons for a while. All three chat endpoints stream: [Chat completions](/en/docs/chat), [Messages](/en/docs/messages) and [Responses](/en/docs/responses).

## Turn on streaming

Set `"stream": true` in the request body. The response has the content type `text/event-stream`, and each event follows the format of the endpoint you called.

| Endpoint | Frame | The stream ends with |
| --- | --- | --- |
| `POST /v1/chat/completions` | `data:` line only | `data: [DONE]` |
| `POST /v1/messages` | `event:` line and `data:` line | the `message_stop` event |
| `POST /v1/responses` | `event:` line and `data:` line | the `response.completed` or `response.failed` event |

Every stream follows the same rules:

- A frame is one event followed by a blank line. The `data:` line holds a JSON object, except the final `data: [DONE]` line of Chat completions, which is not JSON. Check for `[DONE]` before you decode.
- A line that starts with `:` is a comment. kenari sends `: keepalive` when the model has been silent for a while, for example during a long prompt or while it reasons. Ignore these lines. The official SDKs do.
- Errors that happen before the stream starts, such as a bad key, an invalid request, low balance or a rate limit, come back as a normal HTTP error with a JSON body. See [Errors](/en/docs/errors).
- Once the first event is sent, the HTTP status is already 200. A failure after that arrives as an event in the stream, described for each format below.

## Fields and events by API format

| | Chat completions | Messages | Responses |
| --- | --- | --- | --- |
| Answer text | `delta.content` | `text_delta` in `content_block_delta` | `response.output_text.delta` |
| Reasoning | `delta.reasoning` and `delta.reasoning_content`, before the text | a `thinking` block with `thinking_delta` events | a `reasoning` item with `response.reasoning_summary_text.delta` events |
| Function call | `delta.tool_calls`, arguments in pieces | a `tool_use` block with `input_json_delta` events | a `function_call` item with `response.function_call_arguments.delta` events |
| Token usage | `usage` on the last chunk, when token counts are non-zero | `message_start` and `message_delta` | `response.completed` |
| Finish reason | `finish_reason` | `stop_reason` in `message_delta` | `status` in `response.completed` |
| Failure after the start | a chunk with an `error` object, then `[DONE]` | an `error` event | a `response.failed` event |

Reasoning is streamed in all three formats, ahead of the answer text. Only reasoning models produce it, and on some requests a model reasons very little or not at all. On Chat completions you can leave it out of the stream with `"reasoning": {"exclude": true}`. The tokens are still generated and billed. To control how much a model reasons, see [Reasoning](/en/docs/reasoning).

## Chat completions stream

Each chunk is a `chat.completion.chunk` with the same `id` and a `model` that echoes the one you requested.

```bash
curl -N https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "step-3-7-flash:free",
    "stream": true,
    "messages": [{ "role": "user", "content": "Count from 1 to 3." }]
  }'
```

The `-N` flag turns off curl's buffering, so each event prints as it arrives. The stream looks like this, shortened:

```text
data: {"id":"4f1c9a2e7b3d5a60","object":"chat.completion.chunk","created":1790000000,"model":"step-3-7-flash:free","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}

data: {"id":"4f1c9a2e7b3d5a60","object":"chat.completion.chunk","created":1790000000,"model":"step-3-7-flash:free","choices":[{"index":0,"delta":{"content":"1, 2"},"finish_reason":null}]}

data: {"id":"4f1c9a2e7b3d5a60","object":"chat.completion.chunk","created":1790000000,"model":"step-3-7-flash:free","choices":[{"index":0,"delta":{"content":", 3"},"finish_reason":null}]}

data: {"id":"4f1c9a2e7b3d5a60","object":"chat.completion.chunk","created":1790000000,"model":"step-3-7-flash:free","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":14,"completion_tokens":6,"total_tokens":20}}

data: [DONE]
```

- The first chunk carries `"role": "assistant"`. Append every `delta.content` after it to build the answer.
- Reasoning arrives in `delta.reasoning` and `delta.reasoning_content`, with the same text under both names.
- A function call opens with one chunk that has the call's `index`, `id`, `type` and `function.name`, and an empty `arguments`. Later chunks with the same `index` carry pieces of `function.arguments`. Join the pieces, then parse the JSON. The call ends with `finish_reason` set to `tool_calls`. See [Function calling](/en/docs/function-calling).
- The last chunk has an empty `delta`, the `finish_reason`, and `usage` only when the token counts are non-zero. `usage.prompt_tokens_details` is added only when cached tokens are above 0. kenari's own usage chunk does not depend on `stream_options`, so you never need `include_usage`. There is no separate usage chunk with an empty `choices` array.
- When a kenari server tool searched the web, one more chunk with `delta.sources` arrives just before `[DONE]`.
- A failure after the start arrives as a chunk with an `error` object and no `choices`, followed by `[DONE]`:

```text
data: {"error":{"message":"the model's provider interrupted the response before it finished. send the request again as a new request","type":"upstream_error","param":null,"code":"stream_interrupted"}}

data: [DONE]
```

With the OpenAI Python SDK:

```python
import os
from openai import OpenAI

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

stream = client.chat.completions.create(
    model="step-3-7-flash:free",
    messages=[{"role": "user", "content": "Count from 1 to 3."}],
    stream=True,
)
for chunk in stream:
    if chunk.choices and chunk.choices[0].delta.content:
        print(chunk.choices[0].delta.content, end="", flush=True)
    if chunk.usage:
        print("\ntokens:", chunk.usage.total_tokens)
```

With the OpenAI JavaScript SDK:

```js
import OpenAI from "openai";

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

const stream = await client.chat.completions.create({
  model: "step-3-7-flash:free",
  messages: [{ role: "user", content: "Count from 1 to 3." }],
  stream: true,
});
for await (const chunk of stream) {
  const text = chunk.choices[0]?.delta?.content;
  if (text) process.stdout.write(text);
}
```

## Messages stream

Each frame has an `event:` line that names the event and a `data:` line with the JSON. The base URL for the Anthropic SDKs is `https://kenari.id`, as described on [Messages](/en/docs/messages).

| Event | Meaning |
| --- | --- |
| `message_start` | Opens the message with its `id`, `model` and a first `usage`. |
| `content_block_start` | Opens the block at `index`. The type is `text`, `tool_use` (with `id` and `name`) or `thinking`. |
| `content_block_delta` | A piece of the block: `text_delta`, `input_json_delta`, `thinking_delta` or `signature_delta`. |
| `content_block_stop` | Closes the block. |
| `message_delta` | `delta.stop_reason` and the final `usage`, with `output_tokens` and the cache fields when present. |
| `message_stop` | The end of the stream. |
| `ping` | A keep-alive from the provider. Ignore it. |
| `error` | The message failed after it started. No `message_stop` follows. |

A short stream:

```text
event: message_start
data: {"type":"message_start","message":{"id":"4f1c9a2e7b3d5a60","type":"message","role":"assistant","model":"step-3-7-flash:free","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":14,"output_tokens":0}}}

event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"1, 2, 3"}}

event: content_block_stop
data: {"type":"content_block_stop","index":0}

event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":14,"output_tokens":6}}

event: message_stop
data: {"type":"message_stop"}
```

- When the model reasons, a `thinking` block comes first, with its text in `thinking_delta` events followed by a `signature_delta`. The signature can be empty. Send the block back unchanged in the next turn.
- A function call is a `tool_use` block whose `input` starts as `{}`. The arguments arrive as `partial_json` strings in `input_json_delta` events. Join them, then parse the JSON when the block closes. The message ends with `stop_reason` set to `tool_use`.
- A failure after the start is an `error` event. Its `error.type` is `api_error` or `invalid_request_error`. Read `error.message` and treat the stream as over.

```text
event: error
data: {"type":"error","error":{"type":"api_error","message":"the model's provider interrupted the response before it finished. send the request again as a new request"}}
```

With the Anthropic Python SDK:

```python
import os
import anthropic

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

with client.messages.stream(
    model="step-3-7-flash:free",
    max_tokens=512,
    messages=[{"role": "user", "content": "Count from 1 to 3."}],
) as stream:
    for text in stream.text_stream:
        print(text, end="", flush=True)
    message = stream.get_final_message()
    print("\ntokens:", message.usage.output_tokens)
```

With the Anthropic TypeScript SDK:

```ts
import Anthropic from "@anthropic-ai/sdk";

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

const stream = client.messages.stream({
  model: "step-3-7-flash:free",
  max_tokens: 512,
  messages: [{ role: "user", content: "Count from 1 to 3." }],
});
stream.on("text", (text) => process.stdout.write(text));
const message = await stream.finalMessage();
console.log("\ntokens:", message.usage.output_tokens);
```

## Responses stream

Each frame has an `event:` line and a `data:` line. Every event has a `type` and a `sequence_number` that counts up from 0.

| Event | Meaning |
| --- | --- |
| `response.created` | The first event. Carries the response `id`, `model` and `status` of `in_progress`. |
| `response.output_item.added` | An item starts at `output_index`: `reasoning`, `message` or `function_call`. |
| `response.reasoning_summary_part.added` | The reasoning item opens its one summary part. |
| `response.reasoning_summary_text.delta` | A piece of the reasoning text. |
| `response.reasoning_summary_text.done` | The full reasoning text. |
| `response.reasoning_summary_part.done` | The summary part is complete. |
| `response.output_text.delta` | A piece of the answer text. |
| `response.function_call_arguments.delta` | A piece of the function arguments. |
| `response.function_call_arguments.done` | The full arguments string. |
| `response.output_item.done` | The item is complete and carries its full content. |
| `response.completed` | The last event. Carries the full `output` and `usage`. |
| `response.failed` | The response failed after it started. |

kenari does not send `response.in_progress`, `response.content_part.added`, `response.content_part.done`, `response.output_text.done` or `response.incomplete`. A response cut short by the token limit ends with `response.completed` whose `status` is `incomplete`.

- Reasoning comes as one `reasoning` item before any message or function call. Its text streams through the `response.reasoning_summary_text.delta` events.
- A function call is a `function_call` item. Its `arguments` stream through `response.function_call_arguments.delta`, and `response.function_call_arguments.done` carries the whole string.
- `response.completed` holds the final `usage`, including `input_tokens_details.cached_tokens`.
- A failure after the start is a `response.failed` event, with `response.error.code` and `response.error.message`. Nothing follows it.

```text
event: response.failed
data: {"type":"response.failed","response":{"id":"resp_4f1c9a2e7b3d5a60","status":"failed","model":"step-3-7-flash:free","error":{"code":"stream_interrupted","message":"the model's provider interrupted the response before it finished. send the request again as a new request"}},"sequence_number":4}
```

With the OpenAI Python SDK:

```python
import os
from openai import OpenAI

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

stream = client.responses.create(
    model="step-3-7-flash:free",
    input="Count from 1 to 3.",
    stream=True,
)
for event in stream:
    if event.type == "response.output_text.delta":
        print(event.delta, end="", flush=True)
```

With the OpenAI JavaScript SDK:

```js
import OpenAI from "openai";

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

const stream = await client.responses.create({
  model: "step-3-7-flash:free",
  input: "Count from 1 to 3.",
  stream: true,
});
for await (const event of stream) {
  if (event.type === "response.output_text.delta") process.stdout.write(event.delta);
}
```

## Limits and pitfalls

- A stream cannot be resumed. After an error, send the request again as a new request. The status was already 200 when the stream started, so retry logic that reacts to HTTP errors does not see the failure.
- Do not split the stream on network chunks. One chunk can hold several events or half of one. Read it by frames, or let the SDK do it.
- A function call is not valid JSON until its last piece arrives. Parse it after the block or item closes.
- The browser `EventSource` only sends `GET` requests. Use `fetch` with a readable stream, or an SDK.
- kenari sends `Cache-Control: no-cache, no-transform` and `X-Accel-Buffering: no`. If you put your own proxy in front, turn off response buffering there too, or events will arrive in bursts.
- Use streaming for long answers. A request without it holds the connection open until the whole answer is ready, and a proxy or load balancer between you and kenari may close it first.

## See also

- [Chat completions](/en/docs/chat)
- [Messages](/en/docs/messages)
- [Responses](/en/docs/responses)
- [Reasoning](/en/docs/reasoning)


---

# Reasoning

Some models think before they answer. They produce a reasoning trace first, then the final text. kenari gives you one set of parameters for this on every chat format, returns the trace next to the answer, and bills the thinking as Output tokens. More thinking usually means better answers on hard problems. It can also mean a slower reply and more tokens, which cost money wherever Output tokens are billed.

## Find models that support reasoning

`GET /v1/models` describes reasoning for each model. The call is public, so you can filter it without a key:

```bash
curl -s https://kenari.id/v1/models | jq -r '.data[] | select(.reasoning == true) | .id'
```

| Field | Meaning |
| --- | --- |
| `reasoning` | `true` when the model can think. |
| `reasoning_options` | The effort levels the model accepts, for example `["low", "medium", "high"]`. Absent when the model has no effort levels. |
| `reasoning_toggle` | `true` when you can switch reasoning on or off per request. |

The levels are `none`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`. `none` turns reasoning off, and `minimal` is treated as `low`. If you ask for a level the model does not list, kenari uses the highest listed level at or below it. If the model lists no level at or below the one you asked for, the level you asked for is sent unchanged. On a model that does not support reasoning, the reasoning fields are ignored.

## Request reasoning

Each format has its own field. kenari maps all of them to the native reasoning control of the model's provider.

| Format | How to ask |
| --- | --- |
| Chat completions | `reasoning_effort`, or the `reasoning` object below |
| Messages | `thinking: {"type": "enabled", "budget_tokens": N}`, or `output_config: {"effort": "high"}` |
| Responses | `reasoning: {"effort": "high"}` |

With Chat completions:

```bash
curl https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "step-3-7-flash:free",
    "reasoning_effort": "medium",
    "messages": [
      {"role": "user", "content": "What is 17 * 24? Show the steps."}
    ]
  }'
```

With Messages. Point the Anthropic SDK at `https://kenari.id`, without `/v1`:

```python
import os

import anthropic

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

message = client.messages.create(
    model="step-3-7-flash:free",
    max_tokens=4096,
    thinking={"type": "enabled", "budget_tokens": 2048},
    messages=[{"role": "user", "content": "What is 17 * 24? Show the steps."}],
)

for block in message.content:
    if block.type == "thinking":
        print("Reasoning:", block.thinking)
    elif block.type == "text":
        print("Answer:", block.text)
```

`thinking: {"type": "disabled"}` turns reasoning off on Messages only for models served through an Anthropic-style API. For other models it removes the effort setting, and the model's default applies. To turn reasoning off on a model that supports it (`reasoning_toggle` is `true`), use `reasoning: {"enabled": false}` on [Chat completions](/en/docs/chat). A budget must be at least 1,024 and below `max_tokens`. At a `max_tokens` of 1,024 or less, models served through an Anthropic-style API get no thinking at all.

## The reasoning object

Chat completions also accepts an OpenRouter-style `reasoning` object, which you can send on every chat model:

| Field | Type | Description |
| --- | --- | --- |
| `effort` | string | One of the levels above. |
| `enabled` | boolean | `false` turns reasoning off. `true` without `effort` or `max_tokens` turns it on at `medium`. |
| `max_tokens` | integer | A token budget for the thinking. Models that take a level instead of a budget may ignore it, so prefer `effort`. |
| `exclude` | boolean | `true` hides the trace from the response. The thinking is still generated and billed. |

When a request has both `reasoning` and `reasoning_effort`, the `reasoning` object wins.

```bash
curl https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "step-3-7-flash:free",
    "reasoning": {"enabled": false},
    "messages": [
      {"role": "user", "content": "In one word, what is the capital of Indonesia?"}
    ]
  }'
```

kenari turns `enabled: false` into the control the provider needs, so your code does not change per model. A model without `reasoning_toggle` set to `true` may keep thinking.

## Set the level in the model id

Some coding tools offer only a few levels, or send none for a model they do not know. To pick the level anyway, add it to the model id after a colon:

```bash
curl https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "deepseek-v4-1-flash:free:max",
    "messages": [
      {"role": "user", "content": "What is 17 * 24? Show the steps."}
    ]
  }'
```

kenari removes the suffix and runs the model at that level, whatever the request asks for. It writes the level into `reasoning_effort`, the `reasoning` object, `thinking` and `output_config`, and it also overrides a request that turns reasoning off. Everything else sees the plain model id: the `model` in the response, your key's model restrictions, billing and the usage log. The `x-kenari-gated-effort` response header shows the level kenari sent.

- The level must be one the model lists in `reasoning_options`. A level the model does not list, such as `deepseek-v4-pro:medium`, is refused with a 400 that names the levels the model has. `none` works only on a model that lists it, and turns reasoning off.
- `kenari/auto` and a route accept `low`, `medium`, `high`, `xhigh` and `max`. Each model they call gets its highest listed level at or below that one. A `byok/` id sends the level to your provider unchanged.
- The suffix combines with `:free` in either order. `deepseek-v4-1-flash:free:max` and `deepseek-v4-1-flash:max:free` are the same model.
- Ids with a level are not listed in `GET /v1/models`. Type the id into your tool as a custom model name. Some tools look up the context window by model name, so set it by hand if your tool has that option.
- A route name cannot end in a level, for example `fast:high`.

The suffix is a kenari extension. OpenRouter uses the same colon form for its model variants, but none of its variants sets a reasoning level.

## Read the reasoning trace

Where the trace appears depends on the format.

On Chat completions it is in `choices[].message.reasoning`. The same text is also in `reasoning_content`, for tools that read that name.

```json
{
  "choices": [
    {
      "message": {
        "role": "assistant",
        "reasoning": "17 * 24 = 17 * 20 + 17 * 4 = 340 + 68 ...",
        "reasoning_content": "17 * 24 = 17 * 20 + 17 * 4 = 340 + 68 ...",
        "content": "17 * 24 = 408."
      },
      "finish_reason": "stop"
    }
  ]
}
```

On Messages it is a `thinking` block before the text block:

```json
{
  "content": [
    {"type": "thinking", "thinking": "17 * 24 = 17 * 20 + 17 * 4 ..."},
    {"type": "text", "text": "17 * 24 = 408."}
  ]
}
```

On Responses it is a `reasoning` item at the start of `output`, with the text in `summary[].text`. In a stream the trace arrives before the answer. See [Streaming](/en/docs/streaming) for the events of each format.

kenari does not shorten or filter the trace. It is the text the model produced. Some models return no trace, even when they think.

## Billing

Reasoning tokens are billed as Output tokens, at the same rate as answer tokens. A higher effort level or a larger budget can mean more tokens, and so a higher charge on models that bill them. `:free` models cost nothing. `exclude: true` changes nothing about the bill, because the thinking still happens. To spend less, lower `effort` or turn reasoning off. See [How billing works](/en/docs/billing).

<aside class="warn"><strong>Warning</strong> High effort levels can produce very long traces. Match the level to the task instead of leaving it at the maximum.</aside>

## Small max_tokens and empty answers

Thinking uses part of `max_tokens`. If `max_tokens` is small, the thinking can use all of it before the answer starts, and the response has no answer text. kenari reports this as a cut-off, not as a normal stop:

| Format | Signal |
| --- | --- |
| Chat completions | `finish_reason` is `"length"` |
| Messages | `stop_reason` is `"max_tokens"` |
| Responses | `status` is `"incomplete"` with `incomplete_details: {"reason": "max_output_tokens"}` |

The thinking tokens are still billed. Give a reasoning model enough `max_tokens` for the thinking and the answer together, or lower the effort.

## See also

- [Streaming](/en/docs/streaming)
- [Chat completions](/en/docs/chat)
- [Messages](/en/docs/messages)
- [Models and pricing](/en/docs/models)


---

# Prompt caching

Prompt caching lets a provider reuse the start of a prompt it has already processed. When many requests begin with the same text, such as a long system prompt, a set of tools or a large document, the provider reads the repeated part from the cache at a lower price. This usually shortens the time until the response begins. The provider does the caching. kenari passes the cache counts through in `usage` and bills them on their own price lines.

## How a request gets cached

A cache matches the beginning of a prompt, so the order of your prompt decides what can be reused. Put content that stays the same first: system prompt, tool definitions, reference documents. Put what changes last: the user's question. A change anywhere in the cached part breaks the match from that point on.

What you send depends on the API format you call. Some providers cache a repeated prefix on their own. Others cache only the parts marked with `cache_control`.

| Format you call | What to send |
| --- | --- |
| Chat completions | Nothing. If the provider needs marks, kenari marks the system prompt and the last message for you. |
| Responses | Nothing. kenari marks the system prompt and the last message for you when the provider needs marks. |
| Messages | Your own `cache_control` marks. They pass through untouched, and kenari adds none. A request that also lists a `kenari:` server tool is rebuilt by kenari, so your marks are replaced by the two automatic ones. |

You cannot see which style a provider uses, so read `usage` after the request to learn whether anything was cached. On Messages, `cache_control` is ignored when the provider caches on its own. Providers also set a minimum prompt length for caching, and a shorter prompt is never cached.

This Messages request marks a long system prompt for caching. `handbook.txt` stands for any text file long enough to reach the provider's minimum:

```python
import os

import anthropic

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

with open("handbook.txt") as file:
    handbook = file.read()

message = client.messages.create(
    model="claude-sonnet-5-5",
    max_tokens=512,
    system=[
        {"type": "text", "text": handbook, "cache_control": {"type": "ephemeral"}},
    ],
    messages=[{"role": "user", "content": "How many days of annual leave do I get?"}],
)
print(message.usage)
```

Send the same request again shortly after. If the model caches prompts, `message.usage.cache_read_input_tokens` is above `0` on the second response. If it stays `0`, nothing was cached.

## Read the cache in usage

Every format reports cache reads in its own field.

| Format | Cache reads | Cache writes |
| --- | --- | --- |
| Chat completions | `usage.prompt_tokens_details.cached_tokens` | Not reported |
| Messages | `usage.cache_read_input_tokens` | `usage.cache_creation_input_tokens` |
| Responses | `usage.input_tokens_details.cached_tokens` | Not reported |

The formats count differently:

- On Chat completions and Responses, `prompt_tokens` or `input_tokens` is the whole prompt, and `cached_tokens` is the part of it that came from the cache.
- On Messages, `input_tokens` excludes the cached tokens. The whole prompt is `input_tokens + cache_read_input_tokens + cache_creation_input_tokens`.

The Messages cache fields appear only when the provider reports them. On a non-streaming Chat completions response, `cached_tokens` is `0` when nothing was cached. In a stream, `prompt_tokens_details` is present only when something was cached.

## How cache is priced

Each model has Cache read and Cache write prices next to Input and Output, under `pricing` in `GET /v1/models`. kenari bills cached tokens at those prices. How a missing price falls back to Input, and the full formula, are in [How billing works](/en/docs/billing).

## Free cache on subscription plans

Some plans do not count cached tokens against your quota for the models on their free cache list. Catalog prices do not change, but eligible cache tokens are left out of quota usage and out of the balance charge that follows a spent window. See [Subscriptions](/en/docs/subscriptions).

## Keep a conversation together

To make follow-up requests reuse the same cached prefix, send a `prompt_cache_key` string on Chat completions or Responses, and use the same value for every request of one conversation. A valid, nonempty key you send wins over the one kenari would derive from the conversation. kenari trims surrounding whitespace, and ignores an empty key or one longer than 512 bytes, so the derived key applies instead.

Two habits keep caching working:

- Do not change the system prompt or the first user message between turns.
- Start the conversation with text. A conversation whose first user message has no text, such as an image alone, gets no automatic key, so send a `prompt_cache_key` yourself.

## Limits and pitfalls

- A cache entry expires after a time limit that the provider sets. The first request after that is a miss.
- Not every model caches prompts. Check `usage` instead of assuming.
- The first request of a prompt is a miss by definition. Hits start with the second request.
- A hit is never guaranteed. Read `usage` to see what happened, and do not build logic on a request being cached.

## See also

- [How billing works](/en/docs/billing)
- [Subscriptions](/en/docs/subscriptions)
- [Messages](/en/docs/messages)
- [Models and pricing](/en/docs/models)


---

# Server tools

Server tools are tools that kenari runs for you. Add one to the `tools` array of a request and the model decides when to call it. kenari runs the call and gives the result back to the model. You get a single finished answer. The model does not need built-in search, only support for function calling. `GET /v1/models` marks those models with `"tool_call": true`. See [Models and pricing](/en/docs/models).

| Tool | What it does | Billing |
| --- | --- | --- |
| `kenari:web_search` | Searches the web and gives the model titles, URLs and snippets. | Per search |
| `kenari:web_fetch` | Fetches one page and gives the model its text and links. | Per fetch |
| `kenari:x_search` | Searches live posts on X and gives the model a short answer with x.com sources. | Per search |
| `kenari:datetime` | Gives the model the current date and time. | Free |

Server tools work on Chat Completions, Messages and Responses. In all three you declare a server tool the same way, with a `type` of `kenari:<name>` and nothing else. `<name>` is a tool from the table.

## Add a tool to a request

```bash
curl https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "step-3-7-flash:free",
    "tools": [{"type": "kenari:web_search"}],
    "messages": [
      {"role": "user", "content": "What changed in the latest stable Rust release?"}
    ]
  }'
```

Declare several tools together, and keep your own function tools next to them:

```json
{
  "tools": [
    {"type": "kenari:web_search"},
    {"type": "kenari:datetime"},
    {"type": "function", "function": {"name": "save_note", "parameters": {"type": "object"}}}
  ]
}
```

kenari runs only the tools whose type starts with `kenari:`, and passes your own function tools through. A function that has the same name as an enabled server tool, such as `web_search`, `web_fetch`, `x_search` or `datetime`, can collide with it, so give your functions other names. The `kenari:*` declaration is the same on the other formats. Declarations of your own functions differ per format, see [Function calling](/en/docs/function-calling). For example, on Messages:

```bash
curl https://kenari.id/v1/messages \
  -H "x-api-key: $KENARI_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "step-3-7-flash:free",
    "max_tokens": 1024,
    "tools": [{"type": "kenari:web_search"}],
    "messages": [{"role": "user", "content": "What changed in the latest stable Rust release?"}]
  }'
```

A tool call is billed even when the model in the request is free. See [Billing](#billing).

## Streaming

Server tools work with `stream: true`. When the model decides to call a tool mid-stream, kenari runs it, feeds the result back and continues in the same stream. You see one coherent message, with no tool call in it. See [Streaming](/en/docs/streaming) for the event format.

kenari limits how many times the model can call tools in one request: up to 4 rounds without streaming and up to 2 with streaming. When the limit is reached, kenari removes the tools and the model has to answer with what it has.

## Sources

On Chat Completions, the sources a search found are returned in `sources`, a list of `{url, title}` objects. Without streaming it sits on the message. With streaming it arrives in one chunk near the end, in `delta.sources`. When the model cites a source as `[1]`, `[2]` and so on in its text, the message also carries `url_citation` entries in `annotations` for those markers. On Messages, the non-streaming response has the same `sources` list at the top level.

## x_search_filter

The model fills in the arguments of a tool call, so it chooses `x_search_filter` too. You cannot set it in the tool declaration. To steer it, say what you want in the prompt, for example "only posts from these handles since the first of the month". To fix the filter yourself, call [Web and X search](/en/docs/web-search) directly.

| Field | Type | Description |
| --- | --- | --- |
| `allowed_x_handles` | array of strings | Search only these accounts. |
| `excluded_x_handles` | array of strings | Leave these accounts out. Cannot be used with `allowed_x_handles`. |
| `from_date`, `to_date` | string | A date range in `YYYY-MM-DD`. `from_date` must not be after `to_date`. |
| `enable_image_understanding`, `enable_video_understanding` | boolean | Read the images or videos inside posts. |

Handles are written without the `@`, use only letters, digits and underscores, and are limited to 20 per list.

## datetime and timezone

`kenari:datetime` lets the model check what day it is instead of guessing from its training data. The model passes an optional `timezone`, either an IANA name such as `Asia/Jakarta` or `America/New_York`, or a common abbreviation such as `WIB`, `JST` or `UTC`. The default is `Asia/Jakarta`. The result has the current time in UTC and the time in that zone.

## Server tools or provider built-in tools

Some providers have their own search tools, such as `web_search_preview` on OpenAI or `web_search_20250305` on Anthropic. kenari does not run these. It forwards them to the provider unchanged, and they work only for models whose provider supports them. A `kenari:web_search` tool works with any model that can call functions, because kenari does the search. Use one kind of search tool per request.

## Billing

`kenari:web_search`, `kenari:web_fetch` and `kenari:x_search` are billed per call, separately from tokens, and only when the model actually calls them. `kenari:datetime` is free. A call that fails is not charged. How a plan's daily allowance and your balance apply is explained on [Subscriptions](/en/docs/subscriptions).

When a call cannot be funded, it does not run. The model is told the search was unavailable and still answers, so the request does not fail. See [How billing works](/en/docs/billing) and [Subscriptions](/en/docs/subscriptions).

A key restricted to certain models can use search and page fetch only with **Web search and page fetching** ticked under **Paid capabilities**, and X search only with **X search** ticked. See [Authentication and API keys](/en/docs/authentication).

## Calling the tools without a model

The same search, fetch and X search are available as plain endpoints, with no model involved. See [Web and X search](/en/docs/web-search).

## See also

- [Web and X search](/en/docs/web-search)
- [Function calling](/en/docs/function-calling)
- [Streaming](/en/docs/streaming)
- [How billing works](/en/docs/billing)


---

# Routing and routes

Every model you call through kenari is routed automatically. When you want to decide the order yourself, build a named route in the dashboard and send its name in the `model` field. Your client code does not change either way.

## Automatic routing

For each model, kenari picks a healthy provider and sends your request there. If that provider returns an error, hits a limit or is slow, kenari moves on to the next one. Within a session, kenari tends to keep you on the same provider while it stays healthy, so latency and output style stay steady.

You do not configure anything, and responses never reveal which provider served a request. See [Errors](/en/docs/errors) for what you see when every provider fails.

## Named routes

A route is an ordered list of steps. Each step is one model plus one credential: a key or a subscription connection you added under [Providers](/en/docs/byok), or **kenari balance (PAYG)**. You call the route by name, and kenari works through the steps for you.

Routes work on [Chat completions](/en/docs/chat), [Messages](/en/docs/messages) and [Responses](/en/docs/responses). Only chat models can be steps. Image, audio, embedding, rerank and moderation models cannot.

### Create a route

1. Open **Providers & Routes** in the dashboard, then the **Routes** tab.
2. Click **Create Route**, then choose **Custom route**.
3. Enter a **Route Name** such as `cheap-first`, and click **Create**.
4. Click **Edit** on the new route, then **Add Step** for each step. Pick a **Credential** and a **Model** for every step.
5. Click **Save**.

A route name is unique in your account and at most 64 characters. `kenari-free` and any name that starts with `byok/` are reserved.

The create dialog also offers **Per-model route**, which builds a route for one model: it tries your eligible API keys first, then kenari. You need an API key on the **Providers** tab first.

### Choose a strategy

The **Strategy** setting decides which step kenari tries first. If a step fails or hits a limit, kenari continues with the remaining steps.

| Strategy | First step on each request |
| --- | --- |
| **Fallback** | Always step 1. Later steps are used only when earlier ones fail. This is the default. |
| **Round Robin** | The next step in turn, so requests rotate through the list. |
| **Random** | A random step. |
| **Weighted** | A step picked at random in proportion to its **Weight**. |
| **Latency** | The step with the lowest recent latency. Steps with no latency data yet are tried first. |

### Call a route

Send the route name as `model`, exactly like a model id.

```bash
curl https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "cheap-first",
    "messages": [{"role": "user", "content": "Hello!"}]
  }'
```

Because a route only changes the `model` value, you can reorder steps or add a backup without touching client code.

### Common patterns

| Pattern | Steps | Use when |
| --- | --- | --- |
| Own key first | Step 1: your own API key. Step 2: **kenari balance (PAYG)** | You want to spend your provider quota first and keep kenari as a backup. |
| Subscription first | Step 1: a connected subscription, such as ChatGPT, with the provider's model id. Step 2: **kenari balance (PAYG)** | You want to use the allowance you already pay for and continue on your balance when it runs out. See [Providers](/en/docs/byok#use-a-connection-in-a-route). |
| Cheapest first | Step 1: a low-cost model. Step 2: a stronger model as an availability backup | You want the low-cost model for every request and a stronger model only when it fails or hits a limit. Choosing a model by task difficulty needs logic in your own app. |
| Spread load | Strategy **Round Robin** over several keys | You want to share traffic across keys. |

### The kenari-free route

Every account has a read-only route named `kenari-free` that serves free models. See [Free models](/en/docs/free-models).

## Limits and pitfalls

- A malformed request, such as an invalid body, is returned as an error right away. kenari does not retry it on later steps because every step would reject it too.
- A step whose key or connection is disabled, deleted, paused, signed out or lapsed is skipped.
- Named routes are not available on a key with a spend cap, a token cap or a model restriction. The request returns `400`. Use an unrestricted key, or see [Authentication and API keys](/en/docs/authentication).
- Steps that use **kenari balance (PAYG)** are charged by the funding rules that apply to the step's model, so an eligible step can be covered by plan quota or cost nothing on a free model. Otherwise it is billed to your balance. Steps that use your own key are billed by your provider. See [How billing works](/en/docs/billing).

## See also

- [Providers](/en/docs/byok)
- [Free models](/en/docs/free-models)
- [Errors](/en/docs/errors)
- [Authentication and API keys](/en/docs/authentication)


---

# Providers

The **Providers & Routes** page in the dashboard, tab **Providers**, is where you bring your own accounts to kenari. There are two kinds. An API key is a key from a provider account you pay per token. An AI subscription is a login to a plan you already pay for, such as ChatGPT Plus or Pro, Claude Max or Team credits, or SuperGrok. You call both through the same endpoints and the same `kn-` key.

Requests served by your own key or subscription cost Rp 0 on kenari. The provider bills you, your kenari balance is not touched, and usage still appears in your analytics. When a key or subscription cannot serve a request, a [route](/en/docs/routing) can continue on your balance. Per-search fees for [Server tools](/en/docs/server-tools) such as web search still apply.

BYOK (bring your own key) is the name of the model id prefix `byok/`, which addresses these accounts. It is not a dashboard page name any more.

## API keys

### Add a key

1. Open **Providers & Routes** in the dashboard, then the **Providers** tab.
2. In the **API keys** section, click **Add API Key**.
3. Choose a **Provider**, paste the key, add a **Label (optional)** if you want, then click **Save**.

kenari checks the key right after you save it and shows the result on the key's row.

The names `chatgpt` and `grok` are taken by subscription logins, so you cannot add an API key under them.

#### Your provider is not in the list

Choose **Custom (your own endpoint)** and fill in:

- **Provider name**: a unique name such as `my-proxy`. kenari lowercases it and replaces spaces with hyphens. You use this name in `byok/` model ids and when you build routes.
- **Base URL**: the base URL of your endpoint.
- **API type**: **OpenAI compatible** or **Anthropic**.

### Manage keys

Each provider you use appears as a card, with its keys in priority order.

| Control | What it does |
| --- | --- |
| **Check** | Checks the key against the provider again. |
| **Browse models** | Shows the models this key can reach, with **Copy ID** for each one. |
| Row menu | **Edit API Key**, **Copy key ID**, **Disable** or **Enable**, **Delete API Key**. When a provider has two or more keys, it also has **Raise priority** and **Lower priority**. |

#### Key status

Each row shows one status.

| Status | Meaning |
| --- | --- |
| **Active** | The key passed its check, or the check could not confirm but the key served a request in the last 7 days. The row can add "used 2 h ago" or "never used". |
| **Cooldown N min** | The provider rate limits the key, has a temporary problem, or the key's quota ran out. The key becomes usable again on its own when the pause ends. |
| **Failing** | The provider rejects the key. No request goes through this key until you fix it. |
| **Status unknown** | The provider cannot be checked automatically, for example a custom endpoint with no model list. The key becomes **Active** after a request succeeds through it. |
| **Not checked yet** | A new key waiting for its first check. |
| **Inactive** | You turned this key off. It serves no requests until you click **Enable**. |

### kenari balance backup

Each API key provider card has a **kenari balance backup** switch. When it is on and every key you have for that provider fails or hits a limit, kenari finishes the request on its own capacity and charges your balance at PAYG rates. When every key fails and the backup is off, the request fails.

If the switch is on but your balance is too low, the card shows a warning and a **Top up** link. The backup cannot cover requests until you add balance. See [How billing works](/en/docs/billing).

The backup belongs to API keys. A request to a `byok/chatgpt/...` or `byok/grok/...` id never reaches your balance. To continue on your balance after a subscription, use a [route](#use-a-connection-in-a-route).

### Fallback order

kenari tries the keys on one card from top to bottom and moves to the next key when one fails or hits a limit. The balance backup, when on, is always last. Use **Raise priority** and **Lower priority** to change the order.

### Use a catalog model

You do not change any code. Send requests with your `kn-` key as usual. When the model you ask for is served by a provider whose key you saved, kenari tries your key first.

```bash
curl https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "deepseek-v4-flash",
    "messages": [{"role": "user", "content": "Hello!"}]
  }'
```

A subscription login is not used for catalog models. Address it with a `byok/` id or a route.

## AI subscriptions

The **AI subscriptions** section above the API keys has one tile each for ChatGPT, Claude and Grok. Connect a subscription and kenari shows it as a card, with its state and quota.

You can connect up to 5 ChatGPT and Grok logins in total, and one login for each upstream account. Connecting the same ChatGPT or Grok account again replaces its login instead of adding a second one. Claude credits are an ordinary API key, so they do not count toward the 5.

### Members only

ChatGPT and Grok need an active plan. The tile of a non-member shows **For plan subscribers** and a **See plans** link. A trial or a gifted plan counts as an active plan. See [Subscriptions](/en/docs/subscriptions).

If your plan lapses, your logins stay stored and their cards show **Needs an active plan**. kenari skips them, a `byok/chatgpt/...` or `byok/grok/...` request returns `403` and `subscription_required`, and a route skips their steps and continues with the next one. Renew your plan and they work again. kenari keeps refreshing a login that was used or connected in the last 30 days, so a short lapse does not sign you out.

Claude credits are an API key, so adding them is open to everyone. The [Claude Managed Agents](/en/docs/claude-managed-agents) passthrough needs an active plan.

### Connect ChatGPT

Use the Codex allowance in your Plus or Pro plan.

1. On the **ChatGPT** tile, click **Connect**.
2. Read the note, then click **Start**. kenari shows a code in **Your code**.
3. Click **Open ChatGPT**. This opens `https://auth.openai.com/codex/device` in a new tab. Sign in to your ChatGPT account and enter the code.
4. Go back to the dashboard. It shows **Waiting for confirmation** until OpenAI approves, then **ChatGPT connected**.

The code is valid for a limited time and the dialog shows how long is left. If the code expires or you cancel the sign-in, click **Try again** for a new code.

<aside class="warn"><strong>Warning</strong> This login uses your ChatGPT account through kenari. OpenAI may limit accounts used outside its own apps.</aside>

### Connect Grok

Use the allowance in your SuperGrok or X Premium+ plan.

1. On the **Grok** tile, click **Connect**, then **Start**.
2. Click **Open Grok**. This opens the xAI device page at `https://accounts.x.ai/oauth2/device`. Sign in to your xAI account and enter the code.
3. Go back to the dashboard and wait for **Grok connected**.

xAI decides which plans can use this access. If your plan cannot, the card shows **Your Grok plan does not support this access yet**.

### Connect Claude credits

Claude credits are the monthly API credit in a Max or Team plan. They are spent with an ordinary API key from the Anthropic Console, so there is no sign-in code.

1. On the **Claude** tile, click **Connect**.
2. Open claude.ai, then Settings, then Billing, and choose **Link organization** under API credits.
3. Open platform.claude.com, then API keys, and create a new key in that organization.
4. Paste the key into the dialog.
5. Choose your **Plan**: **Max 5x ($100/month)**, **Max 20x ($200/month)**, **Team** or **Other**. For **Team** and **Other**, enter **Credit per month (USD)**, from $1 to $500.
6. Set the **Renewal day**, which is the day your credit renews each month.
7. Leave **Stop automatically when credit runs out** on, or turn it off. It is on by default.

See [Credit estimate and auto-stop](#credit-estimate-and-auto-stop) for what the card shows and what the switch does.

The key is saved as an `anthropic` API key. Use it with `byok/anthropic/<model>`, and with the [Claude Managed Agents](/en/docs/claude-managed-agents) passthrough. Choose **Edit credit** on the card to change the plan or the renewal day later.

### Credit estimate and auto-stop

kenari cannot read your credit balance from Anthropic, so the card shows an estimate. It adds up the requests kenari sent with that key during the current period, valued at Anthropic's list price for each model. It does not know about anything else.

- The period starts on your **Renewal day** at 00:00 UTC and lasts until the next renewal day. When it renews, the estimate starts again from zero.
- A request shows up in the estimate within about 10 seconds.
- When **Stop automatically when credit runs out** is on, a model that has no list price is counted at the most expensive Anthropic list price, so the estimate errs on the high side. When it is off, such a model counts as zero.

When the estimate reaches your monthly credit and the switch is on, kenari skips that key, the same way it skips a paused key. A route continues with its next step, and the last step can be **kenari balance (PAYG)**. A request to `byok/anthropic/...` fails with `403` and `byok_credential_unavailable` when you have no other Anthropic key that can serve it. kenari checks the estimate about every 15 seconds, so a few requests can still go through right after the credit is used up. The key works again when the period renews. You can also raise the credit with **Edit credit**, or turn the switch off.

<aside class="warn"><strong>Warning</strong> The estimate is not a guarantee. It does not count usage that does not go through kenari, and it does not count the <a href="/en/docs/claude-managed-agents">Claude Managed Agents</a> passthrough. The only hard limit is a spend limit you set in the Claude Console, which Anthropic enforces. Set one.</aside>

### Disconnect

Open the card menu and choose **Disconnect**. kenari removes the login and stops using your subscription. You can connect it again any time.

### Card states

| State | Meaning |
| --- | --- |
| **Connected** | The login works. |
| **Limited until** a time | The subscription hit a limit, or the provider has a temporary problem. kenari skips it until that time. |
| **Needs reconnecting** | The login was rejected and cannot be refreshed. Connect it again from its tile and sign in with the same account. |
| **Needs an active plan** | Your plan has lapsed. See [Members only](#members-only). |
| **Your Grok plan does not support this access yet** | xAI refused the access for your plan. |

## Model ids

Address a connection or key with a `byok/` id:

```text
byok/<provider>/<model-at-your-provider>
```

| Part | Rule |
| --- | --- |
| `byok` | Literal, lowercase. |
| `<provider>` | `chatgpt`, `grok` or `anthropic` for the three subscriptions, otherwise the provider of your key, or the **Provider name** of a custom endpoint. Matching ignores case. |
| The rest | Sent to the provider as is, including slashes and colons. Case is kept. |

The built-in key providers are `openai`, `anthropic`, `deepseek`, `xai`, `groq`, `mistral` and `opencode`. A custom endpoint named `my-proxy` uses `byok/my-proxy/<model-at-your-provider>`.

To find the exact id, use **Browse models** on a key row or on a subscription card, then **Copy ID**. These requests never use your balance. If you have several keys or connections for the provider, kenari tries them in priority order.

All three API formats accept these ids: [Chat completions](/en/docs/chat), [Messages](/en/docs/messages) and [Responses](/en/docs/responses).

### ChatGPT

```bash
curl https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "byok/chatgpt/gpt-5.5",
    "messages": [{"role": "user", "content": "Hello!"}]
  }'
```

### Grok

```bash
curl https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "byok/grok/grok-4",
    "messages": [{"role": "user", "content": "Hello!"}]
  }'
```

### Claude credits

```bash
curl https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "byok/anthropic/claude-sonnet-5-5",
    "messages": [{"role": "user", "content": "Hello!"}]
  }'
```

The same ids work on `POST /v1/messages`:

```bash
curl https://kenari.id/v1/messages \
  -H "x-api-key: $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "byok/anthropic/claude-sonnet-5-5",
    "max_tokens": 512,
    "messages": [{"role": "user", "content": "Hello!"}]
  }'
```

## Use a connection in a route

A connection does not serve catalog models by itself. To let a subscription run first and your balance take over after it, build a [route](/en/docs/routing):

1. Open the **Routes** tab and create a custom route, or edit one.
2. Add a step. Under **Credential**, pick the connection, which is listed by provider and account, such as the ChatGPT account you connected. In **Model**, enter the provider's own model id, without the `byok/chatgpt/` prefix.
3. Add a last step with **Credential** set to **kenari balance (PAYG)** and a model kenari serves.
4. Save, then send the route name as `model`.

A step is one connection plus one model. kenari tries the steps in order. A step whose connection is paused, disabled, signed out or lapsed is skipped, and the next step serves. The balance step is the only one that charges your balance.

## Quota

A ChatGPT or Grok card shows how much of your allowance is left. The bars show what remains.

| Card | What it shows |
| --- | --- |
| ChatGPT | **5-hour session** and **Weekly**. Each window shows the share left and when it resets. |
| Grok | **Credit this period**, with the share left and **Period ends** with the date. |
| Claude | **Credit this month (estimate)**, with the amount spent out of your monthly credit and the renewal date. It is kenari's own estimate, see [Credit estimate and auto-stop](#credit-estimate-and-auto-stop). The card also shows whether auto-stop is on. |

kenari reads the figures when you open the page and keeps them for 60 seconds. When a figure is old, the card shows how long ago it was updated and a refresh button. **Check quota** in the card menu refreshes it too. When kenari cannot read the quota, the card says **Quota not available yet**. Your requests are not affected by this.

Under the bars, each subscription card also shows a usage line, such as "1,240 requests, 3.2M tokens in 30 days". It counts the requests kenari served with that connection over the last 30 days, and the tokens are input plus output. The line appears after the connection has served its first request, and it is updated within about 10 seconds.

When a subscription answers that its allowance is used up, kenari pauses that connection until the window resets. The card shows **Limited until** with the time, and routes continue with the next step.

## Errors

These are the errors you can see around this page. The full list is in [Errors](/en/docs/errors).

### Requests

| Status | Code | When |
| --- | --- | --- |
| `400` | `byok_malformed_model_id` | The id after `byok/` has no provider or no model. |
| `400` | `byok_provider_not_found` | You have no key or connection for that provider. |
| `403` | `subscription_required` | A `byok/chatgpt/...` or `byok/grok/...` request from an account without an active plan. |
| `403` | `byok_credential_unavailable` | You have a key or connection for that provider, but none can serve now because it is disabled, paused, or signed out. |
| `403` | `byok_spend_capped_key` | The `kn-` key you sent has a spend cap. |

### Dashboard

These codes come from the dashboard while you connect a subscription or read its quota. The dashboard turns them into the messages you see.

| Status | Code | When |
| --- | --- | --- |
| `400` | `provider_unsupported` | You tried to connect a provider other than ChatGPT or Grok, or the provider is unavailable for now. |
| `403` | `subscription_required` | Connecting or reading the quota needs an active plan. |
| `409` | `limit_reached` | You already have 5 connected subscriptions. At the limit you can still sign in again to replace a login that needs reconnecting. |
| `429` | `rate_limited` | Too many sign-in attempts in an hour, or too many quota refreshes. Try again later. |
| `503` | `quota_unavailable` | kenari could not read the quota and has no earlier figure to show. |

## Keys with a spend cap

A key with a **Spend cap, Rp (optional)** cannot use `byok/` models. The reason, and how to limit BYOK usage, is in [Authentication and API keys](/en/docs/authentication).

## See also

- [Routing and routes](/en/docs/routing)
- [Claude Managed Agents](/en/docs/claude-managed-agents)
- [Subscriptions](/en/docs/subscriptions)
- [Authentication and API keys](/en/docs/authentication)
- [How billing works](/en/docs/billing)
- [Errors](/en/docs/errors)


---

# Rate limits

kenari limits how fast you can call the API in a few places. This page lists each limit, the error it returns, and how to retry. For the error formats and every other code, see [Errors](/en/docs/errors).

## What each limit returns

| Status | Code | Retry-After | What to do |
| --- | --- | --- | --- |
| 429 | `rate_limit_exceeded` | No, except for music | Wait a few seconds and retry. Space your requests out. |
| 429 | `free_quota_rpm` | Yes | Wait for the `Retry-After` seconds, then retry. |
| 429 | `free_quota_daily` | Yes, until 00:00 UTC | Do not retry today. Use a paid model, top up, or wait for the reset. |
| 429 | `plan_limit_reached` | No | Do not retry in a loop. Turn on PAYG or the free fallback, or wait for the quota window to roll. |
| 429 | `upstream_error` | No | A provider is rate limiting. Wait and retry. |
| 503 | `all_providers_failed` | Yes, when providers are paused or busy | Wait for the `Retry-After` seconds, then retry. |

A `Retry-After` header holds a number of seconds. When a response has no `Retry-After`, use exponential backoff.

The codes in this table are the OpenAI-format error codes, returned by Chat completions, Responses and the other endpoints. [Messages](/en/docs/messages) errors have no `code` field. They carry `error.type`, and several of the limits above arrive there as `rate_limit_error`. On Messages, decide by the HTTP status and the `Retry-After` header instead. See [Errors](/en/docs/errors).

## Account requests per minute

Each account has a per-minute request limit for paid models. It is shared by [Chat completions](/en/docs/chat), [Messages](/en/docs/messages), [Responses](/en/docs/responses) and the [Claude Managed Agents](/en/docs/claude-managed-agents) passthrough. Over the limit, the request fails with `429` and `rate_limit_exceeded`, and no `Retry-After` header. The limit is set on your account, so it can differ from one account to another. Requests to `:free` models count against the free-model limits below instead, and the other endpoints do not count against this limit.

## Usage reads

The three [Usage](/en/docs/usage) endpoints share a limit of 60 requests per minute for each account. Over the limit, the request fails with `429` and `rate_limit_exceeded`, and no `Retry-After` header. It is counted apart from the account limit above.

## Per-tool key creation

Each login key can create 20 [per-tool keys](/en/docs/account-keys) per minute. Over the limit, the request fails with `429` and `rate_limit_exceeded`, and no `Retry-After` header. Listing and revoking keys are not limited.

## Free-model limits

Every account has a per-minute limit and a daily quota for `:free` models. The values depend on your account tier. [Free models](/en/docs/free-models) lists the tiers, and `GET /api/public/pricing` returns the current numbers.

- **Per minute.** Over the limit, the request fails with `429` and `free_quota_rpm`, and `Retry-After` says how many seconds remain until the limit window resets.
- **Per day.** The daily quota counts successful requests and resets at 00:00 UTC. Over the quota, the request fails with `429` and `free_quota_daily`, and `Retry-After` is the number of seconds until the reset. Topping up or subscribing raises the quota.
- **Bursts.** Free requests that arrive close together are spaced out and served one after another, not rejected. A sustained flood is rejected with `429` and `rate_limit_exceeded`.
- **Token budget.** A free model can carry a total token budget. When it is used up, that model returns `429` and `rate_limit_exceeded` until the budget window rolls.

A successful free-model response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` when the model has a per-minute limit. `X-RateLimit-Reset` is a Unix timestamp in seconds.

## Subscription quota windows

A subscription covers its models with quota windows. When a window is used up and neither PAYG nor the free fallback is on, requests to covered models fail with `429` and `plan_limit_reached`, and no `Retry-After` header. The `message` of this error is in Indonesian, so branch on `code` (on Messages, on the status and `error.type`). Turn on PAYG or the free fallback in your subscription settings, or wait for the window to roll. See [Subscriptions](/en/docs/subscriptions).

## Music concurrency

Each account can run two music generations at the same time. A third request fails with `429` and `rate_limit_exceeded`, and `Retry-After` is set to 60 seconds. Retry once one of your generations finishes. See [Music](/en/docs/music).

## Provider limits and outages

When a model's provider rate limits kenari, you get `429` and `upstream_error`, with no `Retry-After` header. When a provider times out or fails, you get `503` and `upstream_error`. When every provider for a model is temporarily paused or at capacity, you get `503` and `all_providers_failed`, with a `Retry-After` header. A route or a BYOK fallback can move the request to another provider before you see any of these. See [Routing and routes](/en/docs/routing) and [Providers](/en/docs/byok).

## Shared keys

A shared API key is paced as a whole, so the people using a share cannot use up the owner's account limit. Each client IP address is also limited to 30 requests per minute on one shared key, on `/v1` and on the MCP server alike. Over that limit, the response is `429` with the plain-text body `too many requests, try again shortly`, not JSON. See [Authentication and API keys](/en/docs/authentication).

## Retry safely

On Messages, apply the same rules by HTTP status, because there is no `code`. Retry only errors that can clear on their own: `429` with `rate_limit_exceeded`, `free_quota_rpm` or `upstream_error`, and `503`. Do not retry `free_quota_daily`, `plan_limit_reached` or any `402`, because they stay the same until you change something.

1. If the response has a `Retry-After` header, wait that many seconds.
2. Otherwise wait with exponential backoff and jitter, for example 1, 2, 4 and 8 seconds, each plus a random fraction of a second.
3. Stop after a few attempts and surface the error.

The official OpenAI and Anthropic SDKs already retry `429` and `5xx` responses a couple of times by default and honor `Retry-After`. Raise their retry count with `max_retries` if that is enough. This example shows the logic when you want control over it.

```python
import os
import random
import time

from openai import APIStatusError, OpenAI

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

RETRYABLE = {"rate_limit_exceeded", "free_quota_rpm", "upstream_error", "all_providers_failed"}


def chat(messages, model="step-3-7-flash:free", attempts=5):
    for attempt in range(attempts):
        try:
            return client.chat.completions.create(model=model, messages=messages)
        except APIStatusError as error:
            retryable = error.status_code in (429, 503) and error.code in RETRYABLE
            if not retryable or attempt == attempts - 1:
                raise
            retry_after = error.response.headers.get("retry-after")
            delay = float(retry_after) if retry_after else min(30, 2**attempt)
            time.sleep(delay + random.random())


reply = chat([{"role": "user", "content": "Hello!"}])
print(reply.choices[0].message.content)
```

Do not retry a request that failed in the middle of a stream by resuming it. Send it again as a new request.

## See also

- [Errors](/en/docs/errors)
- [Free models](/en/docs/free-models)
- [Subscriptions](/en/docs/subscriptions)
- [Usage](/en/docs/usage)
- [Streaming](/en/docs/streaming)


---

# Chat completions

Send a conversation and get the model's next message back. The endpoint is compatible with the OpenAI Chat Completions API, so the official OpenAI SDKs work once you point the base URL at `https://kenari.id/v1`.

`POST /v1/chat/completions`

Authenticate with `Authorization: Bearer kn-...`. See [Authentication and API keys](/en/docs/authentication).

## Request

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | Yes | What to run. See [Choose a model](#choose-a-model). |
| `messages` | array | Yes | The conversation so far. See [Messages](#messages). |
| `stream` | boolean | No | Set `true` to receive Server-Sent Events. Default `false`. See [Streaming](/en/docs/streaming). |
| `stream_options` | object | No | Passed to providers served in the OpenAI format. It does not change kenari's own usage chunk. See [Streaming](/en/docs/streaming). |
| `max_tokens` | integer | No | Upper limit on generated tokens, reasoning included. Default 65,536. |
| `max_completion_tokens` | integer | No | Alias of `max_tokens`. `max_tokens` wins if you send both. |
| `temperature` | number | No | Sampling temperature. |
| `top_p` | number | No | Nucleus sampling. |
| `stop` | string or array | No | Text that ends generation. |
| `n` | integer | No | Only `1` is supported. A larger value returns 400. |
| `user` | string | No | An id for your end user. It is passed on as request metadata where the provider accepts one. |
| `tools` | array | No | Function tools, and kenari server tools such as `{"type": "kenari:web_search"}`. See [Function calling](/en/docs/function-calling) and [Server tools](/en/docs/server-tools). |
| `tool_choice` | string or object | No | `auto`, `none`, `required`, or `{"type": "function", "function": {"name": "..."}}`. Any other shape is treated as `auto`, except `{"type": "function"}` without `function.name`, which returns 400. |
| `response_format` | object | No | Ask for JSON output. See [Structured output](/en/docs/structured-output). |
| `reasoning_effort` | string | No | `none`, `minimal`, `low`, `medium`, `high`, `xhigh` or `max`. See [Reasoning](/en/docs/reasoning). |
| `reasoning` | object | No | `{"effort", "enabled", "max_tokens", "exclude"}`. Wins over `reasoning_effort` when both are set. `{"enabled": true}` with no effort or `max_tokens` means `medium`, and `{"enabled": false}` turns reasoning off. See [Reasoning](/en/docs/reasoning). |
| `plugins` | array | No | Optional. `file-parser` picks how attached documents are read: `auto`, `pdf-text`, `native` or `ocr`. See [Read documents](/en/docs/documents). |
| `prompt_cache_key` | string | No | A stable id for one conversation, so follow-up requests reuse its cached prefix. Up to 512 bytes. Longer or blank values are ignored. See [Prompt caching](/en/docs/prompt-caching). |

kenari does not interpret other OpenAI fields such as `frequency_penalty`, `presence_penalty` or `seed`. It passes them to the provider unchanged when the model is served in the OpenAI format and drops them otherwise, so do not depend on them. The same goes for `response_format` when you also declare a kenari server tool.

The routing fields `provider`, `models` and `route` are the exception. kenari passes them on only when the request runs on your own key, such as a BYOK id. On every other model it does not pass them on, so they have no effect. `plugins` is read by kenari's file parser and is never passed on.

### Choose a model

| `model` value | Example | What runs |
| --- | --- | --- |
| Catalog id | `claude-sonnet-5-5` | The model, billed per token. List ids with [Models and pricing](/en/docs/models). |
| `:free` id | `step-3-7-flash:free` | The free version of a model. See [Free models](/en/docs/free-models). |
| Route name | `kenari-free` | A route you created, or the built-in `kenari-free`. See [Routing and routes](/en/docs/routing). |
| BYOK id | `byok/deepseek/deepseek-v4-flash` | The model at one of your own providers or connected subscriptions, in the form `byok/<provider>/<model>`, such as `byok/chatgpt/gpt-5.5`. kenari does not charge tokens for it. See [Providers](/en/docs/byok). |

The `<model>` part of a BYOK id is the provider's own id, copied exactly. It is case sensitive and can contain slashes.

### Messages

`messages` is an array of `{"role", "content"}` objects.

| Role | Notes |
| --- | --- |
| `system`, `developer` | Instructions. Leading ones are merged into one system prompt. One that appears after a user message stays where it is. |
| `user` | `content` is a string or an array of parts. |
| `assistant` | Earlier model replies. May carry `tool_calls`, and `reasoning_content` or `reasoning` to hand earlier reasoning back to the model. |
| `tool` | A function result. Set `tool_call_id` to the id of the call it answers. |

The legacy `function` role returns 400.

A `user` message can mix these content parts. Unknown part types are ignored.

| Part | Shape | See |
| --- | --- | --- |
| `text` | `{"type": "text", "text": "..."}` | |
| `image_url` | `{"type": "image_url", "image_url": {"url": "https://example.com/a.png"}}` | [Images and files as input](/en/docs/vision) |
| `file` | `{"type": "file", "file": {"filename": "a.pdf", "file_data": "data:application/pdf;base64,..."}}` | [Images and files as input](/en/docs/vision) |

`url` is an https URL or a data URL such as `data:image/png;base64,...`.

A `file` part carries a document (PDF, text or image) and works on any model. kenari reads it before routing, and the model receives its text and page images, billed as normal input tokens, so a plan that covers the model covers the document. A request can carry several documents. The optional `file-parser` plugin changes how documents are read, for example `"plugins": [{"id": "file-parser", "pdf": {"engine": "ocr"}}]` for paid OCR, which takes one document per request and is billed per page. See [Read documents](/en/docs/documents).

## Response

The response is an OpenAI `chat.completion` object with one choice.

```json
{
  "id": "4f1c9a2e7b3d5a60",
  "object": "chat.completion",
  "created": 1790000000,
  "model": "step-3-7-flash:free",
  "choices": [
    {
      "index": 0,
      "message": { "role": "assistant", "content": "Hello! How can I help?" },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 12,
    "completion_tokens": 9,
    "total_tokens": 21,
    "prompt_tokens_details": { "cached_tokens": 0 }
  }
}
```

| Field | Description |
| --- | --- |
| `choices[0].finish_reason` | `stop`, `length` (hit `max_tokens`), `tool_calls`, or `content_filter`. |
| `choices[0].message.tool_calls` | Present when the model calls functions. `arguments` is a JSON string. |
| `choices[0].message.reasoning` and `reasoning_content` | The model's reasoning text, under both names with the same content. Present only when the model reasoned and you did not set `reasoning.exclude`. |
| `choices[0].message.sources` | Present when a kenari server tool searched the web. Lists `{url, title}` for each source. |
| `choices[0].message.annotations` | `url_citation` entries for those sources, and one `file` entry for a document read with the `ocr` engine. See [Read documents](/en/docs/documents). |
| `usage.prompt_tokens` | Input tokens, cached ones included. |
| `usage.completion_tokens` | Output tokens. Reasoning tokens are billed as output. |
| `usage.prompt_tokens_details.cached_tokens` | Input tokens served from cache. In a non-streaming response it is always present, `0` when none. In a stream, `prompt_tokens_details` appears on the final chunk only when cached tokens are above 0. See [Prompt caching](/en/docs/prompt-caching). |

The `id` is the same value as the `x-request-id` response header. Quote it when you contact support.

## Examples

### curl

```bash
curl https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "step-3-7-flash:free",
    "messages": [
      { "role": "system", "content": "You answer briefly." },
      { "role": "user", "content": "Hello!" }
    ]
  }'
```

### 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.chat.completions.create(
    model="step-3-7-flash:free",
    messages=[
        {"role": "system", "content": "You answer briefly."},
        {"role": "user", "content": "Hello!"},
    ],
)
print(response.choices[0].message.content)
```

### JavaScript

```js
import OpenAI from "openai";

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

const response = await client.chat.completions.create({
  model: "step-3-7-flash:free",
  messages: [
    { role: "system", content: "You answer briefly." },
    { role: "user", content: "Hello!" },
  ],
});
console.log(response.choices[0].message.content);
```

## Billing

Each request is billed per token at the model's Input, Output, Cache read and Cache write prices. `:free` models cost nothing. Before a paid request runs, kenari checks your balance against the worst case for `max_tokens`, so set a smaller limit when you call an expensive model on a low balance. See [How billing works](/en/docs/billing).

## Errors

Errors use the OpenAI envelope: `{"error": {"message", "type", "param", "code"}}`. Only the errors specific to this endpoint are listed here. The rest are in [Errors](/en/docs/errors).

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | The body is not valid JSON or lacks `model` or `messages`. `n` is above 1. A role is unknown. A `file` part has no `file` object. A document has an unsupported type, is too large, is damaged or has too many pages. A PDF has more than 20 pages without a text layer. A document makes the request longer than the model's context. The `file-parser` engine is not `auto`, `pdf-text`, `native` or `ocr`. With `native`, the model does not read that kind of file, or the part carries an image. With `ocr`, the request carries more than one `file` part. |
| 400 | `model_not_found` | The `model` is not in the catalog and is not one of your routes. |
| 400 | `bad_request` | The model is not a chat model, such as an image or speech model. The message names the endpoint to use instead. |
| 400 | `context_length_exceeded` | The request is longer than the model's context window, shown as `context_length` in [Models and pricing](/en/docs/models). |
| 400 | `byok_malformed_model_id` | A `byok/` id is not in the form `byok/<provider>/<model>`. |
| 400 | `byok_provider_not_found` | You saved no key or connection for the provider named in a `byok/` id. |
| 403 | `subscribers_only` | The model is only for accounts with an active subscription. See [Subscriptions](/en/docs/subscriptions). |
| 403 | `subscription_required` | A `byok/chatgpt/...` or `byok/grok/...` id from an account without an active plan. See [Providers](/en/docs/byok#members-only). |
| 403 | `byok_credential_unavailable` | You have a key or connection for that provider, but none can serve right now. |
| 403 | `byok_spend_capped_key` | The API key has a spend cap, which BYOK requests cannot use. |

## See also

- [Streaming](/en/docs/streaming)
- [Function calling](/en/docs/function-calling)
- [Reasoning](/en/docs/reasoning)
- [Messages](/en/docs/messages)


---

# Messages

Create a message with the Anthropic Messages format, and estimate how many input tokens a request will use. The endpoints are compatible with the Anthropic API, so the official Anthropic SDKs and Claude Code work once you change the base URL.

`POST /v1/messages`

`POST /v1/messages/count_tokens`

Use `https://kenari.id` as the base URL, without `/v1`. The Anthropic SDKs append `/v1/messages` themselves, and a base URL of `https://kenari.id/v1` produces `/v1/v1/messages`, which returns 405. For Claude Code setup, see [Claude Code](/en/docs/claude-code).

Authenticate with `x-api-key: kn-...` or `Authorization: Bearer kn-...`. See [Authentication and API keys](/en/docs/authentication).

## Request

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | Yes | A catalog id, a `:free` id, a route name or a `byok/` id. The same values as in [Chat completions](/en/docs/chat#choose-a-model). |
| `messages` | array | Yes | The conversation. See [Messages array](#messages-array). |
| `max_tokens` | integer | No | Upper limit on generated tokens. Optional here, unlike the Anthropic API. Default 65,536. |
| `system` | string or array | No | System prompt, as a string or an array of `{"type": "text", "text": "..."}` blocks. Text blocks are joined with a blank line, and other block types are ignored. |
| `stream` | boolean | No | Set `true` to receive Server-Sent Events. See [Streaming](/en/docs/streaming). |
| `temperature` | number | No | Sampling temperature. |
| `top_p` | number | No | Nucleus sampling. |
| `top_k` | integer | No | Sample from the top K tokens. |
| `stop_sequences` | array | No | Strings that end generation. |
| `tools` | array | No | Function tools with `name`, `description` and `input_schema`, and kenari server tools such as `{"type": "kenari:web_search"}`. See [Function calling](/en/docs/function-calling) and [Server tools](/en/docs/server-tools). |
| `tool_choice` | object | No | `{"type": "auto"}`, `{"type": "any"}`, `{"type": "none"}` or `{"type": "tool", "name": "..."}`. |
| `thinking` | object | No | `{"type": "enabled", "budget_tokens": 4096}` or `{"type": "disabled"}`. See [Reasoning](/en/docs/reasoning). |
| `output_config` | object | No | `{"effort": "high"}` sets a reasoning level without a token budget. See [Reasoning](/en/docs/reasoning). `{"format": {"type": "json_schema", "schema": {...}}}` asks for JSON that matches a schema. See [Structured output](/en/docs/structured-output). |
| `output_format` | object | No | Older spelling of `output_config.format`, accepted with the same meaning. `output_config.format` wins when both are sent. |
| `metadata` | object | No | Accepted. |

kenari does not interpret other fields. It passes them to the provider unchanged when the model is served in the Anthropic format and drops them otherwise, so do not depend on them.

The routing fields `provider`, `models` and `route` are the exception. kenari passes them on only when the request runs on your own key, such as a BYOK id. On every other model it does not pass them on, so they have no effect. `plugins` is not used on this endpoint and is never passed on.

### Messages array

Each message has a `role` and a `content` field. `role` is `user` or `assistant`. A `system` message is also accepted in the array, which newer Claude Code versions send. `content` is a string or an array of blocks.

| Block | Use |
| --- | --- |
| `text` | Plain text. |
| `image` | An image from a `base64` or `url` source. See [Images and files as input](/en/docs/vision). |
| `document` | A PDF from a `base64` source is read on any model, the same way as a chat `file` part, and billed as input tokens. A `url` source reaches only models served through an Anthropic-style API and is dropped without an error for other models. A `text` source is plain text. See [Read documents](/en/docs/documents). |
| `tool_use` | A function call the model made earlier. |
| `tool_result` | The result of a call. `content` is a string or an array of `text` and `image` blocks. Set `is_error` to report a failure. |
| `thinking`, `redacted_thinking` | Earlier reasoning, sent back unchanged. |

Unknown block types are ignored. A block of a known type that is malformed returns 400.

### Prompt caching

Send `cache_control` markers as you would to Anthropic. kenari keeps them when the model is served in the Anthropic format. It also adds its own cache breakpoints when it rebuilds the request, so caching does not depend on your markers. See [Prompt caching](/en/docs/prompt-caching).

## Response

The response is an Anthropic `message` object.

```json
{
  "id": "4f1c9a2e7b3d5a60",
  "type": "message",
  "role": "assistant",
  "model": "step-3-7-flash:free",
  "content": [
    { "type": "text", "text": "Hello! How can I help?" }
  ],
  "stop_reason": "end_turn",
  "stop_sequence": null,
  "usage": { "input_tokens": 9, "output_tokens": 12 }
}
```

| Field | Description |
| --- | --- |
| `content` | An array of blocks: `text`, `tool_use`, and `thinking` when the model reasoned. See [Reasoning](/en/docs/reasoning). |
| `stop_reason` | `end_turn`, `max_tokens`, `stop_sequence`, `tool_use` or `refusal`. |
| `stop_sequence` | Always `null`. When a stop sequence ends the message, `stop_reason` is `stop_sequence`. |
| `usage.input_tokens` | Input tokens that were not served from or written to the cache. |
| `usage.output_tokens` | Output tokens. Reasoning tokens are billed as output. |
| `usage.cache_read_input_tokens` | Input tokens read from cache. Present when the provider reports it. |
| `usage.cache_creation_input_tokens` | Input tokens written to cache. Present when the provider reports it. |
| `sources` | Present when a kenari server tool searched the web. Lists `{url, title}` for each source. |

## Count tokens

`POST /v1/messages/count_tokens` takes the same body as `/v1/messages` and returns an estimate of the input tokens.

```json
{ "input_tokens": 41 }
```

The number is estimated from the size of the request, not counted with a tokenizer, so treat it as a rough guide for fitting a context window. The call needs a valid key but does not check the `model`, does not run it and costs nothing. A body that cannot be parsed returns 400.

```bash
curl https://kenari.id/v1/messages/count_tokens \
  -H "x-api-key: $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "step-3-7-flash:free",
    "messages": [{ "role": "user", "content": "Hello!" }]
  }'
```

## Examples

### curl

```bash
curl https://kenari.id/v1/messages \
  -H "x-api-key: $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "step-3-7-flash:free",
    "max_tokens": 512,
    "system": "You answer briefly.",
    "messages": [{ "role": "user", "content": "Hello!" }]
  }'
```

### Python

```python
import os
import anthropic

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

message = client.messages.create(
    model="step-3-7-flash:free",
    max_tokens=512,
    system="You answer briefly.",
    messages=[{"role": "user", "content": "Hello!"}],
)
print("".join(block.text for block in message.content if block.type == "text"))
```

### TypeScript

```ts
import Anthropic from "@anthropic-ai/sdk";

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

const message = await client.messages.create({
  model: "step-3-7-flash:free",
  max_tokens: 512,
  system: "You answer briefly.",
  messages: [{ role: "user", content: "Hello!" }],
});
for (const block of message.content) {
  if (block.type === "text") console.log(block.text);
}
```

## Billing

Each message is billed per token at the model's Input, Output, Cache read and Cache write prices. `:free` models cost nothing. `count_tokens` is free. See [How billing works](/en/docs/billing).

## Errors

Errors use the Anthropic envelope: an object with `"type": "error"` and an `error` object that holds `type` and `message`. Only the errors specific to this endpoint are listed here. The rest are in [Errors](/en/docs/errors).

| Status | `error.type` | When |
| --- | --- | --- |
| 400 | `invalid_request_error` | The body is not valid JSON or lacks `model` or `messages`. A role, a `tool_choice` type or a known block type is malformed. A `byok/` id is malformed. The model is not a chat model, such as an image or speech model. The request is longer than the model's context window. |
| 400 | `not_found_error` | The `model` is not in the catalog and is not one of your routes, or you saved no key for the provider in a `byok/` id. |
| 403 | `permission_error` | The model is only for accounts with an active subscription. A `byok/` id names a provider whose keys cannot serve right now. The API key has a spend cap that BYOK requests cannot use. |
| 405 | none | The base URL ends in `/v1`, so the SDK called `/v1/v1/messages`. Use `https://kenari.id`. |

## See also

- [Streaming](/en/docs/streaming)
- [Reasoning](/en/docs/reasoning)
- [Chat completions](/en/docs/chat)
- [Claude Code](/en/docs/claude-code)


---

# Claude Managed Agents

kenari forwards the Anthropic APIs it does not translate straight to Anthropic: Managed Agents, Files, Skills and Message Batches. You keep one base URL and one `kn-` key. Anthropic bills the Anthropic Console organization behind your key, so a Max or Team API credit is spent here too. kenari does not meter it. For chat requests, see [Messages](/en/docs/messages).

This is a plain passthrough. kenari does not read, change or translate the request or the response body. For what each endpoint accepts and returns, see Anthropic's documentation.

## Before you start

- You need an active plan. See [Members only](#members-only).
- You need an Anthropic API key saved in kenari. Add it under **Providers & Routes**, tab **Providers**. See [Providers](/en/docs/byok).
- You need a `kn-` key from your own account. See [Keys](#keys).

## Base URL and paths

Use `https://kenari.id` as the base URL, without `/v1`, and the same paths as Anthropic. These paths are forwarded, each with every path below it:

| Path | What it is |
| --- | --- |
| `/v1/agents` | Agents |
| `/v1/environments` | Environments |
| `/v1/sessions` | Sessions and their events |
| `/v1/vaults` | Vaults |
| `/v1/deployments` | Deployments |
| `/v1/deployment_runs` | Deployment runs |
| `/v1/memory_stores` | Memory stores |
| `/v1/files` | Files |
| `/v1/skills` | Skills |
| `/v1/messages/batches` | Message Batches |

`GET`, `POST`, `PUT`, `PATCH` and `DELETE` are forwarded. A request path that contains `..`, an encoded slash or a backslash is refused with `400` and `invalid_path`.

The official Anthropic SDKs work with this base URL. Set `ANTHROPIC_BASE_URL=https://kenari.id` and use your `kn-` key as the API key, or pass them to the client. The SDK sends the key in `x-api-key`, and kenari accepts it there. The SDK also sets the `anthropic-beta` header for Managed Agents on its own.

## Keys

Send your `kn-` key as `x-api-key: kn-...` or `Authorization: Bearer kn-...`. See [Authentication and API keys](/en/docs/authentication).

The passthrough opens your whole Anthropic organization to whoever holds the key, and kenari's caps cannot see this traffic. So these keys are refused with `403`:

- A shared API key, with `shared_key_not_allowed`.
- A key with a model scope, a spend cap or a token cap, with `restricted_key_not_allowed`.

Use a key from your own account with no restrictions.

## Members only

The passthrough needs an active plan. A trial or a gifted plan counts. Without one, the request fails with `403` and `subscription_required`. See [Subscriptions](/en/docs/subscriptions).

## Which Anthropic key is used

kenari uses only an Anthropic API key you saved yourself. It never uses a key of its own, and it never uses a key that points at a custom base URL. A key counts when it is active, not paused, and has not used up its Claude credit with auto-stop on. See [Providers](/en/docs/byok#credit-estimate-and-auto-stop).

| Your account has | What happens |
| --- | --- |
| One such key | kenari uses it automatically. |
| None | `409` and `anthropic_credential_required`. Add a key on the **Providers** tab. |
| Two or more | `409` and `credential_required`, until you name one with the `x-kenari-credential` header. |

```bash
curl https://kenari.id/v1/agents \
  -H "x-api-key: $KENARI_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "anthropic-beta: managed-agents-2026-04-01" \
  -H "x-kenari-credential: $ANTHROPIC_KEY_ID"
```

To get the id, open **Providers & Routes**, tab **Providers**, open the menu on the Anthropic key's row and choose **Copy key ID**. A value that is not an active Anthropic key of your account returns `404` and `credential_not_found`. If the key you name has used up its Claude credit with auto-stop on, the request returns `409` and `credit_limit_reached`. Raise the credit, turn auto-stop off or wait for the renewal day. kenari never falls back to another key, because an agent, session or file created in one organization cannot be used from another. If you would rather not send the header, keep one active Anthropic key and disable the others on the **Providers** tab.

## What is forwarded

kenari sends Anthropic only these request headers: `anthropic-version`, `anthropic-beta`, `content-type`, `accept` and `last-event-id`. Your `kn-` key is never sent. kenari adds the Anthropic key itself.

From the answer you get the status, the body, and these headers: `content-type`, `retry-after`, `cache-control` and every `anthropic-ratelimit-*` header. Anthropic's request id arrives as `anthropic-request-id`. The `x-request-id` and `request-id` headers are kenari's own.

Bodies stream in both directions without buffering, so server-sent event streams, such as a session's event stream, arrive as Anthropic sends them.

## Examples

### List agents

```bash
curl https://kenari.id/v1/agents \
  -H "x-api-key: $KENARI_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "anthropic-beta: managed-agents-2026-04-01"
```

### Create a session

Create the agent and the environment first. Then start a session that uses them. Replace the ids with your own.

```bash
curl https://kenari.id/v1/sessions \
  -H "x-api-key: $KENARI_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "anthropic-beta: managed-agents-2026-04-01" \
  -H "content-type: application/json" \
  -d '{
    "agent": {"type": "agent", "id": "agent_abc123"},
    "environment_id": "env_abc123"
  }'
```

The response is Anthropic's own, with the session `id`.

## Limits

- A request body can be up to 100 MB. A larger one fails with `413` and `request_too_large`.
- Your account's per-minute request limit applies. See [Rate limits](/en/docs/rate-limits).
- kenari serves a limited number of passthrough requests at the same time for all accounts together. A long event stream holds its place until it ends. When kenari is full, the request fails with `429` and `passthrough_busy`. Try again shortly.

## Billing

kenari does not charge for this traffic, and it records no token usage for it. Anthropic bills the organization behind the key, which is why a Max or Team API credit is spent here. A spend limit set in the Claude Console applies as usual.

## Errors

Errors from kenari use the Anthropic envelope, an object with `"type": "error"` and an `error` object that holds `type`, `message` and `code`. When Anthropic itself answers with an error, you get its status and body unchanged, and that body has no `code`. Branch on `code` when it is there and on `error.type` when it is not. Only the errors specific to this page are listed here. The rest are in [Errors](/en/docs/errors).

| Status | Code | When |
| --- | --- | --- |
| 400 | `invalid_path` | The path contains `..`, an encoded slash or a backslash. |
| 401 | `auth_failed` | The `kn-` key is missing, wrong, expired or revoked. Unlike other endpoints, the body is JSON. |
| 403 | `subscription_required` | The account has no active plan. |
| 403 | `shared_key_not_allowed` | A shared API key was used. |
| 403 | `restricted_key_not_allowed` | The key has a model scope, a spend cap or a token cap. |
| 404 | `credential_not_found` | The `x-kenari-credential` value is not an active Anthropic key of your account. |
| 409 | `anthropic_credential_required` | No Anthropic API key is saved, or none is active. |
| 409 | `credential_required` | Two or more Anthropic keys are active and no `x-kenari-credential` header was sent. |
| 409 | `credit_limit_reached` | The key named in `x-kenari-credential` has used up its Claude credit, and auto-stop is on. |
| 413 | `request_too_large` | The request body is over 100 MB. |
| 429 | `passthrough_busy` | Too many passthrough requests are in progress. |
| 429 | `rate_limit_exceeded` | The account's per-minute request limit is used up. |
| 500 | `auth_failed` | kenari could not look up the `kn-` key. Try again. |
| 500 | none | Something else failed inside kenari. The body is the plain text `internal error`, not JSON. Try again. |
| 503 | `upstream_error` | kenari could not reach Anthropic, or Anthropic returned a gateway error. |

## See also

- [Providers](/en/docs/byok)
- [Messages](/en/docs/messages)
- [Authentication and API keys](/en/docs/authentication)
- [Subscriptions](/en/docs/subscriptions)


---

# Responses

Create a model response with the OpenAI Responses format. This is a stateless subset built for coding agents such as Codex, and the official OpenAI SDKs work once you point the base URL at `https://kenari.id/v1`. For Codex configuration, see [Codex](/en/docs/codex).

`POST /v1/responses`

Authenticate with `Authorization: Bearer kn-...`. See [Authentication and API keys](/en/docs/authentication).

## Stateless by design

kenari stores nothing between requests, so send the whole conversation in `input` every time.

- A `previous_response_id` other than `null` returns 400.
- `store` is accepted and ignored.
- There are no endpoints to retrieve, list or delete a response.

## Request

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | Yes | A catalog id, a `:free` id, a route name or a `byok/` id. The same values as in [Chat completions](/en/docs/chat#choose-a-model). |
| `input` | string or array | Yes | A string is one user message. An array holds [input items](#input-items). It must not be empty. |
| `instructions` | string | No | System instructions. Merged with any leading `developer` items. |
| `tools` | array | No | Flat function tools, each with `type` set to `function`, plus `name`, `description` and `parameters`. A function tool without `name` returns 400. kenari server tools such as `{"type": "kenari:web_search"}` also work. Every other tool type is dropped without an error. See [Function calling](/en/docs/function-calling) and [Server tools](/en/docs/server-tools). |
| `tool_choice` | string or object | No | `auto`, `none`, `required` or `{"type": "function", "name": "get_weather"}` to force one function. Anything else returns 400. |
| `reasoning` | object or null | No | `{"effort": "low"}`. Levels are `none`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`. `summary` is accepted and ignored. See [Reasoning](/en/docs/reasoning). |
| `max_output_tokens` | integer | No | Upper limit on generated tokens, reasoning included. Default 65,536. |
| `temperature` | number | No | Sampling temperature. |
| `top_p` | number | No | Nucleus sampling. |
| `stream` | boolean | No | Set `true` to receive Server-Sent Events. See [Streaming](/en/docs/streaming). |
| `text` | object | No | `{"format": {"type": "text"}}` (the default), `{"format": {"type": "json_object"}}`, or `{"format": {"type": "json_schema", "name": "...", "schema": {...}, "strict": true}}` for JSON output. Any other type returns 400. See [Structured output](/en/docs/structured-output). |
| `prompt_cache_key` | string | No | A stable id for one conversation, so follow-up requests reuse its cached prefix. Up to 512 bytes. Longer or blank values are ignored. See [Prompt caching](/en/docs/prompt-caching). |
| `previous_response_id` | string or null | No | Must be absent or `null`. |
| `store` | boolean | No | Accepted and ignored. |
| `include` | array | No | Accepted and ignored. |
| `metadata` | object | No | Accepted and ignored. |
| `parallel_tool_calls` | boolean | No | Accepted and ignored. |

kenari does not read the routing fields `provider`, `models` and `route`, or `plugins`, on this endpoint. It never passes them on, whether the request runs on your own key or on any other model.

### Input items

| Item | Fields | Notes |
| --- | --- | --- |
| Message | `role`, `content` | `role` is `system`, `developer`, `user` or `assistant`. `type` may be omitted. `content` is a string or an array of parts. |
| Function call | `type` set to `function_call`, `call_id`, `name`, `arguments` | A call the model made earlier. `arguments` is a JSON string. |
| Function result | `type` set to `function_call_output`, `call_id`, `output` | The result for a call. `output` is a string or an array of text parts. |
| Reasoning | `type` set to `reasoning`, `summary` (an array) | Earlier reasoning, handed back to the model. |

Leading `system` and `developer` messages merge into the system prompt. Unknown item types are dropped without an error, and any other `role` returns 400.

Message parts are `input_text` and `output_text`, plus `input_image` with an `image_url` string, which is accepted only in `user` and `assistant` messages (in a `developer` message it returns 400). An `input_file` part returns 400. For documents, use [Chat completions](/en/docs/chat): a `file` part works on any model. See [Images and files as input](/en/docs/vision) and [Read documents](/en/docs/documents).

## Response

The response is a `response` object. Its `output` array holds the items the model produced, in order.

```json
{
  "id": "resp_4f1c9a2e7b3d5a60",
  "object": "response",
  "created_at": 1790000000,
  "status": "completed",
  "model": "step-3-7-flash:free",
  "output": [
    {
      "type": "message",
      "id": "msg_1",
      "role": "assistant",
      "content": [{ "type": "output_text", "text": "Hello! How can I help?" }]
    }
  ],
  "usage": {
    "input_tokens": 9,
    "output_tokens": 12,
    "total_tokens": 21,
    "input_tokens_details": { "cached_tokens": 0 }
  },
  "text": { "format": { "type": "text" } }
}
```

| Output item | Description |
| --- | --- |
| `reasoning` | The model's reasoning as one `summary_text` entry. First in the array, and only present when the model reasoned. |
| `message` | The answer, in an `output_text` part. |
| `function_call` | A function the model wants you to run: `call_id`, `name` and `arguments` as a JSON string. Run it and send a `function_call_output` item with the same `call_id` in the next request. |

`status` is `completed`, or `incomplete` with `incomplete_details.reason` set to `max_output_tokens` when the limit cut the answer short. `text.format` echoes the format you asked for, `{"type": "text"}` by default. `usage.input_tokens_details.cached_tokens` counts input served from cache and is part of `input_tokens`.

After you run a function, send a follow-up request that carries the earlier call and its result in `input`:

```json
{
  "model": "step-3-7-flash:free",
  "input": [
    { "role": "user", "content": "What is the weather in Jakarta?" },
    {
      "type": "function_call",
      "call_id": "call_1",
      "name": "get_weather",
      "arguments": "{\"city\": \"Jakarta\"}"
    },
    { "type": "function_call_output", "call_id": "call_1", "output": "31 C and sunny" }
  ],
  "tools": [
    {
      "type": "function",
      "name": "get_weather",
      "description": "Get the current weather for a city.",
      "parameters": {
        "type": "object",
        "properties": { "city": { "type": "string" } },
        "required": ["city"]
      }
    }
  ]
}
```

## Streaming

Set `"stream": true` to receive the response as Server-Sent Events. The event sequence is in [Streaming](/en/docs/streaming#responses-stream).

## Examples

### curl

```bash
curl https://kenari.id/v1/responses \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "step-3-7-flash:free",
    "instructions": "You answer briefly.",
    "input": "Hello!"
  }'
```

### 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.responses.create(
    model="step-3-7-flash:free",
    instructions="You answer briefly.",
    input="Hello!",
)
print(response.output_text)
```

### JavaScript

```js
import OpenAI from "openai";

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

const response = await client.responses.create({
  model: "step-3-7-flash:free",
  instructions: "You answer briefly.",
  input: "Hello!",
});
console.log(response.output_text);
```

## Billing

Each request is billed per token at the model's Input, Output, Cache read and Cache write prices. `:free` models cost nothing. See [How billing works](/en/docs/billing).

## Errors

Errors use the OpenAI envelope: an `error` object with `message`, `type`, `param` and `code`. Only the errors specific to this endpoint are listed here. The rest, such as `model_not_found`, `context_length_exceeded` and `subscribers_only`, are in [Errors](/en/docs/errors).

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | `input` is missing or empty. `previous_response_id` is not `null`. `text.format.type` is not `text`, `json_object` or `json_schema`. A message has an unknown `role`. An `input_file` part is present. A function tool has no `name`. `tool_choice` is not one of the supported forms. |
| 400 | `bad_request` | The model is not a chat model, such as an image or speech model. The message names the endpoint to use instead. |

## See also

- [Codex](/en/docs/codex)
- [Streaming](/en/docs/streaming)
- [Function calling](/en/docs/function-calling)
- [Chat completions](/en/docs/chat)


---

# Models and pricing

List every model you can call, with what it can do and what it costs. The list is public, so you do not need an API key, and it changes over time, so read it from the endpoint instead of hardcoding ids.

`GET /v1/models`

## Request

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `modality` | string | No | List one specialized kind of model instead of the default list. One of `embedding`, `rerank`, `moderation` or `systemone`. See [Filter by modality](#filter-by-modality). |

Without `modality`, the list holds chat, image, text to speech, transcription, music and video models. It does not hold embedding, rerank, moderation or System One models. Each entry's `endpoints` says which API it serves, so a tool that only wants chat models should keep the entries whose `endpoints` include `chat`. Music models are in the default list too, and there is no `?modality=music` filter. The `kenari/auto` entry, when listed, has no `endpoints`, because the model that serves a request differs each time. A missing `endpoints` means no metadata is recorded, not that the model lacks a capability, so the examples below treat it as an empty list.

## Response

The response is an OpenAI-style list: `{"object": "list", "data": [...]}`. This is one entry:

```json
{
  "id": "step-3-7-flash",
  "object": "model",
  "owned_by": "stepfun",
  "name": "step-3-7-flash",
  "context_length": 262144,
  "max_output_tokens": 65536,
  "modalities": { "input": ["text", "image", "video"], "output": ["text"] },
  "endpoints": ["chat"],
  "protocols": ["openai-chat", "openai-responses", "anthropic-messages"],
  "reasoning": true,
  "reasoning_options": ["low", "medium", "high"],
  "reasoning_toggle": false,
  "tool_call": true,
  "pricing": {
    "input": 4200000000,
    "output": 24000000000,
    "cache_read": 840000000,
    "cache_write": null,
    "free": false,
    "currency": "IDR",
    "unit": "micro_idr_per_1m_tokens"
  },
  "pricing_lines": [
    { "endpoint": "chat", "billable": "input", "unit": "token_1m", "variant": null, "micro_idr": 4200000000 },
    { "endpoint": "chat", "billable": "output", "unit": "token_1m", "variant": null, "micro_idr": 24000000000 },
    { "endpoint": "chat", "billable": "cache_read", "unit": "token_1m", "variant": null, "micro_idr": 840000000 }
  ],
  "sunset_at": null
}
```

A field kenari has no value for is left out instead of being set to `null`. `id`, `object`, `owned_by`, `name` and `sunset_at` are always there. The rest depends on the kind of entry, as the tables below show.

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | The id to send as `model`. |
| `object` | string | Always `model`. |
| `owned_by` | string | The vendor that makes the model, for example `anthropic`. |
| `name` | string | A display name. Every entry has one. When a model has no separate display name, `name` is the same as `id`. |
| `context_length` | integer | The context window in tokens. |
| `max_input_tokens` | integer | The most input tokens one request can carry, when the model limits input below its context window. A request with a larger input is refused with `context_length_exceeded`. Left out when the context window is the only limit. Not on `?modality=` entries. |
| `max_output_tokens` | integer | The longest output the model can produce in one response, in tokens. Left out when unknown, which means nothing is recorded, not that the model has no limit. Not on `?modality=` entries. |
| `modalities` | object | `input` and `output` arrays. The values are `text`, `image`, `pdf`, `file`, `audio` and `video`. `pdf` means the model reads PDFs itself. Every chat model lists `file`, because kenari reads attached documents for any model. Only the `native` engine depends on the model reading files itself. See [Images and files as input](/en/docs/vision). |
| `endpoints` | array | The APIs the model serves: `chat`, `images`, `videos`, `audio_speech`, `audio_transcription`, `music`, `embeddings`, `rerank`, `moderations` or `systemone`. |
| `protocols` | array | The API formats you can call the model with: `openai-chat` for [Chat completions](/en/docs/chat), `openai-responses` for [Responses](/en/docs/responses) and `anthropic-messages` for [Messages](/en/docs/messages). Only on chat models and `kenari/auto`, and every one of them lists all three. Left out on image, speech, video, music and `?modality=` entries. |
| `reasoning` | boolean | Whether the model produces reasoning. See [Reasoning](/en/docs/reasoning). |
| `reasoning_options` | array | The levels `reasoning_effort` accepts for this model, such as `low`, `medium` and `high`. |
| `supported_reasoning_levels` | array | Same values as `reasoning_options`, in the field name some agent gateways read. Omitted when the model has no effort levels. |
| `reasoning_toggle` | boolean | Present on entries of the default list, but not on `?modality=` entries. `true` when you can switch reasoning on or off per request, `false` when you cannot. |
| `tool_call` | boolean | Whether the model supports function calling. See [Function calling](/en/docs/function-calling). |
| `pricing` | object | The price card. See [Prices in the list](#prices-in-the-list). |
| `pricing_lines` | array | One entry per billable item. Left out when the model has none, as on most `:free` models. |
| `sunset_at` | integer or null | The time, in epoch seconds, when kenari plans to stop serving the model. `null` when none is planned. |
| `modality` | string | Only on `?modality=` results. The kind of model. |

Most media fields below are left out when no value is recorded for the model. A missing field means nothing is recorded, not that the model has no limit.

| Field | Applies to | Description |
| --- | --- | --- |
| `voices` | text to speech | Voice names, case sensitive. The first one is the default when a request has no `voice`. |
| `formats` | text to speech, music | Audio formats the model can return, such as `mp3`. The first one is the default when a request has no `response_format`. On a music model it also tells the two kinds of music model apart, see [Music](/en/docs/music). |
| `max_input_chars` | text to speech | The longest `input` the model accepts, in characters. A longer one returns 400. |
| `max_prompt_chars` | music | The longest `prompt`, in characters. |
| `max_lyrics_chars` | music | The longest `lyrics`, in characters. |
| `max_duration_secs` | music | The longest song the model makes, in seconds. For information only, because you cannot request a length. |
| `video_durations` | video | The clip lengths you can request, in seconds. |
| `video_resolutions` | video | The resolutions you can request, such as `720p`. |
| `max_reference_images` | video | The most reference images one request can carry in `input_images`. `0` means the model takes none. Left out when no figure is published for the model, which does not mean it takes none. See [Reference images](/en/docs/videos#reference-images). |
| `video_extensions` | video | `true` when the model can extend a clip through `POST /v1/videos/extensions`. Present only when true, and left out otherwise. |

For how to use these values, see [Text to speech](/en/docs/audio-speech), [Music](/en/docs/music) and [Videos](/en/docs/videos).

### Prices in the list

`pricing` and `pricing_lines` give prices in micro-Rupiah, which is Rupiah times 1,000,000. Divide by 1,000,000 to get Rupiah. In the example above, an `input` of `4200000000` is Rp 4,200 per 1 million tokens.

| `pricing` field | Description |
| --- | --- |
| `input`, `output`, `cache_read`, `cache_write` | The price per 1 million tokens. `null` when the model has no such price. |
| `free` | `true` when the model costs nothing to call. Prices that are still shown on a free model are the list price of its paid version, and are not charged. |
| `currency`, `unit` | Always `IDR` and `micro_idr_per_1m_tokens`. |

Models that are not priced per token, such as image, speech and video models, show `0` for `input` and `output`. Read their price from `pricing_lines` instead. Each line has these fields:

| Field | Description |
| --- | --- |
| `endpoint` | The API the price belongs to, such as `chat` or `audio_speech`. |
| `billable` | What is billed: `input`, `output`, `cache_read`, `cache_write`, `input_audio`, `output_audio`, `output_image` or `output_video`. |
| `unit` | `token_1m` for 1 million tokens, `1k_chars` for 1,000 characters, `second`, `image` or `song` (music). |
| `variant` | A tier of the price, such as a video resolution like `720p`. `null` when there is none. |
| `micro_idr` | The price in micro-Rupiah for one `unit`. |

### Retiring models

A model with a `sunset_at` keeps being listed and served until that time. After it passes, the model leaves the list. Requests for its id are then served by a successor model when kenari has set one, and rejected with `model_not_found` when it has not. If you keep a copy of the list, check this field so you can move to another model before the date. Scheduled retirements also appear on [Model changes](/en/docs/model-changes).

Accounts with a verified email that called a model in the last 30 days get an email when its retirement is scheduled, and again a day before it retires.

### `x-kenari-model-forwarded`

This header is present only when you call a model that kenari has retired and that model has a successor. The request is served by the successor and billed at the successor's price, the response `model` field names the successor, and the header records the forward as `<model you sent>-><model that served>`:

```text
x-kenari-model-forwarded: deepseek-v4-flash->deepseek-v4-1-flash
```

It appears on Chat completions, Messages, Responses and the media endpoints (images, audio, speech, transcription, music, video, embeddings, rerank and moderations). Browsers cannot read it from JavaScript, because CORS does not expose it. Server-side clients and SDKs can.

## Filter by modality

Embedding, rerank, moderation and System One models are listed only when you ask for them with `?modality=`.

| `modality` | Lists | Example ids | Used by |
| --- | --- | --- | --- |
| `embedding` | Embedding models | `bge-m3`, `text-embedding-3-small` | [Embeddings](/en/docs/embeddings) |
| `rerank` | Rerank models | `bge-reranker-base` | [Rerank](/en/docs/rerank) |
| `moderation` | Moderation models | ids from this call | [Moderations](/en/docs/moderations) |
| `systemone` | System One models | `clef:free` | [System One](/en/docs/systemone) |

```bash
curl "https://kenari.id/v1/models?modality=embedding"
```

The entries carry `id`, `object`, `owned_by`, `name`, `sunset_at`, `modality` and `pricing`, and `endpoints`, `context_length` and `pricing_lines` when recorded. They have no `reasoning_toggle`, and `pricing` has only `input`, `free`, `currency` and `unit`, with `pricing.input` holding the price per 1 million tokens. A `modality` value that is not one of the four, including an empty one, returns 400.

```json
{
  "error": {
    "message": "unknown modality 'foo'. Accepted values: embedding, rerank, moderation, systemone.",
    "type": "bad_request",
    "param": null,
    "code": "bad_request"
  }
}
```

## Free versions

A model's free version has the same id with a `:free` suffix, is listed with `pricing.free` set to `true`, and costs nothing. See [Free models](/en/docs/free-models).

## Examples

### curl

```bash
curl https://kenari.id/v1/models
```

### Python

```python
import requests

models = requests.get("https://kenari.id/v1/models").json()["data"]
chat_models = [m["id"] for m in models if "chat" in m.get("endpoints", [])]
print(chat_models)
```

### JavaScript

```js
const response = await fetch("https://kenari.id/v1/models");
const { data } = await response.json();
const chatModels = data.filter((m) => (m.endpoints ?? []).includes("chat")).map((m) => m.id);
console.log(chatModels);
```

## Public price list

`GET /api/public/pricing` is a second public feed, made for price pages. It needs no key and returns `{"items": [...], "usd_idr_rate": ..., "free_tier": {...}}`.

- Each item has `model`, `provider` (the vendor), `free`, `subscriber_only`, `endpoints`, `pricing_lines`, `input_modalities`, `output_modalities`, `sunset_at` and `upcoming_price`, the next scheduled price change, or `null` when there is none.
- The per-token prices are named `price_in_micro_idr_per_1m`, `price_out_micro_idr_per_1m`, `price_cached_micro_idr_per_1m` and `price_cache_write_micro_idr_per_1m`, and `image_micro_idr_per_image` is the price of one image. `market_in_micro_idr_per_1m` and `market_out_micro_idr_per_1m` hold a reference market price, only when kenari's price is lower.
- `subscriber_only` is `true` when only accounts with an active subscription can call the model. See [Subscriptions](/en/docs/subscriptions).
- It lists every model that is for sale, including the `:free` versions and older versions of a family. It leaves out embedding, rerank, moderation and System One models.
- `usd_idr_rate` is the exchange rate in use, and `free_tier` describes what a free account gets. For free-tier limits, read [Free models](/en/docs/free-models).

To find what you can call and what it costs, use `GET /v1/models`.

To see what changed over time, read [Model changes](/en/docs/model-changes).

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | `modality` is not `embedding`, `rerank`, `moderation` or `systemone`. |

The other errors are in [Errors](/en/docs/errors).

## See also

- [Chat completions](/en/docs/chat)
- [Free models](/en/docs/free-models)
- [Reasoning](/en/docs/reasoning)
- [How billing works](/en/docs/billing)

## Prices

The table below is loaded live from `GET /v1/models`, so it shows what is billed today. Token prices are in Rupiah per 1 million tokens. Image, speech, video and the other models that are not priced per token have their own tables underneath, each with its unit. For how a request is billed, see [How billing works](/en/docs/billing).


---

# Model changes

kenari publishes every change to the model list and its prices: a new model, a model that was removed, a price that moved, a discount that started or ended, a price change on a known date, a model that is going to retire, a model joining or leaving a subscription plan, and a model moved to a newer vendor revision under the same id. You can read these changes on a web page, in a Telegram channel, or from a public endpoint that a script or an agent can poll. All three show the same changes.

## What counts as a change

| `kind` | What happened |
| --- | --- |
| `added` | A new model is in the list. |
| `removed` | A model left the list. |
| `price_changed` | The price of a model changed, or it became free or stopped being free. |
| `discount_started` | The market price of a model is now above the kenari price on the input or output side, where it was not before. |
| `discount_ended` | The market price is no longer above the kenari price on a side. |
| `price_scheduled` | A price change was scheduled, with the date it applies. |
| `sunset_scheduled` | A model was scheduled to retire, with the date. |
| `plans_changed` | The subscription plans that include a model changed: it joined a plan, left one, or both. |
| `updated` | The model moved to a new revision from its vendor, such as a new checkpoint, with the same model id and price. |

The market price is the reference price that `GET /api/public/pricing` shows next to the kenari price. See [Models and pricing](/en/docs/models#public-price-list).

Other changes, such as a model becoming subscriber-only, are not published. A plan whose price or name changed without a model joining or leaving it is not a `plans_changed` entry.

## When a change appears

A change is published only after kenari is sure it is real. The list is checked every 5 minutes.

| `kind` | Appears |
| --- | --- |
| `price_changed`, `discount_started`, `discount_ended`, `plans_changed`, `updated` | After the change has held for about 10 minutes. |
| `added` | After the model has been listed for about an hour. |
| `removed` | After the model has been gone for about an hour. |
| `price_scheduled`, `sunset_scheduled` | At once, because the date is the news. |

A change that reverts quickly is never published. A price that is typed wrong and fixed within 10 minutes does not appear, and a model that disappears for a few minutes and comes back is not reported as removed. If a price goes from A to B to C within 10 minutes, the change is published as A to C.

The hour before a new model appears counts from when it was first listed. If its price or plans are edited during that hour, the hour does not start again, and the `added` entry shows the price and plans the model has when it is published.

A model that was already announced with `sunset_scheduled` still gets a `removed` entry in the feed when it leaves. The Telegram channel does not post it a second time.

## Where to read it

- The page [kenari.id/models/changes](https://kenari.id/models/changes) lists the changes. You do not need to sign in.
- The Telegram channel [t.me/kenari_updates](https://t.me/kenari_updates) gets the same changes. Posts are in English, with at most one post per check. Each post groups the changes by kind, with prices in Rupiah per 1M tokens and dates in WIB. A price change shows the percent it moved, a discount line shows the market price it is compared with, a retiring or removed model names its successor when it has one, and a new model names the plans that include it. A post that gets too long is split into more than one message.
- `GET /v1/models/changes` returns the changes as JSON.

## The endpoint

`GET /v1/models/changes`

The endpoint is public. It needs no API key, and browsers can call it from any origin (CORS is open). The response can be cached for 30 seconds, so asking more often than that returns the same answer.

### Request

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `since` | integer | No | Unix time in seconds. Return only changes that were confirmed at or after this moment. Defaults to 0, which means no lower bound. |
| `limit` | integer | No | How many changes to return, from 1 to 200. Defaults to 50. A value outside the range is moved to the nearest end of it. |

### Response

The response is a list: `{"object": "list", "channel": ..., "data": [...]}`.

| Field | Type | Description |
| --- | --- | --- |
| `object` | string | Always `list`. |
| `channel` | string or null | The public Telegram handle that gets the same changes, such as `@kenari_updates`. `null` when there is none. |
| `data` | array | The changes, newest first by `confirmed_at`. Empty when there is nothing at or after `since`. |

Each item in `data` has these fields:

| Field | Type | Description |
| --- | --- | --- |
| `model` | string | The model id, as in `GET /v1/models`. |
| `kind` | string | One of the kinds in [What counts as a change](#what-counts-as-a-change). |
| `old` | object, integer or null | The state before the change. The shape depends on `kind`. See [Old and new by kind](#old-and-new-by-kind). |
| `new` | object, integer or null | The state after the change, in the same shape as `old`. |
| `effective_at` | integer or null | Unix seconds when a scheduled change takes effect. Set for `price_scheduled` and `sunset_scheduled`, `null` for the other kinds. |
| `detected_at` | integer | Unix seconds when kenari first saw the change. |
| `confirmed_at` | integer | Unix seconds when the change entered this list. For scheduled changes it equals `detected_at`. About 10 minutes later for price changes, discounts and plan changes, and about an hour after the model was first listed or gone for `added` and `removed`. |
| `successor` | string or null | The model named as this model's successor, for `sunset_scheduled` and `removed`. `null` otherwise. Naming a successor does not by itself mean requests are forwarded to it. |

```json
{
  "object": "list",
  "channel": "@kenari_updates",
  "data": [
    {
      "model": "step-3-7-flash",
      "kind": "price_changed",
      "old": { "free": false, "price_in": 1000000000, "price_out": 4000000000, "image": null },
      "new": { "free": false, "price_in": 1200000000, "price_out": 4000000000, "image": null },
      "effective_at": null,
      "detected_at": 1791529200,
      "confirmed_at": 1791532800,
      "successor": null
    }
  ]
}
```

### Old and new by kind

Prices are whole numbers in micro-Rupiah, where Rp 1 is 1,000,000 micro-Rupiah. A token price is per 1M tokens, so `1200000000` means Rp 1,200 per 1M tokens. See [How billing works](/en/docs/billing).

**Price object.** Used by `added`, `removed`, `price_changed` and `price_scheduled`. It always has four fields:

| Field | Description |
| --- | --- |
| `free` | `true` when the model is free. |
| `price_in` | Input price per 1M tokens. |
| `price_out` | Output price per 1M tokens. |
| `image` | Price per image, for models that are priced per image. |

A price field is `null` when the model has no price of that kind, for example `image` on a chat model.

On `added`, the price object also has a `plans` field when one or more subscription plans include the new model: the plan names, cheapest plan first. It is left out when no plan includes the model.

On `added`, the price object also has a `sunset_at` field when the model was listed with a retirement time already set. It holds the Unix time in seconds when kenari stops serving the model. When a replacement model is also set, there is a `successor` field too, with that model's public id. `successor` only appears together with `sunset_at`. Requests are forwarded to the successor after `sunset_at` when forwarding is on. Both fields are left out when no retirement time is set.

**Discount object.** Used by `discount_started` and `discount_ended`. It has an `in` key for the input side, an `out` key for the output side, or both. Only the sides that started or ended a discount are present. Each side is `{"price": ..., "market": ...}`:

| Field | Description |
| --- | --- |
| `price` | The kenari price per 1M tokens on that side. |
| `market` | The market price per 1M tokens on that side, or `null` when there is none. |

**Plans object.** Used by `plans_changed`. It has one field:

| Field | Description |
| --- | --- |
| `plans` | The names of the subscription plans that include the model, cheapest plan first. An empty list when no plan includes it. |

To see what changed, compare the two lists: a name only in `new` is a plan the model joined, and a name only in `old` is a plan it left.

**Revision object.** Used by `updated`:

| Field | Description |
| --- | --- |
| `revision` | The vendor's revision name, for example `1017`. On `old` it can be `null` when the model had no revision before. |
| `note` | Only on `new`: a one-line note about the revision, shaped `{"id": ..., "en": ...}` with each language a string or `null`, or `null` when there is no note. |

A revision that is cleared is not published. The current revision is also on every `GET /api/public/pricing` item as `revision`, `revision_at` and `revision_note`.

**Shape per kind:**

| `kind` | `old` | `new` | `effective_at` |
| --- | --- | --- | --- |
| `added` | `null` | Price object. | `null` |
| `removed` | Price object from before the model left. | `null` | `null` |
| `price_changed` | Price object before. | Price object after. | `null` |
| `discount_started` | Discount object before. | Discount object after, with `market` above `price`. | `null` |
| `discount_ended` | Discount object before, with `market` above `price`. | Discount object after. | `null` |
| `price_scheduled` | Price object with today's prices. | Price object with the prices that apply from `effective_at`. | Unix seconds when the new prices apply. |
| `sunset_scheduled` | The previous retirement time in unix seconds, or `null` when none was set. | The retirement time in unix seconds. | The same retirement time. |
| `plans_changed` | Plans object before. | Plans object after. | `null` |
| `updated` | Revision object before. | Revision object after, with `note`. | `null` |

## Examples

### curl

The first call returns the newest changes. Later calls pass `since` to get only what is new.

```bash
curl "https://kenari.id/v1/models/changes?limit=20"

curl "https://kenari.id/v1/models/changes?since=1791532800"
```

### JavaScript

This loop polls every 10 minutes. It keeps the newest `confirmed_at` it has seen and passes it as `since` on the next call.

```js
let since = 0;
const seen = new Set();

async function poll() {
  const response = await fetch(`https://kenari.id/v1/models/changes?since=${since}&limit=200`);
  if (!response.ok) return;
  const { data } = await response.json();

  // Newest first, so walk it backwards to handle the oldest change first.
  for (const change of [...data].reverse()) {
    const key = `${change.confirmed_at}:${change.model}:${change.kind}`;
    if (seen.has(key)) continue;
    seen.add(key);
    console.log(change.kind, change.model, change.old, "->", change.new);
  }
  if (data.length > 0) since = data[0].confirmed_at;
}

await poll();
setInterval(poll, 10 * 60 * 1000);
```

`since` includes its own second, so the newest change comes back once more on the next call. The `seen` set drops it.

## Why `since` uses confirmation time

`since` and the order of the list use `confirmed_at`, the moment a change enters the list, and not `detected_at`. A change is first seen, then confirmed 10 minutes or an hour later. If `since` used the time it was first seen, a change seen just before your last poll and confirmed just after it would look older than your `since`, and you would never get it. A change gets its `confirmed_at` at the moment it enters the list, so it is never older than a poll that did not have it yet. A poller that passes the newest `confirmed_at` it has seen as `since` therefore does not miss a change.

`detected_at` stays on each item, for when the change first happened.

If more than `limit` changes are confirmed between two polls, only the newest `limit` come back. Use `limit=200` and poll at least once an hour.

## Errors

A `since` or `limit` that is not a whole number gets a 400 error. The other errors are in [Errors](/en/docs/errors).

## See also

- [Models and pricing](/en/docs/models)
- [Free models](/en/docs/free-models)
- [Notifications](/en/docs/notifications)
- [How billing works](/en/docs/billing)


---

# Embeddings

Turn text into vectors for search, clustering and retrieval. The endpoint is compatible with the OpenAI Embeddings API, so the official OpenAI SDKs work with only a new base URL.

`POST /v1/embeddings`

Only embedding models are served here. Calling it with a chat model returns `400`. List the current embedding models with a public call that needs no key:

```bash
curl "https://kenari.id/v1/models?modality=embedding"
```

A plain `GET /v1/models` does not list embedding, rerank, moderation or System One models. It does list chat, image, speech, transcription and video models. The examples below use `bge-m3`. Swap in any id the call above returns. Prices are on [Models and pricing](/en/docs/models).

## Request

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | An embedding model id. |
| `input` | string or array of strings | yes | One text, or several texts in one request. An array needs at least one text, and every text must be a string with some non-whitespace content. Up to 2,048 texts and 1 MiB of text in total. |

Other OpenAI fields, such as `encoding_format`, are passed on to the provider. Whether one has an effect depends on the model.

## Response

The response has the OpenAI shape. The example vector is shortened for readability. A real vector has many more values. `object` is `"list"`, `model` is the id you requested, and `data` holds one entry per input, in the same order, with an `index` and the `embedding` vector. A `usage` object appears only when the provider reports token counts.

```json
{
  "object": "list",
  "model": "bge-m3",
  "data": [
    { "object": "embedding", "index": 0, "embedding": [0.0123, -0.0456] }
  ]
}
```

## Examples

### curl

```bash
curl https://kenari.id/v1/embeddings \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "bge-m3",
    "input": "a cat sitting on a rug"
  }'
```

### Python

```python
import os
from openai import OpenAI

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

result = client.embeddings.create(
    model="bge-m3",
    input="a cat sitting on a rug",
)

print(result.data[0].embedding[:5])
```

### JavaScript

```js
import OpenAI from "openai";

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

const result = await client.embeddings.create({
  model: "bge-m3",
  input: "a cat sitting on a rug",
});

console.log(result.data[0].embedding.slice(0, 5));
```

## Billing

Embeddings are billed per input token. kenari estimates the count from the text you send, so the charge does not depend on what the provider reports. See [How billing works](/en/docs/billing) for balance and price dimensions.

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | `model` is unknown or is not an embedding model, `input` is missing, is an empty array, or contains an empty, whitespace-only or non-string item, or the request is over the 2,048 text or 1 MiB limit. |
| 400 | `model_not_found` | The model is listed but no provider serves it right now. Retry later. |
| 503 | `upstream_error`, `all_providers_failed` | No provider returned a usable response. You are not charged. Retry. |

When `model` is not an embedding model, the error message lists the embedding ids that are available. See [Errors](/en/docs/errors) for the other codes.

## See also

- [Models and pricing](/en/docs/models)
- [Rerank](/en/docs/rerank)
- [Authentication and API keys](/en/docs/authentication)


---

# Rerank

Order a list of documents from most to least relevant to a query. Use it as the second stage of search: fetch candidates with embeddings or keyword search, then rerank the best few. The request and response follow the Cohere and Jina rerank style, since OpenAI has no rerank API.

`POST /v1/rerank`

Only rerank models are served here. Calling it with a chat model returns `400`. List the current rerank models with a public call that needs no key:

```bash
curl "https://kenari.id/v1/models?modality=rerank"
```

A plain `GET /v1/models` does not list embedding, rerank, moderation or System One models. It does list chat, image, speech, transcription and video models. The examples below use `bge-reranker-base`. Swap in any id the call above returns. Prices are on [Models and pricing](/en/docs/models).

## Request

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | A rerank model id. |
| `query` | string | yes | What relevance is measured against. Up to 8,192 bytes. |
| `documents` | array of strings | yes | The documents to rank. Up to 100, each up to 8,192 bytes and a non-empty string, and up to 512 KiB in total. |
| `top_n` | integer | no | Return only the best `n` results. From 1 up to the number of documents. Default: all documents. |
| `return_documents` | boolean | no | Include each document's text in its result. Default: `false`. |

## Response

`results` holds one entry per returned document, best match first. Each entry has `index`, the position of the document in the `documents` array you sent, and `relevance_score`. A higher score means a closer match. `document` is an object with a `text` field and appears only when `return_documents` is `true`. `usage` reports the input tokens.

```json
{
  "object": "list",
  "model": "bge-reranker-base",
  "results": [
    { "index": 2, "relevance_score": 0.91 },
    { "index": 0, "relevance_score": 0.42 }
  ],
  "usage": { "prompt_tokens": 24, "total_tokens": 24 }
}
```

## Examples

### curl

```bash
curl https://kenari.id/v1/rerank \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "bge-reranker-base",
    "query": "spicy fried rice recipe",
    "documents": [
      "how to brew iced coffee",
      "growing chili peppers in a pot",
      "spicy home-style fried rice recipe"
    ],
    "top_n": 2
  }'
```

### Python

The OpenAI SDK has no rerank method, so use `requests`.

```python
import os
import requests

resp = requests.post(
    "https://kenari.id/v1/rerank",
    headers={"Authorization": f"Bearer {os.environ['KENARI_API_KEY']}"},
    json={
        "model": "bge-reranker-base",
        "query": "spicy fried rice recipe",
        "documents": [
            "how to brew iced coffee",
            "growing chili peppers in a pot",
            "spicy home-style fried rice recipe",
        ],
        "top_n": 2,
    },
)
print(resp.json()["results"])
```

### JavaScript

```js
const resp = await fetch("https://kenari.id/v1/rerank", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.KENARI_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: "bge-reranker-base",
    query: "spicy fried rice recipe",
    documents: [
      "how to brew iced coffee",
      "growing chili peppers in a pot",
      "spicy home-style fried rice recipe",
    ],
    top_n: 2,
  }),
});
const { results } = await resp.json();
```

## Billing

Rerank is billed per input token, estimated from the query plus every document. The estimate is the charge, and the `usage` in the response is for display only. See [How billing works](/en/docs/billing) for how the reservation and price dimensions work.

## Errors

Size limits are checked first. A request over a limit fails with `400` before any charge.

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | `model` is unknown or is not a rerank model, `query` or `documents` is missing or empty, a limit is exceeded, or `top_n` is not an integer between 1 and the number of documents. |
| 400 | `model_not_found` | The model is listed but no provider serves it right now. Retry later. |

See [Errors](/en/docs/errors) for the shared codes, such as `insufficient_balance` and `all_providers_failed`.

## See also

- [Embeddings](/en/docs/embeddings)
- [Models and pricing](/en/docs/models)
- [How billing works](/en/docs/billing)


---

# Moderations

<aside class="warn"><strong>Warning</strong> If no moderation model is available, every request to this endpoint returns <code>400</code>. Check <code>GET /v1/models?modality=moderation</code> before you call it.</aside>

Classify text and learn whether it is flagged, with a score for each category. The endpoint is compatible with the OpenAI Moderations API.

`POST /v1/moderations`

Only moderation models are served here. List them with a public call that needs no key:

```bash
curl "https://kenari.id/v1/models?modality=moderation"
```

Otherwise the contract below applies to the ids it returns. A plain `GET /v1/models` does not list embedding, rerank, moderation or System One models. It does list chat, image, speech, transcription and video models.

## Request

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | A moderation model id from the call above. |
| `input` | string or array of strings | yes | One text, or several texts in one request. Every text must be a non-empty string. Up to 2,048 texts and 1 MiB of text in total. |

## Response

The response has the OpenAI shape. `id` starts with `modr-`, `model` is the id you requested, and `results` has one entry per input, in the same order. Each entry has `flagged`, `categories` (a boolean per category) and `category_scores` (a number per category). Only the standard OpenAI category names are returned. `<model-id>` below stands for an id returned by the models call.

```json
{
  "id": "modr-...",
  "model": "<model-id>",
  "results": [
    {
      "flagged": false,
      "categories": { "harassment": false, "hate": false },
      "category_scores": { "harassment": 0.001, "hate": 0.0002 }
    }
  ]
}
```

## Examples

Replace `<model-id>` with an id from `GET /v1/models?modality=moderation`.

### curl

```bash
curl https://kenari.id/v1/moderations \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "<model-id>",
    "input": "text to check"
  }'
```

### Python

```python
import os
from openai import OpenAI

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

result = client.moderations.create(
    model="<model-id>",
    input="text to check",
)

print(result.results[0].flagged)
```

### JavaScript

```js
import OpenAI from "openai";

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

const result = await client.moderations.create({
  model: "<model-id>",
  input: "text to check",
});

console.log(result.results[0].flagged);
```

## Billing

Moderation is billed per input token. Moderation models are free by default, so a model without a price set charges Rp 0. The price of each model is in its catalog entry. See [How billing works](/en/docs/billing) for balance and price dimensions.

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | `model` is unknown or is not a moderation model (the message lists the moderation ids that are available), `input` is missing or contains an empty or non-string item, or the request is over the 2,048 text or 1 MiB limit. |
| 400 | `model_not_found` | The model is listed but no provider serves it right now. Retry later. |
| 503 | `upstream_error`, `all_providers_failed` | No provider returned a usable response. You are not charged. Retry. |

See [Errors](/en/docs/errors) for the other codes.

## See also

- [Models and pricing](/en/docs/models)
- [Chat completions](/en/docs/chat)
- [How billing works](/en/docs/billing)


---

# Images

Generate an image from a text prompt, or edit existing images with a prompt. The endpoints are compatible with OpenAI Images, so the official OpenAI SDKs work with the base URL `https://kenari.id/v1`. Only image models are served here. Sending a text model returns `400`.

`POST /v1/images/generations`

`POST /v1/images/edits`

Find the image models, with their prices, in the model list:

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

## Request

### Generations

`POST /v1/images/generations` takes a JSON body.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | Image model id, for example `gpt-image-2`. |
| `prompt` | string | yes | Description of the image. |
| `n` | integer | no | Number of images, 1 to 10. Default `1`. A value outside the range is moved to the nearest limit, and you are billed for that number. |
| `size` | string | no | Image size, for example `"1024x1024"`. A hint, not a guarantee. See [Image size](#image-size). |
| `background` | string | no | `"transparent"`, `"opaque"` or `"auto"`. Any other value returns `400`. Whether the image really has a transparent background depends on the model. |
| `response_format` | string | no | `"url"` or `"b64_json"`. Support varies by model, so leave it out to get the model's default. See [Response](#response). |

### Edits

`POST /v1/images/edits` changes existing images according to a prompt. The request is `multipart/form-data` because the images are sent as files. Repeat the `image` field (or `image[]`) once per source file.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | Image model id, for example `gpt-image-2`. |
| `image` | file | yes | Source image. Send PNG, JPEG or WebP. Repeat the field for up to 16 files, each up to 25 MiB. |
| `prompt` | string | yes | Description of the change. Up to 8,192 bytes. |
| `mask` | file | no | A PNG mask, up to 25 MiB. As in OpenAI Images, transparent areas mark where the image is edited. Without a mask, the whole image can change. |
| `n` | integer | no | Number of output images, 1 to 10. Default `1`. It does not depend on how many source files you send. |
| `size` | string | no | Image size, for example `"1024x1024"`. A hint, not a guarantee. See [Image size](#image-size). |
| `background` | string | no | `"opaque"` or `"auto"`. `"transparent"` is not supported on edits and returns `400`. Use generations for a transparent background. |
| `response_format` | string | no | `"url"` or `"b64_json"`. Same notes as generations. |

The whole request body can be up to 32 MiB. A 17th image, a file above its limit or a body above 32 MiB is rejected before anything is charged. Some models accept a single source image or no mask. Those models answer `400` when you send more.

## Response

Both endpoints return the OpenAI Images shape: `created` and a `data` array with one item per image.

```json
{
  "created": 1718700000,
  "data": [
    { "b64_json": "iVBORw0KGgoAAAANSUhEUg..." }
  ]
}
```

Each item carries `b64_json`, `url`, or both, depending on the model and on `response_format`. A `url` is a self-contained `data:` URI with the image inside it, not a link to a hosted file. Read whichever field is present and decode the base64 part. The response can also include `usage`, `size`, `quality`, `output_format` and a per-image `revised_prompt` when the model reports them.

## Image size

Models treat `size` in three ways. Some use it as given, some ignore it, and some understand only aspect ratios. For a ratio-based model, kenari maps the size you send to the nearest ratio the model supports, so `"1792x1024"` gives a wide 16:9 image at whatever pixel count that model produces, not exactly 1792x1024. The shape lands as close as the model allows, but the pixel count can differ. If exact pixel dimensions matter, check the dimensions of the returned image.

## Long-running requests

An image can take several minutes. If the result is not ready within 45 seconds, kenari sends the `200` status and headers right away, then writes one space every 20 seconds to keep proxies from closing the connection, and finishes with the JSON body. Leading whitespace is valid JSON, so parse the complete body as usual. Set your client timeout to at least 5 minutes.

<aside class="warn"><strong>Warning</strong> A failure after the first 45 seconds arrives as HTTP <code>200</code> with an <code>error</code> object in the body instead of <code>data</code>. Read the full body, then check for <code>error</code> before reading <code>data</code>. Failures before that point keep their real status, such as <code>400</code>, <code>402</code> or <code>503</code>.</aside>

## Examples

### curl

```bash
curl https://kenari.id/v1/images/generations \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-image-2",
    "prompt": "A red panda writing code on a laptop",
    "n": 1,
    "size": "1024x1024"
  }' \
  --max-time 300 \
  --output response.json

if jq -e '.error' response.json > /dev/null; then
  jq -r '.error.message' response.json >&2
else
  jq -r '.data[0] | .b64_json // (.url | split(",")[1])' response.json \
    | base64 -d > image.png
fi
```

### Python

```python
import base64
import os

from openai import OpenAI

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

result = client.images.generate(
    model="gpt-image-2",
    prompt="A red panda writing code on a laptop",
    n=1,
    size="1024x1024",
)

error = getattr(result, "error", None)
if error or not result.data:
    raise RuntimeError(f"Image request failed: {error}")

item = result.data[0]
b64 = item.b64_json or item.url.split(",", 1)[1]
with open("image.png", "wb") as f:
    f.write(base64.b64decode(b64))
```

### 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 result = await client.images.generate({
  model: "gpt-image-2",
  prompt: "A red panda writing code on a laptop",
  n: 1,
  size: "1024x1024",
});

if (result.error || !result.data?.length) {
  throw new Error(`Image request failed: ${JSON.stringify(result.error)}`);
}

const item = result.data[0];
const b64 = item.b64_json ?? item.url.split(",")[1];
fs.writeFileSync("image.png", Buffer.from(b64, "base64"));
```

### Edit with curl

```bash
curl https://kenari.id/v1/images/edits \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -F "model=gpt-image-2" \
  -F "image=@source.png" \
  -F "image=@reference.png" \
  -F "prompt=Change the sky to sunset, in the style of the second image" \
  -F "n=1" \
  --max-time 300 \
  --output response.json

if jq -e '.error' response.json > /dev/null; then
  jq -r '.error.message' response.json >&2
else
  jq -r '.data[0] | .b64_json // (.url | split(",")[1])' response.json \
    | base64 -d > edited.png
fi
```

### Edit with Python

```python
import base64
import os

from openai import OpenAI

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

result = client.images.edit(
    model="gpt-image-2",
    image=[open("source.png", "rb"), open("reference.png", "rb")],
    prompt="Change the sky to sunset, in the style of the second image",
    n=1,
)

error = getattr(result, "error", None)
if error or not result.data:
    raise RuntimeError(f"Image request failed: {error}")

item = result.data[0]
b64 = item.b64_json or item.url.split(",", 1)[1]
with open("edited.png", "wb") as f:
    f.write(base64.b64decode(b64))
```

### Edit with 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 result = await client.images.edit({
  model: "gpt-image-2",
  image: [fs.createReadStream("source.png"), fs.createReadStream("reference.png")],
  prompt: "Change the sky to sunset, in the style of the second image",
  n: 1,
});

if (result.error || !result.data?.length) {
  throw new Error(`Image request failed: ${JSON.stringify(result.error)}`);
}

const item = result.data[0];
const b64 = item.b64_json ?? item.url.split(",")[1];
fs.writeFileSync("edited.png", Buffer.from(b64, "base64"));
```

## Billing

Images are billed per image: `n` times the model's price per image, taken from your balance. Four images cost four times one image, and the number of source files on an edit does not change the price. The amount is held when the request is accepted. You pay for the images actually delivered, so a provider that returns fewer than `n` images costs you less, and a response with no usable image is a failure that releases the whole hold. See [How billing works](/en/docs/billing) for the rest.

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | The model is not an image model, `prompt`, `model` or `image` is missing, `background` is invalid, or an edit has more than 16 images, a file above 25 MiB, or a prompt above 8,192 bytes. |
| 402 | `insufficient_balance` | The balance does not cover `n` images. |
| 413 | none | The request body is above 32 MiB. The response is plain text. |
| 503 | `all_providers_failed`, `upstream_error` | No provider could produce the image. 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)
- [Images and files as input](/en/docs/vision)
- [Errors](/en/docs/errors)


---

# 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)


---

# Transcriptions

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

`POST /v1/audio/transcriptions`

Find the speech-to-text models with:

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

## Request

The request is `multipart/form-data`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | Speech-to-text model id, for example `whisper-large-v3-turbo`. |
| `file` | file | yes | The audio to transcribe. |
| `language` | string | no | Language code of the audio, for example `id` or `en`. Detected automatically when left out. |
| `prompt` | string | no | Text that guides spelling of names and terms or the style of the transcript. |
| `temperature` | number | no | Sampling temperature. Passed to the provider unchanged. |
| `response_format` | string | no | `json` (default), `verbose_json` or `text`. |

kenari does not check the audio format. Which formats work depends on the model. The whole request, file included, can be up to 32 MiB, and a larger body is rejected with `413`. A provider has 120 seconds to answer, so split very long recordings into several files.

`timestamp_granularities` is not supported. Other fields are ignored.

## Response

With `json`, or when `response_format` is left out, the response holds only the text:

```json
{ "text": "Hello, this is an audio transcription." }
```

With `verbose_json`, the response adds `task`, `language`, `duration` in seconds, and `segments` with timing for each segment. Fields the model does not report are left out.

```json
{
  "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 the response from the fields shown above, so any other field the model returns is not passed through. A `words` array appears only if the model returns one.

With `text`, the response body is the plain transcript, not JSON.

`srt` and `vtt` are not supported. They return the default `json` response.

## Examples

The examples use `recording.mp3`. Replace it with the path to your own recording.

### curl

```bash
curl https://kenari.id/v1/audio/transcriptions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -F "file=@recording.mp3" \
  -F "model=whisper-large-v3-turbo" \
  -F "language=en"
```

### Python

```python
import os

from openai import OpenAI

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

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

print(transcript.text)
```

### 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 transcript = await client.audio.transcriptions.create({
  model: "whisper-large-v3-turbo",
  file: fs.createReadStream("recording.mp3"),
  language: "en",
});

console.log(transcript.text);
```

## Billing

Transcription is billed per second of audio, rounded up. The reservation is based on the duration kenari estimates from the file size, so a request can fail with `402` even when the audio is short. See [How billing works](/en/docs/billing) for how the reservation and the charge work, and the model's price in [Models and pricing](/en/docs/models).

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | The model is not a speech-to-text model, `file` or `model` is missing, the multipart body is malformed or cut off, or the provider rejected the audio. |
| 413 | none | The request body is above 32 MiB. The response is plain text. |

See [Errors](/en/docs/errors) for the shared codes, such as `insufficient_balance` and `all_providers_failed`.

## See also

- [Text to speech](/en/docs/audio-speech)
- [Models and pricing](/en/docs/models)
- [How billing works](/en/docs/billing)
- [Errors](/en/docs/errors)


---

# Music

Make a complete song from lyrics, or an instrumental track from a text description. The response is a JSON object that carries the audio as base64. Only music models are served here. Sending any other model returns `400`.

`POST /v1/music/generations`

Music models are marked with `music` in the `endpoints` field of `GET /v1/models`:

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

If the call returns nothing, no music model is available at the moment.

Music models come in two kinds, and `formats` on the model entry tells them apart:

- `["mp3"]`: a sung track is written from `lyrics`, and an instrumental track from `prompt`.
- `["m4a", "wav"]`: every track starts from `prompt`, the style and mood. `lyrics` adds words to sing.

This page calls them an `mp3` model and an `m4a` model.

## Request

The body is JSON.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | Music model id. |
| `lyrics` | string | conditional | The words to sing. On an `mp3` model, required for a sung track, which is when `instrumental` is `false` or left out. Optional on an `m4a` model. |
| `prompt` | string | conditional | Style and mood of the track. On an `mp3` model, required when `instrumental` is `true` and optional on a sung track. Always required on an `m4a` model. |
| `instrumental` | boolean | no | `true` makes a track without vocals. Default `false`. When `true`, an `mp3` model ignores `lyrics`, and an `m4a` model returns `400` if `lyrics` is sent. |
| `response_format` | string | no | One of the model's `formats`: `mp3` on an `mp3` model, `m4a` or `wav` on an `m4a` model. Leave it out to get the first one. Any other value returns `400`. |
| `stream` | boolean | no | Reserved. `true` returns `400`. |
| `lyrics_optimizer` | boolean | no | Reserved. `true` returns `400`. |

## Response

The song is in `data[0].b64_json`, and its format is in `data[0].format`:

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

`data` always has exactly one item. `format` is the format of the audio you got: `mp3`, `m4a` or `wav`. The audio is not a download link, so decode the base64 and write it to a file with that extension.

Because a generation is slow, the body can arrive in two shapes.

- **Leading spaces.** While the song is generated, the gateway writes one space every 20 seconds to keep the connection open, then sends the JSON. Parse the whole body as JSON, which ignores leading whitespace. Do not treat it as raw audio.
- **Late errors.** A failure in the first moments keeps its real status: `400`, `402`, `429` or `503`. A failure after the gateway has already sent `200` comes back as HTTP `200` with a standard error object, for example `{"error": {"code": "...", "message": "...", "param": null, "type": "..."}}`. Always check for an `error` field before decoding the audio.

## Lyrics or a description

`lyrics` is the text that gets sung, not a description of the song. Sending "a relaxed pop song about Jakarta" as `lyrics` makes the model sing that sentence. Describe the song in `prompt` instead. On an `mp3` model, a track with only a description needs `instrumental` set to `true`.

Section markers help the model structure a song:

```text
[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
```

## Length limits

Lengths are counted in Unicode characters, not bytes, so accented text is not cut short. The limits differ per model. When a model records them, `GET /v1/models` shows `max_lyrics_chars` and `max_prompt_chars` on its entry. Without those fields, the defaults apply: 3,500 characters for `lyrics` and 2,000 for `prompt` on an `mp3` model, and 10,000 for each on an `m4a` model. Text over the limit returns `400` before anything is charged.

## Song length

The length of a song is decided by the model. There is no duration field, so you cannot ask for a shorter or longer song. Where the model records it, `max_duration_secs` in `GET /v1/models` gives the longest song in seconds. It is informational: no request is rejected because of it, and real songs are usually shorter.

## Generation time

Most songs take two to three minutes, and longer songs take longer. The gateway keeps the connection open and waits up to 15 minutes before giving up. Set your client timeout to at least 5 minutes, and a generous value such as 15 minutes is safest. A client that stops at 3 minutes will sometimes cancel a song that was about to finish.

## Examples

### curl

The first line picks a music model from the list. Set `MODEL` yourself if you already know the id. The examples send both `lyrics` and `prompt`, which works on both kinds of music model, and save the file with the extension from `format`.

```bash
MODEL=$(curl -s https://kenari.id/v1/models \
  | jq -r '[.data[] | select(.endpoints | index("music"))][0].id')

curl https://kenari.id/v1/music/generations \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"model\": \"$MODEL\",
    \"prompt\": \"warm city pop, female vocal\",
    \"lyrics\": \"[Verse]\nCity lights come on one by one\n\n[Chorus]\nTonight belongs to us\"
  }" \
  --max-time 900 \
  -o response.json

# Stop on an error object, then decode only a non-empty audio string
jq -e 'has("error") | not' response.json > /dev/null || { jq .error response.json; exit 1; }
FORMAT=$(jq -r '.data[0].format' response.json)
jq -er '.data[0].b64_json | select(type == "string" and length > 0)' response.json | base64 -d > "song.$FORMAT"
```

An instrumental track:

```bash
curl https://kenari.id/v1/music/generations \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"model\": \"$MODEL\",
    \"instrumental\": true,
    \"prompt\": \"relaxed lo-fi, soft piano, night rain\"
  }" \
  --max-time 900 \
  -o response.json

jq -e 'has("error") | not' response.json > /dev/null || { jq .error response.json; exit 1; }
FORMAT=$(jq -r '.data[0].format' response.json)
jq -er '.data[0].b64_json | select(type == "string" and length > 0)' response.json | base64 -d > "instrumental.$FORMAT"
```

### Python

There is no OpenAI SDK method for music, so use `requests`.

```python
import base64
import os

import requests

headers = {"Authorization": f"Bearer {os.environ['KENARI_API_KEY']}"}

models = requests.get("https://kenari.id/v1/models", timeout=30).json()["data"]
model = next(m["id"] for m in models if "music" in m["endpoints"])

response = requests.post(
    "https://kenari.id/v1/music/generations",
    headers=headers,
    json={
        "model": model,
        "prompt": "warm city pop, female vocal",
        "lyrics": "[Verse]\nCity lights come on one by one\n\n[Chorus]\nTonight belongs to us",
    },
    timeout=900,
)
body = response.json()  # leading spaces are ignored

if "error" in body:
    raise RuntimeError(body["error"]["message"])

song = body["data"][0]
with open(f"song.{song['format']}", "wb") as f:
    f.write(base64.b64decode(song["b64_json"]))
```

### JavaScript

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

const headers = {
  Authorization: `Bearer ${process.env.KENARI_API_KEY}`,
  "Content-Type": "application/json",
};

const { data: models } = await (await fetch("https://kenari.id/v1/models")).json();
const model = models.find((m) => m.endpoints.includes("music")).id;

const response = await fetch("https://kenari.id/v1/music/generations", {
  method: "POST",
  headers,
  body: JSON.stringify({
    model,
    prompt: "warm city pop, female vocal",
    lyrics: "[Verse]\nCity lights come on one by one\n\n[Chorus]\nTonight belongs to us",
  }),
  signal: AbortSignal.timeout(900_000),
});
const body = JSON.parse(await response.text()); // leading spaces are ignored

if (body.error) throw new Error(body.error.message);

const song = body.data[0];
fs.writeFileSync(`song.${song.format}`, Buffer.from(song.b64_json, "base64"));
```

## Billing

Music is billed a flat price per song, whatever its length. A failed generation is not charged. Each model's price per song is in [Models and pricing](/en/docs/models). See [How billing works](/en/docs/billing) for the rest.

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | The model is not a music model, the field required for your track type is missing, `lyrics` or `prompt` is over the limit, or `response_format` is not one of the model's `formats`. |
| 402 | `insufficient_balance` | The balance does not cover the price of one song. |
| 429 | `rate_limit_exceeded` | The account's music concurrency limit is reached. See [Rate limits](/en/docs/rate-limits). Wait for the `Retry-After` header, then retry. Nothing is charged. |
| 503 | `all_providers_failed`, `upstream_error` | No provider could produce the song. Nothing is charged. Retry. |

Errors after the gateway has sent `200` arrive inside the response body. See [Response](#response). For every other code, see [Errors](/en/docs/errors).

## See also

- [Models and pricing](/en/docs/models)
- [How billing works](/en/docs/billing)
- [Rate limits](/en/docs/rate-limits)
- [Errors](/en/docs/errors)


---

# Videos

Render a video from a prompt, or extend an existing clip. Video endpoints are asynchronous. The request returns a job id right away, you poll the job until it is finished, then download the clip. Only video models are served here. Sending any other model returns `400`.

`POST /v1/videos/generations`

`POST /v1/videos/extensions`

`GET /v1/videos/{id}`

`GET /v1/videos/{id}/content`

## Choose a model

Each video model accepts its own durations, resolutions and number of reference images. Read them from the model list:

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

## Request

### Generations

`POST /v1/videos/generations` takes a JSON body and starts a render.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | Video model id, for example `seedance-2.0-fast`. |
| `prompt` | string | yes | Description of the video. A few image-to-video models also accept a request with an image and no prompt. |
| `duration` | integer | no | Length in seconds. See [Durations and resolutions](#durations-and-resolutions). |
| `resolution` | string | no | Resolution tier, for example `720p`. Defaults to the first tier the model lists. |
| `image_url` | string | no | Source image for image-to-video. The URL must return the image itself, because a redirect is not followed. |
| `end_image_url` | string | no | Final frame, used together with `image_url`. |
| `input_images` | array of strings | no | One or more reference images, each an https URL or a data URL. See [Reference images](#reference-images). |
| `video_url` | string | no | Reference clip for reference-to-video. If a request has both a clip and an image, it renders as a reference render. |
| `aspect_ratio` | string | no | Output shape, for example `16:9` or `9:16`. kenari does not validate it. It reaches the model unchanged, and its effect depends on the model. |

Sending an image selects the model's image-to-video variant, and sending a clip selects reference-to-video. For this choice, `last_image_url` counts like `end_image_url`, and `reference_video_url` counts like `video_url`. A model that cannot render from the input you sent returns `400` and lists what it accepts.

### Reference images

`input_images` sends reference images. A reference is material the model keeps consistent across the clip, such as a character, a product or a style. It is not a frame, so the clip does not open or end on it. To control the first or last frame, send `image_url` and `end_image_url` instead.

Each entry is an https URL or a data URL. A URL must be reachable from the public internet. How many references a model takes is its `max_reference_images` in the model list:

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

- A number above 0 is the most references one request can carry. A request with more returns `400`.
- `0` means the model takes no reference images. A request with `input_images` returns `400`. Send the start frame as `image_url` instead.
- A model without `max_reference_images` has no published figure. It may take references, and a request it cannot take returns `400`.

Some models also refuse a start or end frame in the same request as references. They return `400`, so send frames or references, not both. A reference that cannot be fetched returns `400` with the code `reference_image_fetch_failed`. All of these are rejected before any charge.

### Extensions

`POST /v1/videos/extensions` continues an existing clip from its last frame.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | Video model id. |
| `video.url` | string | yes | URL of the clip to extend. |
| `prompt` | string | no | Description of the extension. |
| `duration` | integer | no | Extra length in seconds. Same rules as generations. |
| `resolution` | string | no | Resolution tier, for example `720p`. Same rules as generations: the default is the first tier the model lists, and a tier outside the list returns `400`. The price depends on it. |

The body is JSON. Not every video model can extend a clip. Find the ones that can:

```bash
curl -s https://kenari.id/v1/models | jq -r '.data[] | select(.video_extensions) | .id'
```

If the command prints nothing, no model can extend right now and the extensions endpoint answers `503`. Otherwise, put one of the printed ids in `model`. This request extends a clip:

```json
{
  "model": "<model-id>",
  "video": { "url": "https://example.com/clip.mp4" },
  "prompt": "The eagle lands on a branch",
  "duration": 5,
  "resolution": "720p"
}
```

Replace `<model-id>` with an id from the command above. A model whose entry lacks `video_extensions` answers `503`, and nothing is charged.

### Durations and resolutions

A model that lists `video_durations` accepts only those values. The first entry is the default, and any other value returns `400` with the allowed list. A model without the list accepts `duration` from 1 to 15 with a default of 6, and moves a value outside that range to the nearest limit. `duration` must be a whole number of seconds.

A model that lists `video_resolutions` accepts only those tiers, and the first is the default. Tier names are matched without regard to case. A tier outside the list returns `400` with the available tiers. kenari never switches you to a cheaper tier. A model that lists no tiers has one rate and ignores `resolution`.

Invalid durations or resolutions are rejected before any charge.

## Response

Both create endpoints return the job at once. The clip does not exist yet.

```json
{
  "id": "7b0e2c9a-4f1d-4c3e-9a55-2d8f6e1b0c47",
  "object": "video.job",
  "status": "rendering",
  "model": "seedance-2.0-fast"
}
```

## Polling

`GET /v1/videos/{id}` returns the current state of a job. Poll every few seconds until `status` is no longer `rendering`. Only the account that created the job can read it.

| Status | Meaning |
| --- | --- |
| `rendering` | The job is still running. Keep polling. |
| `done` | The clip is ready. `url` holds the download link. |
| `failed` | The render failed. The charge is refunded. |
| `expired` | The job did not finish in time. The charge is refunded. |

A poll that returns `429` or `503` is a temporary HTTP failure, not a failed job. Wait and poll again. Any other error status ends the loop. Only `failed` and `expired` are final job statuses besides `done`.

A finished job:

```json
{
  "id": "7b0e2c9a-4f1d-4c3e-9a55-2d8f6e1b0c47",
  "status": "done",
  "url": "https://kenari.id/v1/videos/7b0e2c9a-4f1d-4c3e-9a55-2d8f6e1b0c47/content"
}
```

A `failed` or `expired` job has no `url`. It carries `failure_reason` instead, a short message in Indonesian that says what happened, for example that the capacity is full and you can retry, or that the prompt or image was refused by the content policy.

A job that has not finished after one hour is closed as `expired` and refunded.

## Downloading the clip

`GET /v1/videos/{id}/content` streams the finished clip. The link in `url` is served by kenari and needs a valid API key that belongs to the account that created the job. Other accounts cannot download it. The `Content-Type` is `video/mp4`, `video/webm` or `video/quicktime`. While the job is still rendering, the endpoint returns `400`.

```bash
curl https://kenari.id/v1/videos/7b0e2c9a-4f1d-4c3e-9a55-2d8f6e1b0c47/content \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  --output clip.mp4
```

## Examples

### curl

```bash
JOB=$(curl -s https://kenari.id/v1/videos/generations \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "seedance-2.0-fast",
    "prompt": "An eagle flying over a rainforest at sunrise",
    "duration": 5,
    "resolution": "720p"
  }' | jq -r '.id // empty')

if [ -z "$JOB" ]; then
  echo "The job was not created" >&2
  exit 1
fi

while true; do
  RESULT=$(curl -s "https://kenari.id/v1/videos/$JOB" \
    -H "Authorization: Bearer $KENARI_API_KEY")
  STATUS=$(echo "$RESULT" | jq -r '.status // empty')
  if [ -z "$STATUS" ]; then
    echo "$RESULT" >&2
    exit 1
  fi
  echo "status: $STATUS"
  [ "$STATUS" != "rendering" ] && break
  sleep 5
done

if [ "$STATUS" = "done" ]; then
  curl -s --fail "https://kenari.id/v1/videos/$JOB/content" \
    -H "Authorization: Bearer $KENARI_API_KEY" \
    --output clip.mp4
else
  echo "$RESULT" | jq -r '.failure_reason'
fi
```

### Python

```python
import os
import time

import requests

BASE = "https://kenari.id/v1"
headers = {"Authorization": f"Bearer {os.environ['KENARI_API_KEY']}"}

job = requests.post(
    f"{BASE}/videos/generations",
    headers=headers,
    json={
        "model": "seedance-2.0-fast",
        "prompt": "An eagle flying over a rainforest at sunrise",
        "duration": 5,
        "resolution": "720p",
    },
    timeout=60,
)
job.raise_for_status()
job_id = job.json()["id"]

while True:
    poll = requests.get(f"{BASE}/videos/{job_id}", headers=headers, timeout=30)
    if poll.status_code in (429, 503):
        time.sleep(5)
        continue
    poll.raise_for_status()
    state = poll.json()
    print("status:", state["status"])
    if state["status"] != "rendering":
        break
    time.sleep(5)

if state["status"] == "done":
    clip = requests.get(f"{BASE}/videos/{job_id}/content", headers=headers, timeout=300)
    clip.raise_for_status()
    with open("clip.mp4", "wb") as f:
        f.write(clip.content)
else:
    print(state.get("failure_reason"))
```

### JavaScript

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

const BASE = "https://kenari.id/v1";
const headers = {
  Authorization: `Bearer ${process.env.KENARI_API_KEY}`,
  "Content-Type": "application/json",
};

const created = await fetch(`${BASE}/videos/generations`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    model: "seedance-2.0-fast",
    prompt: "An eagle flying over a rainforest at sunrise",
    duration: 5,
    resolution: "720p",
  }),
});
if (!created.ok) throw new Error(await created.text());
const { id } = await created.json();

let state;
do {
  await new Promise((resolve) => setTimeout(resolve, 5000));
  const poll = await fetch(`${BASE}/videos/${id}`, { headers });
  if (poll.status === 429 || poll.status === 503) {
    state = { status: "rendering" };
    continue;
  }
  if (!poll.ok) throw new Error(await poll.text());
  state = await poll.json();
  console.log("status:", state.status);
} while (state.status === "rendering");

if (state.status === "done") {
  const clip = await fetch(`${BASE}/videos/${id}/content`, { headers });
  if (!clip.ok) throw new Error(await clip.text());
  fs.writeFileSync("clip.mp4", Buffer.from(await clip.arrayBuffer()));
} else {
  console.log(state.failure_reason);
}
```

## Billing

Video is billed per second of the requested duration, at the rate of the resolution tier you ask for. A 5-second render at 720p costs 5 times the model's 720p rate per second. Each tier has its own rate in `pricing_lines` of `GET /v1/models`. The full amount is taken from your balance when the job is accepted. If the job ends as `failed` or `expired`, it is refunded automatically, whether or not you keep polling. See [How billing works](/en/docs/billing) for the rest.

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | The model is not a video model, `prompt` or `video.url` is missing, the duration or resolution is not available for the model, the model cannot render from the input you sent, or the reference images are more than the model's `max_reference_images` or are mixed with a start or end frame. |
| 400 | `reference_image_fetch_failed` | A reference image in `input_images` could not be fetched. Send each reference as a public https URL or a data URL. Nothing is charged. |
| 400 | `bad_request` | On the download endpoint: the clip is not ready yet. |
| 400 | `model_not_found` | The job id does not exist or belongs to another account. |
| 402 | `insufficient_balance` | The balance does not cover the full price of the job. |
| 503 | `upstream_error`, `all_providers_failed` | The model is not available right now, or on `/v1/videos/extensions` the model's entry lacks `video_extensions`. Nothing is charged. Retry once or twice, and if it keeps failing on an extension, choose a model that lists `video_extensions`. |

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

## See also

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


---

# OCR

Extract text from a PDF or a document image, with no chat model involved. You pay the per-page fee and no token charge. To have a model answer questions about the document instead, attach the file to a Chat Completions request, as described in [Read documents](/en/docs/documents), where kenari reads it for any model as ordinary input tokens. This endpoint is the same paid reading as the `ocr` engine there.

`POST /v1/ocr`

## Request

Send a JSON body with either `file` or `reuse_id`, never both.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `file` | object | one of `file` or `reuse_id` | The document to read. |
| `file.filename` | string | yes, with `file` | The file name. It is echoed back as `name`. |
| `file.file_data` | string | yes, with `file` | A base64 data URL: `data:<type>;base64,<contents>`. A plain http URL is not fetched. |
| `reuse_id` | string | one of `file` or `reuse_id` | A `reuse_id` from an earlier reading on your account. Serves the stored text at no charge. |
| `engine` | string | no | Only `ocr` is accepted. Any other value is refused. |

Accepted types are `application/pdf`, `image/png`, `image/jpeg`, `image/webp`, `image/gif` and `image/tiff`. Two limits apply and either can refuse first: a page limit per document that kenari sets, and a size limit of about 15 MB. A document over either limit is refused, not read and not charged. The refusal message names the page limit when that is the one hit.

## Response

The response has the extracted text and what the reading cost. `pages` is the number of pages actually read, and the charge is computed from it. `cost_micro_idr` is the fee in micro-Rupiah (Rupiah times 1,000,000), and it is `0` when the request only reused a stored reading. `content` holds the extracted text. `confidence` and `low_confidence` mean the same as in a chat response with the `ocr` engine: a mean per-page score, which is `null` when the engine gives no score, and a warning flag, not a guarantee. `reuse_id` lets you read the same document again at no charge, from this endpoint or from a chat request with the `ocr` engine.

```json
{
  "id": "req_8f286cec-075b-4083",
  "pages": 3,
  "cost_micro_idr": 225000000,
  "name": "invoice.pdf",
  "hash": "sha256:a3b6919a...",
  "content": [{"type": "text", "text": "# INVOICE\n..."}],
  "confidence": 0.9892,
  "low_confidence": false,
  "reuse_id": "ocr_8f286cec-075b-4083-a462-fc89170feb4f"
}
```

`reuse_id` is present when the reading was stored. Sending the same document again on the same account returns the stored reading and charges nothing, as long as the first reading was stored and carried a `reuse_id`. Treat any figure read from a scan as unverified until a person has checked it.

## Examples

Replace `invoice.pdf` with your own file. Each example encodes it as base64 first, and the curl example needs `jq`.

### curl

```bash
{ printf 'data:application/pdf;base64,'; base64 < invoice.pdf | tr -d '\n'; } > invoice.dataurl

jq -n --rawfile data invoice.dataurl '{file: {filename: "invoice.pdf", file_data: $data}}' \
  | curl https://kenari.id/v1/ocr \
    -H "Authorization: Bearer $KENARI_API_KEY" \
    -H "Content-Type: application/json" \
    -d @-
```

To read again without paying, send the `reuse_id` instead of the file:

```bash
curl https://kenari.id/v1/ocr \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"reuse_id": "ocr_8f286cec-075b-4083-a462-fc89170feb4f"}'
```

### Python

```python
import base64
import os
import requests

with open("invoice.pdf", "rb") as f:
    encoded = base64.b64encode(f.read()).decode()

response = requests.post(
    "https://kenari.id/v1/ocr",
    headers={"Authorization": f"Bearer {os.environ['KENARI_API_KEY']}"},
    json={
        "file": {
            "filename": "invoice.pdf",
            "file_data": f"data:application/pdf;base64,{encoded}",
        }
    },
)
response.raise_for_status()
result = response.json()

print(result["pages"], result["cost_micro_idr"])
print(result["content"][0]["text"])
```

### JavaScript

```js
import { readFile } from "node:fs/promises";

const encoded = (await readFile("invoice.pdf")).toString("base64");

const response = await fetch("https://kenari.id/v1/ocr", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.KENARI_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    file: {
      filename: "invoice.pdf",
      file_data: `data:application/pdf;base64,${encoded}`,
    },
  }),
});
if (!response.ok) throw new Error(await response.text());
const result = await response.json();

console.log(result.pages, result.cost_micro_idr);
console.log(result.content[0].text);
```

## Billing

Reading is billed per page read, from your balance. Plans do not cover it. kenari checks that your balance can cover the document before it reads anything. If it cannot, the request is refused with `402` and nothing is charged, and a failed reading is not charged either. A reuse is free. See [How billing works](/en/docs/billing) for balance and price dimensions.

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | The body has neither `file` nor `reuse_id`, or both. `file_data` is not a base64 data URL, is empty, or is not valid base64. The type is not accepted. The document is over the size limit or the page limit. `engine` is not `ocr`. The `reuse_id` is unknown for your account. |
| 400 | `bad_request` | The key is restricted to certain models and was not granted **Document reading**, or document reading is switched off. A shared API key sent a `reuse_id` that it did not create. |
| 402 | `insufficient_balance` | Your balance cannot cover the document. The message says that document reading is billed per page from your balance and that plans do not cover it, and it suggests sending the file to a model that reads files. It shows the balance available and the most this document can reserve. |
| 503 | `upstream_error` | The reading failed. You are not charged. Retry. |

A body that is not valid JSON, a request without `Content-Type: application/json`, and a `file` object with a missing or wrong-typed `filename` or `file_data` are rejected before the endpoint runs, with a plain-text body and no `code`: `400` for invalid JSON, `415` for the missing content type, and `422` for a missing or wrong-typed field. See [Errors](/en/docs/errors#plain-text-responses).

A key restricted to certain models needs **Document reading** ticked under **Paid capabilities**. See [Authentication and API keys](/en/docs/authentication). See [Errors](/en/docs/errors) for the other codes.

## See also

- [Read documents](/en/docs/documents)
- [Images and files as input](/en/docs/vision)
- [How billing works](/en/docs/billing)
- [Authentication and API keys](/en/docs/authentication)


---

# System One

Ask typed questions about a piece of text, data, an image, audio or video and get one typed answer per question. The Clairic model extracts values instead: you list typed fields and get one typed value per field. System One models are decision and extraction models, not chat models. Because the request is not a chat request, this is kenari's own contract and no OpenAI or Anthropic SDK method maps to it.

`POST /v1/systemone`

Only System One models are served here. Calling it with a chat model returns `400`, and calling a chat endpoint with a System One id returns `400` that points here. List the current System One models with a public call that needs no key:

```bash
curl "https://kenari.id/v1/models?modality=systemone"
```

A plain `GET /v1/models` does not list embedding, rerank, moderation or System One models. It does list chat, image, speech, transcription and video models. The examples below use free models. Swap in an id the call above returns, as long as the model takes what you send. There is no streaming.

## Request

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | A System One model id. |
| `state` | string, object or array | yes | The text or data to evaluate. A `fields` call can leave it out when it sends an image. |
| `questions` | object | yes, or `fields` | One or more questions. Each key is a question id you choose, and the answer comes back under the same key. Up to 128 questions. |
| `fields` | object | yes, or `questions` | Clairic only. The typed fields to extract, in place of `questions`. Send `questions` or `fields`, not both. See [Extract fields](#extract-fields). |
| `reference_date` | string | no | Clairic only. An ISO date, such as `2026-10-25`, that anchors dates written without a year. |
| `options` | object | no | Clairic only. `include_quantiles` adds 99 percentiles to estimates. `return_state_text` returns the text that the `span` positions in the answers refer to, as `state_text`. Both are booleans. |
| `assets` | object | no | Images, each under a reference name you choose. See [Images](#images). |
| `images` | array | no | Images in order, for the models that take this shape. Send it or `assets`, not both. |
| `audio` | array | no | Audio clips, for the models that read audio. See [Audio and video](#audio-and-video). |
| `videos` | array | no | Videos, for the models that read video. See [Audio and video](#audio-and-video). |

The request body can be up to 512 KiB, not counting image, audio and video data. The whole body, that data included, can be up to 32 MiB, and a larger one is rejected with `413`. Base64 adds about a third to the size of a file. Each question is an object:

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | string | yes | `noul`, `choice`, `score` or `box`. |
| `instructions` | string, object or array | yes | The question itself. |
| `criteria` | object or array | no | For `noul`, true and false descriptions. For `choice`, a map of the options. For `score`, an ordered rubric. `box` takes none. |

## Response

The response contains only `model`, `answers` and `usage`, plus `truncated` on a Clairic call and `state_text` when you ask for it. `model` is the versioned id that answered, and falls back to the id you requested. `answers` has one entry per question id, or one per field name for a `fields` call. `usage` reports `input_tokens` and `output_tokens`. A Clairic response is shown under [Extract fields](#extract-fields).

```json
{
  "model": "clef:free",
  "answers": {
    "q": { "type": "noul", "noul": 0.993 }
  },
  "usage": { "input_tokens": 47, "output_tokens": 0 }
}
```

## Images

Some System One models also read images. They list `image` in `modalities.input` in the `?modality=systemone` listing. Sending an image or a `box` question to any other model returns `400`.

Put each image in `assets` under a reference name you choose. Then write that name where the image belongs: in `state`, in a question's `instructions` or `criteria`, or in a field's `instructions`. The name can be the whole string or sit inside text. Object keys and the question `type` are never read as references.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | string | no | `image`. |
| `format` | string | yes | `png`, `jpg` or `jpeg`. It must match the image bytes. |
| `data_base64` | string | yes | The image file in standard base64, without a `data:` prefix. |

The fields below go in the request next to `model`, with an id from that listing:

```json
{
  "assets": {
    "receipt": { "type": "image", "format": "png", "data_base64": "iVBORw0KGgo..." }
  },
  "state": "receipt",
  "questions": {
    "itemized": {
      "type": "noul",
      "instructions": "Does receipt list a price for every item?"
    },
    "total": { "type": "box", "instructions": "The total amount in receipt" }
  }
}
```

A request carries up to 16 images, each at most 16 MiB. A reference name is 1 to 1,024 bytes, and all names together at most 65,536 bytes. Each model also has its own limits on how many images it reads, their size, their pixel dimensions and their file type. A request outside them returns `400` with a message that names the limit.

Some models also take an `images` array instead of `assets`. Each item is a `data:image/png;base64,...` URL, with `image/png`, `image/jpeg` or `image/webp`, or an object with `content_type` and `base64`. The model reads the images in array order. Send `assets` or `images`, not both. A model that reads only `assets` returns `400` for `images`.

### Box questions

A `box` question asks where something is in an image. It takes no `criteria`, and the request needs at least one image. The answer gives each edge of the box as a fraction of the image height or width, from 0 to 1, plus a confidence. Not every image model answers `box` questions.

```json
{ "type": "box", "box": { "top": 0.62, "bottom": 0.7, "left": 0.55, "right": 0.9 }, "confidence": 0.93 }
```

## Audio and video

Clef Omni reads audio and video as well as text and images. Models that read them list `audio` or `video` in `modalities.input` in the `?modality=systemone` listing. Sending audio or video to any other model returns `400`.

Send audio in `audio` and video in `videos`, next to `model`, `state` and `questions`. `state` is still required, so add a short line of text about what you send. Each item is a `data:` URL with base64 content, or an object with `content_type` and `base64`. kenari does not fetch links, so a URL returns `400`.

| Field | Limit | Types |
| --- | --- | --- |
| `audio` | Up to 4 clips, 8 MiB each | `audio/wav` (also `audio/x-wav` and `audio/wave`) or `audio/mpeg` (also `audio/mp3`) |
| `videos` | Up to 2 videos, 16 MiB each | `video/mp4` or `video/webm` |

Audio and video together can be at most 16 MiB per request. Sizes count the file, not its base64 text. The file must be what its type says, a WAV or MP3 for audio and an MP4 or WebM for video, or the request returns `400`.

```bash
curl https://kenari.id/v1/systemone \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "clef-omni:free",
    "state": "A voice note and a short video from the front door.",
    "audio": ["data:audio/wav;base64,UklGRiQAAABXQVZF..."],
    "videos": [{ "content_type": "video/mp4", "base64": "AAAAIGZ0eXBpc29t..." }],
    "questions": {
      "delivered": {
        "type": "noul",
        "instructions": "Was a parcel left at the door?"
      }
    }
  }'
```

The base64 in this example is cut short, so send the whole file. The response has the same shape as for any call with questions.

## Extract fields

Clairic reads a text, and images if you send them, and extracts one typed value per field. Send `fields` in place of `questions`, and send exactly one of the two. Each key in `fields` is a field name you choose, and the answer comes back under the same name. `state` is the text to read. An object or an array is sent on as its JSON text. It can be left out only when the request sends an image, and otherwise it must not be empty.

Each field is an object with a `type` and an optional `instructions` string that says what the field should hold:

| Type | Returns |
| --- | --- |
| `text` | A piece of the state, copied as written. |
| `number` | A number read or worked out from the state. |
| `date`, `time`, `datetime` | A calendar date, a time of day or both, in ISO form. |
| `duration` | A length of time. |
| `list` | Every matching text, number or date, in the order they appear. |
| `estimate` | An estimate of a number or date between the `min` and `max` you set. |
| `forecast` | One estimate for each future step of a series. |

A field name is 1 to 64 characters from A to Z, a to z, 0 to 9, underscore, dot and hyphen, and starts with a letter or underscore. A request holds 1 to 64 fields. kenari sends each field on as you wrote it, so a field also takes the settings of its type, such as `unit`, `integer` and `decimals` for a number, `items` for a list, `min`, `max`, `step`, `scale` and `null_threshold` for an estimate or a forecast, and `horizon` and `interval` for a forecast.

```bash
curl https://kenari.id/v1/systemone \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "clairic:free",
    "state": "Invoice INV-2291, issued 3 March 2026. Total due: Rp 4,250,000.",
    "fields": {
      "invoice_number": { "type": "text", "instructions": "The invoice number" },
      "total": { "type": "number", "instructions": "The total due in rupiah" }
    }
  }'
```

The response keeps `model`, `answers` and `usage`, with one answer per field name. It adds `truncated`, which is `true` when the state was cut to fit the model, and `state_text` when you set `options.return_state_text` to `true`.

```json
{
  "model": "clairic:free",
  "answers": {
    "invoice_number": {
      "type": "text",
      "value": "INV-2291",
      "guess": "INV-2291",
      "confidence": 0.999997,
      "p_null": 0.000056,
      "span": [8, 16]
    },
    "total": {
      "type": "number",
      "value": 4250000,
      "guess": 4250000,
      "confidence": 0.99999,
      "p_null": 0.000019
    }
  },
  "truncated": false,
  "usage": { "input_tokens": 71, "output_tokens": 0 }
}
```

| Key | Meaning |
| --- | --- |
| `type` | The field's type. |
| `value` | The answer, or `null` when the input does not determine it. |
| `guess` | The best candidate. It stays in the answer when `value` is `null`. |
| `confidence` | How sure the model is of the answer. |
| `p_null` | The probability that the input does not determine the value. |
| `span` | For a `text` field, the start and end positions of `guess` in the text that was read. `state_text` returns that text. |

A `list` field adds `spans`, and an `estimate` field adds `median`, `p10` and `p90`. Every value is well formed for its type, but not always correct, so use `confidence` and `p_null` to decide which answers to check by hand.

## Examples

### curl

```bash
curl https://kenari.id/v1/systemone \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "clef:free",
    "state": "a cat sitting on a rug",
    "questions": {
      "q": {
        "type": "noul",
        "instructions": "Is this a sentence about an animal?"
      }
    }
  }'
```

### Python

There is no SDK method for this endpoint, so use `requests`.

```python
import os
import requests

resp = requests.post(
    "https://kenari.id/v1/systemone",
    headers={"Authorization": f"Bearer {os.environ['KENARI_API_KEY']}"},
    json={
        "model": "clef:free",
        "state": "a cat sitting on a rug",
        "questions": {
            "q": {
                "type": "noul",
                "instructions": "Is this a sentence about an animal?",
            }
        },
    },
)
print(resp.json()["answers"])
```

### JavaScript

```js
const resp = await fetch("https://kenari.id/v1/systemone", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.KENARI_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: "clef:free",
    state: "a cat sitting on a rug",
    questions: {
      q: {
        type: "noul",
        instructions: "Is this a sentence about an animal?",
      },
    },
  }),
});
const { answers } = await resp.json();
```

## Billing

System One is billed per input token. Output tokens are reported and not billed. Images, audio and video count as input tokens. The balance held for a request counts tokens for each image from its pixel size, once for every place its reference name appears. For each audio clip and each video it counts a fixed worst case, because kenari does not read how long a clip is. A free model such as `clef:free` charges Rp 0. See [How billing works](/en/docs/billing) for how the estimate, the reservation and the price dimensions work.

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | `model` is unknown or is not a System One model, `state` is missing or is not a string, object or array, `questions` and `fields` are both sent or both missing, `questions` is empty or has more than 128 entries, a question has an unknown `type` or no `instructions`, or the body is over 512 KiB without image, audio and video data. |
| 400 | `bad_request` | The model does not read images, an image is not valid base64 or does not match its `format`, a `box` question has `criteria` or the request has no image, or the request is over the model's image limits. |
| 400 | `bad_request` | The model does not read audio or video, an `audio` or `videos` item is a URL or is not valid base64, its type is not an accepted one or does not match the file, or the request is over the clip limits. |
| 400 | `bad_request` | `fields` is sent to a model other than Clairic, or `questions` is sent to Clairic. A field name or `type` is not valid, `fields` has more than 64 entries, `reference_date` is not a string, an option is not `true` or `false`, or `state` is empty and no image is sent. |
| 400 | `model_not_found` | The model is listed but no provider serves it right now. Retry later. |
| 413 | none | The request body is above 32 MiB. The response is plain text. |

See [Errors](/en/docs/errors) for the shared codes, such as `all_providers_failed`.

## See also

- [Models and pricing](/en/docs/models)
- [How billing works](/en/docs/billing)
- [Free models](/en/docs/free-models)


---

# Web and X search

Search the web, fetch a page, or search live posts on X with a plain HTTP call and no model in the loop. These are the same operations that [Server tools](/en/docs/server-tools) give to a model, and the same operations the [MCP server](/en/docs/mcp) exposes to agents. All three endpoints need an API key.

## Web search

`POST /v1/web/search` returns ranked results for a query.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `query` | string | yes | The search query. It must not be empty. |
| `max_results` | integer | no | How many results to return. The default is 5. A value outside 1 to 10 is moved to the nearest limit, not rejected. A negative value or one above 255 fails parsing with a plain-text `422`. |

The response has a `results` array. Each result has a `title`, a `url` and a `content` snippet. `id` identifies the request and `cost_micro_idr` is the fee charged in micro-Rupiah (Rupiah times 1,000,000). It is `0` when a plan allowance covers the call.

```json
{
  "results": [
    {
      "title": "Announcing Rust 1.87.0",
      "url": "https://blog.rust-lang.org/2025/05/15/Rust-1.87.0.html",
      "content": "The Rust team is happy to announce a new version of Rust..."
    }
  ],
  "id": "req_2c9d0a7e-5f5e-4b6a",
  "cost_micro_idr": 0
}
```

```bash
curl https://kenari.id/v1/web/search \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "latest stable Rust release", "max_results": 5}'
```

## Web fetch

`POST /v1/web/fetch` fetches one page and returns its text.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | string | yes | The page to fetch. It must start with `http://` or `https://` and point to a public host. Addresses on private networks are refused. |

The response has the page `title`, its `content` as plain text, and `links`, a list of the URLs found on the page. `id` and `cost_micro_idr` are as above.

```json
{
  "title": "Announcing Rust 1.87.0",
  "content": "The Rust team is happy to announce a new version of Rust...",
  "links": ["https://www.rust-lang.org/", "https://blog.rust-lang.org/"],
  "id": "req_6b1f4c88-1d27-4e0b",
  "cost_micro_idr": 0
}
```

```bash
curl https://kenari.id/v1/web/fetch \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://blog.rust-lang.org/2025/05/15/Rust-1.87.0.html"}'
```

## X search

`POST /v1/x/search` searches live posts on X and returns a short answer with its sources.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `query` | string | yes | The search query. It must not be empty. |
| `x_search_filter` | object | no | Narrows the search. Unknown fields are refused. |
| `x_search_filter.allowed_x_handles` | array of strings | no | Search only these accounts. At most 20. |
| `x_search_filter.excluded_x_handles` | array of strings | no | Leave these accounts out. At most 20. Cannot be used with `allowed_x_handles`. |
| `x_search_filter.from_date` | string | no | Start of the date range, `YYYY-MM-DD`. |
| `x_search_filter.to_date` | string | no | End of the date range, `YYYY-MM-DD`. It must not be before `from_date`. |
| `x_search_filter.enable_image_understanding` | boolean | no | Read the images inside posts. |
| `x_search_filter.enable_video_understanding` | boolean | no | Read the videos inside posts. |

Handles are written without the `@` and use only letters, digits and underscores, up to 40 characters.

The response has an `answer` and a `citations` array of `{url, title}` objects that point to x.com. `title` can be `null`. `id` and `cost_micro_idr` are as above.

```json
{
  "answer": "People are mostly discussing the new release's faster builds...",
  "citations": [
    {"url": "https://x.com/rustlang/status/1234567890", "title": null}
  ],
  "id": "req_9a40d1b3-7c52-4f1e",
  "cost_micro_idr": 0
}
```

```bash
curl https://kenari.id/v1/x/search \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "reactions to the latest Rust release",
    "x_search_filter": {"from_date": "2026-01-01"}
  }'
```

## Examples from code

There is no SDK method for these endpoints, so call them with `requests` or `fetch`.

### Python

```python
import os
import requests

response = requests.post(
    "https://kenari.id/v1/web/search",
    headers={"Authorization": f"Bearer {os.environ['KENARI_API_KEY']}"},
    json={"query": "latest stable Rust release", "max_results": 3},
)
response.raise_for_status()

for hit in response.json()["results"]:
    print(hit["title"], hit["url"])
```

### JavaScript

```js
const response = await fetch("https://kenari.id/v1/web/search", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.KENARI_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ query: "latest stable Rust release", max_results: 3 }),
});
if (!response.ok) throw new Error(await response.text());

for (const hit of (await response.json()).results) {
  console.log(hit.title, hit.url);
}
```

## Billing

Each successful call is billed a flat fee, separate from tokens, and the fee is returned in `cost_micro_idr`. A call that fails is not charged. A plan can cover search and fetch, and X search is billed from your balance only. See [How billing works](/en/docs/billing) and [Subscriptions](/en/docs/subscriptions) for the rules.

A key restricted to certain models needs **Web search and page fetching** ticked under **Paid capabilities** to call search and fetch, and **X search** ticked to call X search. See [Authentication and API keys](/en/docs/authentication).

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | `query` or `url` is empty. `x_search_filter` is invalid, and the message names the field. The `url` is not a public http or https address. The search found nothing, or the page could not be fetched. The key is restricted and was not granted the capability. The feature is switched off. |
| 402 | `insufficient_balance` | Search or fetch: the daily allowance of your plan is used up or you have no plan, and your balance cannot cover the fee. X search: your balance cannot cover the fee. |
| 429 | `rate_limit_exceeded` | A shared API key is calling too fast. Wait and retry. |
| 503 | `upstream_error` | X search failed to return a usable answer. You are not charged. Retry. |

A body that is not valid JSON, a request without `Content-Type: application/json`, and a missing or wrong-typed `query` or `url` are rejected before the endpoint runs, with a plain-text body and no `code`: `400` for invalid JSON, `415` for the missing content type, and `422` for a missing or wrong-typed field. Only an empty string reaches `bad_request`. See [Errors](/en/docs/errors#plain-text-responses).

Running out of balance on these endpoints returns `402`, the same as every other endpoint, and retrying does not help until you top up. See [Errors](/en/docs/errors) for the other codes.

## See also

- [Server tools](/en/docs/server-tools)
- [MCP server](/en/docs/mcp)
- [How billing works](/en/docs/billing)
- [Authentication and API keys](/en/docs/authentication)


---

# Errors

kenari returns errors in the same format as the API you called. Handle an error by its HTTP status and its `code`, or `error.type` on Messages, never by the `message` text. Most messages are in English and a few are in Indonesian, and the wording can change.

## Error formats

### OpenAI format

Every endpoint except Messages returns this body: Chat completions, Responses, Embeddings, Rerank, Moderations, Images, audio, music, videos, OCR, web and X search, models and account quota.

```json
{
  "error": {
    "message": "free-model rate limit reached; slow down and retry",
    "type": "rate_limit_exceeded",
    "param": null,
    "code": "free_quota_rpm"
  }
}
```

`code` is the field to branch on. `type` repeats `code` for most errors. The exceptions are the free-model limits (`type` is `rate_limit_exceeded`), provider rejections (`type` is `invalid_request_error`), `shared_key_not_allowed`, `restricted_key_not_allowed` and `cli_login_key_required` (`type` is `permission_error`) and an internal failure on `GET /v1/account/balance`, `GET /v1/account/quota` and the [Usage](/en/docs/usage) endpoints (`type` is `api_error`, `code` is `internal_error`). `param` is always `null`, except on a `400` from the Usage endpoints, where it names the parameter.

### Anthropic format

[Messages](/en/docs/messages), including `count_tokens`, returns Anthropic-style errors. There is no `code` field, so branch on the HTTP status and `error.type`.

```json
{
  "type": "error",
  "error": {
    "type": "rate_limit_error",
    "message": "free-model rate limit reached; slow down and retry"
  }
}
```

| `error.type` | HTTP status |
| --- | --- |
| `invalid_request_error` | 400, or the provider's own status for a provider rejection |
| `not_found_error` | 400, or the provider's own status for a provider rejection |
| `payment_required` | 402 |
| `permission_error` | 403 |
| `rate_limit_error` | 429 |
| `api_error` | 400, 500 and 503 |

The HTTP status is the same as in the OpenAI format for the same failure. A provider rejection keeps the provider's own status, so these two types are not always 400. See [Provider rejections](#provider-rejections). `api_error` arrives with 400 when a provider answered with a client error that kenari did not classify.

The [Claude Managed Agents](/en/docs/claude-managed-agents) passthrough uses this envelope as well, with a `code` on the errors kenari raises itself, such as `auth_failed`. An error from Anthropic passes through unchanged and has no `code`. Branch on `code` when it is there and on `error.type` when it is not.

### Errors in a stream

After a stream has started, the HTTP status is already 200, so a failure arrives as an event in the stream. Check every stream for these events. See [Streaming](/en/docs/streaming).

| API format | How the error arrives |
| --- | --- |
| Chat completions | A `data:` chunk with an `error` object whose `type` is `upstream_error` |
| Messages | An `event: error` event with the Anthropic body above |
| Responses | A `response.failed` event, with the error in `response.error` |

On Chat completions and Responses, `code` is `stream_interrupted` when the provider stopped before the answer was complete. The Messages event has `error.type` and no `code`. Send the request again as a new request. A provider rejection in the stream uses the codes under [Provider rejections](#provider-rejections).

### Plain-text responses

Some responses are plain text, not JSON, because the request is rejected before it reaches the API.

| Status | Body | Cause |
| --- | --- | --- |
| 400 | `Failed to parse the request body as JSON...` | On `/v1/web/search` and `/v1/ocr`, the body is not valid JSON |
| 401 | `missing authorization` | No key header was sent |
| 401 | `invalid key` | The key does not start with `kn-`, does not exist, was revoked, has expired, or belongs to an account that is suspended or being deleted |
| 408 | `request body timed out` | The body stopped arriving for 30 seconds, or took more than 5 minutes to arrive. Send it again |
| 413 | `request body too large` | The request body is over 32 MiB |
| 415 | `Expected request with Content-Type: application/json` | On `/v1/web/search` and `/v1/ocr`, the `Content-Type: application/json` header is missing |
| 422 | `Failed to deserialize the JSON body...` | On `/v1/web/search` and `/v1/ocr`, a required field is missing or has the wrong type |
| 429 | `too many requests, try again shortly` | One client IP sent too many requests on a shared key. See [Rate limits](/en/docs/rate-limits). |
| 500 | `db error` | kenari could not look up the key. Retry. |
| 503 | `server busy, try again shortly` | kenari is receiving too many large request bodies at once. Retry shortly |

The [Claude Managed Agents](/en/docs/claude-managed-agents) passthrough answers differently. Its 401, its 429 on a shared key and its `db error` arrive as JSON in the Anthropic format with the code `auth_failed`. A 500 for any other failure inside kenari is the plain text `internal error`.

For keys, see [Authentication and API keys](/en/docs/authentication).

## Error codes

### 400 Bad request

| Code | When | What to do |
| --- | --- | --- |
| `bad_request` | The body or a parameter is invalid, or the key's restrictions refuse the request (model not allowed, capability not granted, route on a restricted key). An attached document with an unsupported type, a size or page count over the limit, or a length over the model's context is also refused here. On Embeddings, Rerank, Moderations and System One, a `model` that is unknown or of another kind is refused here too, and the message lists the ids that are available. Creating a per-tool key is refused here with `key limit reached` when your account is at its key limit | Read `message`, fix the request. |
| `model_not_found` | On Chat completions, Responses and Messages, the model id or route name does not exist, or you cannot use it. On Embeddings, Rerank, Moderations and System One, the model is listed but no provider serves it right now | Pick an id from `GET /v1/models`, or retry later. |
| `request_blocked` | kenari refused the request under its content policy before it reached a provider | Change the content of the request. |
| `byok_malformed_model_id` | A `byok/` model id is not in the form `byok/<provider>/<model>` | Fix the id. See [Providers](/en/docs/byok). |
| `byok_provider_not_found` | You have no key or connection for that provider | Add the key or connect the subscription, or fix the provider name. |
| `invalid_path` | A path on the [Claude Managed Agents](/en/docs/claude-managed-agents) passthrough contains `..`, an encoded slash or a backslash | Send a normal path. |
| `upstream_error` | A provider answered with a client error that kenari did not classify | Check the model and the request fields, or try another model. |

### Provider rejections

These codes mean the model's provider rejected the request itself. The HTTP status is 400, or the provider's own 403, 404, 413, 415, 422 or 429, and `type` is `invalid_request_error`.

| Code | When | What to do |
| --- | --- | --- |
| `invalid_request_error` | The provider rejected the request as malformed or unsupported | Send the same request to another model. If it works there, report it to kenari support. |
| `context_length_exceeded` | The prompt plus `max_tokens` exceed the model's context window | Shorten the input, lower `max_tokens`, or use a model with a longer context. |
| `model_not_found` | The provider does not serve the model right now | Try again later or use another model. |
| `content_policy_violation` | The provider blocked the request under its content policy | Change the content of the request. |
| `prefill_unsupported` | The last message is from the assistant, and the model cannot continue it | End the conversation with a `user` or `tool` message. |
| `reference_image_fetch_failed` | A video request carried a reference image the provider could not fetch | Send each reference as a public https URL or a data URL. |
| `upstream_rejected` | The provider rejected the request for a reason kenari could not classify | Check the model id and the request fields. |

kenari does not retry a rejected request on the same provider. In a route or a BYOK chain it can move on to the next step, because one provider's rejection does not prove the next one will reject the same request, but a context length rejection ends the chain. The `message` is written by kenari and never contains the provider's own text.

### 401 Unauthorized

The body is plain text. See [Plain-text responses](#plain-text-responses). Check the key, or create a new one in the dashboard.

The [Claude Managed Agents](/en/docs/claude-managed-agents) passthrough answers in JSON instead, with this code:

| Code | When | What to do |
| --- | --- | --- |
| `auth_failed` | The `kn-` key is missing, wrong, expired or revoked | Check the key, or create a new one in the dashboard. |

### 402 Payment required

| Code | When | What to do |
| --- | --- | --- |
| `insufficient_balance` | Your balance cannot cover the request or the fee of a web search, page fetch or X search, or the owner of a shared API key has run out of funds | Top up, or send a shorter prompt or a lower `max_tokens`. The message shows your available balance and the most this request could cost, which is the input estimate plus `max_tokens` at the output rate. A document read with the `ocr` engine or `/v1/ocr` has its own message: it says that document reading is billed per page from your balance and not covered by plans, and shows the balance available and the most the document reserves. Top up, or send the file without the `ocr` engine so that it is read as ordinary input tokens. |
| `payg_limit_reached` | You reached the PAYG balance limit you set in your subscription settings. It covers a rolling day or 30 days | Raise or remove the limit, or wait for the window to roll. Plan quota and free models are not affected. |
| `key_spend_cap_reached` | The API key's spend cap or token cap is used up. Your balance is not the problem | The key's owner raises the spend cap or resets its spend on the API Keys page (an unshared key with a spend cap), or uses another key. A token cap and a shared key's caps cannot be edited, so the owner creates a new key. The owner also gets a notification the first time the cap runs out. |
| `member_spend_cap_reached` | You are a member of a team workspace and you reached the spending limit its Owner or an Admin set for you. The limit counts what you draw from the workspace balance over a rolling 30 days: PAYG requests, use beyond your seat's quota, web search, web fetch, X search, images and video. Before that point kenari lowers `max_tokens` to what your remaining limit can pay for, the same way it does for a low balance | Ask the workspace Owner or an Admin to raise or remove your limit on the **Team** page, or wait for the 30-day window to roll. Your seat's quota and free models are not affected. You also get a notification the first time you reach the limit in a window. |

See [How billing works](/en/docs/billing).

### 403 Forbidden

| Code | When | What to do |
| --- | --- | --- |
| `subscribers_only` | The model is only for accounts with an active subscription | Subscribe, or pick another model. See [Subscriptions](/en/docs/subscriptions). |
| `subscription_required` | A `byok/chatgpt/...` or `byok/grok/...` request, or a request to the [Claude Managed Agents](/en/docs/claude-managed-agents) passthrough, from an account without an active plan | Subscribe. See [Providers](/en/docs/byok#members-only). |
| `byok_credential_unavailable` | You have keys or connections for that provider, but none can serve the request right now because they are disabled, paused or signed out | Enable the key, wait for a paused one to recover, or connect the subscription again. |
| `byok_spend_capped_key` | A key with a spend cap called a `byok/` model | Use a key without a spend cap. A token cap is allowed. |
| `shared_key_not_allowed` | A shared API key called `GET /v1/account/balance`, `GET /v1/account/quota`, a [Usage](/en/docs/usage) endpoint, a [Per-tool keys](/en/docs/account-keys) endpoint or the [Claude Managed Agents](/en/docs/claude-managed-agents) passthrough | Use a key from the account itself. |
| `restricted_key_not_allowed` | A restricted key called `GET /v1/account/balance`, `GET /v1/account/quota`, a [Per-tool keys](/en/docs/account-keys) endpoint or the [Claude Managed Agents](/en/docs/claude-managed-agents) passthrough | Use a key with no model restriction, spend cap or token cap. The key's own usage is still readable from [`GET /v1/usage`](/en/docs/usage). See [Account overview](/en/docs/account). |
| `cli_login_key_required` | A key other than the one from `kenari login` called a [Per-tool keys](/en/docs/account-keys) endpoint | Use the key from `kenari login`. Run `kenari login` again if your key is older than per-tool keys. |

### 404 Not found

| Code | When | What to do |
| --- | --- | --- |
| `not_found` | `DELETE /v1/account/keys/{id}` got an id that is not an active per-tool key of the calling key | List your keys with `GET /v1/account/keys` and use an id from it. See [Per-tool keys](/en/docs/account-keys). |
| `credential_not_found` | The `x-kenari-credential` header on the [Claude Managed Agents](/en/docs/claude-managed-agents) passthrough is not an active Anthropic key of your account | Use the id of an active Anthropic key. |

### 409 Conflict

| Code | When | What to do |
| --- | --- | --- |
| `child_key_cap` | The login key already holds the most per-tool keys it can | Revoke one with `DELETE /v1/account/keys/{id}`, then create the new key. See [Per-tool keys](/en/docs/account-keys). |
| `anthropic_credential_required` | The [Claude Managed Agents](/en/docs/claude-managed-agents) passthrough found no active Anthropic API key on your account | Add an Anthropic key on the **Providers** tab. |
| `credit_limit_reached` | The Anthropic key named in `x-kenari-credential` on the passthrough has used up its Claude credit, and auto-stop is on | Raise the credit, turn auto-stop off or wait for the renewal day. See [Providers](/en/docs/byok#credit-estimate-and-auto-stop). |
| `credential_required` | The passthrough found two or more active Anthropic keys and no `x-kenari-credential` header | Name one key in the header, or disable the others. |

### 413 Payload too large

A request body over 32 MiB is refused with a plain-text response, not JSON. See [Plain-text responses](#plain-text-responses). A 413 from a model's provider is a JSON error. See [Provider rejections](#provider-rejections). The code below is for the [Claude Managed Agents](/en/docs/claude-managed-agents) passthrough, which accepts bodies up to 100 MB.

| Code | When | What to do |
| --- | --- | --- |
| `request_too_large` | A request body on the [Claude Managed Agents](/en/docs/claude-managed-agents) passthrough is over 100 MB | Send a smaller body. |

### 429 Too many requests

Every 429 code is explained, with `Retry-After` behavior, in [Rate limits](/en/docs/rate-limits).

| Code | When |
| --- | --- |
| `rate_limit_exceeded` | The account per-minute limit, the usage reads limit, the per-tool key creation limit, a burst of free requests, a free-model token budget, or music concurrency |
| `free_quota_rpm` | The free-model per-minute limit |
| `free_quota_daily` | The free-model daily quota, which resets at 00:00 UTC |
| `plan_limit_reached` | A subscription quota window is used up, and PAYG and the free fallback are off |
| `upstream_error` | A provider rate limited the request |
| `passthrough_busy` | Too many [Claude Managed Agents](/en/docs/claude-managed-agents) passthrough requests are in progress. Try again shortly |

### 500 Internal server error

| Code | When | What to do |
| --- | --- | --- |
| `internal_error` | Something unexpected failed in kenari | Retry once. If it keeps happening, report the `request-id` header to kenari support. |

### 503 Service unavailable

| Code | When | What to do |
| --- | --- | --- |
| `upstream_error` | A provider timed out, could not be reached, or failed | Retry with backoff. |
| `all_providers_failed` | Every provider, or every step of a route, failed. `Retry-After` is set when the providers are paused or at capacity | Retry after the `Retry-After` seconds, or use a route with a fallback step. See [Routing and routes](/en/docs/routing). |
| `no_wire_for_modality` | No provider currently serves this kind of request for the model, such as chat, image, edit or embedding | Try again later, or use another model. |

## Provider names

Error messages never name the provider behind a model. kenari writes every message itself.

## Long requests

A non-streaming request that runs for about two minutes with no response can be cut off by the network edge before the answer reaches you. For long prompts, such as more than 100,000 tokens, large `max_tokens`, or slow reasoning models, set `"stream": true`. A stream keeps the connection open while the model works. See [Streaming](/en/docs/streaming).

When a non-streaming request is still running after a long wait, kenari can keep the connection open by sending spaces before the JSON body. JSON parsers ignore the spaces. The status line is already 200 by then, so check the body for an `error` object before you read the answer.

## Handle errors

1. On OpenAI-format APIs, use the HTTP status and `code`. On Messages, use the HTTP status and `error.type`. Treat `message` as text for people.
2. Retry `429` with `rate_limit_exceeded`, `free_quota_rpm` or `upstream_error`, and retry `500` and `503`. Wait for `Retry-After` when it is present, and use exponential backoff with jitter when it is not.
3. Do not retry other `4xx` errors unchanged. Fix the request, the key or the balance first.
4. For `402`, top up or change the limit that was reached, then send the request again.
5. Check streams for error events, and do not assume every error body is JSON. See [Plain-text responses](#plain-text-responses).
6. Log the `request-id` response header, and include it when you contact kenari support.

## See also

- [Rate limits](/en/docs/rate-limits)
- [Authentication and API keys](/en/docs/authentication)
- [Streaming](/en/docs/streaming)
- [How billing works](/en/docs/billing)


---

# Account overview

Three read-only endpoints tell you the state of your account from code. Use them to check before a long job, to build a spending dashboard, or to give an agent a way to watch its own budget. Reading is free.

## Which endpoint to call

| Question | Endpoint |
| --- | --- |
| How much balance can I spend right now? | [Account balance](/en/docs/account-balance): `GET /v1/account/balance` |
| How much plan quota is left, and when does it reset? | [Account quota](/en/docs/account-quota): `GET /v1/account/quota` |
| What did I spend, and on what? | [Usage](/en/docs/usage): `GET /v1/usage`, `/v1/usage/daily` and `/v1/usage/log` |

## Which keys can read what

| Key | Balance | Quota | Usage |
| --- | --- | --- | --- |
| Unrestricted key | Whole account | Whole account | Whole account |
| Restricted key | Refused | Refused | Only its own usage |
| Shared API key | Refused | Refused | Refused |

A restricted key is one with a model restriction, a spend cap or a token cap. A refused call returns `403` with `restricted_key_not_allowed` for a restricted key and `shared_key_not_allowed` for a shared key. A restricted key still reads its own usage from `GET /v1/usage`. To read all three, use an unrestricted key from the account itself. See [Authentication and API keys](/en/docs/authentication).

## Per-tool keys

To give each tool its own key, use [Per-tool keys](/en/docs/account-keys): `POST`, `GET` and `DELETE` on `/v1/account/keys`. Only the key from `kenari login` can call them, and a revoked login key takes its per-tool keys with it.

## From an MCP client

The [MCP server](/en/docs/mcp) has one tool for each question: `kenari_balance`, `kenari_quota` and `kenari_usage`. They follow the same rules. For a restricted key, `kenari_usage` is scoped to that key, and the other two are refused.

## See also

- [Account balance](/en/docs/account-balance)
- [Account quota](/en/docs/account-quota)
- [Usage](/en/docs/usage)
- [Per-tool keys](/en/docs/account-keys)
- [MCP server](/en/docs/mcp)


---

# Account balance

Read your prepaid balance, how much of it is held by requests that are still running, and how much a new request can use. Call it from a script or an agent before a long job. The endpoint is specific to kenari and has no OpenAI or Anthropic equivalent.

`GET /v1/account/balance`

## Request

The endpoint takes no query parameters and no body. It needs an API key in the `Authorization` header.

| Header | Required | Description |
| --- | --- | --- |
| `Authorization` | yes | `Bearer kn-...`. Only an unrestricted key of your own account works. A shared API key and a restricted key are refused. See [Who can call it](#who-can-call-it). |

## Response

All amounts are integers in micro-Rupiah, where Rp 1 is 1,000,000 micro-Rupiah. See [How billing works](/en/docs/billing).

| Field | Type | Description |
| --- | --- | --- |
| `object` | string | Always `account.balance`. |
| `balance_micro_idr` | integer | Your balance. |
| `reserved_micro_idr` | integer | The amount held by requests that are still running. |
| `available_micro_idr` | integer | `balance_micro_idr` minus `reserved_micro_idr`, never below 0. This is what a new request can reserve against. |
| `currency` | string | Always `IDR`. |

Before a request runs, kenari holds its worst-case cost. When the request finishes, the hold is released and only the real cost is charged. So `reserved_micro_idr` goes up while requests run and returns to 0 when they are done. Use `available_micro_idr` to decide whether a new request fits. `balance_micro_idr` can be negative if a finished request cost more than its hold, while `available_micro_idr` stays at 0 in that case.

```json
{
  "object": "account.balance",
  "balance_micro_idr": 25000000000,
  "reserved_micro_idr": 1500000,
  "available_micro_idr": 24998500000,
  "currency": "IDR"
}
```

This balance is Rp 25,000, with Rp 1.5 held by a request in flight.

## Who can call it

Any API key of your account can call it, except:

- A shared API key gets `403` and `shared_key_not_allowed`.
- A restricted key gets `403` and `restricted_key_not_allowed`. A key is restricted when it has a model restriction, a spend cap or a token cap. The key's own usage is still readable from [`GET /v1/usage`](/en/docs/usage).

Use an unrestricted key from the account itself. See [Authentication and API keys](/en/docs/authentication).

## Examples

### curl

```bash
curl https://kenari.id/v1/account/balance \
  -H "Authorization: Bearer $KENARI_API_KEY"
```

### Python

```python
import os
import requests

response = requests.get(
    "https://kenari.id/v1/account/balance",
    headers={"Authorization": f"Bearer {os.environ['KENARI_API_KEY']}"},
)
response.raise_for_status()
balance = response.json()

print(balance["available_micro_idr"] / 1_000_000)
```

### JavaScript

```javascript
const response = await fetch("https://kenari.id/v1/account/balance", {
  headers: { Authorization: `Bearer ${process.env.KENARI_API_KEY}` },
});
if (!response.ok) throw new Error(await response.text());
const balance = await response.json();

console.log(balance.available_micro_idr / 1_000_000);
```

### Check before a batch

This script stops when the available balance is below what the batch needs. The estimate is yours to choose.

```python
import os
import requests

NEEDED_IDR = 50_000

response = requests.get(
    "https://kenari.id/v1/account/balance",
    headers={"Authorization": f"Bearer {os.environ['KENARI_API_KEY']}"},
)
response.raise_for_status()
available_idr = response.json()["available_micro_idr"] / 1_000_000

if available_idr < NEEDED_IDR:
    raise SystemExit(f"Only Rp {available_idr:,.0f} available. Top up first.")

# Start the batch here.
```

## MCP

The MCP tool `kenari_balance` returns your balance as plain text, in whole Rupiah. See [MCP server](/en/docs/mcp).

## Billing

Reading your balance is free and has no rate limit of its own. See [How billing works](/en/docs/billing).

## Errors

| Status | Code | When |
| --- | --- | --- |
| 401 | None | The key is missing, invalid or expired. The body is a short plain-text message, not JSON. |
| 403 | `shared_key_not_allowed` | The key is a shared API key. |
| 403 | `restricted_key_not_allowed` | The key has a model restriction, a spend cap or a token cap. The key's own usage is still readable from [`GET /v1/usage`](/en/docs/usage). |
| 500 | `internal_error` | Reading the balance failed in kenari. Retry once. |

See [Errors](/en/docs/errors) for the error format and the remaining codes.

## See also

- [Account overview](/en/docs/account)
- [Account quota](/en/docs/account-quota)
- [How billing works](/en/docs/billing)
- [MCP server](/en/docs/mcp)


---

# Account quota

Read how much of your plan quota and coupon budget is left, in whole Rupiah, so an agent or script can check before it starts a long job. The endpoint is specific to kenari and has no OpenAI or Anthropic equivalent.

`GET /v1/account/quota`

## Request

The endpoint takes no query parameters and no body. It needs an API key in the `Authorization` header.

| Header | Required | Description |
| --- | --- | --- |
| `Authorization` | yes | `Bearer kn-...`. A shared API key and a restricted key are refused. See [Authentication and API keys](/en/docs/authentication). |

## Response

The response has three fields, `plan`, `coupon` and `alert_pct`. `plan` and `coupon` are each an object, or `null` when they do not apply.

| Field | Type | Description |
| --- | --- | --- |
| `plan` | object or null | Your active plan. `null` when you have never subscribed, the plan has ended, or the plan is no longer offered. |
| `plan.name` | string | The plan name. |
| `plan.windows` | object | The quota windows that have a ceiling. Possible keys are `five_hour`, `week` and `month`. A window with no ceiling is left out, so the object can be empty. |
| `plan.windows.<key>.budget_rp` | integer | The ceiling of this window in whole Rupiah, rounded down. |
| `plan.windows.<key>.used_rp` | integer | Rupiah used in this window, rounded up. |
| `plan.windows.<key>.remaining_rp` | integer | Rupiah left in this window, rounded down and never below 0. |
| `plan.windows.<key>.used_frac` | number | The fraction of the ceiling that is used, from 0 to 1 with 4 decimals. |
| `plan.windows.<key>.resets_at` | string | When this rolling window resets, in RFC 3339 UTC. |
| `coupon` | object or null | The active coupon. `null` when you have none or it has expired. It is reported separately from the plan, so you can have a coupon without a plan. |
| `coupon.name` | string | The coupon name. |
| `coupon.used_rp` | integer | Rupiah used from the coupon, rounded up. |
| `coupon.remaining_rp` | integer or null | Rupiah left on the coupon: the budget rounded down to whole Rupiah, minus `used_rp`, and never below 0. `null` when the coupon has no spending limit. |
| `coupon.expires_at` | string | When the coupon expires, in RFC 3339 UTC. |
| `coupon.scope_models` | array of strings | The model ids the coupon covers. |
| `alert_pct` | integer | Your quota alert threshold: 50, 80 or 90. Compare it with `used_frac * 100` to see when the quota alert fires. You set it under **Settings**, **Notifications**. See [Notifications](/en/docs/notifications#thresholds). |

```json
{
  "plan": {
    "name": "Indie",
    "windows": {
      "week": {
        "budget_rp": 75000,
        "used_rp": 12000,
        "remaining_rp": 63000,
        "used_frac": 0.16,
        "resets_at": "2026-10-13T07:12:00Z"
      },
      "month": {
        "budget_rp": 300000,
        "used_rp": 12000,
        "remaining_rp": 288000,
        "used_frac": 0.04,
        "resets_at": "2026-10-30T07:12:00Z"
      }
    }
  },
  "coupon": null,
  "alert_pct": 80
}
```

For how windows, coupons and covered models work, see [Subscriptions](/en/docs/subscriptions).

## Examples

### curl

```bash
curl https://kenari.id/v1/account/quota \
  -H "Authorization: Bearer $KENARI_API_KEY"
```

### Python

```python
import os
import requests

response = requests.get(
    "https://kenari.id/v1/account/quota",
    headers={"Authorization": f"Bearer {os.environ['KENARI_API_KEY']}"},
)
response.raise_for_status()
quota = response.json()

for name, window in (quota["plan"] or {}).get("windows", {}).items():
    alert = window["used_frac"] * 100 >= quota["alert_pct"]
    print(name, window["remaining_rp"], window["used_frac"], window["resets_at"], "ALERT" if alert else "")
```

### JavaScript

```javascript
const response = await fetch("https://kenari.id/v1/account/quota", {
  headers: { Authorization: `Bearer ${process.env.KENARI_API_KEY}` },
});
if (!response.ok) throw new Error(await response.text());
const quota = await response.json();

for (const [name, window] of Object.entries(quota.plan?.windows ?? {})) {
  const alert = window.used_frac * 100 >= quota.alert_pct;
  console.log(name, window.remaining_rp, window.used_frac, window.resets_at, alert ? "ALERT" : "");
}
```

## MCP

The MCP tool `kenari_quota` returns the same plan and coupon data as plain text. See [MCP server](/en/docs/mcp).

## Billing

Reading your quota is free. See [How billing works](/en/docs/billing).

## Headers on chat replies

A successful chat reply can also carry the remaining fraction of each window in the `x-kenari-plan-remaining` header, so an agent does not need a separate call. See [Notifications](/en/docs/notifications#response-headers).

## Errors

| Status | Code | When |
| --- | --- | --- |
| 401 | None | The key is missing, invalid or expired. The body is a short plain-text message, not JSON. |
| 403 | `shared_key_not_allowed` | The key is a shared API key. A shared key cannot read the owner's quota. |
| 403 | `restricted_key_not_allowed` | The key has a model restriction, a spend cap or a token cap. Use an unrestricted key from the account itself. The key's own usage is still readable from [`GET /v1/usage`](/en/docs/usage). |
| 500 | `internal_error` | Reading the quota failed in kenari. Retry once. |

See [Errors](/en/docs/errors) for the error format and the remaining codes.

## See also

- [Account overview](/en/docs/account)
- [Account balance](/en/docs/account-balance)
- [Subscriptions](/en/docs/subscriptions)
- [Notifications](/en/docs/notifications)
- [MCP server](/en/docs/mcp)
- [Usage](/en/docs/usage)
- [Authentication and API keys](/en/docs/authentication)


---

# Per-tool keys

Create, list and revoke per-tool keys. A per-tool key is a normal kenari key that belongs to one tool on one machine, for example OpenCode on your laptop. If one tool's config leaks, you revoke that one key and the rest keep working. The endpoints are specific to kenari and have no OpenAI or Anthropic equivalent.

`POST /v1/account/keys`, `GET /v1/account/keys` and `DELETE /v1/account/keys/{id}`

## Who can call it

Only the key that `kenari login` saved can manage per-tool keys. See [kenari CLI](/en/docs/cli). Any other key gets `403` and `cli_login_key_required`, including:

- A key you created under **API Keys** in the dashboard, and the key from `kenari login --api-key`.
- A per-tool key. A tool cannot create more keys for itself.
- A key from `kenari login` that you created before per-tool keys existed. Run `kenari login` again to get one that works.

A shared API key gets `shared_key_not_allowed`, and a restricted key gets `restricted_key_not_allowed`. A key is restricted when it has a model restriction, a spend cap or a token cap. See [Account overview](/en/docs/account).

Each call needs the key in the `Authorization` header as `Bearer kn-...`.

## Create a key

`POST /v1/account/keys` creates a key under the key you call it with.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `tool` | string | Yes | The tool the key is for, such as `opencode`. 1 to 40 characters of `a-z`, `0-9`, `.` and `-`. |
| `host` | string | Yes | The machine the tool runs on, such as `laptop`. 1 to 64 characters of `A-Z`, `a-z`, `0-9`, `.`, `_` and `-`. |

The response is `201` with the new key:

```json
{
  "id": "e72755dd-ea6b-4d93-9e96-eb4c817824d0",
  "key": "kn-984e1408144f2e604c38d27e6596638ec57882ad022a4fdd",
  "prefix": "kn-984e1408",
  "label": "kenari-cli · opencode · laptop",
  "created_at": 1790000000
}
```

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | The id to use when you revoke the key. |
| `key` | string | The full key. It is shown once, so store it now. |
| `prefix` | string | The first characters of the key, to tell keys apart. |
| `label` | string | `kenari-cli · <tool> · <host>`. The same label shows next to the key under **API Keys** in the dashboard. |
| `created_at` | integer | When the key was created, in epoch seconds. |

A per-tool key works like the key it came from for every model and endpoint, except that it cannot manage keys. It follows the same billing as your other keys.

## List keys

`GET /v1/account/keys` takes no parameters. It returns the active per-tool keys of the key you call it with, newest first. Revoked keys are not listed, and the full key is never returned again.

```json
{
  "object": "list",
  "data": [
    {
      "id": "e72755dd-ea6b-4d93-9e96-eb4c817824d0",
      "prefix": "kn-984e1408",
      "label": "kenari-cli · opencode · laptop",
      "created_at": 1790000000,
      "last_used_at": 1790003600
    }
  ]
}
```

`last_used_at` is when the key last made a request, in epoch seconds. It is `null` when no use is recorded in the last 90 days.

## Revoke a key

`DELETE /v1/account/keys/{id}` revokes one per-tool key for good. The next request that uses it gets a `401`.

```json
{
  "id": "e72755dd-ea6b-4d93-9e96-eb4c817824d0",
  "revoked": true
}
```

The `id` must belong to a per-tool key of the key you call it with. Any other id returns `404` and `not_found`, whether the key does not exist, is already revoked, or belongs to someone else.

## Limits

- One login key can hold 30 active per-tool keys. Revoke one to create another.
- Per-tool keys count toward your account's key limit, the same as any other key.
- Each login key can create 20 keys per minute.

## Revoking the login key

When you revoke the key from `kenari login` under **API Keys** in the dashboard, all its per-tool keys are revoked with it. Revoking a single per-tool key does not affect the login key or the other per-tool keys. See [Authentication and API keys](/en/docs/authentication).

## Examples

### curl

```bash
# Create a key
curl https://kenari.id/v1/account/keys \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tool": "opencode",
    "host": "laptop"
  }'

# List keys
curl https://kenari.id/v1/account/keys \
  -H "Authorization: Bearer $KENARI_API_KEY"

# Revoke a key
curl -X DELETE https://kenari.id/v1/account/keys/e72755dd-ea6b-4d93-9e96-eb4c817824d0 \
  -H "Authorization: Bearer $KENARI_API_KEY"
```

Here `$KENARI_API_KEY` is the key from `kenari login`.

### Python

```python
import os
import requests

headers = {"Authorization": f"Bearer {os.environ['KENARI_API_KEY']}"}

created = requests.post(
    "https://kenari.id/v1/account/keys",
    headers=headers,
    json={"tool": "opencode", "host": "laptop"},
)
created.raise_for_status()
tool_key = created.json()
print(tool_key["label"])

requests.delete(
    f"https://kenari.id/v1/account/keys/{tool_key['id']}",
    headers=headers,
).raise_for_status()
```

### JavaScript

```javascript
const headers = { Authorization: `Bearer ${process.env.KENARI_API_KEY}` };

const created = await fetch("https://kenari.id/v1/account/keys", {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({ tool: "opencode", host: "laptop" }),
});
if (!created.ok) throw new Error(await created.text());
const toolKey = await created.json();
console.log(toolKey.label);

const revoked = await fetch(`https://kenari.id/v1/account/keys/${toolKey.id}`, {
  method: "DELETE",
  headers,
});
if (!revoked.ok) throw new Error(await revoked.text());
```

## Billing

Creating, listing and revoking keys is free. Requests made with a per-tool key are billed like any other request. See [How billing works](/en/docs/billing).

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | The body is not JSON with string fields `tool` and `host`, or one of them has characters or a length that is not allowed. Also when your account is at its key limit, with the message `key limit reached`. |
| 401 | None | The key is missing, invalid, revoked or expired. The body is a short plain-text message, not JSON. |
| 403 | `cli_login_key_required` | The key is not the one from `kenari login`. |
| 403 | `shared_key_not_allowed` | The key is a shared API key. |
| 403 | `restricted_key_not_allowed` | The key has a model restriction, a spend cap or a token cap. |
| 404 | `not_found` | On revoke, the id is not an active per-tool key of the key you called with. |
| 409 | `child_key_cap` | On create, the login key already holds 30 active per-tool keys. |
| 429 | `rate_limit_exceeded` | On create, more than 20 requests in one minute. There is no `Retry-After` header. Wait a few seconds and retry. |
| 500 | `internal_error` | Something failed in kenari. Retry once. |

See [Errors](/en/docs/errors) for the error format and the remaining codes.

## See also

- [Account overview](/en/docs/account)
- [kenari CLI](/en/docs/cli)
- [Authentication and API keys](/en/docs/authentication)
- [Errors](/en/docs/errors)


---

# Usage

Read your account usage and cost from a script or an agent: totals per model, a series per day, or the log of single requests. These endpoints are specific to kenari and have no OpenAI or Anthropic equivalent. They return the same figures as the **Usage** page in the dashboard.

```text
GET /v1/usage
GET /v1/usage/daily
GET /v1/usage/log
```

All three need an API key in the `Authorization` header. A key without restrictions reads the usage of the whole account, with every API key on it. See [Who can read what](#who-can-read-what).

## Request

All three endpoints take query parameters and no body. Every parameter is optional. An unknown parameter is ignored. If a parameter is repeated, the first value is used.

| Parameter | Type | Endpoints | Description |
| --- | --- | --- | --- |
| `start` | integer | all | Start of the window, in Unix seconds, inclusive. |
| `end` | integer | all | End of the window, in Unix seconds, inclusive. A value in the future is treated as now. |
| `range` | string | all | A preset window that ends now: `today` (since 00:00 UTC), `24h`, `7d` or `30d`. The default is `30d`. |
| `model` | string | all | Only this exact model id. |
| `api_key_id` | string | all | Only requests made with this key id. See [Who can read what](#who-can-read-what) for restricted keys. |
| `billing_kind` | string | `/v1/usage/log` | Only this billing kind: `payg`, `plan`, `coupon`, `token_alloc`, `free` or `byok`. |
| `page` | integer | `/v1/usage/log` | Page number, starting at 0. The default is 0. |
| `limit` | integer | `/v1/usage/log` | Requests per page. The default is 50. A value outside 1 to 200 is moved to the nearest limit, not rejected. |

## Window rules

All three endpoints resolve the window in the same way.

- With no `start`, `end` or `range`, the window is the last 30 days.
- `start` and `end` win over `range`. When either is present, `range` is ignored, even if its value is not valid.
- With only `start`, the window ends now. With only `end`, it starts 30 days before `end`.
- `end` is clamped to now. `start` and `end` must not be negative, and `start` must not be after `end`.
- `/v1/usage` and `/v1/usage/daily` accept a window that touches at most 366 UTC days. A longer window is a `400`. `/v1/usage/log` has no such cap, but see the depth limit below.

The response always repeats the resolved `start` and `end`, so you can see the window that was used.

## Billing kinds

`billing_kind` says what paid for a request.

| Value | Meaning |
| --- | --- |
| `payg` | PAYG, paid from your balance. |
| `plan` | Covered by your plan quota. |
| `coupon` | Covered by a coupon. |
| `token_alloc` | Covered by a token allocation. |
| `free` | A free model. |
| `byok` | Served by your own BYOK key. |

## Money

Every amount is an integer in micro-Rupiah, which is Rupiah times 1,000,000. Rp 1 is `1000000`. See [How billing works](/en/docs/billing). Each row has two amounts.

- `cost_micro_idr` is what the requests took from your balance. Plan, coupon, free and BYOK usage is `0` here, because none of it is paid from balance.
- `catalog_micro_idr` is what the same usage is worth at the list price of the model. It lets you see the value you got from a plan, a coupon, a free model or a BYOK key, where the charge can be lower than the list price or `0`.

## Summary

`GET /v1/usage` returns one row per model and billing kind, sorted by `cost_micro_idr` from high to low, plus a `total` row. Plan usage is reported under `plan`. A request with no recorded billing kind is reported as `payg`.

| Field | Type | Description |
| --- | --- | --- |
| `object` | string | Always `usage.summary`. |
| `start` | integer | Start of the window, in Unix seconds. |
| `end` | integer | End of the window, in Unix seconds. |
| `data` | array | One row per model and billing kind. Empty when there is no usage. |
| `data[].model` | string | The model id. |
| `data[].billing_kind` | string | One of the billing kinds above. |
| `data[].requests` | integer | Number of requests. |
| `data[].prompt_tokens` | integer | Input tokens. |
| `data[].completion_tokens` | integer | Output tokens. |
| `data[].cached_prompt_tokens` | integer | Input tokens read from the cache. |
| `data[].cache_write_prompt_tokens` | integer | Input tokens written to the cache. |
| `data[].cost_micro_idr` | integer | Amount taken from your balance, in micro-Rupiah. |
| `data[].catalog_micro_idr` | integer | Value at list price, in micro-Rupiah. |
| `total` | object | The same seven counters, summed over all rows. |

```json
{
  "object": "usage.summary",
  "start": 1788300000,
  "end": 1790892000,
  "data": [
    {
      "model": "deepseek-v4-flash",
      "billing_kind": "payg",
      "requests": 120,
      "prompt_tokens": 480000,
      "completion_tokens": 96000,
      "cached_prompt_tokens": 200000,
      "cache_write_prompt_tokens": 0,
      "cost_micro_idr": 15400000,
      "catalog_micro_idr": 15400000
    },
    {
      "model": "step-3-7-flash:free",
      "billing_kind": "free",
      "requests": 40,
      "prompt_tokens": 52000,
      "completion_tokens": 18000,
      "cached_prompt_tokens": 0,
      "cache_write_prompt_tokens": 0,
      "cost_micro_idr": 0,
      "catalog_micro_idr": 900000
    }
  ],
  "total": {
    "requests": 160,
    "prompt_tokens": 532000,
    "completion_tokens": 114000,
    "cached_prompt_tokens": 200000,
    "cache_write_prompt_tokens": 0,
    "cost_micro_idr": 15400000,
    "catalog_micro_idr": 16300000
  }
}
```

## Daily series

`GET /v1/usage/daily` returns one row per UTC day in the window, oldest first. A day with no usage is a row with zeros, so the series has no gaps. The `model` and `api_key_id` filters apply.

| Field | Type | Description |
| --- | --- | --- |
| `object` | string | Always `usage.daily`. |
| `start` | integer | Start of the window, in Unix seconds. |
| `end` | integer | End of the window, in Unix seconds. |
| `data` | array | One row per UTC day. |
| `data[].date` | string | The UTC day, as `YYYY-MM-DD`. |
| `data[].requests` | integer | Number of requests on that day. |
| `data[].cost_micro_idr` | integer | Amount taken from your balance on that day, in micro-Rupiah. |

```json
{
  "object": "usage.daily",
  "start": 1790287200,
  "end": 1790892000,
  "data": [
    { "date": "2026-09-24", "requests": 18, "cost_micro_idr": 2100000 },
    { "date": "2026-09-25", "requests": 0, "cost_micro_idr": 0 },
    { "date": "2026-09-26", "requests": 7, "cost_micro_idr": 950000 }
  ]
}
```

## Request log

`GET /v1/usage/log` returns single requests, newest first. It supports every filter, including `billing_kind`. Plan usage is reported as `plan`.

| Field | Type | Description |
| --- | --- | --- |
| `object` | string | Always `list`. |
| `start` | integer | Start of the window, in Unix seconds. |
| `end` | integer | End of the window, in Unix seconds. |
| `data` | array | The requests on this page. |
| `data[].created_at` | integer | When the request was made, in Unix seconds. |
| `data[].model` | string | The model id. |
| `data[].vendor` | string | The maker of the model. |
| `data[].api_key_id` | string or null | The id of the key that made the request. `null` when there is none. |
| `data[].billing_kind` | string | One of the billing kinds above. |
| `data[].prompt_tokens` | integer | Input tokens. |
| `data[].completion_tokens` | integer | Output tokens. |
| `data[].cached_prompt_tokens` | integer | Input tokens read from the cache. |
| `data[].cache_write_prompt_tokens` | integer | Input tokens written to the cache. |
| `data[].cost_micro_idr` | integer | Amount taken from your balance, in micro-Rupiah. |
| `data[].catalog_micro_idr` | integer | Value at list price, in micro-Rupiah. |
| `page` | integer | The page number that was used. |
| `limit` | integer | The page size that was used, after clamping. |
| `has_more` | boolean | `true` when another page follows. |
| `total` | integer | Number of requests that match the filters in the whole window, not only this page. |

```json
{
  "object": "list",
  "start": 1788300000,
  "end": 1790892000,
  "data": [
    {
      "created_at": 1790891000,
      "model": "deepseek-v4-flash",
      "vendor": "DeepSeek",
      "api_key_id": "a1b2c3d4",
      "billing_kind": "payg",
      "prompt_tokens": 4200,
      "completion_tokens": 310,
      "cached_prompt_tokens": 2000,
      "cache_write_prompt_tokens": 0,
      "cost_micro_idr": 128000,
      "catalog_micro_idr": 128000
    }
  ],
  "page": 0,
  "limit": 50,
  "has_more": true,
  "total": 120
}
```

`page` times `limit` must not be more than 10,000. A deeper page is a `400`. To read further back, narrow the window with `start` and `end` and read it in slices.

## Who can read what

- A key without restrictions reads the usage of the whole account, and can narrow it with `api_key_id`.
- A key with a model scope, a spend cap or a token cap is restricted. It reads only the usage of its own key. Leave `api_key_id` out, or set it to the key's own id. Any other id is a `400`.
- A shared API key cannot read usage at all. It gets `403` and `shared_key_not_allowed`.

See [Authentication and API keys](/en/docs/authentication) for how to restrict a key.

## Limits

The three endpoints share one limit of 60 requests per minute for each account, whichever key makes them. Over the limit, the request fails with `429` and `rate_limit_exceeded`, and there is no `Retry-After` header. Wait a few seconds and retry. This limit is counted apart from the request limit of chat, so reading usage does not use up your chat requests. See [Rate limits](/en/docs/rate-limits).

## Examples

### curl

```bash
curl "https://kenari.id/v1/usage?range=7d" \
  -H "Authorization: Bearer $KENARI_API_KEY"
```

Daily series for a window, and the log filtered to PAYG requests:

```bash
curl "https://kenari.id/v1/usage/daily?start=1788300000&end=1790892000" \
  -H "Authorization: Bearer $KENARI_API_KEY"

curl "https://kenari.id/v1/usage/log?billing_kind=payg&limit=100&page=0" \
  -H "Authorization: Bearer $KENARI_API_KEY"
```

### Python

This script sums this month's spend per model. It reads the summary from the first day of the month in UTC, and adds up the billing kinds of each model.

```python
import os
from collections import defaultdict
from datetime import datetime, timezone

import requests

now = datetime.now(timezone.utc)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)

response = requests.get(
    "https://kenari.id/v1/usage",
    headers={"Authorization": f"Bearer {os.environ['KENARI_API_KEY']}"},
    params={"start": int(month_start.timestamp())},
)
response.raise_for_status()
usage = response.json()

per_model = defaultdict(int)
for row in usage["data"]:
    per_model[row["model"]] += row["cost_micro_idr"]

for model, micro in sorted(per_model.items(), key=lambda item: -item[1]):
    print(f"{model}: Rp {micro / 1_000_000:,.0f}")
print("Total:", f"Rp {usage['total']['cost_micro_idr'] / 1_000_000:,.0f}")
```

### JavaScript

```javascript
const now = new Date();
const monthStart = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) / 1000;

const response = await fetch(`https://kenari.id/v1/usage?start=${monthStart}`, {
  headers: { Authorization: `Bearer ${process.env.KENARI_API_KEY}` },
});
if (!response.ok) throw new Error(await response.text());
const usage = await response.json();

const perModel = {};
for (const row of usage.data) {
  perModel[row.model] = (perModel[row.model] ?? 0) + row.cost_micro_idr;
}

for (const [model, micro] of Object.entries(perModel).sort((a, b) => b[1] - a[1])) {
  console.log(model, Math.round(micro / 1_000_000));
}
```

## Billing

Reading usage is free. See [How billing works](/en/docs/billing).

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | A parameter is not valid: a value that is not an integer, a negative `start` or `end`, `start` after `end`, a `range` that is not one of the four presets (when no `start` or `end` is given), a window longer than 366 days on `/v1/usage` or `/v1/usage/daily`, a `billing_kind` that is not one of the six values, a negative `page`, `page` times `limit` over 10,000, or an `api_key_id` that is not the restricted key's own id. The `param` field names the parameter and `message` says what is wrong. |
| 401 | None | The key is missing, invalid or expired. The body is a short plain-text message, not JSON. |
| 403 | `shared_key_not_allowed` | The key is a shared API key. |
| 429 | `rate_limit_exceeded` | More than 60 usage requests in a minute for the account. There is no `Retry-After` header. |
| 500 | `internal_error` | Reading the usage failed in kenari. Retry once. |

The body is the OpenAI format. A `400` has `type` set to `invalid_request_error` and `param` set to the name of the parameter. See [Errors](/en/docs/errors) for the error format and the remaining codes.

## See also

- [How billing works](/en/docs/billing)
- [Account overview](/en/docs/account)
- [Account balance](/en/docs/account-balance)
- [Account quota](/en/docs/account-quota)
- [Rate limits](/en/docs/rate-limits)
- [Authentication and API keys](/en/docs/authentication)


---

# Notifications

kenari tells you when your plan quota is running out, when a plan is about to end, and when your balance is low. Every alert appears in the bell in the dashboard. You can also send them to Telegram or to a webhook, so they reach you or your own tools without opening the dashboard. This page covers what is sent, how to connect each channel, how to verify a webhook, and how a script or an agent can read the same numbers.

## What kenari notifies

Alerts are grouped into categories. Each Telegram chat and each webhook picks which categories it receives, under **Settings**, **Notifications**.

| Category | What it covers | On by default |
| --- | --- | --- |
| Plan quota | A weekly or monthly quota window reaches your alert threshold, or runs out. | yes |
| Subscription | A plan ends in 3 days or tomorrow, auto-renew succeeded or failed, auto-renew will be short of balance, a plan lapsed, a coupon expired. | yes |
| Balance | Your balance is low, and credits added to your balance. | yes |
| API keys | An API key reached its spend cap. | yes |
| Pods and domains | Pod and domain events, support replies, and a notice when one of your channels is turned off. | yes |
| Announcements | Product announcements from kenari. | no |

The bell always receives everything, whatever you choose for the channels. The quota alert fires once per window when use reaches your threshold (80% unless you change it), and once more when the window is used up. The low balance alert fires when your balance drops below Rp 20,000 (unless you change it). See [Thresholds](#thresholds).

On Telegram, early warnings, the 3-day plan notice, successful auto-renewals and announcements arrive without a sound. Everything else makes a sound.

kenari sends at most 20 alerts per hour to your channels. If something sends more, the rest stay in the bell only.

You can connect up to 5 channels in total, Telegram included.

## Connect Telegram

1. Open **Settings** in the dashboard and go to **Notifications**.
2. Under Telegram, choose **Connect**. This opens `@kenarihq_bot` with a one-time link.
3. Press **Start** in Telegram. The bot answers that the chat is linked to your account.

The link works once and expires after 10 minutes. If it expires, choose **Connect** again. Only private chats work. Messages in groups and channels are ignored. A Telegram chat belongs to one kenari account. If the chat is already linked to another account, send `/disconnect` in that chat first, then open the link again.

The bot speaks English. Its replies and the alerts it sends are in English whatever language your dashboard uses. The bell, email and webhooks keep your dashboard language.

The bot understands four commands:

| Command | What it does |
| --- | --- |
| `/quota` | Shows what is left in each quota window of your plan, with the reset time. |
| `/balance` | Shows your available balance. |
| `/disconnect` | Unlinks the chat and stops alerts. To get alerts here again, choose **Connect** in the dashboard. `/stop` does the same. |
| `/help` | Lists the commands. |

Each alert has a button that opens the matching dashboard page. To unlink from the dashboard, choose **Disconnect** under Telegram. If you block the bot or delete the chat, Telegram refuses the message and kenari turns the channel off.

## Webhooks

Add a webhook under **Settings**, **Notifications**, **Add webhook**. kenari sends a test message first and saves the webhook only if your server answers with a 2xx status. A failed test shows the reason and nothing is saved.

The URL must be `https`. kenari refuses URLs that contain a username or password and URLs that resolve to a private network address. It does not follow redirects, and it waits at most 5 seconds for your answer.

### Formats

kenari picks the format from the URL.

| URL | Format |
| --- | --- |
| `https://discord.com/api/webhooks/...` or `https://discordapp.com/api/webhooks/...` | Discord embed with a link to the dashboard page. |
| `https://hooks.slack.com/...` | Slack message with an **Open** button. |
| Anything else | JSON, signed. |

Only the JSON format is signed and has a secret. Discord and Slack webhooks carry their own token in the URL.

### JSON body

A JSON webhook receives a `POST` with `content-type: application/json` and this body:

| Field | Type | Description |
| --- | --- | --- |
| `type` | string | The kind of alert, with dots, for example `quota.week.warn`. See the table below. |
| `id` | string | The notification id. It is also sent in the `webhook-id` header and stays the same across retries. |
| `created_at` | integer | When the alert was created, in Unix seconds. |
| `title` | string | The short headline. |
| `body` | string | The message text. |
| `link` | string | An absolute URL to the matching dashboard page. |
| `category` | string | One of `quota`, `plan`, `balance`, `keys`, `services`, `announcements`. |
| `data` | object or null | Reserved for extra fields of a kind. It is `null` when an alert has none, so handle both. |

```json
{
  "type": "quota.week.warn",
  "id": "5b7c0d0e-3f5a-4a56-9c8e-0a1d2f9e7b41",
  "created_at": 1791622800,
  "title": "Weekly quota 80% used",
  "body": "Rp 40,000 of Rp 200,000 left, resets 15 Oct 14:00 WIB. After that, usage is billed to your balance.",
  "link": "https://kenari.id/plan",
  "category": "quota",
  "data": null
}
```

The `type` values you will see most:

| Category | `type` |
| --- | --- |
| `quota` | `quota.week.warn`, `quota.week.out`, `quota.month.warn`, `quota.month.out` |
| `plan` | `sub.expiring.3d`, `sub.expiring`, `autorenew.short`, `sub.autorenewed`, `sub.autorenew.failed`, `sub.lapsed`, `coupon.expired` |
| `balance` | `wallet.low` |
| `keys` | `key.cap.hit` |
| `services` | `channel.disabled`, `test`, and pod and domain events |
| `announcements` | `announcement` |

Ignore a `type` you do not recognize and answer 2xx, so new kinds never break your receiver. The test message sent by **Send test** has `type` `test` and an `id` that starts with `test-`.

### Headers

| Header | Value |
| --- | --- |
| `webhook-id` | The same value as `id` in the body. Use it to drop duplicates, because a retry sends the same id. |
| `webhook-timestamp` | When this attempt was sent, in Unix seconds. A retry gets a new timestamp. |
| `webhook-signature` | `v1,` followed by the base64 signature. |

These follow the [Standard Webhooks](https://www.standardwebhooks.com) convention, so a Standard Webhooks library can verify them.

## Webhook signature

Verify every request before you trust it. The signature proves that the message came from kenari and was not changed on the way.

The signing secret looks like `whsec_...` and is shown once, when you add a JSON webhook. If you lose it, choose **Regenerate secret**. The old secret stops working at once, so update your receiver right after.

To verify a request:

1. Take the raw request body, exactly as received. Do not parse it and serialize it again, because that changes the bytes.
2. Build the string `{webhook-id}.{webhook-timestamp}.{body}`.
3. Remove the `whsec_` prefix from the secret and base64-decode the rest. These bytes are the HMAC key.
4. Compute HMAC-SHA256 of the string with that key and base64-encode the result.
5. The header value is `v1,` followed by that result. Compare it with the one you computed in constant time.
6. Reject the request if `webhook-timestamp` is more than 5 minutes away from your clock. This stops someone from replaying an old message.

### Node.js

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const TOLERANCE_SECONDS = 300;

// headers: Node's lowercase request headers. rawBody: the Buffer or string exactly as received.
export function verifyKenariWebhook(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;

  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  // The header can hold several space-separated signatures, each as "v1,<base64>".
  for (const entry of signatures.split(" ")) {
    const [version, value] = entry.split(",");
    if (version !== "v1" || !value) continue;
    const given = Buffer.from(value, "base64");
    if (given.length === expected.length && timingSafeEqual(given, expected)) return true;
  }
  return false;
}

const secret = process.env.KENARI_WEBHOOK_SECRET;

createServer((req, res) => {
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const rawBody = Buffer.concat(chunks);
    if (!verifyKenariWebhook(secret, req.headers, rawBody)) {
      res.writeHead(401).end();
      return;
    }
    const event = JSON.parse(rawBody.toString("utf8"));
    console.log(event.type, event.title);
    res.writeHead(204).end();
  });
}).listen(3000);
```

### Python

```python
import base64
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

TOLERANCE_SECONDS = 300


def verify_kenari_webhook(secret: str, headers, raw_body: bytes) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures:
        return False

    try:
        age = abs(time.time() - int(timestamp))
    except ValueError:
        return False
    if age > TOLERANCE_SECONDS:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = hmac.new(key, signed, hashlib.sha256).digest()

    # The header can hold several space-separated signatures, each as "v1,<base64>".
    for entry in signatures.split(" "):
        version, _, value = entry.partition(",")
        if version != "v1" or not value:
            continue
        try:
            given = base64.b64decode(value)
        except ValueError:
            continue
        if hmac.compare_digest(given, expected):
            return True
    return False


class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        raw_body = self.rfile.read(int(self.headers.get("content-length", 0)))
        secret = os.environ["KENARI_WEBHOOK_SECRET"]
        if not verify_kenari_webhook(secret, self.headers, raw_body):
            self.send_response(401)
            self.end_headers()
            return
        event = json.loads(raw_body)
        print(event["type"], event["title"])
        self.send_response(204)
        self.end_headers()


if __name__ == "__main__":
    HTTPServer(("", 3000), Receiver).serve_forever()
```

Answer with a 2xx status as soon as the signature checks out. Do slow work after you have answered, because kenari waits only 5 seconds.

## Retries and turning off

kenari treats a 2xx answer as delivered. When a send fails, because of a timeout, a connection error or any status other than 2xx, it tries again after 1 minute, 5 minutes, 30 minutes and 2 hours. After the last retry fails, that alert is dropped from the channel and stays in the bell. Telegram follows the same schedule.

A channel turns itself off in two cases:

- It fails 5 sends in a row. A success resets the count.
- Your server answers `404` or `410`, which tells kenari the endpoint is gone. The channel turns off at once.

When a channel turns off, kenari adds a notice to the bell with the reason and sends nothing more to it. After you fix the cause, choose **Turn on again** on the channel.

**Send test** sends a test message to a channel right now and shows the result. It does not count toward turning the channel off. **Send test** and **Add webhook** share a limit of 10 sends per minute for your account. Past that, kenari answers that there were too many attempts, and you can try again shortly.

## Thresholds

Under **Settings**, **Notifications**, **Thresholds** you set when the alerts fire:

| Setting | Choices | Default |
| --- | --- | --- |
| Alert me when plan quota reaches | 50%, 80% or 90% of a quota window | 80% |
| Alert me when balance drops below | A whole Rupiah amount up to Rp 10,000,000. `0` turns balance alerts off. | Rp 20,000 |

Changing the quota threshold in the middle of a window does not send a second alert for that window. The balance alert fires once when your balance falls below the amount, and again only after it has been topped up above the amount and fallen below it again. A wallet-low alert is only sent to accounts that paid from balance in the last 14 days or have auto-renew on.

## For agents and scripts

A script or an agent can check its budget without opening the dashboard.

### Response headers

Successful replies from `/v1/chat/completions`, `/v1/messages` and `/v1/responses` can carry two headers, streaming or not:

| Header | When it is present | Value |
| --- | --- | --- |
| `x-kenari-plan-remaining` | The key is an unrestricted key of your own account and your plan is active. | The fraction of each quota window that is left, for example `week=0.19, month=0.70`. Each value is from 0 to 1 with 2 decimals. A window without a ceiling is left out. |
| `x-kenari-balance-micro-idr` | The request was billed from your balance. | Your available balance in micro-Rupiah, where Rp 1 is 1,000,000. This is your balance minus what running requests hold, the same number as `available_micro_idr` in [Account balance](/en/docs/account-balance). |

`x-kenari-plan-remaining` shows the state before this request was charged. A shared API key and a restricted key never get it. A reply carries only the headers whose condition holds.

Read them as a hint, not as a guarantee. When a non-streaming reply takes very long, kenari sends the `200` status early and keeps the connection open while the model works. The headers of the finished reply cannot be added after that, so a very long non-streaming reply can arrive without these two headers. Streaming replies and replies of normal length carry them. When you need an exact number, call [Account quota](/en/docs/account-quota) or [Account balance](/en/docs/account-balance).

```bash
curl -si https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "step-3-7-flash:free", "messages": [{"role": "user", "content": "Hi"}]}' \
  | grep -i '^x-kenari-'
```

### Quota fields

`GET /v1/account/quota` returns the same numbers with more detail. Each window has `budget_rp` (its ceiling in whole Rupiah) and `used_frac` (the fraction used, from 0 to 1 with 4 decimals), and the top level has `alert_pct`, your quota alert threshold. An agent can pause itself when `used_frac * 100` reaches `alert_pct`. See [Account quota](/en/docs/account-quota).

### Status line

`kenari status --line` prints one line with your quota use and balance, then exits with code 0. It is made for the Claude Code status line and for shell prompts:

```text
quota wk 81% · mo 40% · balance Rp 12,400
```

It shows the used percentage of each window that has a ceiling, then your available balance. If no key is configured, it prints an empty line, so a prompt never breaks. When kenari cannot be reached, it keeps showing the last reading for up to 24 hours and tries again once a minute. The result is cached for 60 seconds, and a fetch waits at most 3 seconds. The key must be an unrestricted key of your own account, as for the quota endpoint.

A status line runs on every prompt redraw, so set `KENARI_API_KEY` in your shell rather than relying on the system keychain, which can prompt for a password or wait on a locked keyring.

To show it in Claude Code, add this to `~/.claude/settings.json`:

```json
{
  "statusLine": {
    "type": "command",
    "command": "kenari status --line"
  }
}
```

When you start a tool through the kenari CLI and a quota window is at or past your alert threshold, the CLI also prints one warning line before it launches. It reads the cached reading only and never waits on the network. See [CLI](/en/docs/cli).

## See also

- [Account quota](/en/docs/account-quota)
- [Account balance](/en/docs/account-balance)
- [Subscriptions](/en/docs/subscriptions)
- [How billing works](/en/docs/billing)
- [CLI](/en/docs/cli)
- [Claude Code](/en/docs/claude-code)


---

# How billing works

kenari bills in Rupiah. Most requests are paid from a prepaid balance, a plan can cover chat usage on listed models, and free models and your own provider keys cost nothing. This page is the one home for money: top up, spend limits, how a request is charged, and what each endpoint is billed by.

## How you pay

| Way to pay | What it means |
| --- | --- |
| PAYG | Each paid request is charged to your balance at the model price. This is the default. |
| Subscription | A plan gives you quota for chat on listed models. See [Subscriptions](/en/docs/subscriptions). |
| Free models | `:free` models cost Rp 0 within daily and per-minute limits. See [Free models](/en/docs/free-models). |
| BYOK | Requests served by your own provider key or connected AI subscription cost Rp 0 on kenari. See [Providers](/en/docs/byok). |

## Balance and top up

Open the **Balance** page, or use **Top up** in the top bar. Choose an amount and a payment method, then continue to the payment page.

| Method | Balance is added |
| --- | --- |
| QRIS | When the payment completes |
| Crypto | When the network confirms the payment |

The minimum and maximum for each method are shown on the **Balance** page.

You always enter the amount in Rupiah. For crypto, kenari issues the invoice in US dollars at the platform exchange rate when the invoice is created, and the network fee is paid by the payer on top of the invoice. For QRIS, the payment page can show a **Service fee** added to the amount. **Total to pay** is the figure your banking app asks for. Your balance is credited the amount you chose.

Each invoice has its own link, so you can reopen the payment page while the invoice is valid. The page checks the status every few seconds, and **Check now** forces a check. Subscription payments use the same page. An expired invoice cannot be paid, so choose **Create new payment**.

A top-up bonus code adds extra balance when you top up. See [Codes and referral](/en/docs/codes-referral).

## PAYG spend limit

You can cap how much PAYG can spend. On the **Plan** page, open the section **When your quota runs out**, set **PAYG balance limit** to **Daily** or **Monthly** with an amount in Rupiah, then click **Save**. The change does not apply until you save. To turn the cap off, choose **No limit** and click **Save**.

- **Daily** counts spend over the last 24 hours. **Monthly** counts spend over the last 30 days. Both are rolling windows.
- Only PAYG spend counts. That includes web and X search fees taken from your balance and video renders that are still in progress. Plan quota, coupons, free models and BYOK do not.
- A paid request is refused with HTTP 402 and code `payg_limit_reached` once the spend in the window has reached the limit, even if your balance has money.
- The limit is checked before a request starts, so the request that crosses it still completes and the next one is refused. A search or page fetch that would take the spend past the limit is refused instead.
- The limit applies to all PAYG spend from your balance, whatever you choose for **Keep using your balance**.

## How a chat request is charged

A paid chat request is charged in two steps. Other endpoints reserve and charge by their own unit, described under [What each endpoint is billed by](#what-each-endpoint-is-billed-by).

1. **Reservation.** Before the request runs, kenari reserves its worst case: the estimated input plus `max_tokens` at the Output price. If you omit `max_tokens`, a large default applies. The reservation must fit in your balance minus the reservations of your other requests in flight.
2. **Settlement.** When the response ends, kenari charges the actual usage and releases the rest of the reservation.

If your balance cannot cover the requested `max_tokens`, kenari lowers `max_tokens` to what the balance can pay for, as long as at least 1,024 output tokens remain. The response can then end early with `finish_reason` set to `length`. Below that floor the request is refused with HTTP 402:

```json
{
  "error": {
    "message": "insufficient balance: available Rp 19,589; this request reserves up to Rp 21,500 worst-case (input estimate + max_tokens x output rate). Send a shorter prompt or top up at https://kenari.id/pay",
    "type": "insufficient_balance",
    "param": null,
    "code": "insufficient_balance"
  }
}
```

The message shows the balance available right now and the worst-case cost of this one request. A request funded by plan quota, a coupon or a free model does not need balance.

### No output, no charge

A chat request that finishes with no output tokens costs Rp 0, even if the prompt was processed. This holds whether the request was funded by balance or by plan quota. The request still appears in your usage history with zero cost.

If you close a stream before it ends and the provider did not report usage, kenari charges for what it had already delivered, estimated from the text sent. Closing the connection after the last token does not make it free.

<aside><strong>Note</strong> Reasoning tokens count as output. A reasoning model that spends all of <code>max_tokens</code> thinking and writes no answer is still charged for the tokens it used. Raise <code>max_tokens</code> if you expect long reasoning.</aside>

## Price dimensions

Chat models are priced on four dimensions per 1M tokens. Each dimension is charged as tokens × price ÷ 1,000,000.

| Dimension | Billed on | If no price is set |
| --- | --- | --- |
| Input | Prompt tokens that were not read from or written to the cache | Always set |
| Output | Tokens the model generates, including reasoning tokens | Always set |
| Cache read | Prompt tokens served from the cache | Billed at the Input price. A Cache read price is never charged above Input. |
| Cache write | Prompt tokens written to the cache | Billed at the Input price |

Prices and balances are stored in micro-Rupiah, where Rp 1 is 1,000,000 micro-Rupiah. `GET /v1/models` returns prices in micro-Rupiah per 1M tokens, so `2000000000` means Rp 2,000 per 1M tokens. The dashboard shows ordinary Rupiah. To read your balance from code, call `GET /v1/account/balance`. See [Account balance](/en/docs/account-balance).

```bash
curl -s https://kenari.id/v1/models \
  | jq '.data[] | select(.id == "deepseek-v4-flash") | .pricing'
```

Prices for every model are on [Models and pricing](/en/docs/models).

## What each endpoint is billed by

| Endpoint | Billed by |
| --- | --- |
| [Chat completions](/en/docs/chat), [Messages](/en/docs/messages), [Responses](/en/docs/responses) | Tokens, on the four price dimensions above. Images and files that the model reads itself count as Input tokens. |
| [Embeddings](/en/docs/embeddings), [Rerank](/en/docs/rerank), [Moderations](/en/docs/moderations) | Input tokens, estimated from the text at about four characters per token |
| [System One](/en/docs/systemone) | Input tokens. The estimate is about four characters per token, and the charge never exceeds it. Output tokens are not billed. |
| [Images](/en/docs/images) | Per image, `n` times the model price |
| [Text to speech](/en/docs/audio-speech) | Per 1,000 input characters, rounded up |
| [Transcriptions](/en/docs/audio-transcriptions) | Per second of audio, rounded up |
| [Music](/en/docs/music) | Per song |
| [Videos](/en/docs/videos) | Per second of video, at the price of the resolution you ask for |
| [OCR](/en/docs/ocr) and the `ocr` engine in [Read documents](/en/docs/documents) | Per page read, for the `ocr` engine and `/v1/ocr` only. These are the only document paths not billed by tokens, and plans never cover them. Every other way of attaching a document is billed as input tokens. See [Images and files as input](/en/docs/vision). |
| [Server tools](/en/docs/server-tools) and [Web and X search](/en/docs/web-search) | Per call |
| [Providers](/en/docs/byok) | Free on kenari |

Paid non-chat requests reserve an amount of balance before they run, calculated from the estimate for that endpoint. Embeddings, Rerank and Moderations charge the estimated input tokens, and the `usage` in the response is for display only. For Transcriptions, kenari estimates the duration from the file size, then charges the real duration the provider reports, and the estimate if the provider reports none. The charge never exceeds the reservation, and the rest is released. A request fails with `402` when the balance cannot cover the reservation, even if the actual audio or text is short.

Plan quota covers chat only. Paid requests to every other endpoint are paid from your balance. That holds while quota remains, after a quota window is spent and after a plan has ended. **Keep using your balance** and **Switch to free models** do not change it. The one exception is the daily allowance of web search and page fetch calls that plans include. Free models, such as `clef:free`, do not need balance. See [Subscriptions](/en/docs/subscriptions) for the endpoints a plan never covers.

## Usage history

Open the **Usage** page in the dashboard. The **Usage** tab shows requests, tokens and cost per model and per API key, with a filter for free, plan, PAYG and own-key usage, and a CSV download. The **Balance & billing** tab shows your balance history, your payments, and an invoice builder for any period.

The same usage is available over the API with `GET /v1/usage`, `/v1/usage/daily` and `/v1/usage/log`. Amounts there are in micro-Rupiah, which is Rupiah times 1,000,000. Reading usage is free. See [Usage](/en/docs/usage).

## See also

- [Free models](/en/docs/free-models)
- [Subscriptions](/en/docs/subscriptions)
- [Codes and referral](/en/docs/codes-referral)
- [Errors](/en/docs/errors)


---

# Free models

Models whose id ends in `:free` cost Rp 0. You can call them with a new account and a zero balance, within per-minute and daily limits that grow when you top up or subscribe. This page covers the `:free` suffix, the `kenari-free` route, and those limits.

## Use a free model

Send a `:free` model id like any other model. This works on [Chat completions](/en/docs/chat), [Messages](/en/docs/messages) and [Responses](/en/docs/responses).

```bash
curl https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "step-3-7-flash:free",
    "messages": [{"role": "user", "content": "Hello!"}]
  }'
```

List the free models that exist right now with a public call that needs no key:

```bash
curl -s https://kenari.id/v1/models | jq -r '.data[] | select(.pricing.free) | .id'
```

A free model can still show a catalog price in `pricing`. It is never charged.

## The kenari-free route

`kenari-free` is a built-in route that picks a free model for you. Send it as the `model` and kenari tries the free models in a random order, moving to the next one when a model fails. It appears as a read-only route in **Providers & Routes** and cannot be edited or deleted.

```json
{
  "model": "kenari-free",
  "messages": [{"role": "user", "content": "Hello!"}]
}
```

If the request includes `tools`, kenari only tries free models that support tool calling. When none can, the request fails with HTTP 400. Send it to a named model that supports tools instead.

A plan can also send requests to `kenari-free` when its quota runs out. See [Subscriptions](/en/docs/subscriptions). For routes you build yourself, see [Routing and routes](/en/docs/routing).

## Limits

Free models have two limits per account, and both apply to every request on a free model.

| Limit | How it works |
| --- | --- |
| Per minute | How many requests the account can send to free models in a minute. |
| Per day | How many successful requests the account can make to free models in a day. A value of `0` means no daily limit. |

The limits depend on the account:

| Account | What applies |
| --- | --- |
| New account | The smallest limits. Free models work from the first request. |
| After a qualifying top up | A higher daily limit and per-minute limit. An account qualifies once its top ups and plan payments add up to a threshold. Bonus balance from codes does not count. |
| With an active plan | The highest per-minute limit. The daily limit comes from the plan if it sets one, and otherwise from the default for accounts with a plan. |

A single model can carry its own per-minute or daily limit, which then applies instead of the account limit for that model. An account that has not yet qualified can also be held to a lower per-minute limit on every free model.

### Where the live numbers are

The limits and the top-up threshold can change, so they are not written here. Read them from either place:

- The **Plan** page in the dashboard, and the plan table at the end of [Subscriptions](/en/docs/subscriptions).
- `GET /api/public/pricing`, which needs no key. Its `free_tier` object describes the three accounts above, with these fields:

```bash
curl -s https://kenari.id/api/public/pricing | jq '.free_tier'
```

| Field | Meaning |
| --- | --- |
| `daily`, `rpm` | Daily and per-minute limits for a new account. |
| `threshold_idr` | Rupiah of top ups and plan payments needed to qualify. |
| `next` | Limits after the account qualifies. It is `null` when no top up is needed, because new accounts already start there. |
| `plan` | Limits with an active plan. A plan's own daily value is in the `free_daily_quota_effective` field of `GET /api/plans`. |

## Availability

Free models run on a best-effort basis, so speed and availability can differ from paid models. If free capacity is not enough for you, use a paid model.

## When you hit a limit

A request over a free-model limit returns HTTP 429. See [Rate limits](/en/docs/rate-limits) for the error codes and how to retry.

## Subscriber-only models

Some models are reserved for accounts with an active plan. A request from any other account returns HTTP 403 with code `subscribers_only`. See [Subscriptions](/en/docs/subscriptions).

## See also

- [How billing works](/en/docs/billing)
- [Rate limits](/en/docs/rate-limits)
- [Models and pricing](/en/docs/models)


---

# Subscriptions

A subscription plan gives you a quota of chat usage on a list of models for a monthly price. While quota remains, requests to those models draw from it and your balance is not touched. This page covers how quota is measured, what happens when it runs out, and which endpoints a plan never covers. For money in general, see [How billing works](/en/docs/billing).

## Plans and quota windows

Each plan has up to three quota windows. A window with no ceiling is unlimited.

| Window | Length |
| --- | --- |
| 5 hours | 5 hours |
| Weekly | 7 days |
| Monthly | 30 days |

Quota is measured in Rupiah at each model's normal price, not in request counts. An expensive request, such as a large model with a long prompt, spends more of it. A request passes only when every window with a ceiling has room.

Windows roll, and they do not follow the calendar. Each window starts when your plan cycle starts and resets every window length after that. Renewing the same plan early extends the cycle and keeps the windows where they are. Upgrading to a higher plan starts new windows, and the remaining days of the old plan are credited toward the new price. [Starting a new cycle](#start-a-new-cycle) resets every window right away.

## Covered models

Each plan lists the models it covers. A request to a covered model draws from quota. A request to any other model is billed to your balance as PAYG, whatever quota is left. The live list for each plan is in the table at the end of this page.

A plan can also carry its own daily quota for `:free` models, separate from the quota windows. See [Free models](/en/docs/free-models).

## Pay for a plan

Choose a plan on the **Plan** page and pay by QRIS, crypto or balance. A plan lasts 30 days from payment.

A QRIS or crypto payment is checked again when it completes. If your plan or the plan price changed in the meantime, or the promo code on the invoice was already used, the plan is not activated. The amount you paid is added to your balance instead and you get a notification. Choose the plan again to buy it at the current price. A promo code that is still on an unpaid invoice cannot be used for a balance payment until you finish or cancel that invoice.

## Renew and auto-renew

The status card at the top of the **Plan** page shows when your plan ends. Turn on **Auto-renew from balance** and the card shows the renewal date and the price that will be taken from your balance. If your balance is short, the card shows how much is missing, with a **Top up** link.

When the cycle ends, kenari takes the plan price from your balance and starts the next 30 days. Auto-renew never splits the price between balance and QRIS or crypto, so the balance must cover all of it. If it does not, or the plan is no longer on sale, the plan ends, auto-renew switches off and you get a notification. Top up and choose a plan again to continue.

## Start a new cycle

When you reach a limit early, you can buy a fresh cycle of your current plan instead of waiting. On the **Plan** page, the tile **Start a new cycle** shows the plan price per cycle. Choose **Start now**. A dialog shows the price, the days you forfeit and your balance after the payment, and you confirm with its payment button.

- You pay the full price of your current plan from your balance. QRIS and crypto are not offered.
- A new 30-day cycle starts now. The 5-hour, weekly and monthly windows all reset to zero.
- The days left in the old cycle are forfeited. The dialog shows how many.
- If your balance is short, the tile shows the amount missing with a **Top up** link.

It works only on a paid plan that is still on sale. A trial cycle, a coupon, and a plan that is no longer sold cannot be restarted, and the dashboard says so. A plan that started from a plan gift is a paid cycle and can be restarted. See [Plan gifts](/en/docs/gifts). If you have a free reset left, **Self-serve quota reset** with its **Reset now** button is offered first, and the tile does not appear while you have one. The plan comparison table lists the price of each plan under **Start a new cycle**.

Starting a new cycle is a dashboard action. There is no API call for it, and an API key cannot do it.

## When quota runs out

When any window with a ceiling is spent, two account settings decide what happens to requests for the models your plan covers. Find them on the **Plan** page, in the section **When your quota runs out**. Both are off by default.

| **Keep using your balance** | **Switch to free models** | What a request does |
| --- | --- | --- |
| On | Off | Continues, billed to your balance. The PAYG spend limit in [How billing works](/en/docs/billing) still applies. |
| On | On | Continues, billed to your balance. Free models are used only when the balance option is off. |
| Off | On | Falls through to [Free models](/en/docs/free-models) at no cost. |
| Off | Off | Refused with HTTP 429 and code `plan_limit_reached` until the window resets. |

```json
{
  "error": {
    "message": "...",
    "type": "plan_limit_reached",
    "param": null,
    "code": "plan_limit_reached"
  }
}
```

The `message` explains the situation to a person. Branch on `code`. See [Errors](/en/docs/errors) for the format.

After a plan has ended, usage is billed to your balance as PAYG, and the PAYG spend limit in [How billing works](/en/docs/billing) still applies. The one exception is when **Switch to free models** is on and **Keep using your balance** is off. Requests to the models the plan covered then go to [free models](/en/docs/free-models) at no cost. A model reserved for subscribers still needs an active plan, as described in [Subscriber-only models](#subscriber-only-models).

## Endpoints a plan never covers

A plan covers chat only. These endpoints are always paid from your balance, even while quota remains:

- Images, text to speech, transcription, music and video
- Embeddings, rerank, moderations and System One
- OCR, meaning the `ocr` engine of [Read documents](/en/docs/documents) and `/v1/ocr`

A document attached to a chat request and read the default way is ordinary input tokens, so a plan that covers the model covers it.

These endpoints, and any model that no plan or coupon covers, are always billed to your balance, whatever the two settings say. That holds while quota remains, after a window is spent and after the plan has ended. An account with no plan and no coupon pays from its balance as usual.

Web search and page fetch work differently. Each plan includes a daily allowance of calls, counted per UTC day. Search and fetch share it, and calls inside it are free. Beyond the allowance, each call is billed to your balance, whatever the two settings say. X search is not part of any plan. It is always billed to your balance, whatever the two settings say. See [Server tools](/en/docs/server-tools) and [Web and X search](/en/docs/web-search).

## Free cache

For some models on some plans, cache tokens do not spend quota. Cache read and cache write tokens are free, while fresh input and output tokens still count as usual. The plan table at the end of this page lists them under Free cache.

Free cache applies when a model is covered by the plan and is listed in that plan's free cache list. One model can have free cache on one plan and not on another. It also applies to the balance charge that follows a spent window when **Keep using your balance** is on. A model the plan does not cover is billed in full. The **Usage** page shows how much free cache saved you.

## Codes and coupons

A code can give time-limited plan access. That grant is a coupon. It appears as **Coupon active** on the **Plan** page and covers the models it lists. A request to a covered model is paid from the coupon the same way a plan pays from quota, so it needs no balance, and the coupon is used before your plan quota. When the coupon budget is spent, an active plan takes over for the models it covers. Without a plan, requests follow the rule for an ended plan in [When quota runs out](#when-quota-runs-out). How codes work is in [Codes and referral](/en/docs/codes-referral), and the coupon fields are in [Account quota](/en/docs/account-quota).

## Subscriber-only models

Some models are reserved for accounts with an active plan. Other accounts get HTTP 403 with code `subscribers_only`, on every endpoint that takes the model, including the media endpoints.

## Check quota from code

`GET /v1/account/quota` returns your plan name, the remaining Rupiah in each window, and any active coupon. The MCP tool `kenari_quota` returns the same data as text. See [Account quota](/en/docs/account-quota) and [MCP server](/en/docs/mcp). In the dashboard, the **Usage** page shows the same windows as percentages with reset times.

## See also

- [How billing works](/en/docs/billing)
- [Free models](/en/docs/free-models)
- [Codes and referral](/en/docs/codes-referral)
- [Account quota](/en/docs/account-quota)

## Plans

The plans below are read live from `GET /api/plans` and `GET /api/public/pricing`, so they always match what is on sale: price, quota windows, covered models, free cache, and the daily quota for free models.

The plans on sale, with their prices and quota windows, are listed as text on the [kenari homepage](https://kenari.id/#harga) and as JSON at `GET https://kenari.id/api/plans`.


---

# Plan gifts

A plan gift is 30 days of a kenari plan that you pay for and someone else uses. You get a gift link and a code to send. The recipient uses the gift from their own kenari account, and it works like a month of the plan bought at its normal price. This page covers buying a gift, sending it, using one you received, and canceling one.

## Buy a gift

1. On the **Plan** page, find **Gift a plan** and choose **Give as a gift**.
2. Pick a plan. A gift always lasts **30 days (1 month)**.
3. Under **Pay with**, choose **Balance**, **QRIS** or **Crypto**, then confirm.

Paying from balance finishes right away, and the dialog turns into **Gift ready to send** with the gift link and the code. QRIS and crypto open the payment page, which shows the same panel once the payment completes.

A gift costs the plan's full price, and promo codes do not apply to it. Any balance can pay for it, including balance from bonuses, cashback and promo codes. Buying a gift does not change your own plan.

You get a notification and an email when the gift is ready. The email holds the gift link and the code.

## Send the gift

A gift link looks like `kenari.id/gift/GIFT-XXXX-XXXX-XXXX-XXXX`. Send the link, or the code on its own, by chat or email. **Copy link** and **Copy code** are on the gift's row under **Gifts you bought**. Where the browser supports sharing, **Share** in the **Gift ready to send** panel opens the share sheet.

<aside class="warn"><strong>Warning</strong> Anyone who holds the link or the code can use the gift. Send it only to the person it is for. If it reaches someone else before it is used, cancel it.</aside>

A gift does not expire. It stays ready until someone uses it or you cancel it.

## Use a gift you received

1. Open the gift link. The page shows the plan, the 30 days, the plan's budget and how many models it covers.
2. If you are signed out, choose **Sign in** or **Sign up**, then come back to the gift page. Signing up does not use the gift by itself.
3. The page says what the gift will do to your plan. Choose **Use gift**.

You can also enter the code in the **Have a code?** box on the **Balance** page. The box shows what the gift gives you, what it does to your plan, and a **Use gift** button. If the gift cannot be used on your account, the box links to the gift page instead.

What the gift does depends on the plan you have now.

| Your plan now | What happens |
| --- | --- |
| No plan, PAYG only, or a plan that has ended | The gift's plan starts now and runs for 30 days. |
| The same plan as the gift, paid | Your plan is extended by 30 days. Your quota windows keep running. |
| A different paid plan | The gift is saved and starts when your current plan ends. While **Auto-renew from balance** is on your plan keeps renewing, so the gift waits until auto-renew stops. |
| A trial | The gift is saved and starts after the trial, including any trial days still waiting. |

If a coupon or a token allocation is all you have, the gift starts now.

A saved gift shows on the status card of the **Plan** page as **Next: Studio (gift), 30 days**. It starts within an hour after your current plan ends, and you get a notification and an email. Several saved gifts start one after another, in the order you saved them.

A gift that has started is a paid month of its plan. Renewing, upgrading and **Start a new cycle** work as they do for a plan you bought. Using a gift does not change your auto-renew setting. When the 30 days end, the plan renews only if **Auto-renew from balance** is on. See [Subscriptions](/en/docs/subscriptions).

You can use a gift you bought on your own account. Its page shows **Use it on my own account**.

## Track your gifts

**Gifts you bought** on the **Plan** page lists every gift with its status.

| Status | Meaning |
| --- | --- |
| **Ready to use** | Paid and not used yet. The row has **Copy link**, **Copy code** and **Cancel**. |
| **Waiting to start** | The recipient saved it, and it starts when their current plan ends. The row shows their email, partly hidden, and **Cancel**. |
| **Used** | The plan started on the recipient's account. The row shows their email, partly hidden. |
| **Canceled** | You or the kenari team canceled the gift, and its price went back to your balance. |
| **Awaiting payment** | A QRIS or crypto payment you have not finished. **Continue payment** opens it. The row stays on the list for 24 hours. |

You get a notification and an email when the recipient uses or saves your gift. Notifications never include the gift code, so one sent to Telegram or a webhook does not expose it. Only the email you get when the gift is ready holds the code. See [Notifications](/en/docs/notifications).

On the **Usage** page, in the **Balance & billing** tab, the **Invoice** card lists your payment as **Gift Studio**. Once the plan starts, the recipient's card lists **Gift received: Studio**.

## Cancel a gift

You can cancel a gift until its plan starts, while it is **Ready to use** or **Waiting to start**. Choose **Cancel** on its row, then **Cancel gift**.

- The full price goes back to your kenari balance, not to the QRIS or crypto payment.
- The link and the code stop working.
- A gift that was **Waiting to start** is removed from the recipient's account, and they get a notification.

A gift whose plan has started cannot be canceled.

## Limits

- You can have up to 20 gifts at once that are **Ready to use** or **Waiting to start**, and buy up to 10 gifts in any 24 hours.
- Gifts are bought and used in your personal workspace. In a team workspace, the gift page shows **Use in Personal**, which switches to your personal workspace.
- For referral cashback, a gift counts as your paid spend on the day its plan starts. As with any spend, the part paid from balance that came from a bonus or from cashback earns nothing, and a gift canceled before it starts earns nothing. See [Codes and referral](/en/docs/codes-referral).

## See also

- [Subscriptions](/en/docs/subscriptions)
- [Codes and referral](/en/docs/codes-referral)
- [How billing works](/en/docs/billing)


---

# Codes and referral

A code can give you a plan discount, a free trial, a top-up bonus, balance or plan access. Referral codes connect two accounts so that both earn cashback on paid spend. This page covers how to enter a code, what each kind does, and how referral cashback works.

## Enter a code

There is one place to enter any kind of code: the **Have a code?** box on the **Balance** page. Type the code and choose **Check code**. You do not need to know what kind of code you hold. The **Plan** and **Share** pages show the code you are holding, but they have no box of their own.

## What each kind of code does

Once a code is checked, what happens next depends on what the code holds.

| Kind | What happens |
| --- | --- |
| Plan discount | The code is held as a **Code active** banner and you are taken to the **Plan** page. Each eligible plan card shows its discounted price, and the discount applies when you take a plan. |
| Free trial | Held the same way. The trial starts when you start it from the **Plan** page. |
| Top-up bonus | Held as the banner, and you are taken to the **Balance** page. The bonus amount appears as you type a top-up amount, and it is added when you top up. |
| Balance credit | The box shows what the code holds and waits for you to choose **Use code**. The credit is added right away. |
| Plan access | Same confirmation. You get a coupon: plan access for a set number of days. See [Subscriptions](/en/docs/subscriptions). |
| Token allocation | Same confirmation. You get a number of tokens, valid for a set number of days. |
| Referral code | Same confirmation. The code is attached to your account. See [Referral cashback](#referral-cashback). |
| Plan gift | The box shows what the gift gives you, what it does to your plan, and a **Use gift** button. A gift link, or a share link with a gift code, opens the gift page. See [Plan gifts](/en/docs/gifts). |
| Task campaign | The box links to **Open the campaign card** on the **Share** page, where the form to submit your entry is. |
| Attribution code | It was recorded when you signed up, so the box only says so and asks for nothing. |

Balance credit, plan access, token allocation and referral codes can be used once and cannot be undone, so they never run until you choose **Use code**.

The **Code active** banner shows the code and what it gives, and it has a **Remove** control. Only one code is active at a time. Checking a valid plan discount, free trial or top-up bonus code replaces the active one. A code that fails, or one that waits for **Use code**, leaves it alone. The banner stays while you move between dashboard pages, and a full page reload clears it.

If a code cannot be used, the box shows the reason: the code was not found, is no longer active, has expired, is not valid yet, has reached its usage limit, was already used by you, or does not apply to the plan.

## Share a code

A code has a share link in the form `kenari.id/code/CODE`. The link behaves like the box:

- Signed in, it checks the code and takes you to the page where the code can be used.
- Signed out, it sends you to sign in. If you sign up instead, the code is attached as soon as the account exists.
- A plan gift is the exception. It is never used at sign-up, only when you choose **Use gift**.

## Referral cashback

When the referral program is on, you earn cashback on what the friends you invite spend, and they earn the same cashback on their own spend.

1. **Create your code.** On the **Share** page, open **Invite friends** and choose **Create my code**. Share the code or use **Copy link**.
2. **Your friend attaches it.** The easiest way is to sign up through your link. A friend with an existing account can enter your code in the **Have a code?** box, choose **Check code**, then choose **Use code**. The same friend can instead enter it in **Referral code** under **Invite friends** and choose **Attach**. A referral code can be attached once, only within a limited time after sign-up, and cannot be changed. Two accounts cannot refer each other.
3. **Cashback accrues.** Each day, kenari adds up the paid spend of the accounts in a referral and credits cashback as a percentage of it. The rate depends on the plan of the account that created the code, and both sides earn the same rate. The **Share** page shows it next to the word **Cashback**. Live rates are in the plan table on [Subscriptions](/en/docs/subscriptions).
4. **Cashback lands in your balance.** Cashback is credited to your balance 3 days after the spend date. Cashback is earned on spend during the 12 months after your friend attached your referral code.

Only paid spend counts. That includes PAYG usage, plan purchases and renewals, Pods and domains. Usage on free models, plan quota and coupons earns nothing, and neither does spending balance that came from a bonus or from cashback. The amount credited can be lower than the rate suggests when the program's monthly limits apply.

The **Invite friends** card on the **Share** page shows your friends and your cashback, split into **Pending cashback** and **Cashback settled**. **Cashback history** lists every entry, marked **From a friend** or **From your own spend**. The card appears only while the program is on.

## See also

- [How billing works](/en/docs/billing)
- [Subscriptions](/en/docs/subscriptions)


---

# Integrations overview

Any tool that speaks the OpenAI or Anthropic API can use kenari by changing the base URL and the key. One `kn-` key works for every model and for both API formats. Create it under **API Keys** in the dashboard. See [Authentication and API keys](/en/docs/authentication).

## Base URLs

| Client type | Base URL | Examples |
| --- | --- | --- |
| OpenAI-compatible SDKs and tools | `https://kenari.id/v1` | OpenAI SDKs, OpenCode, Zoo Code, Hermes, OpenClaw, Kimi Code |
| OpenAI Responses clients | `https://kenari.id/v1` | Codex |
| Anthropic SDKs and Claude Code | `https://kenari.id` | Anthropic SDKs, Claude Code |

The Anthropic row is for the Anthropic SDKs and Claude Code, which add `/v1/messages` themselves, so their base URL has no `/v1`. If you add it, requests go to `/v1/v1/messages` and return `405`.

```python
import os
import anthropic
from openai import OpenAI

openai_client = OpenAI(base_url="https://kenari.id/v1", api_key=os.environ["KENARI_API_KEY"])
anthropic_client = anthropic.Anthropic(base_url="https://kenari.id", api_key=os.environ["KENARI_API_KEY"])
```

Valid model ids are listed in [Models and pricing](/en/docs/models). To test with zero balance, use a `:free` model such as `step-3-7-flash:free`.

## Dedicated guides

- [Claude Code](/en/docs/claude-code): three environment variables.
- [Codex](/en/docs/codex): a provider in `config.toml`.
- [kenari CLI](/en/docs/cli): mix native and kenari models per role.
- [magpie](/en/docs/magpie): add kenari to magpie with one import link.
- [MCP server](/en/docs/mcp): docs, model list and web tools for any MCP client.
- [Docs for AI agents](/en/docs/agent-docs): `llms.txt`, Markdown pages and OpenAPI.

## Claude Code Router

Claude Code Router (CCR) can add kenari as a provider from a link. On a machine with CCR installed, open [add kenari to CCR](ccr://provider?manifest=https%3A%2F%2Fkenari.id%2F.well-known%2Fccr-provider.json). The link points CCR at a public manifest at `https://kenari.id/.well-known/ccr-provider.json`. The manifest holds the base URL, the API format and the chat models that support function calling, so you set nothing by hand. CCR shows a preview before it saves anything, and asks for your `kn-` key in the confirmation dialog. The key is not part of the link or the manifest.

The manifest needs no key and refreshes every few minutes. CCR reads it when you open the link, so open the link again to pick up new models.

If the link does nothing, CCR is not installed or does not handle the `ccr://` scheme. Use [Claude Code](/en/docs/claude-code) with environment variables instead.

## OpenCode

In OpenCode, type `/connect`, pick **Kenari** from the provider list, paste your key, and choose a model. Setting `KENARI_API_KEY` in your environment also works without `/connect`. For `opencode.json` configuration, follow the [OpenCode docs](https://opencode.ai/docs).

## OmniRoute

Run `omniroute setup` or open the OmniRoute dashboard, pick **Kenari** from the provider list, and paste your key. Every catalog model becomes available through your OmniRoute endpoint, together with OmniRoute's own fallback rules. See the [OmniRoute docs](https://omniroute.dev).

## Zoo Code

Open Zoo Code settings, pick **Kenari** under **API Provider**, paste your key, and choose a model. Zoo Code loads the model list from `/v1/models`, so new models appear without an extension update. See the [Zoo Code guide](https://docs.zoocode.dev/providers/kenari).

## Hermes

Hermes accepts an OpenAI-compatible endpoint. Use `https://kenari.id/v1` as the base URL and your kenari key as `OPENAI_API_KEY`. The exact setup differs between versions, so follow the [Hermes docs](https://hermes.nousresearch.com/docs).

## OpenClaw

OpenClaw takes a custom provider through the flags `--custom-base-url`, `--custom-provider-id`, `--custom-model-id` and `--custom-compatibility`. Set the base URL to `https://kenari.id/v1` and the key to your `kn-` key. See the [OpenClaw docs](https://openclaw.dev) for the exact format.

## Kimi Code

Kimi Code can import kenari as a provider from a public registry. Run:

```bash
kimi provider add https://kenari.id/.well-known/models.dev.json
```

The registry lists every kenari chat model with its context window, its longest output, and whether it reads images and calls functions. It needs no key and refreshes every few minutes. Kimi Code checks the same URL again on later starts, so new models appear without another command.

The import does not store your key. Add one line to the `[providers.kenari]` section of `~/.kimi-code/config.toml`, and export the key in your shell:

```toml
api_key_env = "KENARI_API_KEY"
```

Then pick a kenari model with `/model`, or start Kimi Code with `-m` and a model name from the list. The registry shows `0` for prices because kenari bills in Rupiah. See [Models and pricing](/en/docs/models) for the real prices. For the other options of `kimi provider`, follow the [Kimi Code docs](https://moonshotai.github.io/kimi-code/en/reference/kimi-command).

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| `401` with `invalid key` or `missing authorization` | The key is not set, did not load, or does not start with `kn-`. | Copy the key again from **API Keys**, then restart the tool or open a new terminal. |
| `400` with `model_not_found` | The model id is misspelled or not in the catalog. | Copy the id from [Models and pricing](/en/docs/models). |
| `402` with `insufficient_balance` | The model is paid and your available balance cannot cover the estimated cost of the request. | Use a `:free` model, shorten the prompt, or top up. See [How a chat request is charged](/en/docs/billing#how-a-chat-request-is-charged). |
| `405` | The base URL has the wrong shape, such as `/v1/v1` or a missing `/v1`. | Use `https://kenari.id/v1` for OpenAI-compatible clients and `https://kenari.id` for the Anthropic SDKs and Claude Code. In CCR, keep the base URL from its integration link. |

## See also

- [Quickstart](/en/docs/quickstart)
- [Models and pricing](/en/docs/models)
- [Errors](/en/docs/errors)
- [Rate limits](/en/docs/rate-limits)


---

# Claude Code

Claude Code speaks the Anthropic API format, which kenari serves at `/v1/messages`. To connect, set three environment variables. Claude Code needs a chat model that supports tool calling. Check that `tool_call` is `true` for the model in the live catalog:

```bash
curl -s https://kenari.id/v1/models \
  | jq -r '.data[] | select(.tool_call == true) | .id'
```

A request with tools to a model without tool calling returns `400`, and so do image, speech, transcription and video ids.

## Set the variables

You need an API key in `KENARI_API_KEY`. See [Quickstart](/en/docs/quickstart) to create one and export it.

```bash
export ANTHROPIC_BASE_URL=https://kenari.id
export ANTHROPIC_AUTH_TOKEN=$KENARI_API_KEY
export ANTHROPIC_MODEL=step-3-7-flash:free
```

| Variable | Value |
| --- | --- |
| `ANTHROPIC_BASE_URL` | `https://kenari.id`, with no `/v1`. Claude Code adds `/v1/messages` itself. |
| `ANTHROPIC_AUTH_TOKEN` | Your kenari API key, starting with `kn-`. See [Authentication and API keys](/en/docs/authentication). |
| `ANTHROPIC_MODEL` | A model id from [Models and pricing](/en/docs/models). `step-3-7-flash:free` runs with zero balance. |

`ANTHROPIC_API_KEY` also works in place of `ANTHROPIC_AUTH_TOKEN`. kenari accepts the key from either the `Authorization` header or the `x-api-key` header.

### Keep the variables between sessions

Add the `export` lines to your shell profile, such as `~/.zshrc` or `~/.bashrc`, together with `export KENARI_API_KEY=kn-...` so that the source variable exists in every new terminal. Then open a new terminal. Or put them in the `env` block of Claude Code's `settings.json`:

```json
{
  "env": {
    "ANTHROPIC_BASE_URL": "https://kenari.id",
    "ANTHROPIC_AUTH_TOKEN": "kn-...",
    "ANTHROPIC_MODEL": "step-3-7-flash:free"
  }
}
```

A `settings.json` file stores the key in plain text, so keep it out of version control.

### Keep every request on your model

`ANTHROPIC_MODEL` sets the main conversation only. Claude Code still sends some requests to Claude models: background tasks run on the `haiku` model, and a subagent runs on another model when Claude or the subagent's definition asks for one. kenari serves Claude models too, so those requests succeed and are billed at that model's price. To send every request to one model, point every alias at the same id and force that id onto subagents:

```bash
export ANTHROPIC_DEFAULT_FABLE_MODEL=step-3-7-flash:free
export ANTHROPIC_DEFAULT_OPUS_MODEL=step-3-7-flash:free
export ANTHROPIC_DEFAULT_SONNET_MODEL=step-3-7-flash:free
export ANTHROPIC_DEFAULT_HAIKU_MODEL=step-3-7-flash:free
export CLAUDE_CODE_SUBAGENT_MODEL=step-3-7-flash:free
export CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1
```

`CLAUDE_CODE_SUBAGENT_MODEL_FORCE` needs Claude Code v2.1.257 or later. Without it, a subagent that asks for a full Claude model id still runs on that model. In `settings.json`, add the same variables to the `env` block and write the `1` as a string, `"1"`.

## Verify the connection

If you set environment variables, check that the key is set without printing it. Use `ANTHROPIC_API_KEY` in place of `ANTHROPIC_AUTH_TOKEN` if that is the one you chose. If you use `settings.json`, skip this check.

```bash
test -n "$ANTHROPIC_AUTH_TOKEN" && echo set
```

Then start Claude Code and send any message:

```bash
claude
```

A reply means the connection works.

## Token counting

Claude Code asks `POST /v1/messages/count_tokens` before each turn to size its context window. kenari answers with an estimate and does not bill the call. It still needs your key.

## Choose between this and the kenari CLI

The variables above send every Claude Code session to one model. The [kenari CLI](/en/docs/cli) routes only the sessions you start with `kenari claude`, and lets you put a different model behind each role, such as `opus` or `subagents`. Pick one. If you leave the variables set, `kenari claude` overrides them for its own session.

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| `401` with `invalid key` or `missing authorization` | The variable did not load, or the key does not start with `kn-`. | Use the key you saved, then open a new terminal. A lost key cannot be shown again, so create a new one. See [Authentication and API keys](/en/docs/authentication). |
| `400` with `model_not_found` | The model id is misspelled or not in the catalog. | Copy the id from [Models and pricing](/en/docs/models). |
| `402` with `insufficient_balance` | The model is paid and your balance is empty. | Use a `:free` model, or top up. See [How billing works](/en/docs/billing). |
| `405` | The base URL ends in `/v1`, so the request goes to `/v1/v1/messages`. | Set `ANTHROPIC_BASE_URL` to `https://kenari.id`. |
| `model_not_found` for a model you did not choose | Claude Code also sends background requests to a small default model that kenari may not carry. | Set `ANTHROPIC_DEFAULT_HAIKU_MODEL` to a catalog model id. |
| Usage shows a Claude model you did not choose | Claude Code sent subagent or background requests to its default Claude models. | Set the variables in [Keep every request on your model](#keep-every-request-on-your-model). |

## See also

- [kenari CLI](/en/docs/cli)
- [Messages](/en/docs/messages)
- [Integrations overview](/en/docs/integrations)
- [Errors](/en/docs/errors)


---

# Codex

Codex CLI talks to kenari through a custom provider in `~/.codex/config.toml`. Recent Codex versions only use the Responses API format, so the provider must point at kenari's `/v1/responses` endpoint. See [Responses](/en/docs/responses) for the endpoint itself.

## Configure the provider

Create or edit `~/.codex/config.toml`:

```toml
model = "step-3-7-flash:free"
model_provider = "kenari"

[model_providers.kenari]
name = "kenari"
base_url = "https://kenari.id/v1"
wire_api = "responses"
env_key = "KENARI_API_KEY"
requires_openai_auth = false
```

| Setting | Value |
| --- | --- |
| `model` | A model id from [Models and pricing](/en/docs/models). `step-3-7-flash:free` runs with zero balance. |
| `base_url` | `https://kenari.id/v1`. Unlike Claude Code, Codex needs the `/v1`. |
| `wire_api` | `responses`. Codex no longer accepts `chat`. |
| `env_key` | The name of the environment variable that holds your key. |

Then export your key in the shell that runs Codex:

```bash
export KENARI_API_KEY=kn-...
```

Create the key under **API Keys** in the dashboard. See [Authentication and API keys](/en/docs/authentication).

### Store the key in auth.json instead

If you would rather keep the key in `~/.codex/auth.json`, set `requires_openai_auth = true` and remove the `env_key` line. Then store the kenari key in that file by running this command, which reads the key from the environment variable and writes it to `auth.json`:

```bash
printenv KENARI_API_KEY | codex login --with-api-key
```

The file should then hold the kenari key and nothing else:

```json
{ "OPENAI_API_KEY": "kn-..." }
```

The field is named `OPENAI_API_KEY` because Codex uses that name for any API key. The value is your kenari key.

## Verify the connection

Start Codex and send a short prompt:

```bash
codex
```

A reply means the connection works.

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| `401` with `invalid key` or `missing authorization` | `KENARI_API_KEY` is not set in this shell, or the key does not start with `kn-`. | Export the key again, then restart Codex. |
| `400` with `model_not_found` | The model id is misspelled or not in the catalog. | Copy the id from [Models and pricing](/en/docs/models). |
| `402` with `insufficient_balance` | The model is paid and your balance is empty. | Use a `:free` model, or top up. See [How billing works](/en/docs/billing). |
| `405` | `base_url` has a doubled `/v1` or is missing it. | Set `base_url` to exactly `https://kenari.id/v1`. |

## See also

- [Responses](/en/docs/responses)
- [kenari CLI](/en/docs/cli)
- [Integrations overview](/en/docs/integrations)
- [Errors](/en/docs/errors)


---

# kenari CLI

`@kenarihq/cli` is the official kenari wrapper for using native models and kenari models in one Claude Code, Codex or Grok Build session. Plain `claude`, `codex` and `grok` commands keep using their native provider. kenari routing applies only when you start the tool through the wrapper.

To send every session to one kenari model instead, configure the tool directly: Claude Code uses environment variables and Codex uses its configuration file. See [Claude Code](/en/docs/claude-code) or [Codex](/en/docs/codex).

## Prerequisites

The CLI needs Node.js 18 or newer, and the tool you want to run must already be installed. The CLI starts that tool and does not install it. Check Node.js:

```bash
node --version
```

If Node.js is missing, install it from [nodejs.org](https://nodejs.org) or with your package manager, for example `brew install node` or `nvm install --lts`.

## Install

```bash
npm install -g @kenarihq/cli
```

`kenari update` upgrades the global install. `kenari update --check` reports the current and latest versions without installing. It refuses to run on a git checkout.

## Sign in

```bash
kenari login
```

This opens the kenari approval page in your browser. Click **Approve access**, and the CLI receives a new API key through a local callback on `127.0.0.1` and stores it in `~/.kenari/credentials.json`. The key is never printed in full. The callback needs a browser on the same machine as the CLI, and the sign-in times out after 5 minutes.

The new key appears under **API Keys** in the dashboard. You can revoke it at any time like any other key, and requests made with it follow the same billing as your other keys. This key can also create a separate key for each tool. See [Per-tool keys](/en/docs/account-keys).

### On a server or over SSH

On a server, container or SSH session there is no local browser. Create a key on the [API Keys](https://kenari.id/keys) page, then run:

```bash
kenari login --api-key
```

The prompt hides what you paste and shows one `*` per character, so you can see the paste landed. When stdin is not a terminal, the same flag reads the key from stdin, which suits an entrypoint or a CI step:

```bash
echo "$KENARI_KEY" | kenari login --api-key
```

Never pass the key as an argument. `kenari login --api-key kn-...` is rejected, because a key on the command line is saved in your shell history and is visible in `ps` to every user on the machine.

The old `--no-browser`, `--paste` and `--stdin` flags were removed. Use `--api-key` instead.

If your home directory is read-only, as in many containers, point `KENARI_HOME` at a writable path before you log in:

```bash
export KENARI_HOME=/tmp/kenari
echo "$KENARI_KEY" | kenari login --api-key
```

### Check the sign-in

```bash
kenari status --check
```

The `credential` line reads `stored` when a key is saved and `missing` when it is not. With `--check`, the command also reaches out over the network and prints one `check` line per target: `router`, `native_anthropic`, `native_openai`, `native_xai`, `kenari` and `catalog`. Each reads `ok` or `failed` with a reason. The `kenari` and `catalog` checks load the model catalog with your key. They do not prove that your balance covers a paid model. The command exits with a non-zero code when any check fails. Add `--json` for machine-readable output.

## Configure roles

```bash
kenari configure
```

The command asks which tool and which roles should use kenari. If more than one tool is installed, it asks which one to configure and defaults to all of them. If only one is installed, kenari selects it. In scripts, name the tool: `claude`, `codex` or `grok`.

Each role either stays `native` or uses a fixed model with the value `kenari/<model-id>`:

| Tool | Roles |
| --- | --- |
| Claude Code | `main`, `opus`, `sonnet`, `haiku`, `fable`, `subagents` |
| Codex | `main`, `review`, `subagents` |
| Grok Build | `main` |

`native` is the default. A `native` role keeps using the tool's own provider and sign-in, so sign in to Claude Code, Codex or Grok as you normally do. In Codex, `main` also accepts `picker`, which leaves the model choice to Codex, and `review` and `subagents` accept `inherit`, which uses the same model as the session.

Grok subagents use the same model as the session, so kenari copies `main` onto that slot and does not ask for it. Requests that Grok sends to kenari include kenari web search, and requests that stay native keep Grok's own search. If every Grok role is kenari, `kenari login` is enough. A native Grok role still needs `grok login` or `XAI_API_KEY`.

Non-interactive runs need `--yes` and every role of the tool. Grok only needs `--main`:

```bash
kenari configure claude \
  --main native \
  --opus native \
  --sonnet kenari/step-3-7-flash:free \
  --haiku native \
  --fable native \
  --subagents native \
  --yes

kenari configure codex \
  --main picker \
  --review inherit \
  --subagents kenari/step-3-7-flash:free \
  --yes

kenari configure grok \
  --main kenari/step-3-7-flash:free \
  --yes
```

A model id with the `kenari/` prefix always goes to kenari, and the wrapper never falls back to another provider. In Codex and Grok, an id without the prefix always stays native. In Claude Code, an id without the prefix on a main turn stays native, while the aliases `opus`, `sonnet`, `haiku`, `fable`, `best` and `opusplan`, and side requests such as compaction or subagents, follow the matching kenari slot. Choose ids from [Models and pricing](/en/docs/models), or run `kenari models`.

### Reasoning effort per role

A role that uses a kenari model can pin a reasoning level, and every request sent to that role uses it. Add `--<role>-effort`, for example:

```bash
kenari configure claude \
  --main native \
  --opus native \
  --sonnet kenari/glm-5-2 --sonnet-effort max \
  --haiku native \
  --fable native \
  --subagents native \
  --yes
```

The interactive `kenari configure` offers the same choice after you pick a model. The pin overrides the level the session asks for. kenari does not validate the level. If the model does not support it, the gateway uses the nearest supported level, and `kenari status` shows both values:

```text
effort      kenari/glm-5-2 requested=max (pinned) gated=xhigh 200 4m ago
```

`requested` is the level that reached the gateway and `gated` is the level it applied. `unset` means no level was sent, and `unreported` means the gateway did not say.

See the active routes per role:

```bash
kenari status
```

## Run the wrapper

```bash
kenari claude [args...]
kenari codex [args...]
kenari grok [args...]
```

Each launch starts a private router on a random `127.0.0.1` port, starts the original tool with settings that apply only to that process, forwards your terminal input and signals, and stops the router when the tool exits. The wrapper does not write provider settings or credentials into Claude Code, Codex or Grok configuration.

If `~/.claude/settings.json` still contains `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_API_KEY` or `apiKeyHelper`, `kenari claude` warns and ignores those values for the session. It does not edit the file.

To verify, send a first message inside the wrapped tool. A `401` means the credential is missing or revoked, so run `kenari login` again.

## Other commands

```bash
kenari status               # show the routing configuration, offline
kenari status --check       # also check the network, exits non-zero on failure
kenari models               # list models with prices
kenari reset [claude|codex|grok] # remove routes, keep the login
kenari logout               # remove the kenari credential from this machine
kenari update --check       # compare the installed and latest version
```

`kenari reset` does not change the tool's native configuration or your login. `kenari logout` removes only the kenari credential, so native roles keep working.

`kenari configure` downloads the model catalog and keeps it for 24 hours. A configuration with only native roles works without a login or catalog. A role that uses a kenari model fails to start when there is no valid catalog or credential, or when the model no longer exists.

## Files

The CLI keeps its own files in `~/.kenari`: `config.json`, `credentials.json`, `model-cache.json` and `state.json`. On macOS and Linux the directory is readable only by you (mode `0700`) and so are the files (mode `0600`). The key lives only in `credentials.json`, never in Claude Code, Codex or Grok configuration, command arguments or logs.

`KENARI_HOME` moves this directory. `KENARI_BASE_URL` overrides the kenari gateway, and plain HTTP is rejected unless `KENARI_ALLOW_HTTP=1` is set for local development. `CLAUDE_CONFIG_DIR`, `CODEX_HOME` and `GROK_HOME` point the CLI at non-default configuration directories of those tools.

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| `command not found: kenari` | The CLI is not installed, or `PATH` does not include the npm global prefix. | Reinstall with `npm install -g @kenarihq/cli`. Run `npm config get prefix` and add `<prefix>/bin` to `PATH`. |
| `credential  missing` in `kenari status` | No key is stored. | Run `kenari login`, or `kenari login --api-key` on a server. |
| `kenari login` never finishes | There is no browser on this machine, or the sign-in timed out after 5 minutes. | Use `kenari login --api-key` with a key from the [API Keys](https://kenari.id/keys) page. |
| `--paste was removed` or the same message for `--stdin` or `--no-browser` | You used a flag from an older CLI. | Use `kenari login --api-key`. |
| `--api-key takes no value` | You passed the key as an argument. | Run `kenari login --api-key` and paste at the prompt, or pipe the key on stdin. |
| `Kenari login required` | A role uses a kenari model and no key is stored. | Run `kenari login`. |
| `model "kenari/<id>" is not in the Kenari catalog` | The id is misspelled or the model was removed. | Run `kenari models`, then `kenari configure` with a listed id. |
| `check kenari failed: ...` in `kenari status --check` | The CLI cannot reach kenari, or the key was rejected. | Check your network, proxy and firewall, then log in again if the key was revoked. |
| `401` in a wrapped session | The key was revoked in the dashboard. | Run `kenari logout`, then `kenari login`. |
| `402` in a wrapped session | A configured model is paid and your balance is Rp 0. | Switch the role to `kenari/step-3-7-flash:free`, or top up. See [How billing works](/en/docs/billing). |

## See also

- [Claude Code](/en/docs/claude-code)
- [Codex](/en/docs/codex)
- [Models and pricing](/en/docs/models)
- [Authentication and API keys](/en/docs/authentication)


---

# magpie

magpie is a local gateway that points coding agents, such as Claude Code, Codex and OpenCode, at the provider and model you pick. kenari works in magpie as a provider. One link fills in the base URLs, and you paste your own API key.

## Add kenari with the import link

1. Install magpie from [usemagpie.ai](https://usemagpie.ai) and open it once.
2. Open the link [Add kenari to magpie](https://usemagpie.ai/import#name=kenari&chat=https%3A%2F%2Fkenari.id%2Fv1&responses=https%3A%2F%2Fkenari.id%2Fv1&anthropic=https%3A%2F%2Fkenari.id&website=https%3A%2F%2Fkenari.id&keys=https%3A%2F%2Fkenari.id%2Fkeys&icon=https%3A%2F%2Fkenari.id%2Fbrand%2Fmark.png). The page shows that requests go to kenari.id and that no API key is included. Press the button if magpie does not open by itself.
3. Paste a kenari API key from [API Keys](https://kenari.id/keys) and press **Add**.

magpie reads the model list from `https://kenari.id/v1/models`, so new kenari models show up without a new link.

## Add kenari from the terminal

Run this command to add the same provider from a terminal, with your own key in place of `kn-YOUR-KEY`:

```bash
magpie provider add kenari \
  url=https://kenari.id/v1 \
  responses=https://kenari.id/v1 \
  anthropic=https://kenari.id \
  key=kn-YOUR-KEY
```

## Base URLs

| API format | Base URL |
| --- | --- |
| OpenAI Chat Completions | `https://kenari.id/v1` |
| OpenAI Responses | `https://kenari.id/v1` |
| Anthropic Messages | `https://kenari.id` |

The Anthropic base URL has no `/v1`, because the client adds `/v1/messages` itself.

## Pitfalls

- Keep the Anthropic URL set. magpie then sends Claude models over Messages, which keeps prompt caching (Cache read is billed far below Input). Without it, Claude goes over Chat Completions and the cache markers your agent sets are dropped. See [Prompt caching](/en/docs/prompt-caching).
- A `401` means the key is not a kenari key. kenari keys start with `kn-`.
- Cost figures inside magpie do not come from kenari. Your real spend is on the [Usage](https://kenari.id/usage) page.

## See also

- [Integrations overview](/en/docs/integrations)
- [Claude Code](/en/docs/claude-code)
- [Codex](/en/docs/codex)


---

# MCP server

kenari runs a Model Context Protocol server that gives an agent the kenari documentation, the live model list, your account figures, web search, page fetch and X search. Connect it to Claude Code, Cursor or another MCP client, and the agent can look things up and check your balance without leaving its session.

The server is at `https://kenari.id/mcp`. It uses the Streamable HTTP transport: the client sends a JSON-RPC 2.0 message with `POST` and gets one JSON response back. There is no server-initiated stream, so `GET /mcp` returns `405`.

## Connect a client

The two documentation tools need no key. Add a key to use the rest. Create one under **API Keys** in the dashboard. See [Authentication and API keys](/en/docs/authentication).

### Claude Code

```bash
claude mcp add --transport http kenari https://kenari.id/mcp \
  --header "Authorization: Bearer $KENARI_API_KEY"
```

Your shell expands `$KENARI_API_KEY` when you run the command, so the key is stored in Claude Code's MCP config. Leave out `--header` to use the public tools only.

### Cursor

Add the server to `~/.cursor/mcp.json`, or to `.cursor/mcp.json` in a project:

```json
{
  "mcpServers": {
    "kenari": {
      "url": "https://kenari.id/mcp",
      "headers": { "Authorization": "Bearer kn-..." }
    }
  }
}
```

### Other clients

Clients that document the `mcpServers` schema, such as Cursor, accept the entry above. For any other client that supports remote MCP servers over HTTP, set the URL `https://kenari.id/mcp` and an `Authorization: Bearer $KENARI_API_KEY` header in that client's own settings.

## Send your key

Set the key once, in the headers of the server entry. It is never a tool argument. kenari reads it from the `Authorization` header, with or without the `Bearer ` prefix, or from `x-api-key` if your client sends that style. When both are present, `Authorization` wins. A request with no valid key can still call the public tools. A tool that needs a key returns an error telling you to set the header.

## Tools

| Tool | Needs a key | Billed | What it does |
| --- | --- | --- | --- |
| `kenari_search_docs` | No | No | Searches this documentation and returns the most relevant sections. |
| `kenari_list_models` | No | No | Lists models with live prices in Rupiah per 1,000,000 tokens. |
| `kenari_balance` | Yes | No | Returns your balance in Rupiah. Refused for a restricted key. The HTTP equivalent is [Account balance](/en/docs/account-balance). |
| `kenari_usage` | Yes | No | Returns your usage for the last 30 days: requests, tokens and cost per model. For a restricted key, it covers only that key. The HTTP equivalent is [Usage](/en/docs/usage). |
| `kenari_quota` | Yes | No | Returns your plan and coupon quota. Refused for a restricted key. The HTTP equivalent is [Account quota](/en/docs/account-quota). |
| `kenari_web_search` | Yes | Per call | Searches the web and returns titles, URLs and snippets. |
| `kenari_web_fetch` | Yes | Per call | Fetches one URL and returns its text. |
| `kenari_x_search` | Yes | Per call | Searches live posts on X and returns a short answer with sources. |

Billed tools report the fee they charged at the end of the result.

### Arguments

| Tool | Arguments |
| --- | --- |
| `kenari_search_docs` | `query` (string, required). |
| `kenari_list_models` | `search` (string, optional). Filters by model id or provider. |
| `kenari_web_search` | `query` (string, required) and `max_results` (integer, 1 to 10, default 5, and a value outside that range is moved to the nearest limit). |
| `kenari_web_fetch` | `url` (string, required). |
| `kenari_x_search` | `query` (string, required) and `x_search_filter` (object, optional). |
| `kenari_balance`, `kenari_usage`, `kenari_quota` | None. |

`x_search_filter` takes the same fields as `POST /v1/x/search`. `allowed_x_handles` and `excluded_x_handles` each hold at most 20 handles without the `@` and cannot be used together. `from_date` and `to_date` are `YYYY-MM-DD`. `enable_image_understanding` and `enable_video_understanding` read the media inside posts. See [Web and X search](/en/docs/web-search) for the full table.

## Billing

The three search tools are billed per call from your account, the same as the endpoints in [Web and X search](/en/docs/web-search). Web search and page fetch share one daily plan allowance, and `kenari_x_search` is billed from your balance only. A call that fails is not charged. See [How billing works](/en/docs/billing).

## Shared API keys

A shared API key cannot access the owner's financial information. `kenari_balance`, `kenari_usage` and `kenari_quota` are refused for a shared API key, because they would reveal the owner's balance, usage and quota. The search tools still work with a shared API key, within what the share allows. Use a key from your own account to read those figures.

A restricted key, meaning one with a model restriction, a spend cap or a token cap, is refused by `kenari_balance` and `kenari_quota`. `kenari_usage` works but shows only that key's own usage. See [Account overview](/en/docs/account).

## See also

- [Docs for AI agents](/en/docs/agent-docs)
- [Web and X search](/en/docs/web-search)
- [Authentication and API keys](/en/docs/authentication)
- [Claude Code](/en/docs/claude-code)


---

# Docs for AI agents

These docs are available in forms that an AI agent can load without a browser or a key: plain-text indexes, one Markdown file per page, and an OpenAPI spec. Point your agent at them instead of a rendered page, because Markdown is far smaller than HTML and costs far fewer tokens.

## llms.txt and llms-full.txt

`/llms.txt` is a short index. It has instructions for agents, a link to every docs page and its Markdown version, and the list of API endpoints. `/llms-full.txt` is the whole documentation in one file, for an agent that wants everything in a single read.

```bash
curl https://kenari.id/llms.txt
curl https://kenari.id/llms-full.txt
```

## Pages as Markdown

Add `.md` to the URL of any docs page to get clean Markdown, for example `https://kenari.id/en/docs/chat.md`. `https://kenari.id/docs.md` holds every page in one Markdown file.

```bash
curl https://kenari.id/en/docs/chat.md
curl https://kenari.id/docs.md
```

The Markdown files and `llms-full.txt` are generated from the English pages. The Indonesian URLs, such as `https://kenari.id/docs/chat.md`, serve the same English text.

## OpenAPI

`https://kenari.id/openapi.json` is the OpenAPI 3.1 spec for the `/v1` API. It lists every `/v1` endpoint with its request and response shapes, the error format, and the bearer scheme for `kn-` keys. Use it with code generators, Postman and API explorers.

```bash
curl https://kenari.id/openapi.json
```

## Live model list

Do not copy model ids or prices from a page. `GET /v1/models` is public and returns the current models with their prices in Rupiah. See [Models and pricing](/en/docs/models).

```bash
curl https://kenari.id/v1/models
```

## MCP server

An agent that speaks MCP can search these docs, read the model list and call search tools through `https://kenari.id/mcp`. See [MCP server](/en/docs/mcp) for setup and the tool list.

## Instructions to give an agent

Paste this block into the system prompt, `AGENTS.md` or `CLAUDE.md` of any agent that will use kenari.

```text
You are working with the kenari API (https://kenari.id), an LLM gateway billed in Rupiah.
- Docs index: https://kenari.id/llms.txt. Full reference in one file: https://kenari.id/llms-full.txt.
- Add .md to a docs page URL to read it as Markdown, for example https://kenari.id/en/docs/chat.md.
- API spec: https://kenari.id/openapi.json.
- Live model list and prices: GET https://kenari.id/v1/models. Never hardcode model ids or prices.
- OpenAI-compatible clients use the base URL https://kenari.id/v1.
- Anthropic SDKs and Claude Code use the base URL https://kenari.id, without /v1.
- Authenticate with the header Authorization: Bearer $KENARI_API_KEY. Never print or commit the key.
- Test with a :free model id first.
- MCP server: https://kenari.id/mcp.
```

## See also

- [MCP server](/en/docs/mcp)
- [Models and pricing](/en/docs/models)
- [Quickstart](/en/docs/quickstart)


---

# Domains

Register and manage domain names from the kenari dashboard: search, register, edit DNS, renew and move a domain away. Domain purchases are paid from the same prepaid balance as the AI Gateway. Open **Domain** in the dashboard menu to start.

## Register a domain

1. Under **Search for a domain name**, type a name such as `mybusiness` and press **Search**. kenari checks every extension on sale and shows each result as **Available** or **Already registered**, with the price per year.
2. Press **Register** next to the extension you want. The **Register domain** dialog has three steps: **Configuration** (registration period and WHOIS protection), **Registrant** (your contact details) and **Confirmation**.
3. Read the consent line, then press **Register and pay**.

The total is taken from your balance at once. If your balance is too low, kenari sends you to top up first. If the price changed after you opened the dialog, kenari asks you to confirm again at the new price before anything is charged. The domain shows **Processing** until the registrar finishes, then **Active**. If the registrar rejects the registration, the charge is refunded.

Your registrant details are saved and filled in the next time. For an organization, also enter **Company name** and **Person in charge**. By confirming, you accept that registrant data is shown publicly in WHOIS under PANDI rules, and that a purchase cannot be refunded after registration succeeds.

**Protect WHOIS data** hides registrant data from the public. It is free, and only offered for extensions that support it. `.id` extensions do not.

## Extensions and documents

The search shows which extensions are on sale and their current prices, so the list is not repeated here. The `.id` family comes in several second-level forms, such as `.co.id`, `.or.id`, `.sch.id`, `.web.id` and `.my.id`.

Extensions for organizations and institutions, such as `.co.id`, `.or.id`, `.ac.id`, `.sch.id` and `.ponpes.id`, need a supporting document. The dashboard tells you when one is required: the registrant form asks for **Company name**, and the domain gets a **Verification** tab.

1. Open the domain from **My domains** with **Manage**, then open the **Verification** tab.
2. Upload the document as a PDF of up to 1 MB with **Submit document**.
3. Wait for **Verification status** to reach **Verified**. Verification usually takes 1 to 3 business days. The domain shows **Needs verification** until then.

<aside><strong>Note</strong> Use a clear, complete scan. An unreadable or incomplete file fails verification and has to be uploaded again.</aside>

## Manage DNS

Open the **DNS** tab on the domain page. Each record has a **Name**, **Type**, **Content** and **TTL**. Supported types:

| Type | Common use |
| --- | --- |
| `A` | Point a name to an IPv4 address |
| `AAAA` | Point a name to an IPv6 address |
| `CNAME` | Alias to another host name |
| `MX` | Incoming email server |
| `TXT` | Ownership verification, SPF, DKIM |
| `NS` | Delegate a subdomain to other nameservers |
| `SRV` | Service location for a protocol |
| `CAA` | Which certificate authorities may issue certificates |

Press **Add record**, fill in the **Type**, **Name** (`@` for the domain itself, or a subdomain), **Content** and **TTL (seconds)**, and save. TTL accepts 60 to 604,800 seconds. Resolvers around the internet can keep an old answer until the previous TTL runs out.

<aside><strong>Tip</strong> Lower the TTL to 300 seconds at least one old-TTL interval before you move servers, so resolvers drop the old answer in time. Change the destination only after that interval has passed. Raise the TTL to 3,600 or more afterwards so resolvers cache the records longer.</aside>

DNS records, nameservers, locks and the authorization code cannot be changed while a domain is held, suspended, deleted or transferred out, or when its registration did not complete. The dashboard shows a message, and for a held or suspended domain it tells you to contact support.

To use other nameservers, open the **Nameserver** tab, choose **Custom nameservers**, enter at least two and press **Save nameservers**. While custom nameservers are active, the kenari DNS editor is unavailable and you manage records at your nameserver provider. Choose **kenari nameservers** to switch back.

## Renew a domain

New domains have **Auto-renew** switched on, and you can switch it off on the domain page.

- **Auto-renew on.** kenari reminds you 30, 7 and 1 day before expiry. From 30 days before expiry it renews the domain for one year at the current renewal price, taken from your balance. If your balance is too short, the domain is not renewed and you get a notification. kenari tries again, so topping up before expiry is enough. Auto-renew does not run while your account is suspended.
- **Auto-renew off.** You get the same reminders, and you renew by hand.

To renew by hand, open the **Summary** tab, choose a number of years under **Renew domain** and press the button that shows the period, such as **Renew 2 years**. Confirm with **Renew and pay**. Renewal prices are shown per period and can differ from the registration price. Your payments are listed under **Balance & billing**.

If a domain is not renewed in time, it shows **Expired**. It then enters the **Redemption period**, when you can still get it back: open the **Summary** tab and press **Restore domain**. The restore fee is shown there. After that the domain is **Deleted**, and anyone can register the name again.

## Move a domain away

To transfer a domain to another registrar:

1. Open the **Security** tab. Under **Domain locks**, turn off **Transfer lock**.
2. Under **Authorization code (EPP)**, press **Show code**, and copy it. The code is not stored. Use **Reset code** to invalidate the old one and get a new one.
3. Give the code to the new registrar and start the transfer there.

**Registrar lock**, in the same section, is on by default and prevents a registrar change without authorization. Once the transfer completes, the domain shows **Transferred out**.

Transferring a domain in to kenari is not available from the dashboard. Open the **Help** menu and create a ticket if you need it.

## See also

- [How billing works](/en/docs/billing)
- [Pods](/en/docs/pods)


---

# Pods

A pod is a container that runs a ready-made app for you, such as an AI agent or a workflow tool, on kenari infrastructure. You pick an app from a catalog, kenari starts it, and you reach it at its own address. There is no server to manage. Each pod is billed every 30 days from your kenari balance. Open **Pods** in the dashboard menu to start.

## Choose an app

The catalog lists the apps kenari has tested, for example Hermes Agent, WhatsApp Gateway, n8n and OpenClaw. Each app has a description, a minimum tier and its own settings. If the app you need is not listed, use **Request it via Help** on the create page.

## Create a pod

1. Press **Create Pod**.
2. Under **1. Choose an app**, pick an app.
3. Under **2. Choose a tier**, pick the CPU, memory and disk you need. Each tier shows its monthly price. A tier below the app's minimum is marked **Minimum** with the tier name, and a tier the servers have no room for is marked **Not enough server capacity right now**.
4. Under **3. Configuration**, fill in the app's settings. Required settings must be filled. For a setting that takes a kenari key, **Create kenari key** makes one for you, saves it under **API Keys** and deletes it when you delete the pod. For a setting that takes a model, pick one from the kenari catalog or type an id.
5. Press **Create Pod** and confirm.

The first month's price is taken from your balance when the pod is created. If your balance is too low, the page asks you to top up and then try again.

When the pod is ready, a banner shows its **Address:** and a pod password. Copy the password now, because it is shown only once. You can always get in with **Open Console** instead.

If the pod fails to start, it shows **Failed** and the first month's charge is refunded.

## How a pod is billed

A pod moves through these states. Its current state is shown on the pod page.

| State | What it means |
| --- | --- |
| **Provisioning** | The container is being prepared. |
| **Trial** | The first 5 minutes after creation. The first month is already paid. Delete the pod in this window and you get it back in full, together with any tier upgrade you paid for in the same window. |
| **Active** | The pod is running. Every 30 days, counted from creation, the monthly price is taken from your balance. The date is shown as **Next renewal**. |
| **Grace period** | A renewal failed because the balance was insufficient. The pod keeps running for 1 day. |
| **Suspended** | The grace period ended unpaid. The container is stopped and the data is kept for 2 days. |

You get a notification at each step. Topping up your balance brings the pod back automatically. In **Grace period** or **Suspended**, the next billing check takes the monthly price, starts a new 30-day period and returns the pod to **Active**. A suspended pod's container is started again.

If the pod stays suspended for 2 days, kenari deletes it. The data cannot be recovered, and the kenari key made for the pod is deleted with it. In total that is 3 days between a missed renewal and deletion.

Deleting a pod yourself is permanent and the remaining paid time is forfeited, except in the first 5 minutes when you get a full refund.

## Open your pod

Each pod has its own address, `https://pod-<name>.kenari.id`, where `<name>` is two words kenari picks, such as `gesit-kancil`. The name is fixed. Renaming a pod only changes the label in your list, which can be up to 40 characters.

Press **Open Console** on the pod page to go straight in. To sign in without the dashboard, open the address and enter the **Pod password**. Press **Reset password** to get a new one. It is shown only once, and anyone signed in with the old password is signed out.

Some apps have their own login behind the kenari password. After the kenari sign-in, sign in with the app's own account.

## Edit environment variables

Open the pod page and find **Environment**. You can change the variables the app declares, the same ones you filled in when you created the pod. Press **Save changes** to apply them. Editing is free.

- The container is recreated with the new values. It restarts and is briefly unavailable. The `/data` folder, the pod's persistent storage, is kept, so no data is lost.
- For a secret such as an API key, leave the field blank to keep the stored value. Type a new value only to replace it. A stored secret is never shown again. A badge shows **Already set** when one is stored.
- Required variables must stay filled.
- `POD_DOMAIN`, `POD_URL` and `POD_SCHEME` are set by kenari and cannot be changed.
- If the app fails to start with the new values, the pod goes back to the previous working configuration.

You can edit the environment while the pod is **Active** or in **Grace period**. It cannot be edited during **Trial**, while **Suspended**, or while the pod is being updated or moved.

## Manage a pod

| Action | Where | What happens |
| --- | --- | --- |
| Upgrade the tier | **Upgrade Tier** | Applies at once. The cost for the rest of the current 30 days is taken from your balance. During **Trial** the whole 30 days run on the new tier, so the full price difference is taken. Not available while the pod is **Provisioning**. Tiers cannot be downgraded, and the server must have room. |
| Update the app | **App Version** | Shown when a newer version exists. kenari snapshots the data first, restarts the pod and rolls back to the old version if the new one fails to run. |
| Restart | **Restart** | Restarts the container with a short interruption. Only while **Active**, and not while the pod is being updated or moved. |
| Delete | **Delete pod** | Type the pod name to confirm. Permanent. |

If the pod shows **Pod ran out of memory**, the container was stopped because it used all its RAM. Use **Upgrade tier** to add memory. The **Resources** section shows the latest CPU, RAM and disk use.

## Limits and pitfalls

- Pods are paid from balance only. Keep enough balance for the next renewal. See [How a pod is billed](#how-a-pod-is-billed).
- Back up anything you need from the app before you delete a pod. Deletion removes the container and all its data.

## See also

- [How billing works](/en/docs/billing)
- [Domains](/en/docs/domains)


---

# Media kit

Brand assets for articles, integrations and promotional material. Use them as they are, without modification.

## Logo

The wordmark is always lowercase: `kenari` in ink, followed by a small brick square. The mark is a lowercase `k` in ink with the same brick square, on the canvas color.

| Asset | Format |
| --- | --- |
| Mark (`k` icon) | [SVG](/brand/mark.svg), [PNG](/brand/mark.png) |
| Lockup (wordmark) | [SVG](/brand/lockup.svg), [PNG](/brand/lockup.png) |
| Square lockup (avatar) | [SVG](/brand/lockup-square.svg), [PNG](/brand/lockup-square.png) |
| Monochrome | [SVG](/brand/bw.svg) |

The monochrome mark has no background and is drawn in `currentColor`. When you paste the SVG inline in HTML, it takes the color of the text around it, so it works on light and dark pages. Loaded through `<img src="/brand/bw.svg">` it renders black, because an image does not inherit page color. For an image embed, download the file and replace `currentColor` with a fixed color such as `#241F1B`, or use the other marks.

### Usage rules

- Always lowercase. Never write "Kenari" or "KENARI" in the lockup.
- The wordmark is always ink (`#241F1B`) with a brick accent (`#B5362A`). Never make the whole wordmark brick.
- Text on a brick background uses `#FFF4F0`, not pure white.
- No gradients, shadows or skewing. kenari is flat color and sharp corners.
- Keep clear space around the logo of at least the height of the accent square.

## Colors

| Role | Hex |
| --- | --- |
| Canvas | `#FBF6EE` |
| Surface | `#FFFFFF` |
| Ink | `#241F1B` |
| Soft ink | `#6B5D51` |
| Line | `#ECDFD2` |
| Accent (brick) | `#B5362A` |
| On accent | `#FFF4F0` |
| Money (amber) | `#B5781F` |
| Positive | `#4F7A4A` |

## Typography

| Role | Font |
| --- | --- |
| Display | Archivo (700 and 800) |
| Body | Inter |
| Mono | JetBrains Mono |

## Name

The product is called "kenari", in lowercase, at kenari.id. Use "kenari" on its own, with no suffix such as "AI" or "Cloud", unless you are naming a specific product.

## Contact

Need another format or permission for a specific use? Open the **Help** menu in the dashboard and create a ticket.

## See also

- [Integrations overview](/en/docs/integrations)
- [Quickstart](/en/docs/quickstart)
