# 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 them
```

> **Wait for `call.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.

`PATCH /v1/agents/{id}`

**Shell**

```bash
# 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}}'
```

> **The block replaces whole.** Sending `{"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.

**Shell**

```bash
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 |
| --- | --- | --- |
| `name` required | 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. |
| `type` optional | "string" \| "number" \| "boolean" \| "enum" | Defaults to `string`. |
| `description` required | 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. |
| `options` optional | string[] | The allowed values of an `enum`. The model picks one, or leaves the field empty when none applies. |
| `model` optional | 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.

`POST /v1/agents/{id}/post_call/test`

**Shell**

```bash
curl -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."}
    ]
  }'
```

**Response200 OK**

```json
{
  "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"
}
```

> **Test the calls you are worried about, not the clean one.** Paste in the transcript where the caller changed their mind, the one that was cut off after ten seconds, and the one where they said "yeah, maybe". A description that handles those three handles production.

## Reading the results

On the call record, once `call.analysed` has fired:

`GET /v1/calls/{id}`

**Shell**

```bash
curl -s https://voice.sphoro.com/v1/calls/$CALL_ID \
  -H "Authorization: Bearer $SPHORO_API_KEY"
```

**Response200 OK**

```json
{
  "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:

**JSON**

```json
{
  "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](https://voice.sphoro.com/docs/functions) |
| --- | --- | --- |
| 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](https://voice.sphoro.com/docs/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.
