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 → streamThis design keeps S3 buckets fully private and makes recording URLs portable across AWS accounts/buckets.
S3 Bucket Structure
Each environment has its own bucket:
| Environment | Bucket Name |
|---|---|
| Dev | oktivo-recordings-dev-501410019925 |
| Stage | oktivo-recordings-stage-501410019925 |
| Prod | oktivo-recordings-prod-501410019925 |
File path inside the bucket:
{orgUID}/{customerUID}/{campaignUID}/{contactUID}/{phone}_{YYYYMMDD_HHmmss}.mp4Example:
org_F7N5oJv8Lvf271NM/usr_gF5WKKk0ur5gxv5b/test_WtJXFwdffRPU_1774632222035/fYZZXUgVif1K/8398061706_20260327_172348.mp4Recording 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, andegress_endedlater looks the file up byContact.s3RecordingKey. If a laterparticipant_joinedoverwrote the key,egress_endedwould check a key no object was ever written under → the S3 existence check fails →recordingUrlis never set → the recording silently disappears from history even though the file exists in S3.The handler therefore only writes
s3RecordingKey(andparticipantJoinedAt/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:
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:
- Validates the JWT (from
?token=query param viaverifyQueryTokenmiddleware) - Looks up the Contact by
contactUID(scoped to user) - If S3 recording exists, generates a presigned URL and redirects the browser to it
- 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
{
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)
}s3RecordingKeythe actual S3 object key. This is the source of truth. If you migrate buckets, copy files with the same keys and everything works.recordingUrlconvenience field for the frontend. It's a relative path to the proxy endpoint. Never expires.gcpFilePathlegacy 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
| File | Purpose |
|---|---|
services/s3.service.ts | S3 client, key builder, presigned URLs, existence checks |
controllers/contact.controller.ts | streamRecordingRedirect() redirect to presigned S3 URL |
controllers/webhook.controller.ts | Sets s3RecordingKey and recordingUrl during call lifecycle |
middleware/queryAuth.middleware.ts | JWT validation from ?token= query param for <audio> elements |
routes/contact.routes.ts | GET /recording/:contactUID route (uses verifyQueryToken) |
app/src/services/campaign.service.ts | getRecordingStreamUrl() builds full URL with token |
app/src/pages/customer/CampaignDetailPage.tsx | Audio player UI |
app/src/pages/customer/HistoryPage.tsx | Contact side panel with recording player |
Environment Variables
| Variable | Example | Description |
|---|---|---|
S3_RECORDING_BUCKET | oktivo-recordings-dev-501410019925 | S3 bucket name for recordings |
AWS_REGION | ap-south-1 | AWS region |
AWS_ACCESS_KEY_ID | IAM credentials (or use instance role) | |
AWS_SECRET_ACCESS_KEY | IAM credentials (or use instance role) |
Migrating S3 Buckets
If you move to a new AWS account or rename buckets:
- Copy all objects from old bucket to new bucket (preserving the same key paths)
- Update
S3_RECORDING_BUCKETenv var to the new bucket name - 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 - Deploy that's it. No other DB changes needed because:
s3RecordingKeyis just the path inside the bucket (no bucket name)recordingUrlis now/recording/:contactUID(no S3 reference at all)- The backend resolves the bucket from the env var at runtime