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.
Top comments (0)