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

# Conversation Recordings

> Store conversation recordings in your own S3, GCS, or Azure Blob storage. Federated identity — no secrets shared with Tavus.

The `recording_storage` config field works for **Amazon S3, Google Cloud Storage, and Azure Blob Storage** — pick a provider, configure a one-time trust relationship on your side, and pass us the resulting non-secret identifiers.

<Note>
  **No customer secrets are stored at Tavus.** Every supported path uses provider-native federated identity (IAM role assumption, GCP Workload Identity Federation, or Entra ID Federated Credentials). You configure trust on your side; we receive short-lived tokens at runtime.
</Note>

Recordings are typically available in your bucket within seconds to a few minutes after the call ends, depending on call length and provider. Once the recording lands, Tavus fires `application.recording_ready` (with `storage_provider` and a fully-qualified `storage_uri`) to your `callback_url`. See [Webhooks and Callbacks](/sections/webhooks-and-callbacks#application-callbacks).

## Set up your storage

<Tabs>
  <Tab title="Amazon S3">
    S3 is the fastest path — recordings are written directly into your bucket as they finalize. Works in every AWS region.

    <Steps>
      <Step title="Create an IAM role in your AWS account">
        Configure the role's trust relationship with all three of the following — every field is **mandatory**:

        * **Trusted AWS principal:** AWS account ID `291871421005`.
        * **ExternalId:** `tavus`.
        * **Max session duration: 12 hours (43200 seconds).** AWS roles default to 1 hour, but the recording service requests 12-hour sessions when assuming the role. A role with the default duration will fail validation at room creation with `unable to assume role with given parameters`.

        <Note>
          **About the trusted AWS account.** Tavus's recording infrastructure is operated through Daily.co; AWS account ID `291871421005` belongs to them. The same account ID is documented in [Daily's S3 setup guide](https://docs.daily.co/guides/products/live-streaming-recording/storing-recordings-in-a-custom-s3-bucket) for customers running their own security review. The ExternalId `tavus` is Tavus's identifier with Daily, gating cross-account `sts:AssumeRole` per the [confused-deputy AWS pattern](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html).
        </Note>

        Permissions policy (scoped to your bucket):

        ```json theme={null}
        {
          "Version": "2012-10-17",
          "Statement": [{
            "Effect": "Allow",
            "Action": [
              "s3:PutObject",
              "s3:GetObject",
              "s3:ListBucketMultipartUploads",
              "s3:AbortMultipartUpload",
              "s3:ListBucketVersions",
              "s3:ListBucket",
              "s3:GetObjectVersion",
              "s3:ListMultipartUploadParts"
            ],
            "Resource": [
              "arn:aws:s3:::your-bucket-name",
              "arn:aws:s3:::your-bucket-name/*"
            ]
          }]
        }
        ```
      </Step>

      <Step title="Pass the role on conversation creation">
        ```shell cURL {7-12} theme={null}
        curl --request POST \
          --url https://tavusapi.com/v2/conversations \
          --header 'Content-Type: application/json' \
          --header 'x-api-key: <api_key>' \
          --data '{
            "properties": {
              "recording_storage": {
                "provider": "s3",
                "bucket_name": "your-bucket-name",
                "bucket_region": "us-east-1",
                "assume_role_arn": "arn:aws:iam::123456789012:role/TavusRecordingWriter"
              }
            },
            "replica_id": "r5f0577fc829"
          }'
        ```
      </Step>
    </Steps>

    <Accordion title="Legacy: flat S3 fields (still supported)">
      The original setup using flat properties on `properties` (without the `recording_storage` object) continues to work. Existing integrations don't need to change.

      ```shell cURL {7-9} theme={null}
      curl --request POST \
        --url https://tavusapi.com/v2/conversations \
        --header 'Content-Type: application/json' \
        --header 'x-api-key: <api_key>' \
        --data '{
          "properties": {
            "enable_recording": true,
            "recording_s3_bucket_name": "your-bucket-name",
            "recording_s3_bucket_region": "us-east-1",
            "aws_assume_role_arn": "arn:aws:iam::123456789012:role/TavusRecordingWriter"
          },
          "replica_id": "r5f0577fc829"
        }'
      ```

      These map internally to `provider: "s3"`. New integrations should use `recording_storage` — it's the only way to access GCS, Azure, and unsupported S3 regions, and it's where new fields will be added.
    </Accordion>

    <Accordion title="Terraform setup for S3">
      ```hcl theme={null}
      resource "aws_s3_bucket" "recordings" {
        bucket = "your-recording-bucket"
      }

      resource "aws_iam_role" "tavus_writer" {
        name = "TavusRecordingWriter"

        # The recording service requests 12-hour sessions; default 3600s will fail.
        max_session_duration = 43200

        assume_role_policy = jsonencode({
          Version = "2012-10-17"
          Statement = [{
            Effect    = "Allow"
            Principal = { AWS = "arn:aws:iam::291871421005:root" }
            Action    = "sts:AssumeRole"
            Condition = {
              StringEquals = { "sts:ExternalId" = "tavus" }
            }
          }]
        })
      }

      resource "aws_iam_role_policy" "writer" {
        name = "TavusRecordingWriter-s3-write"
        role = aws_iam_role.tavus_writer.id
        policy = jsonencode({
          Version = "2012-10-17"
          Statement = [{
            Effect = "Allow"
            Action = [
              "s3:PutObject",
              "s3:GetObject",
              "s3:ListBucketMultipartUploads",
              "s3:AbortMultipartUpload",
              "s3:ListBucketVersions",
              "s3:ListBucket",
              "s3:GetObjectVersion",
              "s3:ListMultipartUploadParts",
            ]
            Resource = [
              aws_s3_bucket.recordings.arn,
              "${aws_s3_bucket.recordings.arn}/*",
            ]
          }]
        })
      }
      ```
    </Accordion>
  </Tab>

  <Tab title="Google Cloud Storage">
    GCS uses Workload Identity Federation. Tavus exposes an OIDC issuer at `https://recording-copy.tavus.io`; you configure your GCP project to trust that issuer and bind it to a service account that has write access to your bucket.

    <Note>
      **Scope your trust to your account.** The steps below bind trust using your Tavus Workspace ID as an attribute condition (`attribute.customer_id`). This ensures only recordings belonging to your account can authenticate to your GCP resources. Click your user profile on the [Tavus platform](https://platform.tavus.io) to find your Workspace ID.
    </Note>

    <Steps>
      <Step title="Create a Workload Identity Pool + Provider trusting Tavus">
        ```bash theme={null}
        PROJECT_ID="your-gcp-project"

        gcloud iam workload-identity-pools create tavus-recording-pool \
          --project="$PROJECT_ID" \
          --location="global" \
          --display-name="Tavus Recording Storage"

        gcloud iam workload-identity-pools providers create-oidc tavus-worker \
          --project="$PROJECT_ID" \
          --location="global" \
          --workload-identity-pool="tavus-recording-pool" \
          --display-name="Tavus Worker OIDC" \
          --issuer-uri="https://recording-copy.tavus.io" \
          --attribute-mapping="google.subject=assertion.sub,attribute.customer_id=assertion.customer_id" \
          --attribute-condition="attribute.customer_id == '<your_workspace_id>'"
        ```
      </Step>

      <Step title="Create a service account and grant it write access to your bucket">
        ```bash theme={null}
        BUCKET="your-recording-bucket"
        SA_EMAIL="tavus-recording-writer@${PROJECT_ID}.iam.gserviceaccount.com"

        gcloud iam service-accounts create tavus-recording-writer \
          --project="$PROJECT_ID" \
          --display-name="Tavus Recording Writer"

        gsutil iam ch "serviceAccount:${SA_EMAIL}:objectCreator" "gs://${BUCKET}"
        ```
      </Step>

      <Step title="Allow the federated identity to impersonate the service account">
        ```bash theme={null}
        PROJECT_NUMBER=$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')
        # Replace <your_workspace_id> with your Tavus Workspace ID (find in Tavus platform — click your user profile)
        PRINCIPAL="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/tavus-recording-pool/attribute.customer_id/<your_workspace_id>"

        gcloud iam service-accounts add-iam-policy-binding "$SA_EMAIL" \
          --project="$PROJECT_ID" \
          --role="roles/iam.workloadIdentityUser" \
          --member="$PRINCIPAL"
        ```
      </Step>

      <Step title="Pass the federation identifiers on conversation creation">
        ```shell cURL {7-13} theme={null}
        curl --request POST \
          --url https://tavusapi.com/v2/conversations \
          --header 'Content-Type: application/json' \
          --header 'x-api-key: <api_key>' \
          --data '{
          "replica_id": "r90bbd427f71",
          "properties": {
            "recording_storage": {
              "provider": "gcs",
              "bucket_name": "your-recording-bucket",
              "project_id": "your-gcp-project",
              "workload_identity_provider": "projects/123456/locations/global/workloadIdentityPools/tavus-recording-pool/providers/tavus-worker",
              "service_account_email": "tavus-recording-writer@your-gcp-project.iam.gserviceaccount.com"
            }
          }
        }'
        ```

        <Note>
          `workload_identity_provider` is the resource name **without** the `//iam.googleapis.com/` prefix — Tavus prepends it.
        </Note>
      </Step>
    </Steps>

    <Accordion title="Terraform setup for GCP">
      ```hcl theme={null}
      resource "google_iam_workload_identity_pool" "tavus" {
        workload_identity_pool_id = "tavus-recording-pool"
        display_name              = "Tavus Recording Storage"
      }

      resource "google_iam_workload_identity_pool_provider" "tavus_worker" {
        workload_identity_pool_id          = google_iam_workload_identity_pool.tavus.workload_identity_pool_id
        workload_identity_pool_provider_id = "tavus-worker"
        attribute_mapping = {
          "google.subject"        = "assertion.sub"
          "attribute.customer_id" = "assertion.customer_id"
        }
        # Replace with your Tavus Workspace ID (find in Tavus platform — click your user profile)
        attribute_condition = "attribute.customer_id == '<your_workspace_id>'"
        oidc {
          issuer_uri = "https://recording-copy.tavus.io"
        }
      }

      resource "google_service_account" "tavus_writer" {
        account_id   = "tavus-recording-writer"
        display_name = "Tavus Recording Writer"
      }

      resource "google_storage_bucket_iam_member" "writer" {
        bucket = "your-recording-bucket"
        role   = "roles/storage.objectCreator"
        member = "serviceAccount:${google_service_account.tavus_writer.email}"
      }

      resource "google_service_account_iam_member" "wif_user" {
        service_account_id = google_service_account.tavus_writer.name
        role               = "roles/iam.workloadIdentityUser"
        # Replace with your Tavus Workspace ID (find in Tavus platform — click your user profile)
        member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.tavus.name}/attribute.customer_id/<your_workspace_id>"
      }
      ```
    </Accordion>

    #### Customize the object key (optional)

    By default, recordings land at `tavus/<conversation_id>/<epoch_ms>` (no file extension) in your bucket. Add a `key_template` field to your `recording_storage` config to override the destination key shape:

    ```json theme={null}
    {
      "recording_storage": {
        "provider": "gcs",
        "bucket_name": "your-bucket",
        "workload_identity_provider": "...",
        "service_account_email": "...",
        "key_template": "recordings/{conversation_id}/{epoch_ms}.mp4"
      }
    }
    ```

    Tokens substituted at copy time: `{conversation_id}` (Tavus conversation UUID) and `{epoch_ms}` (Daily epoch-ms timestamp, unique per recording instance). Allowed literal characters: `[0-9A-Za-z./_-{}]`. Max 512 characters. No leading slash, no `//`, no `..`. Invalid templates are rejected when your config is saved.

    Common shapes:

    | Goal                                    | `key_template`                                 | Resulting key                        |
    | --------------------------------------- | ---------------------------------------------- | ------------------------------------ |
    | Default (today's behavior)              | (omit field)                                   | `tavus/<conversation_id>/<epoch_ms>` |
    | Add `.mp4` extension                    | `tavus/{conversation_id}/{epoch_ms}.mp4`       | `tavus/<conv>/<epoch>.mp4`           |
    | Custom prefix                           | `my-org/recs/{conversation_id}/{epoch_ms}.mp4` | `my-org/recs/<conv>/<epoch>.mp4`     |
    | Flat layout (one file per conversation) | `my-org/recs/{conversation_id}.mp4`            | `my-org/recs/<conv>.mp4`             |

    <Warning>
      **Overwrite behavior for flat layouts.** A template without `{epoch_ms}` produces the same key for every recording instance on a given conversation. In normal Tavus CVI usage one conversation produces exactly one recording, so this is safe. If your integration calls `startRecording` / `stopRecording` multiple times on the same conversation, or you implement your own recording-error retry, later recordings will overwrite earlier ones in your bucket. Include `{epoch_ms}` in your template for a zero-collision guarantee.
    </Warning>

    <Note>
      **Permission scope.** If you scoped the service-account permission to a specific path prefix (rather than the whole bucket), update it to cover the prefix you choose in `key_template` before applying. Otherwise the recording will fail to deliver with `DESTINATION_AUTH_FAILED`.
    </Note>
  </Tab>

  <Tab title="Azure Blob Storage">
    Azure Blob uses Entra ID Federated Credentials. Tavus exposes an OIDC issuer at `https://recording-copy.tavus.io`; you create an App Registration on your side that trusts JWTs from this issuer.

    <Note>
      **Scope your trust to your account.** The steps below set the federated credential `subject` to your Tavus Workspace ID. This ensures only recordings belonging to your account can authenticate to your Azure resources. Click your user profile on the [Tavus platform](https://platform.tavus.io) to find your Workspace ID.
    </Note>

    <Steps>
      <Step title="Create an App Registration with a federated credential">
        ```bash theme={null}
        TENANT_ID="<your-tenant-uuid>"
        SUBSCRIPTION="<your-subscription-uuid>"
        RESOURCE_GROUP="your-rg"
        STORAGE_ACCOUNT="yourrecordingsaccount"
        CONTAINER="conversation-recordings"

        # 1. Create an App Registration
        az ad app create --display-name "Tavus Recording Storage"
        APP_ID=$(az ad app list --display-name "Tavus Recording Storage" --query "[0].appId" -o tsv)

        # 2. Add the federated credential (this trusts JWTs from Tavus)
        cat > federation.json <<EOF
        {
          "name": "tavus-recording-copy",
          "issuer": "https://recording-copy.tavus.io",
          "subject": "<your_workspace_id>",
          "audiences": ["api://AzureADTokenExchange"]
        }
        EOF
        # Replace <your_workspace_id> with your Tavus Workspace ID (find in Tavus platform — click your user profile)
        az ad app federated-credential create --id "$APP_ID" --parameters federation.json

        # 3. Create a service principal for the app
        az ad sp create --id "$APP_ID"
        SP_ID=$(az ad sp show --id "$APP_ID" --query id -o tsv)
        ```
      </Step>

      <Step title="Grant the App Registration write access to the container">
        ```bash theme={null}
        SCOPE="/subscriptions/${SUBSCRIPTION}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.Storage/storageAccounts/${STORAGE_ACCOUNT}/blobServices/default/containers/${CONTAINER}"

        az role assignment create \
          --assignee-object-id "$SP_ID" \
          --assignee-principal-type ServicePrincipal \
          --role "Storage Blob Data Contributor" \
          --scope "$SCOPE"
        ```
      </Step>

      <Step title="Pass the federation identifiers on conversation creation">
        ```shell cURL {7-13} theme={null}
        curl --request POST \
          --url https://tavusapi.com/v2/conversations \
          --header 'Content-Type: application/json' \
          --header 'x-api-key: <api_key>' \
          --data '{
            "properties": {
              "recording_storage": {
                "provider": "azure_blob",
                "storage_account": "yourrecordingsaccount",
                "container": "conversation-recordings",
                "tenant_id": "11111111-2222-3333-4444-555555555555",
                "client_id": "66666666-7777-8888-9999-000000000000"
              }
            },
            "replica_id": "r5f0577fc829"
          }'
        ```
      </Step>
    </Steps>

    <Accordion title="Terraform setup for Azure">
      <Note>
        **Subscription ID** — the `azurerm` provider needs an explicit subscription ID. Either set `export ARM_SUBSCRIPTION_ID=<your-sub-id>` before `terraform apply`, or set `subscription_id` in your `provider "azurerm"` block. Without this, `terraform plan` hangs without a clear error.
      </Note>

      ```hcl theme={null}
      resource "azuread_application" "tavus" {
        display_name = "Tavus Recording Storage"
      }

      resource "azuread_service_principal" "tavus" {
        client_id = azuread_application.tavus.client_id
      }

      resource "azuread_application_federated_identity_credential" "tavus" {
        application_id = azuread_application.tavus.id
        display_name   = "tavus-recording-copy"
        description    = "Tavus recording delivery"
        audiences      = ["api://AzureADTokenExchange"]
        issuer         = "https://recording-copy.tavus.io"
        # Replace with your Tavus Workspace ID (find in Tavus platform — click your user profile)
        subject        = "<your_workspace_id>"
      }

      resource "azurerm_role_assignment" "writer" {
        scope                = azurerm_storage_container.recordings.resource_manager_id
        role_definition_name = "Storage Blob Data Contributor"
        principal_id         = azuread_service_principal.tavus.object_id
      }
      ```
    </Accordion>

    #### Customize the object key (optional)

    By default, recordings land at `tavus/<conversation_id>/<epoch_ms>` (no file extension) in your container. Add a `key_template` field to your `recording_storage` config to override the destination key shape:

    ```json theme={null}
    {
      "recording_storage": {
        "provider": "azure_blob",
        "storage_account": "your-account",
        "container": "your-container",
        "tenant_id": "...",
        "client_id": "...",
        "key_template": "recordings/{conversation_id}/{epoch_ms}.mp4"
      }
    }
    ```

    Tokens substituted at copy time: `{conversation_id}` (Tavus conversation UUID) and `{epoch_ms}` (Daily epoch-ms timestamp, unique per recording instance). Allowed literal characters: `[0-9A-Za-z./_-{}]`. Max 512 characters. No leading slash, no `//`, no `..`. Invalid templates are rejected when your config is saved.

    Common shapes:

    | Goal                                    | `key_template`                                 | Resulting blob name                  |
    | --------------------------------------- | ---------------------------------------------- | ------------------------------------ |
    | Default (today's behavior)              | (omit field)                                   | `tavus/<conversation_id>/<epoch_ms>` |
    | Add `.mp4` extension                    | `tavus/{conversation_id}/{epoch_ms}.mp4`       | `tavus/<conv>/<epoch>.mp4`           |
    | Custom prefix                           | `my-org/recs/{conversation_id}/{epoch_ms}.mp4` | `my-org/recs/<conv>/<epoch>.mp4`     |
    | Flat layout (one file per conversation) | `my-org/recs/{conversation_id}.mp4`            | `my-org/recs/<conv>.mp4`             |

    <Warning>
      **Overwrite behavior for flat layouts.** A template without `{epoch_ms}` produces the same blob name for every recording instance on a given conversation. In normal Tavus CVI usage one conversation produces exactly one recording, so this is safe. If your integration calls `startRecording` / `stopRecording` multiple times on the same conversation, or you implement your own recording-error retry, later recordings will overwrite earlier ones in your container. Include `{epoch_ms}` in your template for a zero-collision guarantee.
    </Warning>

    <Note>
      **Permission scope.** If you scoped the RBAC role assignment to a specific blob prefix (rather than the whole container), update it to cover the prefix you choose in `key_template` before applying. Otherwise the recording will fail to deliver with `DESTINATION_AUTH_FAILED`.
    </Note>
  </Tab>
</Tabs>

## Start recording

Recording does not start automatically — you need to trigger it from your frontend after the participant joins:

```javascript theme={null}
const call = Daily.createCallObject();

call.on('joined-meeting', () => {
  call.startRecording();
});
```

## Receive the recording

Once the recording lands in your destination, Tavus fires `application.recording_ready` to your `callback_url`:

```json theme={null}
{
  "properties": {
    "bucket_name": "<your-bucket>",
    "s3_key": "<object-key>",
    "duration": 1234,
    "storage_provider": "gcs",
    "storage_uri": "gs://<your-bucket>/<object-key>"
  },
  "conversation_id": "<conversation_id>",
  "event_type": "application.recording_ready",
  "message_type": "application",
  "timestamp": "2026-04-30T22:11:14Z"
}
```

The `s3_key` / `storage_uri` object key follows the pattern `tavus/<conversation_id>/<epoch_ms>` — a fixed `tavus/` prefix, the conversation UUID, and a Unix epoch-milliseconds timestamp assigned by the recording service when the recording starts. The key has no file extension by default; recordings are MP4 files regardless. The same key is reused across delivery retries for a given recording, so it is stable per recording.

<Note>
  GCS and Azure Blob accept an optional `key_template` on `recording_storage` to override the destination key shape — see the [Google Cloud Storage](#google-cloud-storage) or [Azure Blob Storage](#azure-blob-storage) setup section.
</Note>

For GCS and Azure Blob, if delivery to your bucket exhausts retries (typically due to a misconfigured trust policy on your side), Tavus instead fires `application.recording_copy_failed` with `error_code` and `error_message`. The recording is retained in Tavus's recording infrastructure for \~30 days as a manual recovery window. See [event reference](/sections/webhooks-and-callbacks#application-callbacks).

## Verify your setup

After your first recording, check:

1. **`application.recording_ready` arrives** at your callback URL — typically within \~1 minute for an average call, longer for multi-hour recordings.
2. The `storage_uri` resolves — try opening it (or fetching it) from your cloud's CLI.
3. If you see `application.recording_copy_failed` instead, the `error_code` is your starting point: `DESTINATION_AUTH_FAILED` is almost always a trust-policy issue (verify the issuer URI, subject claim, or assume-role principal).
