> ## Documentation Index
> Fetch the complete documentation index at: https://docs.reflections.ai/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> The OpenAPI document at /api-reference/openapi.yaml defines the target contract. Its x-reflections-availability field and each operation's availability notice identify supported test and live forms. Do not infer implementation from a schema or code sample. Live keys support account details, reads of the licenses the calling account holds as licensee or issued as talent (GET /v1/licenses?role=issuer), and GET /v1/generations?license=… for the state of outputs under such a license (summaries only: no prompt, reference, bytes or metadata). Live image checks, submissions and receipts are built but not activated: until activation, a live POST /v1/checks or /v1/generations returns 403 live_not_activated, which must not be retried. Discovery and JWKS are at the origin root (https://app.reflections.ai/.well-known/...), not under /v1.
> Three rules hold everywhere: a decision is never an error (deny and escalate are 200 responses, and a live check still evaluating is a 202 with status checking); generate nothing for a named talent without an allow; deliver nothing until the generation is allow.
> The sandbox accepts personal rk_test_ keys. Persist a separate Idempotency-Key and body for each logical /v1 POST; retry an unknown outcome with the same key and body. OAuth and Talent API MCP remain planned.

# Voice integration design

> Planned voice workflows: reusable speech checks and live sessions with renewal, sampling and termination.

<Note>
  Planned design guide. Fictional voice checks can be tested, but this full workflow requires
  file uploads and live sessions, which are not served by the current sandbox. No provider voice
  is provisioned by these examples.
  Start with the [runnable quickstart](/quickstart) and [sandbox support matrix](/sandbox).
</Note>

These examples use Maya Okafor's fictional speaking-voice license for personal, non-commercial
use. They describe two target workflows: text-to-speech lines under one check, and a live session
with renewals, samples and termination. The sandbox can exercise fixture checks, but the complete
upload and live-session workflows are not available.

| Sandbox object | ID |
| - | - |
| Maya Okafor | `tal_9t0wxyhk5zba2q8v1m3n7p4r6s` |
| Her speaking voice facet | `fct_v1m3n7p4r6s9t0wxyhk5zba2q8` |
| The platform voice license | `lic_fmva0q8p2ymbhdy60snaphp4gt` |

## Speech lines

