Every public API request carries a client key. A key belongs to one knowledge base and only works for that knowledge base.
| Key type | Looks like | Use it in | Origin allowlist | CAPTCHA |
|---|
| Public key | kl_pk_… | Browsers and mobile apps | Optional | Required when enabled |
| Secret pair | kl_ci_… (ID) + kl_cs_… (secret) | Your servers only | Not checked | Never |
Create a key
Open the knowledge base
Go to the knowledge base and open the Integrations tab.
Create the key
Under Client Keys, click Create key. Enter a Key name and pick Public key or Secret pair.
Set the options
Set the options below, then click Create key.
Copy the credentials
A secret is shown once, right after you create it. Store it somewhere safe.
| Option | What it does |
|---|
| Allowed origins | Public keys only. Comma-separated list of sites that may use the key. Empty allows every origin |
| Allow MCP access | Lets the key connect AI tools through the MCP server. On by default |
| Rate limit | Requests per minute. Empty uses the default of 600 |
To revoke a key, click Revoke on its row in Client Keys, then Revoke key. Apps using it stop working at once. This cannot be undone.
An internal knowledge base has no public API, so it cannot have client keys.
Send the key
| Credential | Headers |
|---|
| Public key | X-Client-Key: kl_pk_… |
| Secret pair | X-Client-Id: kl_ci_… and X-Client-Secret: kl_cs_… |
| WebSocket | Query string: ?client_key=kl_pk_… or ?client_id=kl_ci_…&client_secret=kl_cs_… |
From a server:
curl https://app.kelu.dev/api/v1/public/knowledge-bases/KB_ID/search \
-H "X-Client-Id: kl_ci_..." \
-H "X-Client-Secret: kl_cs_..." \
-H "Content-Type: application/json" \
-d '{"query": "rate limits"}'
From a browser, send X-Client-Key: kl_pk_… instead, plus a CAPTCHA token (below).
Never put a secret (kl_cs_…) in browser or mobile code. Anyone can read it there.
Allowed origins
- Each entry is a full origin, such as
https://docs.example.com.
https://*.example.com matches one level of subdomain, such as https://docs.example.com. It does not match https://example.com itself.
- Once a public key has an allowlist, requests with no
Origin header are refused with 403. That includes curl and server code. Use a secret pair there.
CAPTCHA
When Kelu has reCAPTCHA v3 turned on, requests made with a public key must carry a reCAPTCHA token. The widget does this for you.
- Gated endpoints: chat, thread chat, search, feedback (
POST) and form-deflect.
- Not gated: widget-config, suggested questions, related articles, events, and withdrawing a rating.
- HTTP: send the token as
X-Captcha-Token: <token>.
- WebSocket: send it as
captcha_token on each chat frame.
- A missing token returns
403 with CAPTCHA_REQUIRED. A token that fails returns 403 with CAPTCHA_FAILED.
To find out whether a key needs a token, call widget-config. It returns captcha_required and the recaptcha_site_key to mint tokens with.
Got CAPTCHA_REQUIRED from curl or a backend? You are using a public key. There is no way to mint a reCAPTCHA v3 token outside a browser, so switch to a secret pair. It is never CAPTCHA-gated.
Building your own browser client on @kelu/sdk? Pass getCaptchaToken and the SDK sends the header on every request:
const kelu = new Kelu({
knowledgeBaseId: "KB_ID",
clientKey: "kl_pk_...",
getCaptchaToken: () =>
new Promise((resolve) =>
grecaptcha.ready(() =>
grecaptcha.execute(SITE_KEY, { action: "chat" }).then(resolve),
),
),
});
Rate limits
- Each key has its own limit per minute. The default is 600. You can set up to 100,000 when you create the key. Empty or
0 uses the default.
- Every request with the key counts. Requests refused for a missing or failed CAPTCHA do not.
- Over the limit you get
429 with code: "RATE_LIMITED" and a Retry-After header (seconds).
- Responses carry
X-RateLimit-Limit and X-RateLimit-Remaining.
- On WebSocket, each question counts once. Over the limit you get an error frame with
code: "rate_limited".