Your first request from zero

Going from an empty dashboard to your first answer takes three steps and about five minutes. You need an account at kenari.id and a terminal that can run curl.
Sending the request is the easy part. What costs people an afternoon is the base URL: its shape changes depending on the client, and a wrong shape comes back as an empty 405 that looks nothing like a path problem.
Three steps
1. Create a key


Sign in to the dashboard, open API keys, click Create key. The key starts with kn- and shows exactly once, because all we store afterward is its SHA-256 hash. We cannot read the original value either. Copy it now, put it somewhere that does not get committed, and if you lose it, create a new one and revoke the old one.
This single key covers every model, every provider behind the gateway, and both API shapes, OpenAI and Anthropic. Switching models never needs a second key.
2. Skip the balance
The Balance menu is not in play yet. Add a :free suffix to the model id and the request does not debit anything.
When we checked while writing this, step-3-7-flash:free was in the public catalog with pricing.free: true. In exchange you get a per-minute limit and a daily allowance per account, and the current numbers live on the plan page.
If you do send a paid model with a Rp 0 balance, you get 402 insufficient_balance. That is the balance talking, not the key.
3. Send the request
Replace kn-... with the key you just copied.
curl https://kenari.id/v1/chat/completions \
-H "Authorization: Bearer kn-..." \
-H "Content-Type: application/json" \
-d '{
"model": "step-3-7-flash:free",
"messages": [{"role": "user", "content": "Hello!"}]
}'
That is the official snippet from quickstart. Only two fields are required, model and messages, and the answer sits at choices[0].message.content. Auth goes through the Authorization: Bearer header, though some clients send x-api-key and that is accepted too.
The response follows OpenAI’s chat.completion shape, so if you have called OpenAI directly before, none of this is new:
{
"id": "chatcmpl-...",
"object": "chat.completion",
"model": "step-3-7-flash:free",
"choices": [
{ "index": 0, "message": { "role": "assistant", "content": "..." }, "finish_reason": "stop" }
],
"usage": { "prompt_tokens": 12, "completion_tokens": 9, "total_tokens": 21 }
}
usage is filled in even on a :free model. Tokens are still counted so you can see the size of your request. The Rp 0 part is only the cost.
Your client decides the shape
We ran all three. Same key, same body, only the path changes:
K='Authorization: Bearer kn-...'
B='{"model":"step-3-7-flash:free","messages":[{"role":"user","content":"Hello!"}]}'
for path in /v1/v1/chat/completions /chat/completions /v1/chat/completions; do
curl -s -w " <- %{http_code} $path\n" -X POST "https://kenari.id$path" \
-H "$K" -H 'Content-Type: application/json' -d "$B"
done
<- 405 /v1/v1/chat/completions
<- 405 /chat/completions
invalid key <- 401 /v1/chat/completions
Both 405 responses come back with an empty body and no content type. Nothing to read, nothing that says “wrong path”. The 401 on the correct path is plain text and tells you invalid key.
That inverts the usual instinct. An empty body means the path matched no route, so the gateway never got far enough to have an opinion about your key. A readable body, even an angry one, means the path matched and the gateway is answering you. So people hit the empty 405, conclude the key is broken, and go issue a new one, which changes nothing, because the key was never the problem.
OpenAI clients (the official SDK, curl to /chat/completions, almost every OpenAI-shaped wrapper) want the base URL to be exactly https://kenari.id/v1, because the SDK appends the rest of the path itself. Both 405s above come from getting this wrong: the first writes /v1 twice, the second drops it.
Claude Code works the other way around, because it appends /v1 on its own, so the base URL you enter is just https://kenari.id:
export ANTHROPIC_BASE_URL=https://kenari.id
export ANTHROPIC_AUTH_TOKEN=kn-...
export ANTHROPIC_MODEL=step-3-7-flash:free
The Anthropic SDK differs from both, because its Messages docs use base_url="https://kenari.id/v1", and it posts a different payload to a different path:
curl https://kenari.id/v1/messages \
-H "Authorization: Bearer kn-..." \
-H "Content-Type: application/json" \
-d '{
"model": "step-3-7-flash:free",
"max_tokens": 512,
"messages": [{"role": "user", "content": "Hi!"}]
}'
max_tokens is optional on this path, and leaving it out makes the server use its 65,536 default. The answer moves too, from choices[0].message.content to content[0].text.
| Client | Base URL | Answer at |
|---|---|---|
| OpenAI SDK / curl chat | https://kenari.id/v1 |
choices[0].message.content |
| Claude Code | https://kenari.id |
handled by the tool |
| Anthropic SDK (as in the docs) | https://kenari.id/v1 |
content[0].text |
Per-editor setup, including Codex and OpenCode, is in point your editor at kenari.
If it fails, read the status
| Symptom | What it means | Fix |
|---|---|---|
401 invalid api key |
kn-... is wrong, truncated, or never exported |
Create a new key, copy it whole. The 401 body is plain text, not JSON |
model_not_found |
Typo in the id | Write step-3-7-flash:free exactly, or pull the id from GET /v1/models |
402 insufficient_balance |
Paid model, Rp 0 balance | Switch to :free, or top up first |
405 |
Base URL has a doubled /v1 or a missing /v1 |
OpenAI: exactly https://kenari.id/v1. Claude Code: https://kenari.id |
429 |
Free-lane limit (per minute or daily) | Wait, read the Retry-After header. Detail in errors |
GET https://kenari.id/v1/models is public and needs no key, so use it to confirm an id still exists before you paste it into code. The catalog changes, so do not hardcode it.
From code, not just curl
The base URL https://kenari.id/v1 drops straight into the slot where https://api.openai.com/v1 was. The rest of your code stays untouched.
from openai import OpenAI
client = OpenAI(base_url="https://kenari.id/v1", api_key="kn-...")
r = client.chat.completions.create(
model="step-3-7-flash:free",
messages=[{"role": "user", "content": "Hello!"}],
)
print(r.choices[0].message.content)
import OpenAI from "openai";
const client = new OpenAI({ baseURL: "https://kenari.id/v1", apiKey: "kn-..." });
const res = await client.chat.completions.create({
model: "step-3-7-flash:free",
messages: [{ role: "user", content: "Hello!" }],
});
console.log(res.choices[0].message.content);
The /v1 endpoints send CORS headers, so fetch straight from a browser works without a proxy. It works, and it also puts your kn- key in devtools for anyone who opens the page. Keep it for local prototypes. Before anyone else loads the app, move the call to a server and keep the key there.
After the first curl
The messages field accepts the system, user, assistant, and tool roles, though one user is plenty for now. Streaming is just "stream": true, and the official SDKs parse the SSE for you.
Moving to a paid model means dropping the :free suffix, since the paid id in the same family usually has no suffix at all, like step-3-7-flash. Topping up is covered in pay for tokens with QRIS.
The dashboard also has a Routes menu. A route is a named list of models tried in order, and you send the route name in the model field instead of an id, something like opus-hemat. There is a built-in one called kenari-free that points at free models and cannot be edited.
One thing you may have noticed in that response: there is not a single provider name in it. That is deliberate. You talk to the gateway, routing picks the path, and we do not leak the upstream name, error messages included. For a first request, what matters is one key and one answer.