<Steps>
  <Step title="Export your test key">
    ```bash theme={null}
    export REFLECTIONS_KEY="rk_test_…"
    ```
  </Step>

  <Step title="Check the first line with reuse">
    A text-to-speech product speaks many short lines for one user. Ask for `reuse` so one decision
    covers up to 50 lines over 15 minutes, and pass `on_escalate: "deny"` so your user never
    waits on a person.

    <CodeGroup>
      ```bash cURL theme={null}
      CHECK=$(curl -sS --fail-with-body https://app.reflections.ai/v1/checks \
        -H "Authorization: Bearer $REFLECTIONS_KEY" \
        -H "Reflections-Version: 2026-10-15" \
        -H "Idempotency-Key: $(uuidgen)" \
        -H "Content-Type: application/json" \
        -d '{
          "talent": ["tal_9t0wxyhk5zba2q8v1m3n7p4r6s"],
          "facet": "fct_v1m3n7p4r6s9t0wxyhk5zba2q8",
          "inputs": [
            { "role": "script", "text": "Morning, Ridge Runners. Five miles at an easy pace. See you at the trailhead." }
          ],
          "use": {
            "modality": "speech",
            "kind": "impersonation",
            "purpose": "personal",
            "commercial": false,
            "channels": ["in_app"],
            "territories": ["US"]
          },
          "end_user": { "id": "usr_48213" },
          "execution": { "provider": "example-voice-provider", "model": "tts-2", "candidates": 1 },
          "on_escalate": "deny",
          "reuse": { "max_generations": 50, "ttl": "PT15M" }
        }')
      echo "$CHECK" | jq '{decision, reasons}'
      CHECK_ID=$(echo "$CHECK" | jq -r .id)
      ```

      ```typescript TypeScript theme={null}
      const api = "https://app.reflections.ai/v1";
      const auth = {
        Authorization: `Bearer ${process.env.REFLECTIONS_KEY}`,
        "Reflections-Version": "2026-10-15",
      };
      const json = { ...auth, "Content-Type": "application/json" };

      async function ok(res: Response) {
        if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); // the problem details
        return res.json();
      }

      const check = await fetch(`${api}/checks`, {
        method: "POST",
        headers: { ...json, "Idempotency-Key": crypto.randomUUID() },
        body: JSON.stringify({
          talent: ["tal_9t0wxyhk5zba2q8v1m3n7p4r6s"],
          facet: "fct_v1m3n7p4r6s9t0wxyhk5zba2q8",
          inputs: [
            { role: "script", text: "Morning, Ridge Runners. Five miles at an easy pace. See you at the trailhead." },
          ],
          use: {
            modality: "speech",
            kind: "impersonation",
            purpose: "personal",
            commercial: false,
            channels: ["in_app"],
            territories: ["US"],
          },
          end_user: { id: "usr_48213" },
          execution: { provider: "example-voice-provider", model: "tts-2", candidates: 1 },
          on_escalate: "deny",
          reuse: { max_generations: 50, ttl: "PT15M" },
        }),
      }).then(ok);
      if (check.decision !== "allow") {
        throw new Error(check.reasons.map((r: { sentence: string }) => r.sentence).join(" "));
      }
      ```

      ```python Python theme={null}
      import os, uuid, requests

      API = "https://app.reflections.ai/v1"
      auth = {
          "Authorization": f"Bearer {os.environ['REFLECTIONS_KEY']}",
          "Reflections-Version": "2026-10-15",
      }

      def ok(res):
          if not res.ok:
              raise RuntimeError(f"{res.status_code} {res.text}")  # the problem details
          return res.json()

      check = ok(requests.post(
          f"{API}/checks",
          headers={**auth, "Idempotency-Key": str(uuid.uuid4())},
          json={
              "talent": ["tal_9t0wxyhk5zba2q8v1m3n7p4r6s"],
              "facet": "fct_v1m3n7p4r6s9t0wxyhk5zba2q8",
              "inputs": [
                  {"role": "script", "text": "Morning, Ridge Runners. Five miles at an easy pace. See you at the trailhead."}
              ],
              "use": {
                  "modality": "speech",
                  "kind": "impersonation",
                  "purpose": "personal",
                  "commercial": False,
                  "channels": ["in_app"],
                  "territories": ["US"],
              },
              "end_user": {"id": "usr_48213"},
              "execution": {"provider": "example-voice-provider", "model": "tts-2", "candidates": 1},
              "on_escalate": "deny",
              "reuse": {"max_generations": 50, "ttl": "PT15M"},
          },
      ))
      if check["decision"] != "allow":
          raise SystemExit(" ".join(r["sentence"] for r in check["reasons"]))
      ```
    </CodeGroup>

    The authorization covers 50 generations for this end user until it expires:

    ```json theme={null}
    {
      "id": "chk_bws47ej106d607wxkepzqs1wyq",
      "object": "check",
      "livemode": false,
      "status": "allowed",
      "decision": "allow",
      "talent": [
        {
          "talent": "tal_9t0wxyhk5zba2q8v1m3n7p4r6s",
          "name": "Maya Okafor",
          "matched_by": "explicit",
          "confidence": "exact",
          "decision": "allow",
          "facets": ["fct_v1m3n7p4r6s9t0wxyhk5zba2q8"],
          "license": "lic_fmva0q8p2ymbhdy60snaphp4gt"
        }
      ],
      "obligations": [
        { "type": "generation_check", "required": "all" },
        {
          "type": "generation_instructions",
          "instructions": ["Do not discuss politics or religion.", "Do not make promises on her behalf."],
          "term": "license:lic_fmva0q8p2ymbhdy60snaphp4gt/terms.voiceLineGrant.never"
        },
        { "type": "disclosure", "level": "licensed", "placement": "metadata", "text": "AI voice of Maya Okafor, licensed." },
        { "type": "c2pa_manifest" },
        { "type": "usage_report", "units": "seconds", "within": "P1D" }
      ],
      "authorization": {
        "token": "eyJhbGciOiJFUzI1NiIs…",
        "max_generations": 50,
        "generations_used": 0,
        "expires_at": "2026-10-17T18:15:00Z"
      }
    }
    ```

    For speech, apply `generation_instructions` to the words: do not send a line that breaks one.
  </Step>

  <Step title="Speak and submit each line">
    Send each line to your voice provider with `authorization.token`, then submit the audio. Short
    lines can be sent inline as base64 `data`. The sandbox limit is 7 MiB decoded bytes in a
    10 MiB JSON body; the protocol's 10 MB inline limit is a future target. Under `reuse`, leave out `candidate_index`:
    each line is its own generation and counts once against `max_generations`. Send the line's
    words as `transcript`; later lines are checked on their own audio and words.

    <CodeGroup>
      ```bash cURL theme={null}
      # printf is a shell builtin, so the audio's base64 is not limited by the argument size.
      printf '{
        "check": "%s",
        "artifact": { "data": "%s", "sha256": "%s", "media_type": "audio/wav", "duration_ms": 3400 },
        "transcript": "Morning, Ridge Runners. Five miles at an easy pace. See you at the trailhead.",
        "execution": { "provider": "example-voice-provider", "model": "tts-2" }
      }' "$CHECK_ID" "$(base64 < line-1.wav | tr -d '\n')" "$(shasum -a 256 line-1.wav | cut -d' ' -f1)" |
      curl -sS --fail-with-body https://app.reflections.ai/v1/generations \
        -H "Authorization: Bearer $REFLECTIONS_KEY" \
        -H "Reflections-Version: 2026-10-15" \
        -H "Idempotency-Key: $(uuidgen)" \
        -H "Content-Type: application/json" \
        --data-binary @- > line-1.json
      jq '{decision, reasons}' line-1.json
      LINE_ID=$(jq -r .id line-1.json)
      ```

      ```typescript TypeScript theme={null}
      import { readFile } from "node:fs/promises";
      import { createHash } from "node:crypto";

      async function submitLine(path: string, words: string, durationMs: number) {
        const bytes = await readFile(path);
        return fetch(`${api}/generations`, {
          method: "POST",
          headers: { ...json, "Idempotency-Key": crypto.randomUUID() },
          body: JSON.stringify({
            check: check.id,
            artifact: {
              data: bytes.toString("base64"),
              sha256: createHash("sha256").update(bytes).digest("hex"),
              media_type: "audio/wav",
              duration_ms: durationMs,
            },
            transcript: words,
            execution: { provider: "example-voice-provider", model: "tts-2" },
          }),
        }).then(ok);
      }

      const line = await submitLine(
        "line-1.wav",
        "Morning, Ridge Runners. Five miles at an easy pace. See you at the trailhead.",
        3400,
      );
      if (line.decision !== "allow") throw new Error("Do not play this line.");
      ```

      ```python Python theme={null}
      import base64, hashlib

      def submit_line(path: str, words: str, duration_ms: int) -> dict:
          with open(path, "rb") as f:
              data = f.read()
          return ok(requests.post(
              f"{API}/generations",
              headers={**auth, "Idempotency-Key": str(uuid.uuid4())},
              json={
                  "check": check["id"],
                  "artifact": {
                      "data": base64.b64encode(data).decode(),
                      "sha256": hashlib.sha256(data).hexdigest(),
                      "media_type": "audio/wav",
                      "duration_ms": duration_ms,
                  },
                  "transcript": words,
                  "execution": {"provider": "example-voice-provider", "model": "tts-2"},
              },
          ))

      line = submit_line(
          "line-1.wav",
          "Morning, Ridge Runners. Five miles at an easy pace. See you at the trailhead.",
          3400,
      )
      if line["decision"] != "allow":
          raise SystemExit("Do not play this line.")
      ```
    </CodeGroup>

    Speech up to 60 seconds returns its decision in the response:

    ```json theme={null}
    {
      "id": "gen_bvmw7kydzhy23ypte3d7qs68sm",
      "object": "generation",
      "livemode": false,
      "check": "chk_bws47ej106d607wxkepzqs1wyq",
      "status": "allowed",
      "decision": "allow",
      "artifact": { "sha256": "26723c6d5d61484451f584dcfa2ec9e4bb42c1d151824508d7e8fe1b2492f858", "media_type": "audio/wav", "duration_ms": 3400 },
      "findings": [
        { "term": "license:lic_fmva0q8p2ymbhdy60snaphp4gt/terms.requiredAttributes", "attribute": "identity.samePerson", "result": "preserved" },
        { "term": "license:lic_fmva0q8p2ymbhdy60snaphp4gt/terms.voiceLineGrant.never", "rule": "Do not discuss politics or religion.", "result": "preserved" }
      ],
      "assurance": "observed",
      "receipt": "rcpt_ytq5grk2b5xgrdatr8dw2vf982",
      "c2pa": {
        "label": "ai.reflections.license",
        "data": {
          "receipt": "eyJhbGciOiJFUzI1NiIs…",
          "verify_url": "https://app.reflections.ai/r/rcpt_ytq5grk2b5xgrdatr8dw2vf982"
        }
      }
    }
    ```

    Embed `c2pa` in the audio file's C2PA manifest, write the disclosure text into its metadata,
    and play it. When `generations_used` reaches 50 or the authorization expires, run a new check:
    a generation past either returns `409 authorization_consumed` or `409 check_expired`.
  </Step>

  <Step title="See what an interactive deny looks like">
    Run the check again with `rfl_test_escalate` anywhere in the script. Without `on_escalate`
    this would escalate to a person. With `on_escalate: "deny"` it returns at once:

    ```json theme={null}
    {
      "id": "chk_tk70ze99cwj132w1jws193rpye",
      "object": "check",
      "status": "denied",
      "decision": "deny",
      "reasons": [
        {
          "code": "review_required",
          "stage": "check",
          "talent": "tal_9t0wxyhk5zba2q8v1m3n7p4r6s",
          "sentence": "A person must review this request before it can be spoken."
        }
      ],
      "remedy": { "type": "retry_with_hold" },
      "authorization": null
    }
    ```

    Tell your user the line cannot be spoken, using `reasons[].sentence`. `retry_with_hold` means
    the same request with `on_escalate: "hold"` would go to a reviewer instead.
  </Step>
