Skip to content

Call Recording Storage, URL Generation & Playback ​

How call recordings are captured, stored in S3, and served to the frontend. Last updated: April 2026


Overview ​

Call recordings are captured by LiveKit Egress, stored in private S3 buckets, and streamed to the frontend through a backend proxy endpoint. The client never talks to S3 directly the backend authenticates the user and streams the file.

LiveKit Egress → uploads .mp4 → S3 (private bucket)
                                    ↑
Frontend <audio> → GET /recording/:contactUID?token=jwt → Backend → S3 GetObject → stream

This design keeps S3 buckets fully private and makes recording URLs portable across AWS accounts/buckets.


S3 Bucket Structure ​

Each environment has its own bucket:

EnvironmentBucket Name
Devoktivo-recordings-dev-501410019925
Stageoktivo-recordings-stage-501410019925
Prodoktivo-recordings-prod-501410019925

File path inside the bucket:

{orgUID}/{customerUID}/{campaignUID}/{contactUID}/{phone}_{YYYYMMDD_HHmmss}.mp4

Example:

org_F7N5oJv8Lvf271NM/usr_gF5WKKk0ur5gxv5b/test_WtJXFwdffRPU_1774632222035/fYZZXUgVif1K/8398061706_20260327_172348.mp4

Recording Lifecycle ​

Step 1: S3 Key Generation (call starts) ​

When a SIP participant joins a room, the webhook handler builds the S3 key and stores it on the Contact document.

File: controllers/webhook.controller.ts → participant_joined event Function: buildRecordingS3Key() in services/s3.service.ts

Contact.s3RecordingKey = "org_xxx/usr_xxx/campaign_xxx/contact_xxx/phone_timestamp.mp4"

The S3 key is generated once and must never be regenerated.buildRecordingS3Key() embeds the current timestamp (..._YYYYMMDD_HHmmss.mp4) in the filename, so calling it a second time produces a different key. Egress (started by the agent's /start-recording) writes to whatever key exists at that moment, and egress_ended later looks the file up by Contact.s3RecordingKey. If a later participant_joined overwrote the key, egress_ended would check a key no object was ever written under → the S3 existence check fails → recordingUrl is never set → the recording silently disappears from history even though the file exists in S3.

The handler therefore only writes s3RecordingKey (and participantJoinedAt / callStartedAt) when the Contact does not already have one — the first SIP join wins and the key stays stable for the whole call.

Warm-transfer interaction (why this matters) ​

In a transfer-to-human warm transfer the human agent is dialed into the same room as a second SIP participant (identity human_agent_<number>), which fires participant_joined again mid-call. That second join is not a new call and must not touch the recording key or the call-start clock. The handler short-circuits it:

ts
if (participant.identity.startsWith("human_agent_")) {
  // transferred-in human-agent leg — preserve existing key + timestamps
  return res.json({ status: "ok", skipped: "human_agent_transfer_leg" });
}

This is what keeps a single, continuous recording (AI conversation + human conversation) resolvable in the UI after a transfer. See TRANSFER-TO-HUMAN.md.

Step 2: Egress Recording (call in progress) ​

After the call is answered, the Python agent starts a LiveKit Room Composite Egress that records to S3 using the pre-built key.

File: Agent/ (Python agent code) Destination: S3 bucket configured via S3_RECORDING_BUCKET env var

Step 3: Recording URL Set (egress ends) ​

When LiveKit sends the egress_ended webhook, the backend verifies the file exists in S3 and stores the proxy URL on the Contact.

File: controllers/webhook.controller.ts → egress_ended event

Contact.recordingUrl = "/recording/{contactUID}"

There's also a safety net in the room_finished event that recovers the recording URL if egress_ended was missed.

Step 4: Playback (frontend) ​

The frontend builds a full streaming URL with the JWT token as a query parameter:

File: app/src/services/campaign.service.ts → getRecordingStreamUrl()

https://api.oktivo.com/recording/fYZZXUgVif1K?token=eyJhbGciOi...

The <audio> element uses this URL as its src. The backend endpoint:

  1. Validates the JWT (from ?token= query param via verifyQueryToken middleware)
  2. Looks up the Contact by contactUID (scoped to user)
  3. If S3 recording exists, generates a presigned URL and redirects the browser to it
  4. Falls back to GCP signed URL for legacy recordings

File: controllers/contact.controller.ts → streamRecordingRedirect()Route: GET /recording/:contactUID (auth via ?token= query param)


What's Stored on the Contact Document ​

javascript
{
  s3RecordingKey: "org_xxx/usr_xxx/.../phone_timestamp.mp4",  // permanent S3 path (source of truth)
  recordingUrl: "/recording/fYZZXUgVif1K",                    // relative proxy URL for in-app playback
  egressId: "EG_SFs4vWcD4cis",                                // LiveKit egress ID
  gcpFilePath: null,                                           // legacy GCP path (old recordings only)
}
  • s3RecordingKey the actual S3 object key. This is the source of truth. If you migrate buckets, copy files with the same keys and everything works.
  • recordingUrl convenience field for the frontend. It's a relative path to the proxy endpoint. Never expires.
  • gcpFilePath legacy field for recordings stored in GCP before the S3 migration. The streaming endpoint falls back to GCP signed URLs for these.

Export URLs ​

For CSV exports where recording URLs need to be shareable outside the app, use getExportPresignedUrl() in services/s3.service.ts. This generates a temporary S3 presigned URL (7-day expiry) directly from the s3RecordingKey. These are only generated at export time, never stored in the DB.


Key Files ​

FilePurpose
services/s3.service.tsS3 client, key builder, presigned URLs, existence checks
controllers/contact.controller.tsstreamRecordingRedirect() redirect to presigned S3 URL
controllers/webhook.controller.tsSets s3RecordingKey and recordingUrl during call lifecycle
middleware/queryAuth.middleware.tsJWT validation from ?token= query param for <audio> elements
routes/contact.routes.tsGET /recording/:contactUID route (uses verifyQueryToken)
app/src/services/campaign.service.tsgetRecordingStreamUrl() builds full URL with token
app/src/pages/customer/CampaignDetailPage.tsxAudio player UI
app/src/pages/customer/HistoryPage.tsxContact side panel with recording player

Environment Variables ​

VariableExampleDescription
S3_RECORDING_BUCKEToktivo-recordings-dev-501410019925S3 bucket name for recordings
AWS_REGIONap-south-1AWS region
AWS_ACCESS_KEY_IDIAM credentials (or use instance role)
AWS_SECRET_ACCESS_KEYIAM credentials (or use instance role)

Migrating S3 Buckets ​

If you move to a new AWS account or rename buckets:

  1. Copy all objects from old bucket to new bucket (preserving the same key paths)
  2. Update S3_RECORDING_BUCKET env var to the new bucket name
  3. Run the DB migration script to update old presigned URLs to the new proxy format:
    bash
    # Dry run first see what would change
    npx ts-node --esm scripts/migrate-recording-urls.ts --dry-run
    
    # Apply
    npx ts-node --esm scripts/migrate-recording-urls.ts
  4. Deploy that's it. No other DB changes needed because:
    • s3RecordingKey is just the path inside the bucket (no bucket name)
    • recordingUrl is now /recording/:contactUID (no S3 reference at all)
    • The backend resolves the bucket from the env var at runtime

Last updated: