Sessions and client secrets
A session is one conversation between one person and one voice. Your server creates it, a client connects to it, and it ends. Everything else is built on that.
Create a session on your server
Section titled “Create a session on your server”POST /v1/sessions with your API key. You choose the voice and, if you like, the languages. The answer holds:
session_id;client_secret: avalue(starts withkvcs_) and itsexpires_at;connect: thewebsocket_url(for servers and toys) and thewebrtc_url(for browsers and apps, used by the browser SDK);- the pinned
voice, thelanguages, thelimits, andwarnings.
The request and answer are in the REST reference.
const REQUEST_TIMEOUT_MS = 15_000;/** The longest wait for one retry, whatever `Retry-After` says. */const MAX_WAIT_S = 30;
export interface CreateSessionOptions { apiUrl: string; /** Your API key. Keep it on the server: read it from the environment, never ship it to a browser or a toy. */ apiKey: string; voiceId: string; /** Default `["en", "hi"]`. */ languages?: string[]; /** An opaque ID for your end user (not a name, e-mail address or other personal data). */ endUserRef?: string; /** `false` = the voice is never cut off (for devices without echo cancellation). */ interruptions?: boolean;}
export interface CreatedSession { sessionId: string; /** The single-use client secret (`kvcs_…`). It is valid for 60 seconds. */ clientSecret: string; websocketUrl: string; webrtcUrl: string;}
/** An error answer from the API: the HTTP status, the error code and the request ID to quote to support. */export class KheelonaApiError extends Error { readonly status: number; readonly code: string; readonly requestId: string | undefined; /** Seconds from the `Retry-After` header, if the API sent one. */ readonly retryAfterSeconds: number | undefined; /** For status 0 (no answer): the name of the failure, e.g. `TimeoutError`. Never its message. */ readonly causeName: string | undefined;
constructor(status: number, code: string, requestId?: string, retryAfterSeconds?: number, causeName?: string) { super(`Kheelona API error ${String(status)} ${code}${requestId ? ` (request_id ${requestId})` : ''}`); this.name = 'KheelonaApiError'; this.status = status; this.code = code; this.requestId = requestId; this.retryAfterSeconds = retryAfterSeconds; this.causeName = causeName; }}
/** Creates a session with `POST /v1/sessions`. Retries 429 and 503 (up to 3 attempts) with one idempotency key. */export async function createSession(opts: CreateSessionOptions): Promise<CreatedSession> { const idempotencyKey = globalThis.crypto.randomUUID(); // one per logical session: a retry never creates a second one const body = JSON.stringify({ voice_id: opts.voiceId, languages: opts.languages ?? ['en', 'hi'], ...(opts.endUserRef === undefined ? {} : { end_user_ref: opts.endUserRef }), ...(opts.interruptions === undefined ? {} : { interruptions: opts.interruptions }), }); for (let attempt = 1; ; attempt += 1) { let res: Response; try { res = await fetch(`${opts.apiUrl.replace(/\/+$/, '')}/v1/sessions`, { method: 'POST', headers: { Authorization: `Bearer ${opts.apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey }, body, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), // never wait forever for the API }); } catch (error) { // Keep only the kind of failure: its message can echo the request. throw new KheelonaApiError(0, 'upstream_unreachable', undefined, undefined, error instanceof Error ? error.name : 'Error'); } if (res.ok) { const created = (await res.json()) as { session_id: string; client_secret: { value: string }; connect: { websocket_url: string; webrtc_url: string }; }; return { sessionId: created.session_id, clientSecret: created.client_secret.value, websocketUrl: created.connect.websocket_url, webrtcUrl: created.connect.webrtc_url, }; } const error = await readError(res); const retryable = res.status === 429 || res.status === 503; if (!retryable || attempt >= 3) throw error; await new Promise((resolve) => setTimeout(resolve, Math.min(error.retryAfterSeconds ?? 1, MAX_WAIT_S) * 1000)); }}
/** Keeps only the error code and request ID from the answer (never the whole body). */async function readError(res: Response): Promise<KheelonaApiError> { const header = res.headers.get('retry-after'); const retryAfter = header !== null && /^\d+$/.test(header) ? Math.min(Number(header), Number.MAX_SAFE_INTEGER) : undefined; // a huge value stays a number the cap handles try { const parsed = (await res.json()) as { error?: { code?: unknown; request_id?: unknown } }; const code = plain(parsed.error?.code) ?? 'unknown_error'; const requestId = plain(parsed.error?.request_id); return new KheelonaApiError(res.status, code, requestId, retryAfter); } catch { return new KheelonaApiError(res.status, 'unknown_error', undefined, retryAfter); }}
/** A short identifier made of plain characters, or undefined: nothing else from an answer is repeated. */function plain(value: unknown): string | undefined { return typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(value) ? value : undefined;}import asyncioimport reimport uuidfrom collections.abc import Sequence
import httpx
MAX_ATTEMPTS = 3MAX_WAIT_S = 30
class KheelonaApiError(Exception): """The API refused the request. Carries the status, error code and request id, never the key."""
def __init__(self, status: int, code: str, request_id: str | None, retry_after: int | None = None): super().__init__(f"Kheelona API error {status} {code} (request_id {request_id or '-'})") self.status = status self.code = code self.request_id = request_id self.retry_after = retry_after
async def create_session( client: httpx.AsyncClient, *, api_url: str, api_key: str, voice_id: str, languages: Sequence[str] | None = None, end_user_ref: str | None = None, interruptions: bool = True,) -> dict: """Create a session and return the API's answer: session_id, client_secret, connect, ...
One Idempotency-Key covers every attempt, so a retry never creates a second session. A 429 or 503 is retried up to 3 times in all, after the seconds in its Retry-After header (1 if it has none). """ body: dict = {"voice_id": voice_id, "languages": list(languages or ["en", "hi"])} if end_user_ref: body["end_user_ref"] = end_user_ref # an opaque id of yours, never a name or an email if not interruptions: body["interruptions"] = False headers = {"Authorization": f"Bearer {api_key}", "Idempotency-Key": str(uuid.uuid4())} for attempt in range(1, MAX_ATTEMPTS + 1): response = await client.post(f"{api_url}/v1/sessions", json=body, headers=headers) if response.is_success: return response.json() error = _api_error(response) if response.status_code not in (429, 503) or attempt == MAX_ATTEMPTS: raise error await asyncio.sleep(min(error.retry_after or 1, MAX_WAIT_S)) raise AssertionError("unreachable")
def _api_error(response: httpx.Response) -> KheelonaApiError: try: detail = response.json()["error"] code, request_id = _plain(detail["code"]) or "unknown_error", _plain(detail.get("request_id")) except (ValueError, KeyError, TypeError): code, request_id = "unknown_error", None retry_after = response.headers.get("Retry-After", "") return KheelonaApiError(response.status_code, code, request_id, int(retry_after) if retry_after.isdigit() else None)
def _plain(value: object) -> str | None: """A short identifier made of plain characters, or None: nothing else from an answer is repeated.""" return value if isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_.:-]{1,64}", value) else NoneThe client secret
Section titled “The client secret”The client secret is what lets one client connect to one session. It is not your API key.
- It lives about 60 seconds and works once. It is used up when the connection reaches
session.ready. - Mint it on your server. Your API key stays there. A browser, an app or a toy gets only the client secret and the session ID.
- Treat it like a password for those 60 seconds: don’t log it and don’t put it in a URL you share.
- If the connection fails for any reason, create a new session. A failed
connect()needs a new secret.
A mint route for your own pages
Section titled “A mint route for your own pages”A web page or an app cannot hold your key, so it asks your server for a session (the
browser quickstart shows the whole path). The example mint route is
POST /api/voice-session. It returns only sessionId, clientSecret and endpoints.
export function createApp(env: ExampleEnv): express.Express { const app = express(); app.disable('x-powered-by');
// Same-origin by design (no CORS headers): serve your page and this route from one origin. // Only your own page may call this route: a request with no Origin, or another one, never reaches Kheelona. const guard: express.RequestHandler = (req, res, next) => { const origin = req.get('origin'); if (origin !== env.allowedOrigin) { res.status(403).json({ error: { code: 'origin_not_allowed' } }); return; } res.set({ Vary: 'Origin', 'Cache-Control': 'no-store' }); // JSON only: a cross-site HTML form cannot send it, so it cannot spend your key. if (!req.is('application/json')) { res.status(415).json({ error: { code: 'unsupported_media_type' } }); return; } next(); };
app.all('/api/voice-session', guard); // express.json (body-parser) checks Content-Length against the limit before it reads the body. app.post('/api/voice-session', express.json({ limit: '2kb', type: 'application/json' }), async (req, res) => { const body = req.body as Record<string, unknown> | undefined; // Only `languages` is accepted. (Language codes here are 2-3 letters, as the API's are; the API validates too.) const languages = body?.['languages']; const knownKeys = typeof body === 'object' && !Array.isArray(body) && Object.keys(body).every((key) => key === 'languages'); const validLanguages = languages === undefined || (Array.isArray(languages) && languages.length >= 1 && languages.length <= 11 && languages.every((l) => typeof l === 'string' && /^[a-z]{2,3}$/.test(l))); if (!knownKeys || !validLanguages) { res.status(400).json({ error: { code: 'invalid_request' } }); return; } try { // Authenticate your user here: only signed-in users may start a session. const session = await createSession({ apiUrl: env.apiUrl, apiKey: env.apiKey, voiceId: env.voiceId, ...(languages === undefined ? {} : { languages: languages as string[] }), endUserRef: 'demo-user', // replace with the signed-in user's opaque ID }); // Return only what the page needs: never the API key, and nothing else from the answer. res.status(201).json({ sessionId: session.sessionId, clientSecret: session.clientSecret, endpoints: { webrtcUrl: session.webrtcUrl, websocketUrl: session.websocketUrl }, }); } catch (error) { const api = error instanceof KheelonaApiError ? error : undefined; console.error(`mint failed: ${api ? `${String(api.status)} ${api.code} request_id ${api.requestId ?? '-'}` : 'unexpected error'}`); if (api && (api.status === 429 || api.status === 503)) { if (api.retryAfterSeconds !== undefined) res.set('Retry-After', String(api.retryAfterSeconds)); res.status(api.status); } else { res.status(502); } res.json({ error: { code: api?.code ?? 'internal', ...(api?.requestId ? { requestId: api.requestId } : {}) } }); } });
// A malformed JSON body is the caller's mistake, not ours. // Express recognises an error handler by its four parameters, so `_next` stays. // eslint-disable-next-line @typescript-eslint/no-unused-vars app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { const tooLarge = (error as { status?: number }).status === 413; res.status(tooLarge ? 413 : 400).json({ error: { code: tooLarge ? 'payload_too_large' : 'invalid_request' } }); }); return app;}@app.post("/api/voice-session")async def mint(request: Request) -> JSONResponse: # Only your own page may call this: a missing or foreign Origin is refused before anything else. if request.headers.get("origin") != allowed_origin: return _error(403, "origin_not_allowed") # JSON only, so a cross-site HTML form cannot start a session. media_type, _, _ = request.headers.get("content-type", "").partition(";") if media_type.strip().lower() != "application/json": return _error(415, "unsupported_media_type", NO_STORE) # Read the body in pieces and stop as soon as it passes the limit, even with no Content-Length. raw = bytearray() async for chunk in request.stream(): raw += chunk if len(raw) > MAX_BODY_BYTES: return _error(413, "payload_too_large", NO_STORE) try: languages = _languages(json.loads(raw)) except ValueError: return _error(400, "invalid_request", NO_STORE)
# Authenticate your user here: only signed-in users may start a session. # The end_user_ref is that user's opaque id (never a name or an email); this demo uses a fixed one. try: session = await create_session( client, api_url=api_url, api_key=api_key, voice_id=voice_id, languages=languages, end_user_ref="demo-user", ) except KheelonaApiError as err: log.warning("create session failed: status=%s code=%s request_id=%s", err.status, err.code, err.request_id) body = {"code": err.code, **({"requestId": err.request_id} if err.request_id else {})} if err.status in (429, 503): headers = {**NO_STORE, **({"Retry-After": str(err.retry_after)} if err.retry_after else {})} return JSONResponse({"error": body}, status_code=err.status, headers=headers) return JSONResponse({"error": body}, status_code=502, headers=NO_STORE) except httpx.HTTPError as err: log.warning("create session failed: %s", type(err).__name__) return _error(502, "upstream_unreachable", NO_STORE)
# Return only what the page needs, nothing else from the API's answer. return JSONResponse( { "sessionId": session["session_id"], "clientSecret": session["client_secret"]["value"], "endpoints": { "webrtcUrl": session["connect"]["webrtc_url"], "websocketUrl": session["connect"]["websocket_url"], }, }, status_code=201, headers=NO_STORE, )Before you copy it into production:
- Authenticate your user where the example says so. Without that, anyone who can reach the route can create sessions on your key and use up your limits.
- The route listens on loopback (
127.0.0.1) by default and accepts only one page origin, only as JSON, and sends no CORS headers: it is meant to be same-origin with your page. Keep it that way, or put it behind your own gateway. - Pass your user’s opaque ID as
end_user_ref: an ID of yours such asu_8f3a2c. Never a name, an email address or anything else that identifies a child. It is kept with the session record.
Idempotency-Key
Section titled “Idempotency-Key”Send an Idempotency-Key header with POST /v1/sessions: one new UUID per logical session. If the request is sent
twice, for example after a timeout, the second answer is the same session with a new client secret and the old
secret stops working. You never end up with two sessions for one tap on a toy.
- Keys are remembered for 10 minutes.
- Use the same key when you retry the same request. Use a new key for a new session.
- Replaying a key for a session that is already live, or for a different request, answers
409invalid_request.
A session that is never connected
Section titled “A session that is never connected”Creating a session reserves a place for it. If nothing connects within 60 seconds, the reservation expires and the place is freed. A reserved session counts towards your concurrent-session limit until it connects, expires or is ended, so don’t create sessions far ahead of use. See Limits.
Ending a session
Section titled “Ending a session”A session ends when:
- the client sends
session.end(client_ended); - your server calls
POST /v1/sessions/{id}/end(partner_ended). It also cancels a reserved session that was never connected. Ending a session that is already over does nothing; - it reaches its maximum length (
max_duration), or nobody speaks and nothing plays for long enough (idle_timeout; see Quiet sessions below); - the connection drops (
disconnected), or Kheelona stops the session (server_shutdown,provider_failure,suspended,service_stopped,error).service_stoppedmeans the voice service was switched off; start a new session once it is back.
session.ended tells the client the reason. After that the session cannot be used again. For another conversation, create a
new session.
Quiet sessions
Section titled “Quiet sessions”When nobody speaks, the voice does not just wait for the idle timeout. This happens by default:
- After about 20 seconds of silence, the voice gently asks if the person is still there.
- If it is still quiet about 40 seconds after that, the voice says a friendly goodbye, and the session ends with the
reason
idle_timeout.
So, once the voice has spoken, a quiet session ends after about a minute, even if the idle timeout you were given is longer. The idle timeout is now the longest a session can stay silent. Before the voice has spoken (the greeting is off, the default, and the person has not said anything yet) there is nothing to check in on: that session lasts until the idle timeout, and then ends with the goodbye. If your idle timeout is short (30 seconds, say), the check-in still comes first and the goodbye follows at the limit.
What your client sees: a session.warning with the code idle_soon at least 20 seconds before the end (the goodbye takes a few seconds more), two short replies
with no new transcript.final before them (like a greeting), then reply.done and session.ended with the reason
idle_timeout. Any speech from the person, or a typed turn, starts the count again. Muting the microphone pauses the check-in and goodbye counts, but not the idle timeout: a muted session that nobody comes back to still ends at the idle timeout, with the goodbye first.
Kheelona can switch this off per deployment, and then a quiet session simply ends at the idle timeout, in silence.
What Kheelona stores
Section titled “What Kheelona stores”No audio and no conversation text is ever stored or logged. That covers what the person says, what the voice says and
the instructions you send for a session, in readable form. Instructions reach the voice encrypted and are held in a
short-lived encrypted record until the connection starts (deleted within about two minutes).
What is kept is the record of the session, with counts instead of words:
- who and how: your partner and API key, your
end_user_refandmetadata, the client kind, the voice, the declared languages and the languages heard; - when and how long: created, connected and ended times, and the duration;
- how it went: the number of turns and interruptions, response times, audio seconds, token counts, and counts of safety actions (by category) and errors (by code);
- why it ended: the reason;
- the connection: the IP address, the user agent and the web origin, if any, and the country and region the IP address comes from (never an exact location). IP addresses are shortened after 30 days; the country and region stay with the record.
Session records are kept for 12 months (see the data and privacy policy). You can read them with GET /v1/sessions and GET /v1/sessions/{id}, and in your
console. Because end_user_ref and metadata are stored, keep personal data out of them.