<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: VoiceDash</title>
    <description>The latest articles on DEV Community by VoiceDash (voicedash).</description>
    <link>https://dev.to/voicedash</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F15332%2Fdc13fbc7-f755-40e8-8bce-1dd8913ce962.png</url>
      <title>DEV Community: VoiceDash</title>
      <link>https://dev.to/voicedash</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/voicedash"/>
    <language>en</language>
    <item>
      <title>Monitoring Retell Voice Agents in Production: Alert Rules, Disconnection Reasons and the Failures Transcripts Hide</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Fri, 30 Oct 2026 10:48:25 +0000</pubDate>
      <link>https://dev.to/voicedash/monitoring-retell-voice-agents-in-production-alert-rules-disconnection-reasons-and-the-failures-3kf</link>
      <guid>https://dev.to/voicedash/monitoring-retell-voice-agents-in-production-alert-rules-disconnection-reasons-and-the-failures-3kf</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I'm Nabeel, founder of VoiceDash, a client portal for Retell agencies. This is an adapted republish of a guide from the VoiceDash blog, rewritten for developers. Nothing below needs VoiceDash; it uses Retell's own features plus a little glue code.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A voice agent that passed every test on launch day can quietly break a month later: a calendar token expires, a webhook starts failing, the business changes its hours, or a provider has a bad afternoon. The agent keeps answering and sounds fine, and books nothing. Monitoring in production means watching four layers (is the line answering, are tools working, is quality holding, is usage normal), wiring a failover before you need it, and routing alerts to one place. Retell now ships most of the primitives; this post maps each layer to the field or feature that covers it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why testing is not enough
&lt;/h2&gt;

&lt;p&gt;Pre-launch testing proves the agent worked on the day you shipped it (my checklist for that is in &lt;a href="https://www.voice-dash.com/blog/how-to-test-a-voice-ai-agent" rel="noopener noreferrer"&gt;How to Test a Voice AI Agent Before Go-Live&lt;/a&gt;). Production is different, because the world around the agent keeps moving:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Credentials expire.&lt;/strong&gt; OAuth tokens for calendars and CRMs get revoked or rotated when someone at the client changes a password.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The business changes without telling you.&lt;/strong&gt; New hours, a new service, a staff member who left. The prompt is now wrong and nothing errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrations fail silently.&lt;/strong&gt; A workflow throws on a field that changed shape, and the agent still tells the caller "you are all booked."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vendors have incidents.&lt;/strong&gt; You sit on top of Retell, a telephony provider and a model provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own edits break things.&lt;/strong&gt; A prompt change for one branch shifts behavior in another.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these stop the phone ringing. That is what makes them dangerous.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: is the line answering at all?
&lt;/h2&gt;

&lt;p&gt;The loud failure: calls ring out, drop on connect, or end in an error. Rare, and the one clients never forgive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Heartbeat call.&lt;/strong&gt; Once or a few times a day, place an automated test call to each production number from a separate number. Check that it connects, the agent greets within a normal window, and the call ends cleanly. A small scheduled workflow (n8n, a cron job, anything) plus a Twilio number does it. Alert after two consecutive failures, not one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Volume floor.&lt;/strong&gt; Every client has a rhythm. Compare today's call count against the same weekday's average and alert when it falls far below during business hours. Zero calls by noon at a busy office is usually a forwarding change on the client side, not an AI problem, but it is still yours to catch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disconnection reasons.&lt;/strong&gt; Every Retell call records a &lt;code&gt;disconnection_reason&lt;/code&gt;, available in Call History and on the Get Call API. &lt;a href="https://docs.retellai.com/reliability/debug-call-disconnect" rel="noopener noreferrer"&gt;Retell's disconnection table&lt;/a&gt; splits them roughly into three buckets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;expected (call_status = ended)
  user_hangup, agent_hangup, call_transfer, voicemail_reached,
  inactivity, max_duration_reached, user_requested_dnc ...

not connected (outbound)
  dial_busy, dial_no_answer, dial_failed, invalid_destination,
  marked_as_spam, network_blocked ...

errors (call_status = error)
  concurrency_limit_reached, error_llm_websocket_*, error_asr,
  error_no_audio_received, error_retell, error_unknown ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Log the field for every call and alert on any spike in the error bucket. Two details worth knowing from the docs: &lt;code&gt;dial_failed&lt;/code&gt; is also what you see when carriers have flagged your outbound number as spam after many short calls, and the &lt;code&gt;error_llm_websocket_*&lt;/code&gt; family only appears when you run a custom LLM over Retell's websocket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: are tools and integrations working?
&lt;/h2&gt;

