Skip to content

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.

POST /v1/sessions with your API key. You choose the voice and, if you like, the languages. The answer holds:

  • session_id;
  • client_secret: a value (starts with kvcs_) and its expires_at;
  • connect: the websocket_url (for servers and toys) and the webrtc_url (for browsers and apps, used by the browser SDK);
  • the pinned voice, the languages, the limits, and warnings.

The request and answer are in the REST reference.

kheelona.ts
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;
}

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 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.

server.ts
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;
}

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 as u_8f3a2c. Never a name, an email address or anything else that identifies a child. It is kept with the session record.

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 409 invalid_request.

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.

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_stopped means 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.

When nobody speaks, the voice does not just wait for the idle timeout. This happens by default:

  1. After about 20 seconds of silence, the voice gently asks if the person is still there.
  2. 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.

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_ref and metadata, 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.