# Recordings API

> List call recordings, mint a presigned download URL, and get pushed a webhook the moment a recording lands. Two operations plus one event.

A recorded call produces a single audio object in your tenant's storage. The
API over it is deliberately small:

| You want | Operation | Scope |
| --- | --- | --- |
| The list, with filters and stats | `cdr.recordings` (query) | `mcp:cdr:read` |
| The bytes for one call | `storage.signedUrl` (query) | `mcp:media:read` |
| To be told when one lands | `voice.recording.ready` webhook | — |

Everything is keyed by **`call_sid`** — the same id the CDR, the transcript,
the usage rows, and (for an external agent) `ctx.job.id` all use. That is the
join key across every surface.

## Authenticate

Use an org-scoped `mpk_…` key, minted in the console under **Settings → API
keys**. Scopes are per-operation and narrow: a key minted to fetch recordings
cannot place a call.

  
```ts
import { createAdminClient } from "@telequick/admin-sdk";

const admin = createAdminClient({
  baseUrl: "https://portal.telequick.dev",
  apiKey:  process.env.TELEQUICK_API_KEY!,   // mpk_…
  orgId:   process.env.TELEQUICK_ORG_ID!,
});
```
  
  
```bash
# Queries are GET with a JSON `input` query param; mutations are POST.
curl -sG "https://portal.telequick.dev/trpc/cdr.recordings" \
  -H "authorization: Bearer $TELEQUICK_API_KEY" \
  --data-urlencode 'input={"orgId":"org_abc","limit":50}'
```
  

## List recordings

```ts
const { rows, total, stats } = await admin.cdr.recordings.query({
  limit:     50,
  offset:    0,
  fromMs:    Date.now() - 7 * 86_400_000,
  toMs:      Date.now(),
  direction: "outbound",          // "inbound" | "outbound"
  agent:     "salesbot",          // case-insensitive substring on CDR attribution
  search:    "+9199",             // substring over call_id / caller / callee
});
```

- **``** (`number`, default `200`) — 1–500 rows per page.
- **``** (`number`, default `0`) — Pagination offset.
- **``** (`number`) — Epoch milliseconds, inclusive, on call start.

- **``** (`string`) — Case-insensitive substring on the CDR's agent attribution.
- **``** (`string`) — Case-insensitive substring over `call_id`, `caller`, and `callee`.

Each row:

| Field | Meaning |
| --- | --- |
| `call_id` | The call sid — pass this to `storage.signedUrl` |
| `caller` / `callee` | ANI / DNIS from the CDR |
| `starting_time` | Call start |
| `duration` | Seconds |
| `direction` | `inbound` / `outbound` |
| `agent` | Agent attribution from the CDR |
| `recording_url` | `s3://<bucket>/<key>` — an internal locator, **not** fetchable. Presign it |
| `recording_size_bytes` | Object size |

`stats` describes exactly the filtered set the rows come from —
`total_recordings`, `total_duration`, `total_bytes`, `avg_duration` — so a
dashboard's KPI cards and its grid can never disagree.

> **NOTE:**
> **Two eras, merged for you.** Recordings written before 2026-06-03 carry a
>   `cdrs.recording_url`; everything since is a storage object discovered by
>   listing your tenant prefix. The operation merges both, one row per call,
>   newest-first. You do not need to know which era a call belongs to.

## Download one recording

`recording_url` is an internal `s3://` locator. To read the audio, mint a
**presigned GET** — valid for one hour:

```ts
const { url, expiresIn } = await admin.storage.signedUrl.query({
  callSid: "cs_01J9Z…",
});
// expiresIn === 3600
const wav = await fetch(url).then((r) => r.arrayBuffer());
```

The call must belong to your organization: the server resolves the object only
within your tenant, so guessing another org's `call_sid` returns `NOT_FOUND`
rather than a URL. Objects in the shared fallback bucket are additionally
proven against your CDR rows before a URL is minted.

Files are WAV (or Opus) named `<call_sid>.<ext>`, both legs mixed.

> **WARNING:**
> Mint the URL **when you are about to fetch**, not when you list. It expires in
>   an hour, and each mint is recorded as a playback/download event against the
>   requesting user — presigning en masse produces misleading audit data.

## Get pushed instead of polling

Subscribe to `voice.recording.ready` and the platform tells you the moment the
upload completes — no list-and-diff loop. Add the **Transcripts & recordings**
event group to a webhook endpoint in the console (it carries both
`voice.transcript.ready` and `voice.recording.ready`).

```json
{
  "type": "voice.recording.ready",
  "data": {
    "bucket": "telequick-recordings",
    "object": "cs_01J9Z….wav",
    "call_id": "cs_01J9Z…"
  }
}
```

Then presign `call_id` and fetch. Webhook deliveries are signed — verify
`X-Clutchcall-Signature` (`t=…,v1=…`, HMAC over `"<t>.<body>"`) before you
trust one. See [Webhooks](/modalities/voice/api/webhooks).

## Storage and retention

`storage.info` reports what your tenant is holding and the retention window:

```ts
const { bucket, prefix, retention_days, usage_bytes, usage_objects } =
  await admin.storage.info.query({});
```

Recordings are retained **30 days** by default.

## Recording an external agent's calls

Recording is a **call-layer** capture (the RTP tap), not an agent-pipeline
feature. A call handled by your own `livekit-agents` worker over our transport
is therefore recorded exactly like a native agent's call — both legs, same
storage, same API. Nothing in your agent needs to participate.

Correlate it from inside your agent by logging `ctx.job.id`, which is the same
`call_sid` this API is keyed by.

## Related

  - **[Webhooks](/modalities/voice/api/webhooks)** — Every event type, the signature scheme, and retry behaviour.
  - **[Admin API SDK](/sdks/admin-sdk)** — The typed client, the key model, and the full operation surface.
  - **[Observability](/modalities/voice/observability/overview)** — Where recordings sit beside CDR, MOS, and transcripts.
  - **[LiveKit Agent over TeleQuick Transport](/modalities/voice/integrations/livekit)** — What an external agent does and does not get automatically.