&lt;p&gt;The quiet failure, and in my experience the most common one. The conversation sounds perfect; the booking, CRM update or follow-up text never happens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log every tool call and its result.&lt;/strong&gt; Whether the agent calls your endpoint directly or goes through a workflow tool, record the request, response, status and latency, keyed by &lt;code&gt;call_id&lt;/code&gt;. If tool results are not logged you cannot see this failure at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give every workflow an error branch.&lt;/strong&gt; Every workflow the agent depends on needs an error path that alerts with the client name, the workflow and the failing step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make the agent honest about failure.&lt;/strong&gt; The prompt should say exactly what to do when a tool returns an error: apologise, take the caller's details, promise a callback, flag the call. It must never confirm a booking that did not go through. A caller who gets a callback is fine; a caller who shows up for an appointment that does not exist is a complaint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reconcile against the source of truth.&lt;/strong&gt; Once a day, compare calls marked &lt;code&gt;booked&lt;/code&gt; against bookings that actually landed in the client's calendar or CRM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// nightly reconcile, per client&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;booked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;call_analysis&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;custom_analysis_data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;outcome&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;booked&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;landed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;calendar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listEventsCreatedBetween&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;voice-agent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;booked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;landed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;booked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; calls say booked, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;landed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; bookings exist`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;outcome&lt;/code&gt; field here is whatever you defined in post-call analysis; the calendar call is your own integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: is conversation quality holding?
&lt;/h2&gt;

&lt;p&gt;Some failures are not errors. The agent answers, the tools work, and it still handles callers badly because the business changed or an edge case became common.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Post-call analysis as the quality signal.&lt;/strong&gt; Extract a structured outcome for every call (booked, answered question, transferred, message taken, spam) plus a flag for a frustrated caller or a request for a human. Track the rates per client week over week. A jump in transfers or frustrated callers means the prompt no longer matches reality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A weekly transcript sample.&lt;/strong&gt; Numbers tell you something changed; transcripts tell you what. Skim a handful of calls per client each week, weighted toward transfers, very short calls and anything flagged frustrated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency, per component.&lt;/strong&gt; After a call ends, Get Call returns a &lt;a href="https://docs.retellai.com/reliability/check-actual-latency" rel="noopener noreferrer"&gt;&lt;code&gt;latency&lt;/code&gt; object&lt;/a&gt; with &lt;code&gt;p50&lt;/code&gt;, &lt;code&gt;p90&lt;/code&gt;, &lt;code&gt;p95&lt;/code&gt;, &lt;code&gt;p99&lt;/code&gt;, &lt;code&gt;min&lt;/code&gt;, &lt;code&gt;max&lt;/code&gt; for components such as &lt;code&gt;e2e&lt;/code&gt;, &lt;code&gt;llm&lt;/code&gt;, &lt;code&gt;tts&lt;/code&gt;, &lt;code&gt;asr&lt;/code&gt; and &lt;code&gt;knowledge_base&lt;/code&gt; (not every field is present on every call). Store &lt;code&gt;e2e.p90&lt;/code&gt; per call and watch its trend. If it creeps up, the breakdown tells you whether it is the model, the voice or retrieval. Note that &lt;code&gt;e2e&lt;/code&gt; excludes turns where the agent only ran a tool without speaking, so slow tools need their own timing from your logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: is usage behaving normally?
&lt;/h2&gt;

&lt;p&gt;Usage anomalies are both a cost problem and a symptom:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Average duration spiking&lt;/strong&gt; often means the agent is stuck in a loop, or robocalls are getting long conversations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Very short calls climbing&lt;/strong&gt; can mean callers hang up on a bad greeting or a slow first response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minutes running past the client's bundle&lt;/strong&gt; is a margin problem you want to see mid-month, not on the invoice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Set a maximum call duration on every agent (it ends calls with &lt;code&gt;max_duration_reached&lt;/code&gt;), then alert when a client's daily minutes run well above normal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retell alert rules: the built-in layer
&lt;/h2&gt;

&lt;p&gt;Retell has &lt;a href="https://docs.retellai.com/features/alerting-overview" rel="noopener noreferrer"&gt;alert rules&lt;/a&gt; that evaluate aggregate metrics on a schedule and send an email or a webhook when a threshold is crossed. Metrics include &lt;code&gt;call_count&lt;/code&gt;, &lt;code&gt;call_success_rate&lt;/code&gt;, &lt;code&gt;negative_sentiment_rate&lt;/code&gt;, &lt;code&gt;custom_function_latency&lt;/code&gt;, &lt;code&gt;custom_function_failure_count&lt;/code&gt;, &lt;code&gt;transfer_call_failure_count&lt;/code&gt;, &lt;code&gt;concurrency_used&lt;/code&gt;, &lt;code&gt;total_call_cost&lt;/code&gt; and &lt;code&gt;api_error_count&lt;/code&gt;. Rules can be absolute ("success rate below 90% over the last hour") or relative to the previous cycle, and can be filtered by agent, environment tag, disconnection reason or post-call extraction field.&lt;/p&gt;

&lt;p&gt;Two constraints shape how you use them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rules are dashboard-only.&lt;/strong&gt; They are created in the dashboard, not through the public API, so if you manage many client workspaces, budget time to set them up per workspace and keep a written list of what exists where.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They are aggregate, not per call.&lt;/strong&gt; For "this specific call failed a booking", use your own webhook handler. A sensible split: Retell rules for volume, success rate, function failures and cost; your own code for heartbeats, reconciliation and anything that needs client-specific logic.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Plan the failover before you need it
&lt;/h2&gt;

