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

# Recordings & transcripts

Every connection to a voice agent is recorded as a **session**. The Sessions API lists past sessions, retrieves any one with its recording and full conversation timeline, and deletes them. It is what you build a call-history view, transcript review, QA workflow, or usage dashboard on.

Send your API key in the `Authorization` header (a `Bearer ` prefix is accepted and stripped). The base URL is `https://agents.assemblyai.com`. For the field-level schema of each endpoint, see the [Sessions API reference](/api-reference/voice-agent-api/list-sessions).

## Find completed sessions

Poll `GET /v1/sessions` and process the ones whose `status` is `completed`, fetching each and downloading its artifacts. Keep the last `session_id` you handled (or the newest `created_at`) so each poll only picks up new sessions.

You also know a session's `session_id` the moment it connects, since it arrives over the WebSocket in `session.ready`. Store it live and pull the recording and transcript once the session ends.

## List sessions

`GET /v1/sessions` returns a page of sessions, newest first, without artifacts. Narrow the results with `status` and `agent_id`, size the page with `limit` (1 to 200, default 50), and page forward with `cursor`.

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://agents.assemblyai.com/v1/sessions?limit=50&agent_id=$AGENT_ID" \
    -H "Authorization: $ASSEMBLYAI_API_KEY"
  ```

  ```python Python theme={null}
  # pip install requests
  import os
  import requests

  resp = requests.get(
      "https://agents.assemblyai.com/v1/sessions",
      headers={"Authorization": os.environ["ASSEMBLYAI_API_KEY"]},
      params={"limit": 50, "agent_id": os.environ["AGENT_ID"]},
  )
  resp.raise_for_status()
  print(resp.json()["sessions"])
  ```

  ```javascript Node.js theme={null}
  // Node 18+ has fetch built in
  const url = new URL("https://agents.assemblyai.com/v1/sessions");
  url.searchParams.set("limit", "50");
  url.searchParams.set("agent_id", process.env.AGENT_ID);

  const res = await fetch(url, {
    headers: { Authorization: process.env.ASSEMBLYAI_API_KEY },
  });
  const data = await res.json();
  console.log(data.sessions);
  ```
</CodeGroup>

```json theme={null}
{
  "sessions": [
    {
      "id": "sess_9a648a2ab75747a9a597ba046ced3e13",
      "agent_id": "7ad24396-b822-4dca-871a-be9cc4781cf9",
      "status": "completed",
      "public_close_reason": "client_end",
      "duration_seconds": 42.6,
      "created_at": "2026-07-14T18:04:27.607110Z",
      "ended_at": "2026-07-14T18:05:10.204981Z"
    }
  ],
  "has_more": true,
  "response_metadata": { "next_cursor": "c2Vzc185YTY0OGEyYWI3NTc0N2E5" }
}
```

To pull the full history, keep passing `next_cursor` back as `cursor` until `has_more` is `false`:

<CodeGroup>
  ```python Python theme={null}
  import os
  import requests

  BASE = "https://agents.assemblyai.com"
  HEADERS = {"Authorization": os.environ["ASSEMBLYAI_API_KEY"]}


  def all_sessions(**filters):
      cursor = None
      while True:
          resp = requests.get(
              f"{BASE}/v1/sessions",
              headers=HEADERS,
              params={"limit": 200, "cursor": cursor, **filters},
          )
          resp.raise_for_status()
          page = resp.json()
          yield from page["sessions"]
          cursor = page["response_metadata"]["next_cursor"]
          if not page["has_more"] or not cursor:
              break


  for session in all_sessions(agent_id=os.environ["AGENT_ID"]):
      print(session["id"], session["status"], session["duration_seconds"])
  ```

  ```javascript Node.js theme={null}
  const BASE = "https://agents.assemblyai.com";
  const HEADERS = { Authorization: process.env.ASSEMBLYAI_API_KEY };

  async function* allSessions(filters = {}) {
    let cursor = "";
    do {
      const url = new URL(`${BASE}/v1/sessions`);
      url.searchParams.set("limit", "200");
      if (cursor) url.searchParams.set("cursor", cursor);
      for (const [k, v] of Object.entries(filters)) url.searchParams.set(k, v);

      const res = await fetch(url, { headers: HEADERS });
      const page = await res.json();
      yield* page.sessions;
      cursor = page.response_metadata.next_cursor;
      if (!page.has_more || !cursor) break;
    } while (true);
  }

  for await (const s of allSessions({ agent_id: process.env.AGENT_ID })) {
    console.log(s.id, s.status, s.duration_seconds);
  }
  ```
</CodeGroup>

## Retrieve a session

`GET /v1/sessions/{id}` returns the full session. On top of the list metadata it adds two things: `config`, the session's configuration as it ran (system prompt, input/output, tools, LLM), and `artifacts`, each a short-lived **pre-signed download URL**.

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://agents.assemblyai.com/v1/sessions/$SESSION_ID" \
    -H "Authorization: $ASSEMBLYAI_API_KEY"
  ```

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

  resp = requests.get(
      f"https://agents.assemblyai.com/v1/sessions/{os.environ['SESSION_ID']}",
      headers={"Authorization": os.environ["ASSEMBLYAI_API_KEY"]},
  )
  resp.raise_for_status()
  session = resp.json()
  print(session["status"], session["artifacts"])
  ```

  ```javascript Node.js theme={null}
  const res = await fetch(
    `https://agents.assemblyai.com/v1/sessions/${process.env.SESSION_ID}`,
    { headers: { Authorization: process.env.ASSEMBLYAI_API_KEY } },
  );
  const session = await res.json();
  console.log(session.status, session.artifacts);
  ```
</CodeGroup>

```json expandable theme={null}
{
  "id": "sess_9a648a2ab75747a9a597ba046ced3e13",
  "agent_id": "7ad24396-b822-4dca-871a-be9cc4781cf9",
  "status": "completed",
  "public_close_reason": "client_end",
  "duration_seconds": 42.6,
  "config": {
    "system_prompt": "You are a friendly support agent.",
    "greeting": "Hi, how can I help?",
    "input": { "type": "audio", "format": { "encoding": "audio/pcm", "sample_rate": 24000 }, "keyterms": [], "turn_detection": null },
    "output": { "type": "audio", "voice": "ivy", "format": { "encoding": "audio/pcm", "sample_rate": 24000 } },
    "tools": [],
    "llm": []
  },
  "created_at": "2026-07-14T18:04:27.607110Z",
  "ended_at": "2026-07-14T18:05:10.204981Z",
  "artifacts": [
    { "type": "audio", "url": "https://s3.amazonaws.com/.../audio.ogg?X-Amz-Signature=...", "content_type": "audio/ogg" },
    { "type": "timeline", "url": "https://s3.amazonaws.com/.../timeline.json?X-Amz-Signature=...", "content_type": "application/json" },
    { "type": "metadata", "url": "https://s3.amazonaws.com/.../metadata.json?X-Amz-Signature=...", "content_type": "application/json" }
  ]
}
```

The three artifacts appear once the session completes; the array is empty while it is still active. They are the **audio** recording (OGG/Opus), the **timeline** (the conversation as JSON), and **metadata** (recording details). Download each straight from its `url` with no `Authorization` header, since the signature already authorizes the request.

<Warning>
  Artifact URLs expire after a short TTL. Store the `session_id`, not the URL, and re-fetch the session for fresh links right before you download.
</Warning>

## Parse the conversation timeline

The `timeline` artifact is the whole conversation, turn by turn:

```json expandable theme={null}
{
  "session_id": "sess_9a648a2ab75747a9a597ba046ced3e13",
  "started_at_unix_ms": 1768413867285,
  "turns": [
    {
      "turn_id": "resp_c5f07b9645b649fdb31bfbdc790d224b",
      "item_id": "msg_7ff94d121e0a473f9bfb53216294538a",
      "status": "completed",
      "trigger": "greeting",
      "user_transcript": null,
      "agent_text": "Hi, how can I help?",
      "agent_reply_started_at_ms": 1768413868332,
      "agent_reply_ended_at_ms": 1768413871652,
      "time_to_first_audio_ms": 1013
    },
    {
      "turn_id": "resp_86122ef6e8264c1e94e1bd88ed1f9fbc",
      "item_id": "msg_66d56698eb0e4b3a96ec9bd494db44b8",
      "status": "completed",
      "trigger": "user_speech",
      "user_transcript": "What's the weather in Paris?",
      "user_confidence": 0.98,
      "agent_text": "It's 22 degrees and sunny in Paris.",
      "agent_reply_started_at_ms": 1768413878530,
      "time_to_first_audio_ms": 1457,
      "tool_calls": [
        {
          "call_id": "call_abc",
          "name": "get_weather",
          "arguments": { "latitude": 48.85, "longitude": 2.35 },
          "result": "{\"temp_c\": 22, \"condition\": \"sunny\"}",
          "dispatched_at_ms": 1768413876500,
          "result_received_at_ms": 1768413876920,
          "duration_ms": 420,
          "is_error": false
        }
      ]
    }
  ]
}
```

Each turn pairs what the user said (`user_transcript`, `null` on agent-initiated turns like the greeting) with what the agent replied (`agent_text`), and carries a `tool_calls` array **only on turns where a tool actually fired**. `turn_id` is the model response ID and `status` is the turn outcome (`completed` or `interrupted`). Timestamps ending `_at_ms` are absolute Unix epoch milliseconds; durations ending `_ms` (like `time_to_first_audio_ms` and `duration_ms`) are relative spans.

A turn can also have **no** `user_transcript` and **no** `agent_text`: an interruption before the agent spoke, or a brief noise that opened a turn without a resolved transcript. Skip those when you build a transcript rather than assuming every turn has text (the code below does).

<Note>
  Empty arrays are dropped from the JSON: a session where no one spoke has **no** `turns` key at all, and a turn with no tools has **no** `tool_calls`. Default them when you read: `timeline.get("turns", [])` in Python, `timeline.turns ?? []` in JS.
</Note>

To render or store the conversation, download the timeline and flatten its turns into an ordered list of messages:

<CodeGroup>
  ```python Python theme={null}
  import os
  import requests

  BASE = "https://agents.assemblyai.com"
  HEADERS = {"Authorization": os.environ["ASSEMBLYAI_API_KEY"]}


  def artifact_url(session, kind):
      return next((a["url"] for a in session["artifacts"] if a["type"] == kind), None)


  def load_transcript(session_id):
      session = requests.get(
          f"{BASE}/v1/sessions/{session_id}", headers=HEADERS
      ).json()

      url = artifact_url(session, "timeline")
      if url is None:
          return []  # session still active, or no recording

      timeline = requests.get(url).json()  # pre-signed URL: no auth header

      messages = []
      for turn in timeline.get("turns", []):  # `turns` is omitted when empty
          if turn.get("user_transcript"):
              messages.append({"role": "user", "text": turn["user_transcript"]})
          for call in turn.get("tool_calls", []):
              messages.append({
                  "role": "tool",
                  "name": call["name"],
                  "arguments": call.get("arguments"),
                  "result": call.get("result"),
                  "error": call["is_error"] or call.get("timed_out", False),
              })
          if turn.get("agent_text"):
              messages.append({"role": "agent", "text": turn["agent_text"]})
      return messages


  for m in load_transcript(os.environ["SESSION_ID"]):
      if m["role"] == "tool":
          flag = " (error)" if m["error"] else ""
          print(f"  ↳ {m['name']}({m['arguments']}) → {m['result']}{flag}")
      else:
          print(f"{m['role']}: {m['text']}")
  ```

  ```javascript Node.js theme={null}
  const BASE = "https://agents.assemblyai.com";
  const HEADERS = { Authorization: process.env.ASSEMBLYAI_API_KEY };

  const artifactUrl = (session, kind) =>
    session.artifacts.find((a) => a.type === kind)?.url ?? null;

  async function loadTranscript(sessionId) {
    const session = await (
      await fetch(`${BASE}/v1/sessions/${sessionId}`, { headers: HEADERS })
    ).json();

    const url = artifactUrl(session, "timeline");
    if (!url) return []; // session still active, or no recording

    const timeline = await (await fetch(url)).json(); // pre-signed: no auth header

    const messages = [];
    for (const turn of timeline.turns ?? []) {  // `turns` is omitted when empty
      if (turn.user_transcript)
        messages.push({ role: "user", text: turn.user_transcript });
      for (const call of turn.tool_calls ?? []) {
        messages.push({
          role: "tool",
          name: call.name,
          arguments: call.arguments,
          result: call.result,
          error: call.is_error || call.timed_out,
        });
      }
      if (turn.agent_text)
        messages.push({ role: "agent", text: turn.agent_text });
    }
    return messages;
  }

  for (const m of await loadTranscript(process.env.SESSION_ID)) {
    if (m.role === "tool") {
      console.log(
        `  ↳ ${m.name}(${JSON.stringify(m.arguments)}) → ${m.result}` +
          (m.error ? " (error)" : ""),
      );
    } else {
      console.log(`${m.role}: ${m.text}`);
    }
  }
  ```
</CodeGroup>

```text theme={null}
agent: Hi, how can I help?
user: What's the weather in Paris?
  ↳ get_weather({"latitude": 48.85, "longitude": 2.35}) → {"temp_c": 22, "condition": "sunny"}
