Storing Retell Call Recordings for Client Portals: Expiring Links, Retention and Per-Tenant Access
Disclosure: I'm Nabeel, founder of VoiceDash, a client portal for Retell agencies. This post is about the plumbing, not the product, and every Retell behaviour below is taken from Retell's own docs (linked inline). TL;DR: Retell recording URLs do not expire unless you opt in to secure URLs, and Rete

ll keeps call data forever unless you set a retention period. If you show recordings to clients in your own portal, decide up front whether you proxy Retell's links or copy the audio into your own storage, scope every lookup by tenant, and make your retention match (or beat) the one you configured in Retell. A finished call object (from the Get Call API or the call_ended / call_analyzed webhooks) carries up to four audio links: recording_url: the mixed recording recording_multi_channel_url: each party on its own channel scrubbed_recording_url and scrubbed_recording_multi_channel_url: the same audio with PII intervals beeped out, when the agent uses the "Everything except PII" storage mode There is also public_log_url, which deserves the same care as the audio. Two agent-level settings decide how risky those links are: secure URLs and data retention.
Both are off by default. Retell's secure URL page is explicit: generated URLs for recordings and logs do not expire unless you opt in. If one of those links lands in a client's inbox, a CRM note or a support ticket, anyone holding it can play the call. Opting in is per agent: { "opt_in_signed_url": true, "signed_url_expiration_ms": 3600000 } Leave signed_url_expiration_ms unset and you get 24 hours.
The allowed range is 1 minute to 7 days. Two details matter when you turn this on for an agent that already has history: Files created before you opted in keep generating unsigned, non-expiring URLs. If you later opt out, files created while the setting was on keep their signed behaviour; only new files go back to permanent links. So "we enabled secure URLs" is not the same as "every recording link we have stored is now safe".
Any URL you already persisted in a database or pushed to a CRM is still live. Treat stored Retell URLs as secrets, and plan to stop storing them at all (next section). Once a client can log into your portal, you have two sane designs. Proxy (store nothing, ask Retell each time).
Your portal stores only call_id and tenant_id. When an authorised user presses play, your server calls Get Call, reads the fresh signed URL and redirects. With secure URLs on, Retell signs a new URL each time you request it, so a short expiry (5 to 15 minutes) works well. Upside: no audio storage, one source of truth.
Downside: Retell's retention is now your retention, and every play is an API call. Copy (own the audio). On call_analyzed, download the file and put it in your own bucket under a tenant prefix. Your portal mints its own short-lived links.
Upside: you control retention, region and access logs, and Retell can be set to a short retention. Downside: you are now the one holding call audio, with everything that implies for consent and deletion requests. You must copy if the agent uses "Everything except PII" or "Basic Attributes Only". The webhook docs say Retell deletes the raw recording, transcript and log files about 10 minutes after post-call processing ends, shortly after call_analyzed.
The webhook still carries the full unscrubbed data, so it is your one window to grab it, and your one chance to decide whether you should keep the raw file or only the scrubbed one. The copy design lives or dies in the webhook handler. Retell gives you three constraints worth designing around: Verify X-Retell-Signature against the raw body, not re-serialised JSON (secure webhook docs). Only the API key with the webhook badge verifies.
The webhook times out after 10 seconds and is retried up to 3 times without a 2xx, so the same event can arrive more than once. Retell's own guidance is to key idempotency on event + call_id. Acknowledge fast. Downloading a long WAV inside the request is how you earn duplicate deliveries.
import { Retell } from "retell-sdk"; import express from "express"; const app = express(); app.use(express.raw({ type: "application/json" })); app.post("/retell/webhook", async (req, res) => { const raw = req.body.toString("utf-8"); const sig = req.headers["x-retell-signature"]; if (typeof sig !== "string" || !(await Retell.verify(raw, process.env.RETELL_WEBHOOK_KEY!, sig))) { return res.status(401).end(); } const { event, call } = JSON.parse(raw); if (event === "call_analyzed") { // Unique (event, call_id) row makes retries a no-op. const inserted = await db.webhookEvents.insertIfAbsent({ event, call_id: call.call_id, received_at: new Date(), }); if (inserted) { await queue.send("copy-recording", { call_id: call.call_id, agent_id: call.agent_id, url: call.scrubbed_recording_url ?? call.recording_url, }); } } res.status(204).end(); }); The worker resolves agent_id to a tenant from your own table (never from anything the caller said), streams the file into s3://recordings/{tenant_id}/{call_id}.wav, and records the object key. Because of the 10-minute deletion window on the stricter storage modes, alert if the queue for this job backs up past a few minutes.
Choosing the scrubbed URL when it exists is a policy decision, not a technical one. For most client portals the scrubbed version is what a business owner should hear; keep the raw one only if the client has a documented reason and the consent to back it. create table call_recordings ( call_id text primary key, tenant_id uuid not null references tenants(id), agent_id text not null, object_key text, -- null in the proxy design is_scrubbed boolean not null, started_at timestamptz not null, delete_after timestamptz not null ); create index on call_recordings (tenant_id, started_at desc); Every read goes through one function that takes the session's tenant_id and the call_id and filters on both. If you use Postgres row-level security, put the policy on this table first: it holds the most sensitive rows in the system.
The playback endpoint then does three things in order: load the row with tenant_id from the session, write an access log line (who, which call, when), and return a link that expires in minutes. With the AWS SDK that last step is getSignedUrl(s3, new GetObjectCommand({ Bucket, Key }), { expiresIn: 600 }). Never put the object key or a Retell URL in the HTML that lists calls; mint the link only when someone presses play. Retell's data retention policy is per agent, set with data_storage_retention_days (1 to 730, or null for keep forever, the default).
The dropdown offers 1, 3, 7, 30, 60, 90, 180, 365 and 730 days. Deletion runs daily and is permanent: recordings, transcripts, logs, dynamic variables and metadata go, and the call stops appearing in API responses. That creates two failure modes: Proxy design with retention on: the portal shows a call row whose recording silently 404s on day 91. Store delete_after and hide or label expired calls.
Copy design with retention on in Retell only: Retell deletes on day 30, your bucket keeps the file forever, and your client's "we keep recordings for 30 days" promise is false. Add a bucket lifecycle rule and a daily job that deletes rows past delete_after. Which number to choose depends on the client's industry and the states their callers are in. That is a legal question, not an engineering one; I cover the consent side in Voice AI Call Recording Consent and AI Disclosure: The Compliance Checklist I Run for US Clients.
Secure URLs on for every client agent, with an expiry you can justify. Old, unsigned URLs purged from your database, CRM notes and automations. Proxy or copy decided per agent, and copy enforced for "Everything except PII" and "Basic Attributes Only" agents. Webhook verified on the raw body, idempotent on event + call_id, acknowledged before the download.
agent_id to tenant_id mapping owned by you, checked on every read. Playback links minted on click, expiring in minutes, with an access log. One retention number per client, applied in Retell and in your own storage. None of this is visible to the client when it works.
It becomes very visible the first time a recording link travels further than it should. Drafted with AI assistance and fact-checked against each vendor's docs.
Key Takeaways
- โขIf you show recordings to clients in your own portal, decide up front whether you proxy Retell's links or copy the audio into your own storage, scope every lookup by tenant, and make your retention match (or beat) the one you configured in Retell.
- โขThis story was reported by Dev.to, covering developments in the dev space.
- โขAI advancements continue to reshape industries โ read the full article on Dev.to for complete coverage.
๐ Continue reading the full article:
Read Full Article on Dev.to โShare this article