&lt;p&gt;Monitoring tells you something broke; failover decides how much it costs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backup mode.&lt;/strong&gt; In Retell, each phone number can have a fallback number. With &lt;a href="https://docs.retellai.com/deploy/inbound-call" rel="noopener noreferrer"&gt;Backup mode&lt;/a&gt; turned on, new inbound calls go straight to that fallback before an agent is selected, which is your kill switch while you investigate. Configure the fallback number at launch; Backup mode cannot be enabled until one exists, and if the fallback cannot be reached the call ends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A human transfer path&lt;/strong&gt; for when the agent is confused, tested as part of every release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A written commitment&lt;/strong&gt; in the client contract: you monitor and respond within a defined window, and upstream vendor outages are excluded from credits. Subscribe to &lt;a href="https://status.retellai.com/" rel="noopener noreferrer"&gt;Retell's status page&lt;/a&gt; so you know about their incidents as early as they do.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Route alerts so someone actually sees them
&lt;/h2&gt;

&lt;p&gt;The fastest way to kill a monitoring setup is noise. If every short call pings your phone, you will mute the channel within a week and miss the one that mattered.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One channel&lt;/strong&gt;, with the client name in every message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only alert on things you would act on:&lt;/strong&gt; failed heartbeat, workflow error, volume far below normal, error disconnections, a booked count that does not reconcile, a Retell rule firing. Everything else goes into a daily digest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tell the client before they tell you:&lt;/strong&gt; what happened, when it started, what you did and which calls were affected. Handled like that, an incident builds trust instead of eroding it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;The agent that worked on launch day is not the agent that is answering today, because everything around it changed. Watch the line, the tools, the quality and the usage; put &lt;code&gt;disconnection_reason&lt;/code&gt;, &lt;code&gt;latency&lt;/code&gt; and your post-call outcome on every call record; let Retell's alert rules cover the aggregates; and set the fallback number before the first incident, not during it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI assistance and fact-checked against each vendor's docs.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>voiceai</category>
      <category>ai</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Storing Retell Call Recordings for Client Portals: Expiring Links, Retention and Per-Tenant Access</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Sun, 25 Oct 2026 10:43:20 +0000</pubDate>
      <link>https://dev.to/voicedash/storing-retell-call-recordings-for-client-portals-expiring-links-retention-and-per-tenant-access-1f51</link>
      <guid>https://dev.to/voicedash/storing-retell-call-recordings-for-client-portals-expiring-links-retention-and-per-tenant-access-1f51</guid>
      <description>&lt;p&gt;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).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Retell recording URLs do not expire unless you opt in to secure URLs, and Retell 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Retell actually gives you
&lt;/h2&gt;

&lt;p&gt;A finished call object (from the &lt;a href="https://docs.retellai.com/api-references/get-call.md" rel="noopener noreferrer"&gt;Get Call API&lt;/a&gt; or the &lt;code&gt;call_ended&lt;/code&gt; / &lt;code&gt;call_analyzed&lt;/code&gt; webhooks) carries up to four audio links:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;recording_url&lt;/code&gt;: the mixed recording&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;recording_multi_channel_url&lt;/code&gt;: each party on its own channel&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scrubbed_recording_url&lt;/code&gt; and &lt;code&gt;scrubbed_recording_multi_channel_url&lt;/code&gt;: the same audio with PII intervals beeped out, when the agent uses the "Everything except PII" storage mode&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is also &lt;code&gt;public_log_url&lt;/code&gt;, which deserves the same care as the audio.&lt;/p&gt;

&lt;p&gt;Two agent-level settings decide how risky those links are: secure URLs and data retention. Both are off by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: by default the links never expire
&lt;/h2&gt;

&lt;p&gt;Retell's &lt;a href="https://docs.retellai.com/accounts/signed-secure-url.md" rel="noopener noreferrer"&gt;secure URL page&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;Opting in is per agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"opt_in_signed_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"signed_url_expiration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3600000&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave &lt;code&gt;signed_url_expiration_ms&lt;/code&gt; unset and you get 24 hours. The allowed range is 1 minute to 7 days.&lt;/p&gt;

&lt;p&gt;Two details matter when you turn this on for an agent that already has history:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Files created &lt;strong&gt;before&lt;/strong&gt; you opted in keep generating unsigned, non-expiring URLs.&lt;/li&gt;
&lt;li&gt;If you later opt out, files created while the setting was on keep their signed behaviour; only new files go back to permanent links.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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).&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: proxy or copy?
&lt;/h2&gt;

&lt;p&gt;Once a client can log into your portal, you have two sane designs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proxy (store nothing, ask Retell each time).&lt;/strong&gt; Your portal stores only &lt;code&gt;call_id&lt;/code&gt; and &lt;code&gt;tenant_id&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Copy (own the audio).&lt;/strong&gt; On &lt;code&gt;call_analyzed&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;You must copy if the agent uses "Everything except PII" or "Basic Attributes Only". The &lt;a href="https://docs.retellai.com/features/webhook-overview.md" rel="noopener noreferrer"&gt;webhook docs&lt;/a&gt; say Retell deletes the raw recording, transcript and log files about 10 minutes after post-call processing ends, shortly after &lt;code&gt;call_analyzed&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ingestion handler
&lt;/h2&gt;

