> ## Documentation Index
> Fetch the complete documentation index at: https://docs.support.onclarity.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Call-ended webhook

> Receive a dispatched call's vCon document at your HTTPS endpoint when the call ends.

When you [dispatch a call](/voice/outbound-calls.md) with `on_call_ended_url`, Clarity sends a `POST` to that URL after the call ends. The body is the call's [vCon](https://datatracker.ietf.org/doc/draft-ietf-vcon-vcon-core/) document: who took part, every line of the transcript, the tool calls the agent made, and the fields it extracted.

## When it is sent

* Only for calls placed with `dispatch-call` and an `on_call_ended_url`. Campaign calls and inbound calls do not send it.
* After the call has ended and its record has been written. If your organization redacts personal data from calls, the vCon is written after redaction, so the webhook carries the redacted text.
* Not at all for a call that left no record. A call where the agent never produced a recording or transcript, for example because the session failed before it started, has no vCon to send.

> [!NOTE]
>
> The webhook is a notification, not a guaranteed delivery. Keep a fallback that reads conversations you have not heard back about: `GET /v1/projects/{projectId}/conversations/{conversationId}`. See [Follow the call](/voice/outbound-calls.md#follow-the-call).

## The request

| | |
| - | - |
| Method | `POST` |
| URL | Your `on_call_ended_url`, exactly as given. It must be `https`. |
| `Content-Type` | `application/json` |
| Body | The vCon document, described below. |
| Authentication | None. Clarity adds no signature and no auth header. |

Because the request is not signed, treat the URL itself as the secret: include a long random token in its path or query string, and reject requests that do not carry it. Use the `conversation_id` in the body to fetch the conversation from the API if you need to confirm it is real.

## Delivery and retries

* Each attempt waits up to **4 seconds** for your response. Answer quickly and process the body afterwards.
* Any `2xx` answer counts as delivered.
* A `4xx` answer is not retried.
* A `5xx` answer, a timeout or a network error is retried **once**, about half a second later.
* If both attempts fail, a later stage of Clarity's own call-end processing may try again. Do not rely on it.
* After a successful delivery Clarity does not send the same call again. In rare failure cases a second delivery is possible, so make your handler idempotent: the vCon `uuid` is the same for every delivery of one conversation.

## The vCon document

A trimmed example for an outbound call where the agent looked up free slots:

```json theme={null}
{
  "uuid": "c1f44047-8766-5132-8156-38ca98d2338f",
  "vcon": "0.4.0",
  "created_at": "2026-10-20T14:03:34.000Z",
  "updated_at": "2026-10-20T14:03:34.000Z",
  "parties": [
    { "tel": "+14155550123" },
    { "name": "Appointments", "uuid": "5e1d7c2a-0f4b-4b8e-8c3d-9a6f2e1b7c40" }
  ],
  "dialog": [
    {
      "type": "text",
      "start": "2026-10-20T14:02:02.000Z",
      "parties": [1],
      "originator": 1,
      "body": "Hi, this is Clarity Dental. Am I speaking with Dana?",
      "mediatype": "text/plain",
      "duration": 3,
      "message_id": "2f1219f4-24d6-5c1d-922a-2e44e2e182c6"
    },
    {
      "type": "text",
      "start": "2026-10-20T14:02:05.000Z",
      "parties": [0],
      "originator": 0,
      "body": "Yes. I need to move my appointment.",
      "mediatype": "text/plain",
      "message_id": "6a3a2e55-91ae-57b5-8b9d-ee3a0c6914d1"
    },
    {
      "type": "recording",
      "start": "2026-10-20T14:02:00.000Z",
      "parties": [0, 1],
      "duration": 94,
      "url": "gs://example-bucket/calls/3f6c1a52-8e0b-4f6e-9a1d-2b7c4e9f0a11/AJ_k3P9mQ2/recording.wav",
      "mediatype": "audio/wav"
    }
  ],
  "attachments": [
    {
      "purpose": "clarity_call_context",
      "start": "2026-10-20T14:03:34.000Z",
      "party": 0,
      "dialog": 2,
      "mediatype": "application/json",
      "body": {
        "system": "clarity-voice",
        "organization_id": 42,
        "conversation_id": "0b6f6c1e-5d0a-4f43-9a51-3e2f3b7f1a10",
        "voice_agent_ids": ["5e1d7c2a-0f4b-4b8e-8c3d-9a6f2e1b7c40"],
        "primary_agent_id": "5e1d7c2a-0f4b-4b8e-8c3d-9a6f2e1b7c40",
        "hangup_reason": "customer_hangup",
        "duration_seconds": 94,
        "language": "English (US)",
        "extracted_fields": { "new_date": "2026-10-22" }
      },
      "encoding": "none"
    }
  ],
  "analysis": [
    {
      "type": "tool_calls",
      "dialog": [0, 1],
      "vendor": "clarity-voice",
      "body": "{\"calls\":[{\"call_id\":\"call_7Hq2\",\"name\":\"find_slots\",\"start\":\"2026-10-20T14:02:10.000Z\",\"arguments\":{\"date\":\"2026-10-22\"},\"duration_ms\":812,\"result\":\"3 slots\",\"success\":true}]}",
      "encoding": "json"
    },
    {
      "type": "sentiment",
      "dialog": [0, 1],
      "vendor": "clarity-voice",
      "body": "{\"scope\":\"call\",\"subject\":\"caller\",\"level\":\"positive\"}",
      "encoding": "json"
    }
  ]
}
```

### Top level

**uuid** `string`

Derived from the conversation id, so it is the same for every delivery of one call. Use it to de-duplicate.

**vcon** `string`

The vCon syntax version.

**created\_at** `string`

When the call ended (ISO 8601). `updated_at` carries the same value.

### `parties`

The people and agents on the call, referenced by their index everywhere else in the document.

* **Index 0 is always the customer.** It has `tel` when the phone number is known. It may have a `meta` object with `contact_name` (the name a campaign item carried), `gender` (when the agent inferred it) or `customer_id` (a matched support contact). Most calls have no `meta`.
* **Indexes 1 and up are agents.** Each has `name` (the agent's display name) and `uuid` (the agent id). A call handed from one agent to another lists each agent once.

### `dialog`

The transcript, then the recording.

* Each spoken line is a `text` entry, in time order. `originator` and `parties` hold the speaker's party index, `body` holds the words, and `start` is when the line began. `duration` is the number of seconds until the next line starts; the last line has none. `message_id` is stable for the same line across deliveries.
* The last entry is the `recording`, with the call's `duration` in seconds. Its `url` is Clarity's internal storage location and cannot be downloaded. To get the audio, request a signed link from the API: see [Conversations and recordings](/voice/conversations.md#download-the-recording).

### `attachments`

One attachment, `purpose: "clarity_call_context"`. Its `body` is a JSON object (`encoding: "none"`):

| Field | Meaning |
| - | - |
| `conversation_id` | The conversation id, as returned by `dispatch-call`. |
| `organization_id` | Your organization. |
| `voice_agent_ids` | Every agent that took part. |
| `primary_agent_id` | The agent that started the call. |
| `hangup_reason` | How the call ended, as a free-form string such as `customer_hangup`. May be `null`. |
| `duration_seconds` | Call length in seconds. May be `null`. |
| `language` | The language the call was held in, as the agent's language label. May be `null`. |
| `extracted_fields` | The values the agent extracted at the end of the call, keyed by the field names of the agent's output schema. Present only when something was extracted. |
| `campaign_id`, `campaign_item_id` | Present only on campaign calls. |

### `analysis`

Each entry's `body` is a **JSON-encoded string** (`encoding: "json"`). Parse it before use. An entry appears only when it has something to say.

| `type` | `body` |
| - | - |
| `tool_calls` | `{"calls": [...]}`. Each call has `name`, `start`, `arguments`, and when it finished, `result`, `success`, `duration_ms` and `error`. |
| `sentiment` | The caller's sentiment over the whole call: `scope`, `subject`, `level` (`negative`, `neutral` or `positive`), and when available `score`, `confidence` and `probabilities`. Present only when caller sentiment is switched on for the agent. |
| `pii_redaction` | Which categories of personal data were removed from the call. Present only on redacted calls. |

### What is not in the vCon

* **The call summary.** Summaries are written after the call ends, separately from the vCon. Read `conversation_summary` from `GET /v1/projects/{projectId}/conversations/{conversationId}` once `summary_status` is `generated`.
* **Metrics** such as token counts and latency. See [Conversations and recordings](/voice/conversations.md).

## A minimal receiver

```javascript theme={null}
import express from "express";

const app = express();
app.use(express.json({ limit: "10mb" }));

app.post("/clarity/call-ended/:token", (req, res) => {
  if (req.params.token !== process.env.CLARITY_WEBHOOK_TOKEN) return res.sendStatus(404);
  res.sendStatus(204); // answer within 4 seconds

  const vcon = req.body;
  const context = vcon.attachments.find((a) => a.purpose === "clarity_call_context")?.body;
  const transcript = vcon.dialog
    .filter((d) => d.type === "text")
    .map((d) => `${d.originator === 0 ? "Customer" : "Agent"}: ${d.body}`)
    .join("\n");
  const toolCalls = vcon.analysis
    .filter((a) => a.type === "tool_calls")
    .flatMap((a) => JSON.parse(a.body).calls);

  queueForProcessing({ uuid: vcon.uuid, context, transcript, toolCalls });
});
```

`queueForProcessing` stands for your own job queue. Register the URL with the token in it, for example `https://hooks.example.com/clarity/call-ended/7b4f…`, as the `on_call_ended_url`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.