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

# Webhooks

> Receive call and conversation analysis events in your CRM, automation tool or backend, signed in the Standard Webhooks format.

Workspace webhooks send an HTTPS `POST` to your server when something happens in
the workspace, such as a call ending. Add endpoints in
**Workspace settings → Webhooks**. Owners and members can add, edit, rotate and
delete them; owners, members and viewers can read the delivery log.

## Conversation initiation

The same page has a separate **Conversation initiation client data webhook**
card. It sets the default endpoint agents can use to look up caller context
before a phone conversation starts. Each agent must turn it on in **Settings →
Security**, where it can also override the endpoint.
Initiation waits for a JSON response; it does not use the event delivery queue
or the event signing secret. See [Personalization](https://cntillion.com/docs/agents/personalization.md) for
the request, response, headers, override permissions and failure behavior.

## Event endpoints

Each endpoint has:

* **URL**: a public HTTPS address on port 443. Private and local addresses are
  refused, and the address is checked again before every delivery.
* **Events**: the event types it receives. Leave every box empty to receive all
  events, including types added later.
* **Enabled**: a disabled endpoint receives nothing. Deliveries queued for it
  while it is disabled fail and can be resent later.
* **Signing secret**: a `whsec_` secret shown once, when the endpoint is created
  or the secret is rotated.

A workspace can have 10 endpoints.

## Events

Every request body is one event:

```json theme={null}
{
  "type": "call.ended",
  "version": 1,
  "timestamp": "2026-10-16T12:01:35.000Z",
  "data": { }
}
```

`version` changes only for a breaking change to the envelope or an event's
`data`. New fields can appear in `data` at any time, so ignore fields you do not
know.

### `call.ended`

Sent once per call, after the call is closed and, for outbound calls, after its
outcome is read from the transcript. A call that closes without the agent
reporting it, for example one that dropped, is sent within about ten minutes.
It reaches only endpoints that were enabled and subscribed to the event when
the call ended: turning an endpoint back on, or adding an event to its filter,
never replays calls from before the change.

```json theme={null}
{
  "type": "call.ended",
  "version": 1,
  "timestamp": "2026-10-16T12:01:35.000Z",
  "data": {
    "callId": "cm2abc123",
    "agentId": "cm1agent",
    "direction": "outbound",
    "from": "+17625550142",
    "to": "+14155550134",
    "startedAt": "2026-10-16T12:00:00.000Z",
    "endedAt": "2026-10-16T12:01:35.000Z",
    "durationSeconds": 95,
    "resolution": "resolved",
    "outcome": "interested",
    "numberOnDoNotCallList": false,
    "dialOutcome": "answered",
    "summary": "Asked for a quote for the annual plan.",
    "variables": { "budget": 1200, "decision": "yes" },
    "transcriptUrl": "https://app.cntillion.com/api/v2/agents/cm1agent/conversations/voice-123/messages",
    "campaignId": "cm3campaign",
    "contactId": "cm4contact"
  }
}
```

| Field | Description |
| - | - |
| `direction` | `inbound` or `outbound`. |
| `from`, `to` | E.164 numbers. For an inbound call `from` is the caller and `to` is your line; for an outbound call it is the other way round. `null` for calls from the website. |
| `durationSeconds` | Connected time. `0` for an outbound call nobody answered. |
| `resolution` | `resolved`, `escalated`, `handed_off`, `missed` or `failed`. |
| `outcome` | Outbound calls only: what the conversation achieved, for example `interested`, `callback_requested` or `do_not_call`, or the dial result in lower case when nobody answered. `null` for inbound calls. |
| `numberOnDoNotCallList` | Outbound calls only: whether the number the call reached is on your do-not-call list when the event is sent. A person on a wrong number who asks not to be called puts the number on the list while `outcome` reads `wrong_person`, so read this field, not `outcome`, to keep your own suppression list. `null` for inbound calls, and for an outbound call with no number to check: inbound callers who opt out on a call are not reported here. |
| `dialOutcome` | Outbound calls only: `answered`, `voicemail`, `no_answer`, `busy`, `declined`, `failed` or `rate_limited`. |
| `summary` | The call summary, or `null`. |
| `variables` | The workflow variables the call collected, by name. Values are strings, numbers, booleans or `null`. |
| `transcriptUrl` | The transcript in the [developer API](https://cntillion.com/docs/api-reference/developer-api.md). Request it with a workspace API key. `null` when the call has no transcript. |
| `campaignId`, `contactId` | The campaign that placed the call and the contact called, or `null`. |

### `call.transferred`

Sent once when a teammate joins a call and the agent stops talking.

```json theme={null}
{
  "type": "call.transferred",
  "version": 1,
  "timestamp": "2026-10-16T12:00:30.000Z",
  "data": {
    "callId": "cm2abc123",
    "agentId": "cm1agent",
    "direction": "inbound",
    "transferredAt": "2026-10-16T12:00:30.000Z",
    "destination": { "type": "teammate", "id": "cm5member", "name": "Dana" },
    "mode": "teammate_join",
    "outcome": "connected"
  }
}
```

### `conversation.analyzed`

Sent after extraction, sentiment, topics and search indexing finish for a
conversation. The result and event are committed together. A worker retry
cannot queue the same event twice; a manual rerun creates a new analysis ID.
Only enabled endpoints subscribed at completion receive the event.

```json theme={null}
{
  "type": "conversation.analyzed",
  "version": 1,
  "timestamp": "2026-10-26T12:01:35.000Z",
  "data": {
    "agentId": "cm1agent",
    "conversationId": "cm2conversation",
    "source": "CONVERSATION_LOG",
    "analysisId": "cm3analysis",
    "dataPointCount": 3,
    "sentiment": "Positive",
    "topics": ["Refunds"]
  }
}
```

`source` is `CONVERSATION_LOG` or `CHANNEL_CONVERSATION`. `conversationId` is
Centillion's stored conversation ID, not the external chat session ID. Read
values, rationales and trajectory from
`GET /api/v2/agents/{agentId}/analysis/{conversationId}?source={source}`
with a workspace API key. This event contains a summary, not the transcript or
all extracted values.

### `conversation.evaluated`

Sent after one criterion finishes. Data includes `agentId`, `conversationId`,
`source`, `evaluationId`, `criterionId`, `criterionName` and `rationale`.
Binary criteria include `result` (`success`, `failure` or `unknown`). Numeric
criteria include `score`, which is `null` when the evidence is insufficient.
An empty transcript returns `result: "unknown"`. Each evaluation ID is emitted
once; a manual rerun creates a new ID. The analysis endpoint returns the
scoring configuration and latest result for every criterion.

## Headers and signatures

Requests follow the [Standard Webhooks](https://www.standardwebhooks.com)
specification, so any of its open-source verification libraries works.

| Header | Value |
| - | - |
| `webhook-id` | The delivery's ID. It stays the same on every retry and resend, so use it to ignore duplicates. |
| `webhook-timestamp` | Unix time in seconds of this attempt. |
| `webhook-signature` | `v1,` followed by the base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{body}`. Several signatures are separated by spaces. |

The HMAC key is the part of the secret after `whsec_`, base64-decoded. Verify the
raw body exactly as received, before parsing it, and reject timestamps more than
five minutes from your clock.

```js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyWebhook(headers, rawBody, secret) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) {
    return false;
  }
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return false;
  }
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest();
  return signatures.split(" ").some((signature) => {
    const [version, value] = signature.split(",");
    const received = Buffer.from(value ?? "", "base64");
    return (
      version === "v1" &&
      received.length === expected.length &&
      timingSafeEqual(received, expected)
    );
  });
}
```

## Rotating the secret

**Rotate secret** issues a new secret at once. For the next 24 hours every
request is signed with both the new and the previous secret, so a receiver that
holds either one keeps accepting deliveries while you update it. After 24 hours
only the new secret signs. Rotating again within those 24 hours replaces the
previous secret with the one you are rotating away from, so the oldest secret
stops signing at once.

## Retries and the delivery log

Answer with any `2xx` status within 8 seconds. A timeout, a network error, `408`,
`425`, `429` or any `5xx` is retried after 1 minute, 5 minutes, 30 minutes, 2
hours, 5 hours, 10 hours, 14 hours, 20 hours and 20 hours: ten attempts over
about three days. A `Retry-After` header on a retried status is honoured, up to
6 hours. Any other status, such as `400` or `410`, is not retried.

The log under the endpoints lists the last 30 days of deliveries for the selected
endpoint: event, status, attempts and the status code your server returned.
**Resend** sends a delivered or failed event again with the same `webhook-id` and
body. If that attempt fails with a retried status, the event keeps retrying on
the schedule above until the delivery has used its ten attempts; a delivery
that already used them gets exactly one more.


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