&lt;p&gt;The copy design lives or dies in the webhook handler. Retell gives you three constraints worth designing around:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Verify &lt;code&gt;X-Retell-Signature&lt;/code&gt; against the &lt;strong&gt;raw&lt;/strong&gt; body, not re-serialised JSON (&lt;a href="https://docs.retellai.com/features/secure-webhook.md" rel="noopener noreferrer"&gt;secure webhook docs&lt;/a&gt;). Only the API key with the webhook badge verifies.&lt;/li&gt;
&lt;li&gt;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 &lt;code&gt;event&lt;/code&gt; + &lt;code&gt;call_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Acknowledge fast. Downloading a long WAV inside the request is how you earn duplicate deliveries.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Retell&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;retell-sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/retell/webhook&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-retell-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Retell&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RETELL_WEBHOOK_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;call_analyzed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Unique (event, call_id) row makes retries a no-op.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inserted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhookEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertIfAbsent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;received_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inserted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;copy-recording&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrubbed_recording_url&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recording_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The worker resolves &lt;code&gt;agent_id&lt;/code&gt; to a tenant from your own table (never from anything the caller said), streams the file into &lt;code&gt;s3://recordings/{tenant_id}/{call_id}.wav&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A schema that makes cross-tenant leaks hard
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;call_recordings&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;call_id&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;tenant_id&lt;/span&gt;     &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;tenants&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;agent_id&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;object_key&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;-- null in the proxy design&lt;/span&gt;
  &lt;span class="n"&gt;is_scrubbed&lt;/span&gt;   &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;started_at&lt;/span&gt;    &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;delete_after&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;call_recordings&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every read goes through one function that takes the session's &lt;code&gt;tenant_id&lt;/code&gt; and the &lt;code&gt;call_id&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The playback endpoint then does three things in order: load the row with &lt;code&gt;tenant_id&lt;/code&gt; 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 &lt;code&gt;getSignedUrl(s3, new GetObjectCommand({ Bucket, Key }), { expiresIn: 600 })&lt;/code&gt;. Never put the object key or a Retell URL in the HTML that lists calls; mint the link only when someone presses play.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retention: pick one number and make both systems obey it
&lt;/h2&gt;

&lt;p&gt;Retell's &lt;a href="https://docs.retellai.com/accounts/data-retention.md" rel="noopener noreferrer"&gt;data retention policy&lt;/a&gt; is per agent, set with &lt;code&gt;data_storage_retention_days&lt;/code&gt; (1 to 730, or &lt;code&gt;null&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;That creates two failure modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Proxy design with retention on:&lt;/strong&gt; the portal shows a call row whose recording silently 404s on day 91. Store &lt;code&gt;delete_after&lt;/code&gt; and hide or label expired calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy design with retention on in Retell only:&lt;/strong&gt; 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 &lt;code&gt;delete_after&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;a href="https://www.voice-dash.com/blog/voice-ai-call-recording-compliance" rel="noopener noreferrer"&gt;Voice AI Call Recording Consent and AI Disclosure: The Compliance Checklist I Run for US Clients&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist before a client sees a single recording
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Secure URLs on for every client agent, with an expiry you can justify.&lt;/li&gt;
&lt;li&gt;Old, unsigned URLs purged from your database, CRM notes and automations.&lt;/li&gt;
&lt;li&gt;Proxy or copy decided per agent, and copy enforced for "Everything except PII" and "Basic Attributes Only" agents.&lt;/li&gt;
&lt;li&gt;Webhook verified on the raw body, idempotent on &lt;code&gt;event&lt;/code&gt; + &lt;code&gt;call_id&lt;/code&gt;, acknowledged before the download.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;agent_id&lt;/code&gt; to &lt;code&gt;tenant_id&lt;/code&gt; mapping owned by you, checked on every read.&lt;/li&gt;
&lt;li&gt;Playback links minted on click, expiring in minutes, with an access log.&lt;/li&gt;
&lt;li&gt;One retention number per client, applied in Retell and in your own storage.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI assistance and fact-checked against each vendor's docs.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>voiceai</category>
      <category>webdev</category>
      <category>security</category>
      <category>api</category>
    </item>
    <item>
      <title>Retell AI for Agencies: One Account or One Per Client? How to Structure Accounts, Keys and Phone Numbers</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Tue, 20 Oct 2026 10:44:35 +0000</pubDate>
      <link>https://dev.to/voicedash/retell-ai-for-agencies-one-account-or-one-per-client-how-to-structure-accounts-keys-and-phone-2o9d</link>
      <guid>https://dev.to/voicedash/retell-ai-for-agencies-one-account-or-one-per-client-how-to-structure-accounts-keys-and-phone-2o9d</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I'm Nabeel, founder of VoiceDash, a client portal for Retell agencies. This post is about account design, not the product.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you build Retell AI agents for more than one business, you hit a design question long before you hit a scaling problem: does every client live in one Retell account, or does each client get their own? It looks like a billing choice. It is really a tenancy choice, and it decides how you attribute cost, how far a leaked API key can reach, who owns the phone numbers, and how messy offboarding gets.&lt;/p&gt;

