Post-call analysis
When a call ends, the transcript is read and turned into two things: a short summary, and whatever typed fields you asked for. It is how a hundred calls become a hundred rows rather than a hundred transcripts nobody reads.
When it runs
call.ended the call is over — duration, turns, end_reason
│
│ a few seconds
▼
call.analysed summary, extracted, and the model that produced themcall.analysed, not
call.ended. This is the most common integration bug against this API. Code
that reads summary in a call.ended handler finds it empty every single
time and concludes the summary is broken. The two events are seconds apart and both are
delivered.The summary
On by default. A short, neutral account of what happened, written onto the call record.
/v1/agents/{id}# Off, for a line where the transcript is the record and a summary is noise.
curl -s -X PATCH https://voice.sphoro.com/v1/agents/$AGENT_ID \
-H "Authorization: Bearer $SPHORO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"post_call": {"summary": false}}'{"post_call": {"extraction": […]}} without "summary": true turns the
summary off, because the block you sent does not ask for one.Extractions
A typed field the model fills from the transcript alone, written onto the call under its own name. This is the part that makes calls queryable.
curl -s -X PATCH https://voice.sphoro.com/v1/agents/$AGENT_ID \
-H "Authorization: Bearer $SPHORO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"post_call": {
"summary": true,
"extraction": [
{
"name": "outcome",
"type": "enum",
"options": ["booked", "callback_requested", "not_interested", "wrong_number", "unclear"],
"description": "How the call ended. Use booked only if a specific date and time were agreed and read back. Use unclear if the call was cut off before an outcome."
},
{
"name": "agreed_to_pay",
"type": "boolean",
"description": "Whether the caller agreed to the payment plan. True only if they said so explicitly — a maybe, or a request to think about it, is false."
},
{
"name": "callback_number",
"type": "string",
"description": "A telephone number the caller asked to be reached on, if they gave one that is different from the number we called. Digits only, no spaces."
},
{
"name": "amount_agreed",
"type": "number",
"description": "The rupee amount the caller agreed to pay, if any. The number only, without a currency symbol."
}
]
}
}'| Field | Type | Description |
|---|---|---|
namerequired | string | The key the value is stored under on the call. Letters, digits and underscores. Stable once live — your integrations read it, so renaming one is a breaking change on your side. |
typeoptional | "string" | "number" | "boolean" | "enum" | Defaults to
string. |
descriptionrequired | string | What to look for. This is the whole of what the model reads besides the transcript, so it is the field that decides whether an extraction works. See below. |
optionsoptional | string[] | The allowed values of an enum. The
model picks one, or leaves the field empty when none applies. |
modeloptional | string | On the post_call block rather than the
field. Overrides the model the pass runs on. The default is a small fast one — this is a
reading job, not a writing one. |
Writing a description that answers reliably
The single highest-leverage thing on this page. A description is an instruction to somebody who has the transcript and nothing else — not a column header.
| Instead of | Write |
|---|---|
| "Outcome" | "How the call ended. Use booked only if a specific date and
time were agreed and read back." |
| "Did they agree?" | "Whether the caller agreed to the payment plan. True only if they said so explicitly — a maybe is false." |
| "Callback number" | "A number the caller asked to be reached on, if different from the number we called. Digits only, no spaces." |
| "Budget" | "The monthly budget the caller stated, as a number in rupees, without a currency symbol. Leave empty if they gave a range." |
Three rules that carry most of the value:
- Say what the edge cases resolve to. "A maybe is false" removes an entire class of disagreement between two people reading the same call.
- Say the format for anything you will parse. "Digits only, no spaces" is the
difference between a number you can dial and
"+91 98765 43210 (mobile)". - Add an "unclear" option to every enum. Without one, a call that was cut off early either gets an empty field or gets the model's best guess, and you cannot tell which happened.
Test it without placing a call
Paste a transcript in and see what the extractions produce. This is how you find out that a field never fires — before thirty calls have gone out with it.
/v1/agents/{id}/post_call/testcurl -s -X POST https://voice.sphoro.com/v1/agents/$AGENT_ID/post_call/test \
-H "Authorization: Bearer $SPHORO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"transcript": [
{"role": "agent", "text": "Hello, this is Acme about your outstanding balance of 4,200 rupees."},
{"role": "user", "text": "I can do two thousand now and the rest next month."},
{"role": "agent", "text": "That works. So two thousand today and the balance on the fifteenth?"},
{"role": "user", "text": "Yes, that is fine."}
]
}'{
"summary": "The caller agreed to a split payment: 2,000 rupees now and the remaining balance on the fifteenth.",
"extracted": {
"outcome": "booked",
"agreed_to_pay": true,
"amount_agreed": 2000
},
"model": "claude-haiku-4-5"
}Reading the results
On the call record, once call.analysed has fired:
/v1/calls/{id}curl -s https://voice.sphoro.com/v1/calls/$CALL_ID \
-H "Authorization: Bearer $SPHORO_API_KEY"{
"id": "call_8b21f4c9a07e3d15",
"status": "completed",
"end_reason": "ended_by_api",
"duration_seconds": 96,
"summary": "The caller agreed to a split payment…",
"extracted": {
"outcome": "booked",
"agreed_to_pay": true,
"amount_agreed": 2000,
"callback_number": ""
}
}Or as the webhook body, which is what you actually want in production:
{
"id": "evt_2f9c…",
"type": "call.analysed",
"call_id": "call_8b21f4c9a07e3d15",
"created_at": "2026-09-08T09:31:08Z",
"data": {
"summary": "The caller agreed to a split payment…",
"extracted": { "outcome": "booked", "agreed_to_pay": true, "amount_agreed": 2000 },
"model": "claude-haiku-4-5"
}
}An empty field is a real answer
A field the model could not fill comes back empty rather than guessed. Treat that as information: it usually means the call did not cover the thing, not that extraction failed.
| You see | Usually |
|---|---|
| One field empty on most calls | The conversation does not reliably reach it. Either the prompt does not ask, or the field is measuring something the call is not about. |
| Every field empty on one call | The call was too short to analyse — check
duration_seconds and end_reason. |
| An enum that always picks the same option | The options overlap, or the description does not distinguish them. Test three real transcripts against it. |
| A number arriving as text | The description did not say "the number only, without a currency symbol". |
Extraction or function?
| Extraction | Function | |
|---|---|---|
| Runs | After the call | During it |
| Can affect the conversation | No | Yes |
| Costs the caller time | None | A round trip they wait through |
| Right for | "What was the outcome", "did they agree", "what number did they give" | "What is this caller's balance" |
Most things people build a function for turn out to be extractions. If the answer does not have to be spoken back during the call, it belongs here — it is cheaper, it cannot fail the call, and it is retried on your side rather than on the caller's.
What happens to the work afterwards
Extractions produce values; workflows act on them. Booking the slot, updating the CRM, sending the confirmation email — that is the step after this one, and it runs off the same finished call.