</Steps>

## Live session

A session runs one check at the start, then keeps a short-lived token alive. The token expires 5
minutes after issue; you renew it every 60 seconds, submit samples of what the voice says, and stop
within one second if Reflections terminates the session.

<Steps>
  <Step title="Create a session">
    <CodeGroup>
      ```bash cURL theme={null}
      SESSION=$(curl -sS --fail-with-body https://app.reflections.ai/v1/sessions \
        -H "Authorization: Bearer $REFLECTIONS_KEY" \
        -H "Reflections-Version: 2026-10-15" \
        -H "Idempotency-Key: $(uuidgen)" \
        -H "Content-Type: application/json" \
        -d '{
          "talent": ["tal_9t0wxyhk5zba2q8v1m3n7p4r6s"],
          "facet": "fct_v1m3n7p4r6s9t0wxyhk5zba2q8",
          "inputs": [
            { "role": "system", "text": "You are a fan voice chat. Speak as Maya Okafor in a friendly tone." }
          ],
          "use": {
            "modality": "speech",
            "kind": "impersonation",
            "purpose": "personal",
            "commercial": false,
            "channels": ["in_app"],
            "territories": ["US"]
          },
          "end_user": { "id": "usr_48213" },
          "execution": { "provider": "example-voice-provider", "model": "realtime-voice-2" }
        }')
      echo "$SESSION" | jq '{status, reasons, expires_at}'
      SESSION_ID=$(echo "$SESSION" | jq -r .id)
      ```

      ```typescript TypeScript theme={null}
      const session = await fetch(`${api}/sessions`, {
        method: "POST",
        headers: { ...json, "Idempotency-Key": crypto.randomUUID() },
        body: JSON.stringify({
          talent: ["tal_9t0wxyhk5zba2q8v1m3n7p4r6s"],
          facet: "fct_v1m3n7p4r6s9t0wxyhk5zba2q8",
          inputs: [{ role: "system", text: "You are a fan voice chat. Speak as Maya Okafor in a friendly tone." }],
          use: {
            modality: "speech",
            kind: "impersonation",
            purpose: "personal",
            commercial: false,
            channels: ["in_app"],
            territories: ["US"],
          },
          end_user: { id: "usr_48213" },
          execution: { provider: "example-voice-provider", model: "realtime-voice-2" },
        }),
      }).then(ok);
      if (session.status !== "active") {
        throw new Error(session.reasons.map((r: { sentence: string }) => r.sentence).join(" "));
      }
      ```

      ```python Python theme={null}
      session = ok(requests.post(
          f"{API}/sessions",
          headers={**auth, "Idempotency-Key": str(uuid.uuid4())},
          json={
              "talent": ["tal_9t0wxyhk5zba2q8v1m3n7p4r6s"],
              "facet": "fct_v1m3n7p4r6s9t0wxyhk5zba2q8",
              "inputs": [{"role": "system", "text": "You are a fan voice chat. Speak as Maya Okafor in a friendly tone."}],
              "use": {
                  "modality": "speech",
                  "kind": "impersonation",
                  "purpose": "personal",
                  "commercial": False,
                  "channels": ["in_app"],
                  "territories": ["US"],
              },
              "end_user": {"id": "usr_48213"},
              "execution": {"provider": "example-voice-provider", "model": "realtime-voice-2"},
          },
      ))
      if session["status"] != "active":
          raise SystemExit(" ".join(r["sentence"] for r in session["reasons"]))
      ```
    </CodeGroup>

    ```json theme={null}
    {
      "id": "ses_k5v9fvrx5w37jt6q6vm26zxzfh",
      "object": "session",
      "livemode": false,
      "status": "active",
      "check": "chk_qpdejnr6jkk2ag2yks7v6sb7g2",
      "license": "lic_fmva0q8p2ymbhdy60snaphp4gt",
      "token": "eyJhbGciOiJFUzI1NiIs…",
      "expires_at": "2026-10-17T18:05:00Z",
      "renew_every": "PT60S",
      "sampling": { "audio_window_max": "PT15S", "transcript": "every_turn" },
      "obligations": [
        { "type": "disclosure", "level": "licensed", "placement": "visible", "text": "AI voice of Maya Okafor, licensed." },
        { "type": "generation_instructions", "instructions": ["Do not discuss politics or religion.", "Do not make promises on her behalf."] },
        { "type": "usage_report", "units": "seconds", "within": "P1D" }
      ],
      "stream_url": "wss://app.reflections.ai/v1/sessions/ses_k5v9fvrx5w37jt6q6vm26zxzfh/stream"
    }
    ```

    If `status` is `denied`, no token is issued and `reasons` say why. Otherwise show the
    disclosure on screen, add the instructions to your voice model's system prompt, and give the
    provider the session `token`.
  </Step>

  <Step title="Connect to the stream">
    The stream pushes `session.terminated` the moment it happens, so connect before any audio
    plays. From a client, authenticate with the session token as a WebSocket subprotocol; from a
    server, you can send your secret key in `Authorization` instead. Send a `ping` every 25
    seconds when nothing else is flowing, inside the 30-second limit.

    <CodeGroup>
      ```typescript TypeScript theme={null}
      let token = session.token;
      let speaking = true;

      function stopOutput() {
        speaking = false; // Cut the audio going to the listener here, within one second.
      }

      const stream = new WebSocket(session.stream_url, ["tlp.session.v1", `tlp.session.${token}`]);

      stream.addEventListener("message", (message) => {
        const event = JSON.parse(message.data);
        if (event.type === "session.terminated") stopOutput();
        if (event.type === "session.warning") console.warn(event.code, event.act_by);
      });

      const keepAlive = setInterval(() => stream.send(JSON.stringify({ type: "ping" })), 25_000);
      ```

      ```python Python theme={null}
      # pip install websockets
      import asyncio, json, websockets
      from datetime import datetime, timezone

      speaking = True

      def stop_output():
          global speaking
          speaking = False  # Cut the audio going to the listener here, within one second.

      async def listen(session: dict):
          url = session["stream_url"]
          protocols = ["tlp.session.v1", f"tlp.session.{session['token']}"]
          async with websockets.connect(url, subprotocols=protocols) as ws:
              async def keep_alive():
                  while True:
                      await asyncio.sleep(25)
                      await ws.send(json.dumps({"type": "ping"}))

              pinger = asyncio.create_task(keep_alive())
              async for raw in ws:
                  event = json.loads(raw)
                  if event["type"] == "session.terminated":
                      stop_output()
                      break
                  if event["type"] == "session.warning":
                      print(event["code"], event["act_by"])
              pinger.cancel()
      ```
    </CodeGroup>

    The first message is `session.ready`. `session.warning` arrives before something ends the
    session: `renewal_due`, `sample_overdue`, `license_ending` or `max_duration`, each with an
    `act_by` time.
  </Step>

  <Step title="Renew every 60 seconds">
    Each renewal returns a fresh token. Pass it to your voice provider. A renewal fails with
    `409 session_not_active` once the license is revoked, suspended or ends, or the session was
    terminated: stop output at once. On a network error, `429` or `5xx`, keep retrying, and stop
    when the current token expires if no renewal succeeded.

    <CodeGroup>
      ```bash cURL theme={null}
      curl -sS --fail-with-body -X POST "https://app.reflections.ai/v1/sessions/$SESSION_ID/renew" \
        -H "Authorization: Bearer $REFLECTIONS_KEY" \
        -H "Reflections-Version: 2026-10-15"
      ```

      ```typescript TypeScript theme={null}
      // Never speak past the current token's expiry.
      let expiry = setTimeout(stopOutput, Date.parse(session.expires_at) - Date.now());

      const renewTimer = setInterval(async () => {
        try {
          const res = await fetch(`${api}/sessions/${session.id}/renew`, { method: "POST", headers: auth });
          if (res.ok) {
            const renewed = await res.json();
            token = renewed.token;
            clearTimeout(expiry);
            expiry = setTimeout(stopOutput, Date.parse(renewed.expires_at) - Date.now());
          } else if (res.status === 409) {
            stopOutput(); // session_not_active
            clearInterval(renewTimer);
          } // 429 or 5xx: retry on the next tick
        } catch {
          // Network error: retry on the next tick
        }
      }, 60_000);
      ```

      ```python Python theme={null}
      async def renew_every_minute(session: dict):
          while speaking:
              await asyncio.sleep(60)
              try:
                  res = await asyncio.to_thread(
                      requests.post, f"{API}/sessions/{session['id']}/renew", headers=auth, timeout=5
                  )
              except requests.RequestException:
                  res = None  # Network error: retry on the next tick.
              if res is not None and res.ok:
                  session.update(res.json())  # token, expires_at
              elif res is not None and res.status_code == 409:
                  stop_output()  # session_not_active
              elif datetime.now(timezone.utc) >= datetime.fromisoformat(session["expires_at"]):
                  stop_output()  # 429, 5xx or no answer: never past the current token's expiry

      async def main(session: dict):
          await asyncio.gather(listen(session), renew_every_minute(session))

      asyncio.run(main(session))
      ```
    </CodeGroup>

    ```json theme={null}
    {
      "token": "eyJhbGciOiJFUzI1NiIs…",
      "expires_at": "2026-10-17T18:06:00Z"
    }
    ```
  </Step>

  <Step title="Submit samples">
    `sampling` sets the minimum: an audio window of at most 15 seconds for every 15 seconds of
    speech, and the words of every turn. Audio goes as base64 WAV or Ogg, up to 2 MB per sample;
    16 kHz mono WAV keeps a 15-second window well under that.

    <CodeGroup>
      ```bash cURL theme={null}
      printf '{
        "kind": "audio",
        "window": { "start_ms": 0, "end_ms": 15000 },
        "data": "%s",
        "media_type": "audio/wav"
      }' "$(base64 < window-0.wav | tr -d '\n')" |
      curl -sS --fail-with-body "https://app.reflections.ai/v1/sessions/$SESSION_ID/samples" \
        -H "Authorization: Bearer $REFLECTIONS_KEY" \
        -H "Reflections-Version: 2026-10-15" \
        -H "Content-Type: application/json" \
        --data-binary @-
      ```

      ```typescript TypeScript theme={null}
      async function submitSample(sample: object) {
        const result = await fetch(`${api}/sessions/${session.id}/samples`, {
          method: "POST",
          headers: json,
          body: JSON.stringify(sample),
        }).then(ok);
        if (result.decision === "deny") stopOutput();
        return result;
      }

      // Every 15 seconds of speech, the window just spoken, as WAV bytes:
      const windowWav = await readFile("window-0.wav");
      await submitSample({
        kind: "audio",
        window: { start_ms: 0, end_ms: 15000 },
        data: windowWav.toString("base64"),
        media_type: "audio/wav",
      });

      // After every turn:
      await submitSample({ kind: "transcript", text: "Great question. My favorite trail is the ridge loop." });
      ```

      ```python Python theme={null}
      def submit_sample(session_id: str, sample: dict) -> dict:
          result = ok(requests.post(f"{API}/sessions/{session_id}/samples", headers=auth, json=sample))
          if result["decision"] == "deny":
              stop_output()
          return result

      # Every 15 seconds of speech, the window just spoken, as WAV bytes:
      with open("window-0.wav", "rb") as f:
          window_wav = f.read()
      submit_sample(session["id"], {
          "kind": "audio",
          "window": {"start_ms": 0, "end_ms": 15000},
          "data": base64.b64encode(window_wav).decode(),
          "media_type": "audio/wav",
      })

      # After every turn:
      submit_sample(session["id"], {"kind": "transcript", "text": "Great question. My favorite trail is the ridge loop."})
      ```
    </CodeGroup>

    ```json theme={null}
    {
      "id": "smp_xy8jsm8mg8wckjkr8wyh27wpea",
      "object": "session_sample",
      "session": "ses_k5v9fvrx5w37jt6q6vm26zxzfh",
      "kind": "audio",
      "decision": "allow",
      "reasons": [],
      "findings": [
        { "term": "license:lic_fmva0q8p2ymbhdy60snaphp4gt/terms.requiredAttributes", "attribute": "identity.samePerson", "result": "preserved" }
      ]
    }
    ```
  </Step>

  <Step title="Handle termination">
    Submit a transcript sample whose text contains `rfl_test_terminate`. The sample is denied:

    ```json theme={null}
    {
      "id": "smp_gqjynq1k05j6qnnren7z29yr4b",
      "object": "session_sample",
      "session": "ses_k5v9fvrx5w37jt6q6vm26zxzfh",
      "kind": "transcript",
      "decision": "deny",
      "reasons": [
        {
          "code": "prohibited_custom_term",
          "stage": "session",
          "talent": "tal_9t0wxyhk5zba2q8v1m3n7p4r6s",
          "term": "license:lic_fmva0q8p2ymbhdy60snaphp4gt/terms.voiceLineGrant.never",
          "sentence": "Do not discuss politics or religion."
        }
      ]
    }
    ```

    and the stream pushes:

    ```json theme={null}
    {
      "type": "session.terminated",
      "session": "ses_k5v9fvrx5w37jt6q6vm26zxzfh",
      "reason": "prohibited_custom_term",
      "sentence": "Do not discuss politics or religion.",
      "revocation": null,
      "sample": "smp_gqjynq1k05j6qnnren7z29yr4b",
      "at": "2026-10-17T18:03:10Z"
    }
    ```

    Stop output within one second of whichever arrives first. Do not renew again. The
    `session.terminated` webhook reports the same termination; read `data.object.termination`. To
    test a license ending instead, suspend the license with the
    [test helper](/api-reference/test-helpers/revoke-a-license) and `action: "suspend"`: the stream
    carries `license_suspended`. Resume it with `action: "resume"` so the fixture keeps working.

    <Warning>
      Between samples, up to 15 seconds of speech is unchecked, and a revoked license can keep a
      session's current token valid for the rest of its 5 minutes. Stopping on the stream message
      is what keeps both bounds short.
    </Warning>
  </Step>

  <Step title="End the session">
    End every session when the call is over, including one Reflections terminated.

    <CodeGroup>
      ```bash cURL theme={null}
      curl -sS --fail-with-body -X POST "https://app.reflections.ai/v1/sessions/$SESSION_ID/end" \
        -H "Authorization: Bearer $REFLECTIONS_KEY" \
        -H "Reflections-Version: 2026-10-15"
      ```

      ```typescript TypeScript theme={null}
      clearInterval(renewTimer);
      clearInterval(keepAlive);
      const ended = await fetch(`${api}/sessions/${session.id}/end`, { method: "POST", headers: auth }).then(ok);
      ```

      ```python Python theme={null}
      ended = ok(requests.post(f"{API}/sessions/{session['id']}/end", headers=auth))
      ```
    </CodeGroup>

    This session was terminated, so it keeps that status and its `termination`. A session your
    user ends returns `status: "ended"`.

    ```json theme={null}
    {
      "id": "ses_k5v9fvrx5w37jt6q6vm26zxzfh",
      "object": "session",
      "status": "terminated",
      "token": null,
      "termination": {
        "reason": "prohibited_custom_term",
        "sentence": "Do not discuss politics or religion.",
        "revocation": null,
        "sample": "smp_gqjynq1k05j6qnnren7z29yr4b",
        "at": "2026-10-17T18:03:10Z"
      },
      "ended_at": "2026-10-17T18:03:11Z"
    }
    ```
  </Step>

  <Step title="Report usage">
    The `usage_report` obligations ask for seconds within a day. Report the speech lines and the
    session together: each record names the generation or session it settles.

    <CodeGroup>
      ```bash cURL theme={null}
      NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
      printf '{
        "records": [
          { "subject": "%s", "units": "seconds", "quantity": 3.4, "occurred_at": "%s" },
          { "subject": "%s", "units": "seconds", "quantity": 96, "occurred_at": "%s" }
        ]
      }' "$LINE_ID" "$NOW" "$SESSION_ID" "$NOW" |
      curl -sS --fail-with-body https://app.reflections.ai/v1/usage_records \
        -H "Authorization: Bearer $REFLECTIONS_KEY" \
        -H "Reflections-Version: 2026-10-15" \
        -H "Idempotency-Key: $(uuidgen)" \
        -H "Content-Type: application/json" \
        --data-binary @-
      ```

      ```typescript TypeScript theme={null}
      const usage = await fetch(`${api}/usage_records`, {
        method: "POST",
        headers: { ...json, "Idempotency-Key": crypto.randomUUID() },
        body: JSON.stringify({
          records: [
            { subject: line.id, units: "seconds", quantity: 3.4, occurred_at: line.created_at },
            { subject: session.id, units: "seconds", quantity: 96, occurred_at: ended.ended_at },
          ],
        }),
      }).then(ok);
      ```

      ```python Python theme={null}
      usage = ok(requests.post(
          f"{API}/usage_records",
          headers={**auth, "Idempotency-Key": str(uuid.uuid4())},
          json={
              "records": [
                  {"subject": line["id"], "units": "seconds", "quantity": 3.4, "occurred_at": line["created_at"]},
                  {"subject": session["id"], "units": "seconds", "quantity": 96, "occurred_at": ended["ended_at"]},
              ]
          },
      ))
      ```
    </CodeGroup>

    For the session, `quantity` is the seconds of speech it generated in her voice. Each record is
    accepted or rejected on its own:

    ```json theme={null}
    {
      "object": "list",
      "has_more": false,
      "data": [
        { "id": "ur_5rzxkm8x1rs38251nw2eewgsc8", "object": "usage_record", "status": "accepted", "subject": "gen_bvmw7kydzhy23ypte3d7qs68sm", "units": "seconds", "quantity": 3.4, "license": "lic_fmva0q8p2ymbhdy60snaphp4gt", "statement": "stmt_v6ptrh4c51w9y14ybax0hxdgzg" },
        { "id": "ur_adw0qh36zbh9xawrndzeg2d0ea", "object": "usage_record", "status": "accepted", "subject": "ses_k5v9fvrx5w37jt6q6vm26zxzfh", "units": "seconds", "quantity": 96, "license": "lic_fmva0q8p2ymbhdy60snaphp4gt", "statement": "stmt_v6ptrh4c51w9y14ybax0hxdgzg" }
      ]
    }
    ```

    A rejected record carries `rejection.code`, such as `exceeds_observed` when you report more
    than Reflections recorded. Send a correction as a new record with `correction_of`.
  </Step>
</Steps>

## Next

<Columns cols={2}>
  <Card title="Voice platforms" icon="microphone" href="/guides/voice-platforms">
    Provider-held voices, scripts and sessions in production.
  </Card>

  <Card title="Real-time sessions" icon="tower-broadcast" href="/build/real-time-sessions">
    Renewal, sampling, termination and the one-second stop.
  </Card>

  <Card title="Session stream" icon="plug" href="/api-reference/realtime/session-stream">
    Every message on the WebSocket.
  </Card>

  <Card title="Reporting usage" icon="chart-line" href="/build/reporting-usage">
    Batching, units, reconciliation and corrections.
  </Card>
</Columns>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.