&lt;p&gt;TL;DR: run one agency account for clients on a managed retainer, where you carry the usage and bill a flat fee, and use a client-owned account only when the client insists on owning the vendor relationship, works in a regulated industry that wants its own agreements, or is big enough to want its own billing. Whichever you pick, decide four things up front: who pays Retell, who owns the numbers, how agents are named and tagged per client, and what the client gets to see. I build production voice agents for US clients on Retell, n8n, GoHighLevel and Twilio. Here is how I structure it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why account structure matters more than it looks
&lt;/h2&gt;

&lt;p&gt;Nobody thinks about account structure on client one. You sign up for Retell, build an agent, buy a number, and ship. By client five you have a single account holding five businesses' agents, numbers, recordings and transcripts, all paid for on your card, all reachable with one API key.&lt;/p&gt;

&lt;p&gt;That setup creates four real problems as you grow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost attribution.&lt;/strong&gt; Retell bills per minute, and the bill arrives as one number. If you cannot split minutes by client, you cannot tell which retainer is profitable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access and data isolation.&lt;/strong&gt; Anyone with access to the account, a contractor or a new hire, can see every client's calls. Clients themselves can never be given that login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ownership disputes.&lt;/strong&gt; When a client leaves, the question "whose phone number is this and who keeps the recordings" needs an answer you agreed in writing, not one you invent on the day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blast radius.&lt;/strong&gt; A failed payment, a leaked key or a rate limit on one account touches every client at once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this means one account is wrong. It means one account needs rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: One agency account for all clients
&lt;/h2&gt;

&lt;p&gt;This is what most agencies run, and what I recommend for the managed retainer model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it works.&lt;/strong&gt; You own the Retell account and the payment method. Every client's agents live in it. You pay Retell for all minutes and charge each client a flat monthly retainer that covers usage up to an agreed level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it works.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One place to build, test and update every agent, which matters when you roll the same fix across ten clients.&lt;/li&gt;
&lt;li&gt;One integration setup for n8n, GoHighLevel or your own middleware.&lt;/li&gt;
&lt;li&gt;Your margin stays private. The client sees what their agent did, not what it cost you.&lt;/li&gt;
&lt;li&gt;Onboarding is fast because nothing new has to be created on the client's side.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What it needs to stay sane.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A strict naming convention. I prefix every agent with the client code, for example &lt;code&gt;ACME-receptionist-v3&lt;/code&gt;, so nothing is ambiguous in logs, webhooks or reports.&lt;/li&gt;
&lt;li&gt;Per-client metadata on every call, passed in from the phone number or the agent, so post-call data can be split by client. Retell's call &lt;code&gt;metadata&lt;/code&gt; and post-call analysis are what make this reliable; I wrote up the fields I use in &lt;a href="https://www.voice-dash.com/blog/retell-ai-post-call-analysis" rel="noopener noreferrer"&gt;Retell AI Post-Call Analysis: The Fields I Set Up on Every Client Agent&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Usage tracked per client every month, even if you bill flat, so you notice when one client's volume quietly eats the margin.&lt;/li&gt;
&lt;li&gt;A client-facing layer that is not the Retell dashboard. Clients must never log into the shared account. More on that below.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Option 2: A separate Retell account per client
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How it works.&lt;/strong&gt; Each client has their own Retell account, usually with their own card on it. You get access to build and maintain the agent, and the client pays Retell directly for minutes. You charge a management fee on top.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When I use it.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The client wants to own the vendor relationship. Some owners, especially those who have been burned by an agency before, want the account, the card and the data in their own name.&lt;/li&gt;
&lt;li&gt;Regulated industries. A medical practice or law firm may want its own agreements with each vendor rather than routing through yours.&lt;/li&gt;
&lt;li&gt;High or unpredictable volume. Outbound campaigns and large multi-location groups can swing usage enough that carrying it on your card is a real cash flow risk.&lt;/li&gt;
&lt;li&gt;The client plans to take it in-house eventually, and you are being paid to build and hand over.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The costs.&lt;/strong&gt; Every client account means a separate setup, separate API keys to manage, separate webhook configuration and separate places to check when something breaks. Updates that take minutes across one account become a checklist across ten. You also lose the clean flat retainer, because the client now sees the raw per-minute bill and will compare it to your fee.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hybrid I actually run
&lt;/h2&gt;

&lt;p&gt;In practice I end up with a mix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Managed retainer clients live in the agency account. That is the majority.&lt;/li&gt;
&lt;li&gt;Clients who insist on ownership or need their own agreements get their own account, and I document my access in the contract.&lt;/li&gt;
&lt;li&gt;Pilots always start in the agency account, so I can test fast without asking a prospect for a card. If the client converts and wants their own account, migrating the agent is a planned step, not a surprise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule that makes the hybrid work is writing the choice down at onboarding, in the same intake where you collect hours, services and escalation contacts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phone numbers: decide ownership before you buy one
&lt;/h2&gt;