agent: It's 22 degrees and sunny in Paris.
```

## Work with the recording

The `audio` artifact is an OGG/Opus file; the `metadata` artifact describes it:

```json theme={null}
{
  "session_id": "sess_9a648a2ab75747a9a597ba046ced3e13",
  "started_at": "2026-07-14T18:04:27.607110Z",
  "ended_at": "2026-07-14T18:05:10.204981Z",
  "format": "ogg/opus",
  "channels": 2,
  "channel_layout": "stereo (left=user, right=agent)",
  "sample_rate": 24000,
  "file": "sess_9a648a2ab75747a9a597ba046ced3e13/recording/audio.ogg"
}
```

The recording is stereo with the **user on the left channel and the agent on the right**, so you can split channels for diarized playback or per-speaker analysis. To save it locally:

<CodeGroup>
  ```python Python theme={null}
  import os
  import requests

  BASE = "https://agents.assemblyai.com"
  HEADERS = {"Authorization": os.environ["ASSEMBLYAI_API_KEY"]}

  session = requests.get(
      f"{BASE}/v1/sessions/{os.environ['SESSION_ID']}", headers=HEADERS
  ).json()

  audio = next(a for a in session["artifacts"] if a["type"] == "audio")
  meta = next(a for a in session["artifacts"] if a["type"] == "metadata")

  with requests.get(audio["url"], stream=True) as r:  # pre-signed: no auth header
      r.raise_for_status()
      with open("recording.ogg", "wb") as f:
          for chunk in r.iter_content(chunk_size=8192):
              f.write(chunk)

  print(requests.get(meta["url"]).json())
  ```

  ```javascript Node.js theme={null}
  import { writeFile } from "node:fs/promises";

  const BASE = "https://agents.assemblyai.com";
  const HEADERS = { Authorization: process.env.ASSEMBLYAI_API_KEY };

  const session = await (
    await fetch(`${BASE}/v1/sessions/${process.env.SESSION_ID}`, {
      headers: HEADERS,
    })
  ).json();

  const audio = session.artifacts.find((a) => a.type === "audio");
  const meta = session.artifacts.find((a) => a.type === "metadata");

  const buf = Buffer.from(await (await fetch(audio.url)).arrayBuffer());
  await writeFile("recording.ogg", buf);

  console.log(await (await fetch(meta.url)).json());
  ```
</CodeGroup>

To play it back in your app, keep the API key server-side: expose a small endpoint that returns a fresh audio URL, then point an `<audio>` element at it.

<CodeGroup>
  ```javascript Server (Express) theme={null}
  import express from "express";

  const app = express();

  app.get("/api/sessions/:id/audio", async (req, res) => {
    const r = await fetch(
      `https://agents.assemblyai.com/v1/sessions/${req.params.id}`,
      { headers: { Authorization: process.env.ASSEMBLYAI_API_KEY } },
    );
    if (!r.ok) return res.status(r.status).end();

    const session = await r.json();
    const audio = session.artifacts.find((a) => a.type === "audio");
    // The URL is short-lived, so the client requests it right before playback.
    res.json({ url: audio?.url ?? null });
  });

  app.listen(3000);
  ```

  ```tsx React theme={null}
  import { useEffect, useState } from "react";

  export function SessionRecording({ sessionId }: { sessionId: string }) {
    const [url, setUrl] = useState<string | null>(null);

    useEffect(() => {
      fetch(`/api/sessions/${sessionId}/audio`)
        .then((r) => r.json())
        .then((d) => setUrl(d.url));
    }, [sessionId]);

    if (!url) return <p>Loading recording…</p>;

    return (
      <audio controls preload="metadata" src={url}>
        Your browser does not support the audio element.
      </audio>
    );
  }
  ```
</CodeGroup>

<Note>
  OGG/Opus plays natively in Chrome, Firefox, and Edge; Safari support is limited. To support Safari, transcode server-side first, for example `ffmpeg -i recording.ogg recording.m4a`.
</Note>

## Delete a session

`DELETE /v1/sessions/{id}` soft-deletes a session and returns `204`. It stops appearing in `GET /v1/sessions` and its artifacts become inaccessible.

```bash cURL theme={null}
curl -X DELETE "https://agents.assemblyai.com/v1/sessions/$SESSION_ID" \
  -H "Authorization: $ASSEMBLYAI_API_KEY"
```
