Call Recording Storage Architecture
Overview
Petunia's call recording system stores voice call recordings from multiple providers (Twilio, RingCentral, Retell, Cartesia) in a unified architecture. Call recordings are referenced by URL in the database but stored externally by the voice providers. This document covers the storage patterns, retention policies, compliance considerations, and cleanup procedures.
Status: Recording URL storage and playback UI implemented. Recordings are stored by voice providers (Twilio, Retell, etc.) and accessed via provider URLs. S3 upload infrastructure exists in lib/services/file-upload.ts for future self-hosted storage migration. Recording consent tracking and GDPR deletion are not yet implemented.
Architecture
Current Implementation
┌─────────────────────┐
│ Voice Providers │
│ (Twilio, RC, etc) │
└──────────┬──────────┘
│ recordingUrl
▼
┌─────────────────────┐
│ CallRecord Model │
│ - recordingUrl │ ────▶ External URL (provider-hosted)
│ - transcriptUrl │ ────▶ External URL (provider-hosted)
│ - duration │
│ - status │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ ExtractedData │
│ - transcriptions │
│ - metadata │
└─────────────────────┘
Database Schema
CallRecord Model
Location: /Users/tiago/Documents/Projects/petunia/prisma/schema.prisma (lines 151-184)
model CallRecord {
id String @id @default(cuid())
callId String @unique
contactId String
businessId String?
portalId String?
status String
direction String // inbound, outbound
phoneNumber String
duration Int?
recordingUrl String? // External URL to call recording
transcriptUrl String? // External URL to transcript
agentId String?
voiceId String?
callbackUrl String?
webhookUrl String?
isAutomated Boolean @default(false)
initiatedAt DateTime @default(now())
answeredAt DateTime?
endedAt DateTime?
provider String? // twilio, ringcentral, retell, cartesia
providerMetadata Json? // Provider-specific data
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Contact Contact @relation(fields: [contactId], references: [id])
Portal Portal? @relation("PortalCallRecords", fields: [portalId], references: [id])
Message Message[]
ExtractedData ExtractedData[]
@@index([contactId])
@@index([businessId])
@@index([portalId])
@@index([provider])
}
ExtractedData Model
Stores transcriptions and other extracted information from calls:
model ExtractedData {
id String @id @default(cuid())
callRecordId String
dataType String // voice_extraction, transcription, etc.
data String @db.Text // JSON stringified data
confidence Float @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
CallRecord CallRecord @relation(fields: [callRecordId], references: [id], onDelete: Cascade)
@@index([callRecordId])
@@index([dataType])
}
Storage Patterns
Provider-Hosted Recordings
Currently, all call recordings remain hosted by the voice provider:
Twilio
- Storage: Twilio-hosted, S3-backed
- URL Format:
https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Recordings/{RecordingSid}.mp3 - Access: Requires Twilio credentials for authentication
- Retention: Twilio's default is indefinite, but can be configured
- Source:
/Users/tiago/Documents/Projects/petunia/lib/services/voice/callLogAdapters/twilioCallLogAdapter.ts
RingCentral
- Storage: RingCentral-hosted
- URL Format: Platform-specific URLs from RingCentral API
- Access: OAuth token required
- Retention: RingCentral's default retention policy
- Webhook:
/Users/tiago/Documents/Projects/petunia/app/api/communications/ringcentral/webhooks/route.ts(line 305)
Retell
- Storage: Retell-hosted
- URL Format: Retell-specific CDN URLs
- Access: API key authentication
- Provider:
/Users/tiago/Documents/Projects/petunia/lib/services/voice/retellProvider.ts
Cartesia
- Storage: Cartesia-hosted
- URL Format: Cartesia Line API URLs
- Access: API key authentication
- Adapter:
/Users/tiago/Documents/Projects/petunia/lib/services/voice/callLogAdapters/cartesiaCallLogAdapter.ts
Future: Self-Hosted Storage (Not Yet Implemented)
Planned Architecture (from feature documentation):
┌─────────────────────┐
│ Voice Provider │
│ Webhook │
└──────────┬──────────┘
│ Download recording
▼
┌─────────────────────┐
│ Upload Handler │
│ /api/recordings/ │
│ upload │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ S3 / Supabase │
│ Storage Bucket │
│ petunia-recordings│
└─────────────────────┘
│
▼
┌─────────────────────┐
│ CallRecord │
│ recordingUrl │ ────▶ CDN URL or Presigned URL
└─────────────────────┘
Infrastructure Available:
- S3 Client configured in
/Users/tiago/Documents/Projects/petunia/lib/services/file-upload.ts - AWS credentials support via
AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY - Presigned URL generation capability via
@aws-sdk/s3-request-presigner - Default bucket:
petunia-uploads(configurable viaS3_BUCKET)
Retention Policies
GDPR Compliance Framework
Implementation: /Users/tiago/Documents/Projects/petunia/lib/services/gdpr-compliance.ts
Default Retention Periods
export const DATA_RETENTION_POLICIES: Record<string, RetentionPolicy> = {
// Behavior analytics: 90 days
behaviorAnalytics: {
days: 90,
description: 'User behavior analytics and interaction patterns',
tables: ['user_behavior_events', 'user_behavior_analytics'],
exceptions: ['aggregated_metrics'],
},
// Session data: 30 days
sessionData: {
days: 30,
description: 'Session logs and temporary interaction data',
tables: ['user_sessions', 'session_events'],
exceptions: [],
},
// ML training data: 180 days
mlTrainingData: {
days: 180,
description: 'Machine learning model training data',
tables: ['profile_request_history', 'ml_training_examples'],
exceptions: ['model_metrics'],
},
// Audit logs: 365 days
auditLogs: {
days: 365,
description: 'Security and compliance audit logs',
tables: ['audit_logs', 'data_access_logs'],
exceptions: [],
},
};
Call Recording Retention
Current Status: No specific retention policy implemented for call recordings.
Recommended Policy (based on industry best practices):
| Recording Type | Retention Period | Justification |
|---|---|---|
| Automated calls (AI) | 90 days | Analytics and quality improvement |
| Customer service calls | 180 days | Dispute resolution and training |
| Sales calls | 1 year | Contract verification and compliance |
| Compliance-sensitive | 7 years | Legal/regulatory requirements |
| Voicemail recordings | 30 days | Standard telephony retention |
Implementation Needed:
- Add
retentionPolicyfield to CallRecord model - Create retention enforcement in GDPR compliance service
- Add automated cleanup job
GDPR Data Retention Infrastructure
Database Tables: /Users/tiago/Documents/Projects/petunia/supabase/migrations/20250706_gdpr_compliance_tables.sql
-- Data retention metadata
CREATE TABLE IF NOT EXISTS data_retention_log (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
table_name TEXT NOT NULL,
records_deleted INTEGER NOT NULL DEFAULT 0,
retention_days INTEGER NOT NULL,
cutoff_date TIMESTAMPTZ NOT NULL,
execution_time_ms INTEGER,
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
INDEX idx_retention_log_created_at ON data_retention_log(created_at DESC)
);
Enforcement Cron: /Users/tiago/Documents/Projects/petunia/app/api/cron/data-retention/route.ts
- Schedule: Daily at 3 AM PST
- Monitoring: Sentry monitor
data-retention-daily - Authentication: HMAC signature or
CRON_SECRET - Action: Calls
GDPRComplianceService.applyRetentionPolicies()
Compliance Considerations
GDPR (General Data Protection Regulation)
Implemented Features:
-
Consent Tracking (
user_consenttable)- Records user consent for data processing
- Tracks consent types and withdrawals
- Logs IP address and user agent (optional for privacy)
-
Audit Trail (
gdpr_audit_logtable)- Tracks all GDPR-related actions:
data_export- User data export requestsdata_deletion- User data deletion requestsconsent_update- Consent preference changesdata_access- Data access requests
- Records action metadata and IP addresses
- Tracks all GDPR-related actions:
-
Data Deletion
- Automatic scheduling on user account deletion
- 30-day grace period before permanent deletion
- Cascading deletion via
onDelete: Cascadein schema
Not Yet Implemented for Call Recordings:
- Call recording consent tracking
- Recording-specific data export
- Automated recording deletion after retention period
- Subject Access Request (SAR) for call recordings
CCPA (California Consumer Privacy Act)
Required Capabilities (partially implemented):
-
Right to Know: Users can request what personal data is collected
- ✅ GDPR audit log tracks data access
- ❌ No specific call recording inventory endpoint
-
Right to Delete: Users can request deletion of personal data
- ✅ GDPR deletion framework exists
- ❌ Not extended to call recordings
-
Right to Opt-Out: Users can opt out of sale of personal data
- ⚠️ Not applicable (Petunia doesn't sell data)
-
Non-Discrimination: Can't discriminate against users who exercise privacy rights
- ✅ Implemented in auth and access control
Additional Compliance Requirements
Recording Consent Laws (US State-Level)
Two-Party Consent States:
- California, Connecticut, Florida, Illinois, Maryland, Massachusetts, Michigan, Montana, Nevada, New Hampshire, Pennsylvania, Washington
Requirements:
- Must obtain consent from ALL parties before recording
- Must provide audible beep or verbal notification
- Violation can result in criminal penalties
Implementation Status: ❌ Not implemented
- No consent prompt before recording
- No "this call is being recorded" notification
- No state-specific logic
Recommendation: Add consent tracking to CallRecord:
model CallRecord {
// ... existing fields ...
recordingConsent Boolean? // Did user consent to recording?
consentMethod String? // verbal, beep, written
consentTimestamp DateTime? // When consent was obtained
consentMetadata Json? // State-specific consent data
}
Payment Card Industry (PCI) Compliance
If processing payment information over phone:
- Requirement: Must not store credit card numbers in recordings
- Implementation: ⚠️ Not specifically addressed
- Recommendation:
- Pause recording during payment capture
- Use DTMF masking for card numbers
- Add PCI compliance flags to CallRecord
Health Insurance Portability and Accountability Act (HIPAA)
If handling protected health information (PHI):
- Requirement: Encrypted storage, access controls, audit trails
- Implementation: ⚠️ Partial (audit trails exist, encryption not specific to recordings)
- Recommendation:
- Add PHI flag to CallRecord
- Require additional encryption for PHI recordings
- Implement Business Associate Agreements (BAA) with providers
Access Controls
Database-Level Security
Row-Level Security (RLS): Enabled on GDPR tables
-- User consent: Users can only see their own consent records
CREATE POLICY "Users can view own consent records" ON user_consent
FOR SELECT USING (auth.uid() = user_id);
-- GDPR audit log: Users can view their own audit records
CREATE POLICY "Users can view own GDPR audit records" ON gdpr_audit_log
FOR SELECT USING (auth.uid() = user_id);
-- Data retention log: Only service role can access
CREATE POLICY "Service role only for retention logs" ON data_retention_log
FOR ALL USING (auth.role() = 'service_role');
CallRecord Access Control: Enforced in API routes
- Location:
/Users/tiago/Documents/Projects/petunia/app/api/calls/[callId]/route.ts - Method: Client-scoped queries via
Contact.clientId
// Example from GET /api/calls/[callId]
const callRecord = await prisma.callRecord.findFirst({
where: {
callId: paramResult.data.callId,
Contact: { clientId }, // Enforces client isolation
},
// ... includes ...
});
API Authentication
Authentication Methods:
- User Sessions: Auth.js session-based authentication
- CRON Jobs: HMAC signature +
CRON_SECRETheader - Webhooks: Provider-specific verification (Twilio, RingCentral)
Implementation: /Users/tiago/Documents/Projects/petunia/lib/middleware/security/internal-api-auth.ts
Provider-Specific Access
Twilio:
- Recordings protected by Account SID + Auth Token
- URL-based access requires authentication
- Recording SID is not guessable
RingCentral:
- OAuth 2.0 token required for recording access
- Token rotation handled by connection service
- Stored in
ConnectionIntegration.credentials
Retell/Cartesia:
- API key authentication
- Keys stored securely in environment or database
- Resolved via
apiKeyResolverservice
Retrieval and Playback
Current Implementation
UI Component: /Users/tiago/Documents/Projects/petunia/components/inbox/CallMessageItem.tsx
// Audio playback handler (lines 109-120)
if (!call.recordingUrl) {
toast({ title: 'No recording available', variant: 'destructive' });
return;
}
const audio = new Audio(call.recordingUrl);
audio.play();
// Download link (lines 288-304)
{call.recordingUrl && (
<a href={call.recordingUrl} target="_blank" rel="noopener noreferrer" download>
Download Recording
</a>
)}
API Endpoint: /Users/tiago/Documents/Projects/petunia/app/api/calls/[callId]/route.ts
- Returns
recordingUrlin response - No presigned URL generation
- Direct link to provider-hosted recording
Issues with Current Approach
- No Access Control: Direct URLs bypass API authentication
- Provider Dependency: Recordings unavailable if provider changes URL scheme
- No Expiration: URLs may expose recordings indefinitely
- No Analytics: Can't track who accessed which recordings
- No Transcoding: Can't convert formats or apply compression
Recommended Implementation (Not Yet Built)
Presigned URL Pattern:
// GET /api/recordings/[recordingId]/playback
export async function GET(request: NextRequest, { params }: { params: { recordingId: string } }) {
const session = await auth();
if (!session?.user) return unauthorized();
// Verify user has access to this recording
const callRecord = await prisma.callRecord.findFirst({
where: {
id: params.recordingId,
Contact: { clientId: session.user.clientId }
}
});
if (!callRecord?.recordingUrl) return notFound();
// If self-hosted, generate presigned URL
if (callRecord.recordingUrl.startsWith('s3://')) {
const s3Client = new S3Client({ /* ... */ });
const command = new GetObjectCommand({
Bucket: 'petunia-recordings',
Key: callRecord.recordingUrl.replace('s3://petunia-recordings/', '')
});
const presignedUrl = await getSignedUrl(s3Client, command, {
expiresIn: 3600 // 1 hour
});
return NextResponse.json({ url: presignedUrl });
}
// If provider-hosted, proxy or redirect
return NextResponse.redirect(callRecord.recordingUrl);
}
Benefits:
- Time-limited access (URLs expire)
- Audit trail of who accessed what
- Provider abstraction
- Client isolation enforced
Cleanup Procedures
Manual Cleanup
Delete Single Call Record:
# DELETE /api/calls/[callId]
curl -X DELETE https://app.petunia.gardenpatch.xyz/api/calls/{callId} \
-H "Authorization: Bearer $SESSION_TOKEN"
Implementation: /Users/tiago/Documents/Projects/petunia/app/api/calls/[callId]/route.ts (lines 347-443)
- Deletes related messages first
- Then deletes CallRecord
- Uses transaction for atomicity
- Does NOT delete external recording from provider
await prisma.$transaction([
prisma.message.deleteMany({
where: { callRecordId: existing.id }
}),
prisma.callRecord.delete({
where: { callId: paramResult.data.callId }
})
]);
Automated Cleanup
GDPR Data Retention Cron:
- Endpoint:
/Users/tiago/Documents/Projects/petunia/app/api/cron/data-retention/route.ts - Schedule: Daily at 3 AM PST
- Auth: HMAC signature or
CRON_SECRETheader - Service:
GDPRComplianceService.applyRetentionPolicies()
Vercel Cron Configuration (recommended):
// vercel.json
{
"crons": [{
"path": "/api/cron/data-retention",
"schedule": "0 3 * * *" // 3 AM daily
}]
}
Missing: Recording-Specific Cleanup
What's Needed:
- Retention Policy Enforcement:
// Example implementation needed
async function cleanupExpiredRecordings() {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - 90); // 90 days
const expiredRecordings = await prisma.callRecord.findMany({
where: {
endedAt: { lt: cutoffDate },
recordingUrl: { not: null }
}
});
for (const record of expiredRecordings) {
// 1. Delete from provider (if possible)
await deleteProviderRecording(record.provider, record.recordingUrl);
// 2. Clear URL from database
await prisma.callRecord.update({
where: { id: record.id },
data: { recordingUrl: null, transcriptUrl: null }
});
// 3. Log the deletion
await logRetentionAction('call_recordings', record.id);
}
}
- Provider-Specific Deletion:
async function deleteProviderRecording(provider: string, recordingUrl: string) {
switch (provider) {
case 'twilio':
// Delete via Twilio API
const recordingSid = extractSidFromUrl(recordingUrl);
await twilioClient.recordings(recordingSid).remove();
break;
case 'ringcentral':
// Delete via RingCentral API
await ringcentralClient.delete(recordingUrl);
break;
case 's3':
// Delete from S3 bucket
await s3Client.send(new DeleteObjectCommand({
Bucket: 'petunia-recordings',
Key: extractKeyFromUrl(recordingUrl)
}));
break;
}
}
Security Best Practices
Storage Security
Current State:
- ✅ Provider-hosted recordings use provider security
- ❌ No encryption at rest enforcement
- ❌ No encryption in transit verification
- ⚠️ Credentials stored in database (encrypted in
ConnectionIntegration.credentials)
Recommendations:
- Encryption at Rest (for self-hosted storage):
// Use AWS S3 server-side encryption
await s3Client.send(new PutObjectCommand({
Bucket: 'petunia-recordings',
Key: recordingKey,
Body: recordingBuffer,
ServerSideEncryption: 'AES256', // or 'aws:kms'
Metadata: {
'x-amz-meta-call-id': callId,
'x-amz-meta-encrypted': 'true'
}
}));
- Encryption in Transit:
- Enforce HTTPS for all recording URLs
- Reject HTTP URLs from providers
- Use TLS 1.2+ for provider API calls
- Key Management:
- Rotate provider API keys quarterly
- Use AWS KMS for S3 encryption keys
- Store credentials in environment variables, not database
- Use secrets management service (AWS Secrets Manager, HashiCorp Vault)
Access Logging
Current Implementation: Partial via GDPR audit log
Recommended Enhancement:
model RecordingAccessLog {
id String @id @default(cuid())
callRecordId String
userId String
action String // viewed, downloaded, deleted
ipAddress String?
userAgent String?
accessedAt DateTime @default(now())
CallRecord CallRecord @relation(fields: [callRecordId], references: [id])
@@index([callRecordId])
@@index([userId])
@@index([accessedAt])
}
Compliance Monitoring
Recommendations:
- Regular Audits:
-- Find recordings without retention policy
SELECT id, callId, initiatedAt, provider
FROM "CallRecord"
WHERE "recordingUrl" IS NOT NULL
AND "endedAt" < NOW() - INTERVAL '90 days'
ORDER BY "endedAt" DESC;
-- Count recordings by age
SELECT
DATE_TRUNC('month', "endedAt") AS month,
COUNT(*) AS recording_count,
COUNT(*) FILTER (WHERE "recordingUrl" IS NOT NULL) AS with_recording
FROM "CallRecord"
GROUP BY month
ORDER BY month DESC;
- Compliance Dashboard Metrics:
- Total recordings stored
- Recordings past retention period
- Missing consent records
- Provider distribution
- Storage cost trends
- Alerts:
- Recording retention SLA violations
- Unusual access patterns
- Failed deletions
- Provider quota warnings
Migration Path
From Provider-Hosted to Self-Hosted
Phase 1: Parallel Storage (1-2 weeks)
- Implement upload handler
- Create S3 bucket and configure access
- Update webhook handlers to upload to both provider and S3
- Add
s3RecordingUrlfield to CallRecord - Test with subset of portals
Phase 2: Migration (2-4 weeks)
- Backfill existing recordings from provider to S3
- Update playback endpoints to use S3 URLs
- Monitor for issues
- Gradually increase percentage of S3-backed recordings
Phase 3: Deprecation (4-8 weeks)
- Stop writing provider URLs for new recordings
- Delete old recordings from providers
- Update
recordingUrlto point to S3 - Remove provider URL fallback logic
Phase 4: Optimization (ongoing)
- Implement retention policies
- Add transcoding for size optimization
- Enable CDN for global access
- Implement tiered storage (hot/cold/archive)
Cost Considerations
Provider Costs
Twilio:
- Recording storage: $0.0025/minute/month
- Transcription: $0.05/minute
- Bandwidth: $0.02/MB
RingCentral:
- Included in subscription
- Unlimited storage for Enterprise plans
- Additional storage: ~$50/user/year
Retell/Cartesia:
- Pricing varies by plan
- Typically included in per-minute usage
Self-Hosted Costs (AWS S3)
Storage:
- Standard: $0.023/GB/month
- Infrequent Access: $0.0125/GB/month (after 30 days)
- Glacier: $0.004/GB/month (after 90 days)
Bandwidth:
- First 10 TB/month: $0.09/GB
- Next 40 TB/month: $0.085/GB
Requests:
- PUT: $0.005 per 1,000 requests
- GET: $0.0004 per 1,000 requests
Example Calculation (1000 calls/month, 5 min avg, 1 MB/min):
Storage: 1000 calls × 5 min × 1 MB × $0.023/GB = $0.115/month
Requests: 1000 uploads + 2000 downloads × $0.005/1000 = $0.015/month
Bandwidth: 1000 × 5 MB × $0.09/GB × 1/1024 = $0.44/month
Total: ~$0.57/month (vs. Twilio: ~$12.50/month)
Monitoring and Alerts
Metrics to Track
-
Storage Metrics:
- Total recordings count
- Total storage used (GB)
- Average recording size
- Recordings by provider
- Growth rate (recordings/day)
-
Access Metrics:
- Playback requests/day
- Download requests/day
- Failed access attempts
- Average access time
-
Compliance Metrics:
- Recordings past retention period
- Missing consent records
- Pending deletion requests
- Audit log coverage
-
Cost Metrics:
- Storage cost/month
- Bandwidth cost/month
- Provider cost/month
- Cost per recording
Recommended Alerts
// Example Sentry alert configuration
Sentry.captureMessage('Recording retention SLA violated', {
level: 'warning',
tags: {
type: 'compliance',
policy: 'retention'
},
extra: {
expiredCount: 150,
oldestRecording: '2024-01-15'
}
});
Alert Conditions:
- Recordings > 90 days old without deletion
- Failed recording uploads
- Storage quota > 80%
- Unusual access patterns (>100 downloads/hour)
- Provider API errors
Testing Recommendations
Unit Tests
- Recording Access Control:
describe('CallRecord API - Access Control', () => {
it('should deny access to recording from different client', async () => {
const response = await fetch(`/api/calls/${otherClientCallId}`, {
headers: { Authorization: `Bearer ${userToken}` }
});
expect(response.status).toBe(404);
});
});
- Retention Policy:
describe('GDPR Compliance - Retention', () => {
it('should delete recordings older than retention period', async () => {
await seedOldCallRecords(100); // 100 days old
await applyRetentionPolicies();
const remaining = await prisma.callRecord.count({
where: { recordingUrl: { not: null } }
});
expect(remaining).toBe(0);
});
});
Integration Tests
- End-to-End Recording Flow:
describe('Call Recording Flow', () => {
it('should store recording URL from Twilio webhook', async () => {
const webhookPayload = {
CallSid: 'CA123',
RecordingUrl: 'https://api.twilio.com/recordings/RE123.mp3',
CallStatus: 'completed'
};
await fetch('/api/webhooks/twilio', {
method: 'POST',
body: new URLSearchParams(webhookPayload)
});
const callRecord = await prisma.callRecord.findFirst({
where: { callId: 'CA123' }
});
expect(callRecord?.recordingUrl).toBe(webhookPayload.RecordingUrl);
});
});
Load Tests
- Concurrent Playback:
// Test 100 concurrent recording playback requests
import { test } from '@playwright/test';
test('concurrent recording playback', async ({ browser }) => {
const contexts = await Promise.all(
Array.from({ length: 100 }, () => browser.newContext())
);
const results = await Promise.all(
contexts.map(async (context) => {
const page = await context.newPage();
const start = Date.now();
await page.goto(`/calls/${callId}`);
await page.click('button[aria-label="Play recording"]');
return Date.now() - start;
})
);
const avgLatency = results.reduce((a, b) => a + b) / results.length;
expect(avgLatency).toBeLessThan(2000); // < 2 seconds
});
Implementation Checklist
Phase 1: Foundation (Immediate)
- Document current provider recording behavior
- Add recording consent fields to CallRecord model
- Create recording access audit log table
- Implement presigned URL endpoint for existing provider URLs
- Add recording access logging
Phase 2: Compliance (1-2 weeks)
- Implement consent capture before recording
- Add state-specific consent logic (two-party states)
- Create GDPR data export endpoint for recordings
- Extend retention policies to call recordings
- Add automated cleanup job for expired recordings
Phase 3: Self-Hosting (2-4 weeks)
- Create S3 bucket for recordings (or Supabase Storage)
- Implement webhook recording upload handler
- Build recording migration service
- Add CDN distribution
- Create recording transcoding pipeline
Phase 4: Optimization (4-8 weeks)
- Implement tiered storage (hot/cold/archive)
- Add recording compression
- Create cost monitoring dashboard
- Optimize bandwidth usage
- Add recording analytics
Related Documentation
- Call Log Ingestion System
- GDPR Compliance Service
- Data Retention Cron Job
- File Upload Service
- Database Schema
Frequently Asked Questions
Q: Are call recordings automatically deleted? A: Currently, no. Recordings remain with the provider indefinitely unless manually deleted. A retention policy system exists for other data types but hasn't been extended to call recordings.
Q: Can users download their call recordings?
A: Yes, if a recordingUrl exists, users can access it via the UI (CallMessageItem component) or API (GET /api/calls/[callId]). However, access control relies on client-scoped queries, not presigned URLs.
Q: How are recordings stored? A: Currently, all recordings are stored by the voice provider (Twilio, RingCentral, etc.). Only the URL is stored in the Petunia database.
Q: Is recording consent tracked? A: No, this feature is not yet implemented. You should add consent tracking before deploying to two-party consent states.
Q: What happens when a user deletes their account? A: The GDPR compliance system schedules data deletion after a 30-day grace period, but call recordings are not yet included in this process.
Q: How much does call recording storage cost? A: It depends on the provider. See the "Cost Considerations" section above. Self-hosting on S3 is typically 10-20x cheaper than provider storage.
Q: Can we migrate existing recordings to self-hosted storage? A: Yes, but this requires building a migration service to download recordings from providers and upload to S3/Supabase Storage. See "Migration Path" section.
Q: Are recordings encrypted? A: Provider-hosted recordings use the provider's encryption. For self-hosted storage, you must enable server-side encryption (AES-256 or KMS).
Support and Troubleshooting
Common Issues:
-
Recording URL returns 403 Forbidden:
- Provider credentials may have expired
- Check
ConnectionIntegration.credentialsfor the portal - Verify OAuth token refresh is working (RingCentral)
-
Recording not created:
- Verify recording is enabled in provider settings
- Check webhook delivery (Twilio, RingCentral)
- Review call status (some call statuses don't generate recordings)
-
Playback fails in browser:
- Recording format may not be browser-compatible (codec issue)
- CORS headers may be missing from provider
- Use download link as fallback
Logging:
// Enable debug logging for call recordings
const logger = createLogger('call-recordings', { level: 'debug' });
logger.debug('Recording URL received', {
callId,
provider,
recordingUrl,
hasTranscript: !!transcriptUrl
});
Contact:
- Engineering Team: support@gardenpatch.xyz
- Security/Compliance: support@gardenpatch.xyz
- Customer Support: support@gardenpatch.xyz