&lt;p&gt;Phone numbers cause more offboarding fights than anything else, so I treat them separately from the account question.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Numbers bought inside the agency account belong to the agency account. If the client prints that number on their trucks, you have created a dependency they did not agree to.&lt;/li&gt;
&lt;li&gt;The client's main business line should stay theirs. Forward to the agent or route through a carrier the client controls, rather than porting their main number into your account.&lt;/li&gt;
&lt;li&gt;Tracking or campaign numbers you buy for a client should have a written rule: they port to the client on exit, or they are released.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Recordings, transcripts and offboarding
&lt;/h2&gt;

&lt;p&gt;Call data lives where the account lives. In a single agency account, every client's recordings and transcripts sit in your account, which is good for support and bad if you have not agreed retention and export rules.&lt;/p&gt;

&lt;p&gt;What I put in writing for every client, whichever structure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How long recordings and transcripts are kept, and who decides.&lt;/li&gt;
&lt;li&gt;What the client receives when they leave: an export of their call history, their prompt and configuration if the contract says so, and their numbers ported or released.&lt;/li&gt;
&lt;li&gt;How quickly you delete their data from the agency account after exit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Offboarding cleanly is also a sales point. Telling a prospect exactly how they can leave makes them more comfortable signing.&lt;/p&gt;

&lt;h2&gt;
  
  
  API keys and access hygiene
&lt;/h2&gt;

&lt;p&gt;A shared account is only as safe as its access rules.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep the key server side. Never put a Retell API key in a client-facing tool, a shared spreadsheet or a no-code step that clients can open.&lt;/li&gt;
&lt;li&gt;Limit who can log in. Contractors get access for the work they are doing, and lose it when they finish.&lt;/li&gt;
&lt;li&gt;Rotate keys when someone leaves or a key may have been exposed, and know in advance which integrations will break when you do.&lt;/li&gt;
&lt;li&gt;Monitor all clients, not just the loud ones. One account means one place to watch for failed calls.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the client sees: never the raw account
&lt;/h2&gt;

&lt;p&gt;Whichever structure you choose, the client should not be working inside Retell. Retell's dashboard is built for builders: agents, prompts, settings, and in a shared account, every other client's calls. Even in a client-owned account, the raw dashboard shows cost and configuration the owner does not need to see.&lt;/p&gt;

&lt;p&gt;So the client-facing layer is a separate, tenant-scoped read model: calls synced from the API or webhooks, keyed by the client identifier you put in &lt;code&gt;metadata&lt;/code&gt; or the agent prefix, and every query filtered by that tenant before anything renders. Whether you build that yourself or use an off-the-shelf portal, the account design above is what makes it possible. If agent names and call metadata are not consistent, no reporting layer can split the data cleanly afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  The account structure checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Default structure chosen: agency account for managed retainers, client account by exception&lt;/li&gt;
&lt;li&gt;Reason for any client-owned account written into the contract&lt;/li&gt;
&lt;li&gt;Agent naming convention with a client prefix, applied to every agent&lt;/li&gt;
&lt;li&gt;Client identifier passed into every call for usage and reporting splits&lt;/li&gt;
&lt;li&gt;Monthly usage reviewed per client, even on flat pricing&lt;/li&gt;
&lt;li&gt;Phone number ownership and exit rule agreed before buying any number&lt;/li&gt;
&lt;li&gt;Recording retention, export and deletion terms in writing&lt;/li&gt;
&lt;li&gt;API keys server side only, access limited, rotation plan known&lt;/li&gt;
&lt;li&gt;Monitoring covering every client in the account&lt;/li&gt;
&lt;li&gt;A scoped client view in place, so no client ever logs into Retell&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Structure your Retell setup on purpose before client five forces it on you. One agency account is the right default for a managed voice AI agency because it keeps building fast and your margin private, as long as you name agents by client, split usage by client, agree ownership of numbers and data up front, and give every client their own scoped view instead of the raw account. Use client-owned accounts when ownership, regulation or volume genuinely call for it, and write that choice down.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI assistance and fact-checked against each vendor's docs.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>voiceai</category>
      <category>saas</category>
      <category>api</category>
    </item>
    <item>
      <title>Retell Post-Call Analysis: Designing Fields That Turn Calls Into Client KPIs</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Fri, 16 Oct 2026 11:16:21 +0000</pubDate>
      <link>https://dev.to/voicedash/retell-post-call-analysis-designing-fields-that-turn-calls-into-client-kpis-3ol9</link>
      <guid>https://dev.to/voicedash/retell-post-call-analysis-designing-fields-that-turn-calls-into-client-kpis-3ol9</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I'm Nabeel, founder of VoiceDash, a client portal for Retell AI agencies. This post is about the Retell API itself and works with any stack.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Retell's post-call analysis turns each transcript into structured data, but only if you design the fields like a schema, not a wish list. Listen for &lt;code&gt;call_analyzed&lt;/code&gt; (not &lt;code&gt;call_ended&lt;/code&gt;), give every custom field a narrow type and a precise description, and store the result in a table you can aggregate per client. Then the monthly numbers a client sees are just queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Retell actually gives you after a call
