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

# Download call recordings

> Sync every recording completely, fetch download URLs only when needed, and handle revocation correctly.

## Before you start

Use a server-side API key with `recordings:read`. The scope is independent of
`calls:read` and only appears on the key-creation form once an organization
administrator has enabled recordings for your organization; see
[Authentication and scopes](/en/authentication) for how to add the scope to a
new key.

## List recordings

Call `GET /recordings`. Results cover every recording the organization has,
across both call-storage tables, ordered newest-first by recording time.

<CodeGroup>
  ```curl theme={null}
  curl --request GET 'https://api.brightalk.ai/recordings?limit=20' \
    --header "Authorization: Bearer $BRIGHTALK_API_KEY" \
    --header "Brightalk-Version: 2026-07-16"
  ```

  ```javascript theme={null}
  const response = await fetch("https://api.brightalk.ai/recordings?limit=20", {
    method: "GET",
    headers: {
      Authorization: `Bearer ${process.env.BRIGHTALK_API_KEY}`,
      "Brightalk-Version": "2026-07-16",
    },
  });
  console.log(response.status, await response.json());
  ```

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

  response = requests.request(
      "GET",
      "https://api.brightalk.ai/recordings?limit=20",
      headers={
      "Authorization": f"Bearer {os.environ['BRIGHTALK_API_KEY']}",
      "Brightalk-Version": "2026-07-16",
      },
  )
  print(response.status_code, response.json())
  ```
</CodeGroup>

Add `include=download_url` to mint a signed URL for every row in the same
response — the right choice when you are about to fetch many recordings in
bulk and want to avoid one extra request per row. Omit it for a lighter,
metadata-only page. `expires_in` only means something alongside
`include=download_url` on this operation; sending it without `include`
returns `400 validation_error` instead of being silently ignored, because a
setting that appears to apply but does not would be worse than an error.

## Sync recordings completely

`recorded_at` is the call's time, not the time its `download_url`-bearing row
became fetchable. A recording whose URL is written well after the call — a
repair job, a re-run, a manual fix — can be written after your sync cursor has
already moved past it, and incremental sync will never see it again. This is
a real, measured limitation, not a hypothetical one, so the contract has two
layers.

### Incremental sync — best-effort

Track the timestamp of your last successful sync. On the next run, call
`GET /recordings?created_after=<last sync time minus 24 hours>` and dedupe
results against what you already have by `id`. The 24-hour overlap absorbs
the normal case — on measured production data, a recording written more than
six hours after its call is vanishingly rare — but it does not absorb one
repaired long after the fact. Run this as often as you like; it is cheap.

### Full reconciliation — the only guarantee of completeness

Periodically walk the full cursor with no `created_after` at all, deduping by
`id` against what you already have. This is the only way to guarantee you
have every recording, and it is inexpensive. Measured across our full
platform on 2026-09-01: 32,261 recordings divided by 100 per page is about
323 requests, comfortably under six minutes at 60 requests per minute, and
every one of those requests returns metadata only — no audio bytes. That
count is our total across every organization, not yours — what carries over
to your own organization is the ratio (about one request per 100 recordings
you have) and the fact that a full walk is metadata-only regardless of
scale. Run this daily or weekly depending on how much a missed recording
costs you. Do not rely on incremental sync alone and call it complete.

## Fetch a URL only when you are about to use it

<CodeGroup>
  ```curl theme={null}
  curl --request GET 'https://api.brightalk.ai/recordings/22222222-2222-5222-9222-222222222222' \
    --header "Authorization: Bearer $BRIGHTALK_API_KEY" \
    --header "Brightalk-Version: 2026-07-16"
  ```

  ```javascript theme={null}
  const response = await fetch("https://api.brightalk.ai/recordings/22222222-2222-5222-9222-222222222222", {
    method: "GET",
    headers: {
      Authorization: `Bearer ${process.env.BRIGHTALK_API_KEY}`,
      "Brightalk-Version": "2026-07-16",
    },
  });
  console.log(response.status, await response.json());
  ```

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

  response = requests.request(
      "GET",
      "https://api.brightalk.ai/recordings/22222222-2222-5222-9222-222222222222",
      headers={
      "Authorization": f"Bearer {os.environ['BRIGHTALK_API_KEY']}",
      "Brightalk-Version": "2026-07-16",
      },
  )
  print(response.status_code, response.json())
  ```
</CodeGroup>

`GET /recordings/{recording_id}` always includes `download_url` — no
`include` needed. If you are building a player inside your own product,
request this URL when the listener presses play, not when the page listing
recordings first loads. The URL defaults to a 900-second lifetime (override
with `expires_in`, 60–3600 seconds); a page left open longer than that would
otherwise hold a value that silently stops working.

## Revoking access does not invalidate an issued URL

| Action                                        | Effect on `GET /recordings` | Effect on an already-issued `download_url` |
| --------------------------------------------- | --------------------------- | ------------------------------------------ |
| Remove `recordings:read` from a key           | Immediate `403`             | Unaffected; keeps working until it expires |
| Delete or deactivate the API key              | Immediate `401`             | Unaffected; keeps working until it expires |
| Turn off the organization's recordings access | Immediate `403`             | Unaffected; keeps working until it expires |

This is inherent to how a signed URL works, not an oversight: the URL itself
is the credential, and nothing checks the originating key again once it has
been handed out. The residual window is exactly the `expires_in` value the
URL was minted with — at most one hour, 15 minutes by default. A customer who
assumes revocation is instant for already-issued links will make the wrong
security decision; plan around the expiry, not the revocation.
