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.
TL;DR: 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 call_analyzed (not call_ended), 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.
What Retell actually gives you after a call
When a call finishes, Retell sends webhook events in order. Two matter here:
-
call_ended: every field of the call object exceptcall_analysis. -
call_analyzed: the full call data includingcall_analysis.
If your pipeline writes KPIs on call_ended, it will see an empty analysis and count nothing. Write the raw call on call_ended if you want it early, then upsert the analysis on call_analyzed.
The call_analysis object (per the Get Call API reference) contains:
| Field | Type | Notes |
|---|---|---|
call_summary | string | A short summary of the call |
in_voicemail | boolean | Whether the call reached voicemail |
user_sentiment | enum | Positive, Neutral, Negative, Unknown |
call_successful | boolean | Whether the agent appears to have finished its task |
custom_analysis_data | object | Your own fields, extracted from the transcript. Can be empty. |
Alongside it, the call object carries the fields you need for reporting: agent_id, start_timestamp and end_timestamp (ms since epoch), duration_ms, disconnection_reason, recording_url, metadata, retell_llm_dynamic_variables and call_cost.
Treat the built-in fields with care
The built-ins are useful, but they answer generic questions.
-
call_successfulmeans "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. -
user_sentimentis a model's read of tone. Fine as a filter for which calls a human should review, weak as a headline number. -
in_voicemailis 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. -
call_summarybelongs in a call list, not in a KPI.
Client-facing KPIs should come from custom fields you define.
Designing custom fields as a schema
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: Text, Selector, Boolean, Number. The description is effectively a prompt for extraction, so write it as a definition with an explicit rule for edge cases.
A set that covers most receptionist agents:
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. Rules that keep extraction reliable:
- Prefer Selector and Boolean over Text. You can group by an enum; you cannot group by free text.
- Define the negative case. "False if the caller only asked about availability" prevents the most common false positive.
- One question per field. A field like
outcome_and_next_stepproduces prose you cannot aggregate. - Allow empty. For numbers especially, an explicit "leave empty" beats a hallucinated value in a revenue chart.
- Version the schema. When you change a description, record the date. KPI trends across a definition change are not comparable.
Receiving call_analyzed safely
Retell signs each webhook with the x-retell-signature header. Verify it against the raw 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.
import express from "express"; import Retell from "retell-sdk"; const app = express(); app.post( "/webhooks/retell", express.raw({ type: "application/json" }), async (req, res) => { const raw = req.body.toString("utf8"); const signature = req.header("x-retell-signature") ?? ""; if (!Retell.verify(raw, process.env.RETELL_API_KEY!, signature)) { return res.status(401).end(); } const { event, call } = JSON.parse(raw); res.status(204).end(); // acknowledge inside the 10s window if (event === "call_analyzed") { await upsertAnalysis(call); // idempotent on call.call_id } } ); 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 agent_id to client, or a client_id you put in metadata when you create outbound calls. Never trust a client identifier taken from the transcript.
A table you can aggregate
Flatten the analysis into columns. JSON blobs are fine for the raw payload, but reporting queries should hit typed columns.
create table call_analysis ( call_id text primary key, client_id text not null, agent_id text not null, started_at timestamptz not null, duration_ms integer, disconnection_reason text, in_voicemail boolean, user_sentiment text, call_successful boolean, appointment_booked boolean, call_intent text, lead_quality text, escalated_to_human boolean, estimated_job_value numeric, schema_version integer not null, raw jsonb not null ); create index on call_analysis (client_id, started_at); async function upsertAnalysis(call: any) { const a = call.call_analysis ?? {}; const c = a.custom_analysis_data ?? {}; await db.query( `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`, [call.call_id, clientFor(call), call.agent_id, call.start_timestamp, call.duration_ms, call.disconnection_reason, a.in_voicemail ?? null, a.user_sentiment ?? null, a.call_successful ?? null, c.appointment_booked ?? null, c.call_intent ?? null, c.lead_quality ?? null, c.escalated_to_human ?? null, c.estimated_job_value ?? null, SCHEMA_VERSION, call] ); } The on conflict clause matters: a retried webhook must not create a second row.
From rows to client KPIs
Once the columns exist, a month of reporting is a handful of queries:
select count(*) filter (where call_intent <> 'spam') as real_calls, count(*) filter (where appointment_booked) as bookings, round(100.0 * count(*) filter (where appointment_booked) / nullif(count(*) filter (where call_intent in ('new_booking','reschedule')), 0), 1) as booking_rate_pct, count(*) filter (where escalated_to_human) as handed_to_staff, count(*) filter (where extract(hour from started_at at time zone 'America/New_York') not between 9 and 16) as after_hours_calls from call_analysis where client_id = $1 and started_at >= date_trunc('month', now() - interval '1 month') and started_at < date_trunc('month', now()); 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.
Test the extraction before a client relies on it
Analysis fields are model output, so test them like model output:
- Make a set of 15 to 20 test calls that cover each
call_intentvalue plus awkward cases: a caller who asks about times and hangs up, a reschedule that becomes a cancel, a spam call. - Write down the expected value of every field for each call before you look at the results.
- Compare. Any field that is wrong more than once gets a sharper description, then re-run the set.
- Spot check a few real calls each week after launch, sorted by
user_sentiment = 'Negative'first.
Checklist
- [ ] KPIs written on
call_analyzed, notcall_ended - [ ] Signature verified on the raw body
- [ ] Idempotent upsert keyed on
call_id - [ ] Every custom field has a type, a definition and a negative case
- [ ] Client mapping from
agent_idormetadata, never from the transcript - [ ] Spam and voicemail excluded from rates
- [ ] Schema version stored with every row
References: Retell docs on post-call analysis, webhooks and the Get Call API.
Drafted with AI assistance and fact-checked against each vendor's docs.