&lt;/h2&gt;

&lt;p&gt;When a call finishes, Retell sends webhook events in order. Two matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;call_ended&lt;/code&gt;: every field of the call object &lt;strong&gt;except&lt;/strong&gt; &lt;code&gt;call_analysis&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;call_analyzed&lt;/code&gt;: the full call data &lt;strong&gt;including&lt;/strong&gt; &lt;code&gt;call_analysis&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your pipeline writes KPIs on &lt;code&gt;call_ended&lt;/code&gt;, it will see an empty analysis and count nothing. Write the raw call on &lt;code&gt;call_ended&lt;/code&gt; if you want it early, then upsert the analysis on &lt;code&gt;call_analyzed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;call_analysis&lt;/code&gt; object (per the Get Call API reference) contains:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;call_summary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;A short summary of the call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;in_voicemail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;boolean&lt;/td&gt;
&lt;td&gt;Whether the call reached voicemail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;user_sentiment&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;enum&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Positive&lt;/code&gt;, &lt;code&gt;Neutral&lt;/code&gt;, &lt;code&gt;Negative&lt;/code&gt;, &lt;code&gt;Unknown&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;call_successful&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;boolean&lt;/td&gt;
&lt;td&gt;Whether the agent appears to have finished its task&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;custom_analysis_data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;object&lt;/td&gt;
&lt;td&gt;Your own fields, extracted from the transcript. Can be empty.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Alongside it, the call object carries the fields you need for reporting: &lt;code&gt;agent_id&lt;/code&gt;, &lt;code&gt;start_timestamp&lt;/code&gt; and &lt;code&gt;end_timestamp&lt;/code&gt; (ms since epoch), &lt;code&gt;duration_ms&lt;/code&gt;, &lt;code&gt;disconnection_reason&lt;/code&gt;, &lt;code&gt;recording_url&lt;/code&gt;, &lt;code&gt;metadata&lt;/code&gt;, &lt;code&gt;retell_llm_dynamic_variables&lt;/code&gt; and &lt;code&gt;call_cost&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat the built-in fields with care
&lt;/h2&gt;

&lt;p&gt;The built-ins are useful, but they answer generic questions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;call_successful&lt;/code&gt; means "the agent seems to have completed its task". It does not know that your client counts success as "appointment booked". Do not show it to a client as a success rate.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;user_sentiment&lt;/code&gt; is a model's read of tone. Fine as a filter for which calls a human should review, weak as a headline number.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;in_voicemail&lt;/code&gt; is the one to use for outbound campaigns: exclude voicemail calls from any conversion rate, or the rate will look much worse than the conversations really were.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;call_summary&lt;/code&gt; belongs in a call list, not in a KPI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Client-facing KPIs should come from custom fields you define.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing custom fields as a schema
&lt;/h2&gt;

&lt;p&gt;In the agent's post-call analysis settings, each custom field has a name, a description and a format example, and one of four types: &lt;strong&gt;Text, Selector, Boolean, Number&lt;/strong&gt;. The description is effectively a prompt for extraction, so write it as a definition with an explicit rule for edge cases.&lt;/p&gt;

&lt;p&gt;A set that covers most receptionist agents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;appointment_booked (Boolean)
  True only if the caller confirmed a specific date and time and the
  booking tool returned success during this call. False if the caller
  only asked about availability.

call_intent (Selector: new_booking, reschedule, cancel, question, sales_lead, spam, other)
  The single main reason for the call. If several apply, pick the one
  the caller raised first.

lead_quality (Selector: hot, warm, cold, not_a_lead)
  hot = asked for a booking or a quote for a service the business offers.

escalated_to_human (Boolean)
  True if the call was transferred or a callback by staff was promised.

callback_number (Text)
  The number the caller asked to be called back on, in E.164 format.
  Empty if none was given.

estimated_job_value (Number)
  Only if the caller stated a budget or a price was quoted from the
  approved price list. Otherwise leave empty. Never guess.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules that keep extraction reliable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Prefer Selector and Boolean over Text.&lt;/strong&gt; You can group by an enum; you cannot group by free text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Define the negative case.&lt;/strong&gt; "False if the caller only asked about availability" prevents the most common false positive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One question per field.&lt;/strong&gt; A field like &lt;code&gt;outcome_and_next_step&lt;/code&gt; produces prose you cannot aggregate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allow empty.&lt;/strong&gt; For numbers especially, an explicit "leave empty" beats a hallucinated value in a revenue chart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version the schema.&lt;/strong&gt; When you change a description, record the date. KPI trends across a definition change are not comparable.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Receiving &lt;code&gt;call_analyzed&lt;/code&gt; safely
&lt;/h2&gt;

