Recording & storage
Recordings, transcripts and exports can be written to your bucket rather than the platform's. For a regulated customer this is usually the deciding question, so it is configured per organization, per artifact kind, and validated with a real write before it is trusted.
Where artifacts go
A storage destination is a bucket plus credentials plus a scope. Eight drivers exist:
| Driver | Secrets it needs | Notes |
|---|---|---|
platform | none | The platform's managed bucket. The default when nothing else is configured. |
s3 | access_key_id, secret_access_key | Amazon S3. Needs a region. |
s3_sts | external_id | Assume-role, no static keys. Needs role_arn in settings. |
r2 | access_key_id, secret_access_key | Cloudflare R2. Needs an account_name. |
azure_blob | account_key | Azure Blob Storage. The bucket field is the container. |
gcs | service_account_json | Google Cloud Storage. |
supabase | access_key_id, secret_access_key | Supabase's S3 gateway. |
s3_compatible | access_key_id, secret_access_key | MinIO, Backblaze B2, Wasabi. Requires an endpoint. |
Each driver declares which fields it needs — region, endpoint, account_name — and the picker only offers drivers this deployment can actually build, checked against the Flysystem adapters present in the installation. A driver you cannot construct is not offered as a choice you will discover is broken later.
| Field | Meaning |
|---|---|
bucket | The container name. |
region / endpoint | Required by driver. |
path_prefix | Everything CallAgent writes goes under this. |
use_path_style | For S3-compatible endpoints that need it. |
applies_to | recordings, transcripts or exports — one destination need not take all three. |
retention_days | Purge after this many days. |
is_default | Used when no more specific destination matches. |
Validate before you rely on it
The Test action performs a real write, read and delete against the bucket and records the outcome. "The credentials are saved" and "the bucket works" are different claims and the interface keeps them apart.
Storage destinations are configured in the dashboard under Platform → Storage destinations. There is no /api/v1 endpoint for them.
Custody is tracked per artifact: call_artifacts.storage_destination_id records which destination an object actually landed in, so changing the default does not orphan everything written before. Existing artifacts are moved by a relocation job rather than being silently re-pointed.
Recording
Recording is per assistant, in artifact_plan, and per phone number.
| Field | Default | Notes |
|---|---|---|
recording_enabled | true | An announcement can be played; a refusal cannot be honoured — see below. |
recording_format | wav | wav · mp3 · ogg |
stereo_recording | false | |
transcript_enabled | true | |
messages_enabled | true | |
events_enabled | true | |
logs_enabled | true | |
debug_json_enabled | false | |
retention_days | — | 0–3650. |
pii_redaction | off | See below. |
post_call_transcription | off | { enabled, provider, model, language }. Sonix is the only adapter. |
The storage path is not configurable
storagePathTemplate exists in the contract with a default of org/{{call.organizationId}}/calls/{{call.id}}/{{artifact.kind}}.{{artifact.ext}}. The recorder never reads it — it hardcodes org/{organizationId}/calls/{callId}/{channel}.wav. Use path_prefix on the storage destination if you need a prefix of your own.
Dual-channel
mono mixes both parties into one channel. stereo and dual keep the caller and the assistant separable, which is what makes a two-track waveform, per-speaker analysis and a clean redaction pass possible — you cannot cleanly remove the caller's card number from a mixed recording.
This is plumbed through to the carrier, not simulated: the Twilio integration sets RecordingChannels to dual, and the FreeSWITCH directory generator emits record_stereo for BYO trunks. Twilio, Telnyx and BYO SIP all declare dual-channel recording as implemented; check the provider's capability matrix before assuming a fourth carrier does.
call_legs.recording_channel records which channel each leg occupied, and the artifact row carries channels, sample_rate, codec, duration_seconds, size_bytes and a checksum_sha256.
Consent
recording_consent_plan.mode sets what is said:
| Mode | Behaviour |
|---|---|
none | Default. Nothing is said. |
announcement | The configured text is spoken before the greeting. |
assistant_spoken | The assistant says it in its own voice. |
custom_audio | Your own audio file at audio_url. |
An announcement is played. Consent is not obtained.
Three things about this, all verified in the code:
Recording starts before the announcement. The recorder is constructed and started, and playout begins, and then the notice is spoken. The announcement is itself in the recording.
A refusal cannot be honoured. The gateway reports exactly one consent state: announced. It never reports granted or denied, because nothing collects an acknowledgement — no DTMF prompt, no phrase matcher. on_refusal is carried in the call config, is not read, and cannot fire.
regions is documentation. Nothing branches on it. The service that computes the plan states in its own comment that it does not determine whether a recording is lawful.
If your obligation is to inform, an announcement satisfies it. If it is to obtain and honour consent, handle that before the call reaches the assistant, or do not record. See AI disclosure & consent.
The state is recorded on the call as consent_policy, consent_state, consent_recorded_at and consent_region. Read consent_state: "announced" as "a notice was played", not as agreement.
PII redaction
artifact_plan.pii_redaction removes categories from stored text — message contents, the transcript, the summary and event payloads — once, at call finalisation. It is off by default. Enabling it defaults to card (Luhn-validated) and national_id (US SSN, Romanian CNP, UK NINo); also available are email, phone, iban and custom patterns you supply.
redact_recording does not redact the recording
The field exists. The audio file is never modified. If a caller reads a card number aloud, it is in the WAV. To keep it out of storage, use zero data retention or do not record.
Getting a recording
GET /api/v1/calls/{call}/recording a signed URL
DELETE /api/v1/calls/{call}/recording erase it
GET /api/v1/calls/{call}/artifacts everything the call produced
GET /api/v1/calls/{call}/artifacts/{artifact}/downloadThe download route is the one exception to bearer authentication in the whole API: it is authorised by a signed URL instead, so a recording link can be handed to a browser or an audio player without leaking a key.
# Returns a short-lived signed URL rather than the audio itself
curl "$CALLAGENT_API_URL/calls/01JQ…/recording" \
-H "Authorization: Bearer $CALLAGENT_API_KEY"const { data } = await fetch(`${process.env.CALLAGENT_API_URL}/calls/${callId}/recording`, {
headers: { Authorization: `Bearer ${process.env.CALLAGENT_API_KEY}` },
}).then((response) => response.json());
// data.url is signed and expires; do not store it, request it again.Deleting a recording is a real erasure, which is what a GDPR request needs. See Security.
Retention and zero data retention
Retention is per organization and per artifact category. Six categories, each with its own {category}_days field: recordings_days, transcripts_days, events_days, logs_days, structured_outputs_days, analytics_days.
null means keep forever. 0 means purge on the next run. The nightly job is php artisan callagent:calls:apply-retention, scheduled at 03:15.
zero_data_retention is the organization-wide mode. It nulls message contents, tool arguments and results, word timings, event payloads, the transcript, summary, analysis, structured output and topics, and deletes recording artifacts — leaving the artifact row marked purged as evidence that something existed. It deliberately keeps the call id, status, duration, the two E.164 numbers, costs, metrics, legs and the audit log, because billing and dispute resolution need them and none of them is conversation content.
zdr_delay_minutes: 0 runs it inline at finalisation rather than waiting for the nightly job.
A call records when each policy was applied (redaction_applied_at, zdr_applied_at, content_purged_at), so an audit can show not just that a policy exists but that it ran.
Deleting a recording leaves the row
RecordingService::purge() deletes the audio and sets recording_status = purged, keeping the artifact row as evidence that a recording existed and was removed. That is what an erasure request usually wants — proof of deletion, not the absence of any trace.
Next
- Calls & artifacts — everything else a call leaves behind.
- Security & tenancy — retention, audit and erasure.