# Sessions and client secrets

> What a session is, how a one-time client secret reaches the client, and how a session ends.

Source: https://docs.voice.kheelona.com/concepts/sessions/

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

`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](https://docs.voice.kheelona.com/guides/browser-sdk.md));
- the pinned `voice`, the `languages`, the `limits`, and `warnings`.

The request and answer are in the [REST reference](https://docs.voice.kheelona.com/reference/generated/api/createsession.md).

**Node**

```ts title="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;
}
```

**Python**

```python title="kheelona.py"
import asyncio
import re
import uuid
from collections.abc import Sequence

import httpx

MAX_ATTEMPTS = 3
MAX_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 None
```

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

A web page or an app cannot hold your key, so it asks your server for a session (the
[browser quickstart](https://docs.voice.kheelona.com/get-started/quickstart-browser.md) shows the whole path). The example mint route is
`POST /api/voice-session`. It returns only `sessionId`, `clientSecret` and `endpoints`.

**Node**

```ts title="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;
}
```

**Python**

```python title="app.py"
@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 as `u_8f3a2c`. Never a name, an email address or
  anything else that identifies a child. It is kept with the session record.

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

## 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](https://docs.voice.kheelona.com/concepts/limits.md).

## 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](#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.

## Quiet sessions

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.
## 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_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](https://docs.voice.kheelona.com/policies/data-and-privacy.md)). 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.