&lt;p&gt;Retell signs each webhook with the &lt;code&gt;x-retell-signature&lt;/code&gt; header. Verify it against the &lt;strong&gt;raw&lt;/strong&gt; request body with the SDK; a re-serialized JSON object will not match. Retell waits 10 seconds for a 2xx and retries up to 3 times, so acknowledge fast and make the write idempotent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Retell&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;retell-sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/retell&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-retell-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;Retell&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RETELL_API_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// acknowledge inside the 10s window&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;call_analyzed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;upsertAnalysis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// idempotent on call.call_id&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you run several clients on one Retell account, decide how a call maps to a client before it happens. Two common options: a lookup table from &lt;code&gt;agent_id&lt;/code&gt; to client, or a &lt;code&gt;client_id&lt;/code&gt; you put in &lt;code&gt;metadata&lt;/code&gt; when you create outbound calls. Never trust a client identifier taken from the transcript.&lt;/p&gt;

&lt;h2&gt;
  
  
  A table you can aggregate
&lt;/h2&gt;

&lt;p&gt;Flatten the analysis into columns. JSON blobs are fine for the raw payload, but reporting queries should hit typed columns.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;call_analysis&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;call_id&lt;/span&gt;            &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;client_id&lt;/span&gt;          &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;agent_id&lt;/span&gt;           &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;started_at&lt;/span&gt;         &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;duration_ms&lt;/span&gt;        &lt;span class="nb"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;disconnection_reason&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;in_voicemail&lt;/span&gt;       &lt;span class="nb"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_sentiment&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;call_successful&lt;/span&gt;    &lt;span class="nb"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;appointment_booked&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;call_intent&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;lead_quality&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;escalated_to_human&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;estimated_job_value&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;schema_version&lt;/span&gt;     &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;raw&lt;/span&gt;                &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;call_analysis&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;upsertAnalysis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;call_analysis&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;custom_analysis_data&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`insert into call_analysis (call_id, client_id, agent_id, started_at,
       duration_ms, disconnection_reason, in_voicemail, user_sentiment,
       call_successful, appointment_booked, call_intent, lead_quality,
       escalated_to_human, estimated_job_value, schema_version, raw)
     values ($1,$2,$3,to_timestamp($4/1000.0),$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
     on conflict (call_id) do update set
       appointment_booked = excluded.appointment_booked,
       call_intent = excluded.call_intent,
       lead_quality = excluded.lead_quality,
       raw = excluded.raw`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;clientFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start_timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disconnection_reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;in_voicemail&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_sentiment&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;call_successful&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;appointment_booked&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;call_intent&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lead_quality&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;escalated_to_human&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;estimated_job_value&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SCHEMA_VERSION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;on conflict&lt;/code&gt; clause matters: a retried webhook must not create a second row.&lt;/p&gt;

&lt;h2&gt;
  
  
  From rows to client KPIs
&lt;/h2&gt;

&lt;p&gt;Once the columns exist, a month of reporting is a handful of queries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;call_intent&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'spam'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                    &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;real_calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;appointment_booked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;bookings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;appointment_booked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;nullif&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;call_intent&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'new_booking'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'reschedule'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                                                                   &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;booking_rate_pct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;escalated_to_human&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;handed_to_staff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="k"&gt;at&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="k"&gt;zone&lt;/span&gt; &lt;span class="s1"&gt;'America/New_York'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                   &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;between&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                           &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;after_hours_calls&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;call_analysis&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;client_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'1 month'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;  &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the denominators: spam is excluded from call counts, and the booking rate divides by calls that wanted a booking, not by every call. Use the client's own time zone for anything hour based.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the extraction before a client relies on it
&lt;/h2&gt;

&lt;p&gt;Analysis fields are model output, so test them like model output:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Make a set of 15 to 20 test calls that cover each &lt;code&gt;call_intent&lt;/code&gt; value plus awkward cases: a caller who asks about times and hangs up, a reschedule that becomes a cancel, a spam call.&lt;/li&gt;
&lt;li&gt;Write down the expected value of every field for each call before you look at the results.&lt;/li&gt;
&lt;li&gt;Compare. Any field that is wrong more than once gets a sharper description, then re-run the set.&lt;/li&gt;
&lt;li&gt;Spot check a few real calls each week after launch, sorted by &lt;code&gt;user_sentiment = 'Negative'&lt;/code&gt; first.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] KPIs written on &lt;code&gt;call_analyzed&lt;/code&gt;, not &lt;code&gt;call_ended&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Signature verified on the raw body&lt;/li&gt;
&lt;li&gt;[ ] Idempotent upsert keyed on &lt;code&gt;call_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Every custom field has a type, a definition and a negative case&lt;/li&gt;
&lt;li&gt;[ ] Client mapping from &lt;code&gt;agent_id&lt;/code&gt; or &lt;code&gt;metadata&lt;/code&gt;, never from the transcript&lt;/li&gt;
&lt;li&gt;[ ] Spam and voicemail excluded from rates&lt;/li&gt;
&lt;li&gt;[ ] Schema version stored with every row&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;References: Retell docs on &lt;a href="https://docs.retellai.com/features/post-call-analysis" rel="noopener noreferrer"&gt;post-call analysis&lt;/a&gt;, &lt;a href="https://docs.retellai.com/features/webhook-overview" rel="noopener noreferrer"&gt;webhooks&lt;/a&gt; and the &lt;a href="https://docs.retellai.com/api-references/get-call" rel="noopener noreferrer"&gt;Get Call API&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI assistance and fact-checked against each vendor's docs.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
