> This page is for Cloud Call Center.

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.hams.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.hams.ai/_mcp/server.

# Webhooks

> Receive call events at your own URL, verify them, and replay the ones that matter.

A webhook is an HTTPS endpoint of yours. It receives a `POST` for each call event that you
subscribe to. To create a webhook, use **Developer** › **Webhooks**. You can also use the
API with the `webhooks:manage` scope.

![The Add webhook drawer has a name, an https endpoint, and the events to subscribe to, in guaranteed and best-effort groups.](/_fern-img/a45914312570f92653dddca29b0733b48730399f057f2a297bb3f10620d56d91.webp)

## Event tiers

Events have 2 tiers. The tier decides what you can build on an event.

| Tier        | Events                                                              | Promise                                                                                                                                                                               |
| ----------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Guaranteed  | `call.completed`, `call.recording_ready`, `call.analysis_completed` | The system records the event before it sends it. It retries with backoff, keeps a dead delivery after the last attempt, and can replay it. You can build a write-back on these events |
| Best-effort | `call.started`, `call.answered`, `call.transferred`, `call.ended`   | The system sends the event once from the live call. It never retries and never records the event. The absence of an event means nothing                                               |

A best-effort event is a hint about a call in progress. Use it to update a screen. Do not use it to
update a record. The record of a call is `call.completed`. Reconcile against `call.completed` or
against `GET /cdrs`. Never reconcile against the best-effort tier.

| Event                     | When it fires                                                                                                      |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `call.started`            | A call begins                                                                                                      |
| `call.answered`           | A party answers                                                                                                    |
| `call.transferred`        | A transfer completes or fails                                                                                      |
| `call.ended`              | The call ends                                                                                                      |
| `call.completed`          | The system seals the record of the call, a few seconds after the call ends                                         |
| `call.recording_ready`    | The system finishes the recording of the call                                                                      |
| `call.analysis_completed` | The post-call analysis of the call gives a result. See [Post-call analysis](/cloud-call-center/post-call-analysis) |

## Create a subscription

```bash
curl -X POST https://telephony.hams.ai/api/developer/v1/webhooks \
  -H "Authorization: Bearer $CCC_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "crm-writeback",
    "url": "https://crm.example.com/hooks/calls",
    "event_types": ["call.completed", "call.recording_ready"]
  }'
```

The `201` response has the endpoint and a `secret`. The `secret` is the HMAC key for the signature of
the endpoint. The response shows it one time. The dashboard also shows it one time, on its confirmation
screen. Store it now.

The URL must start with `https://`. Use `PATCH /webhooks/{id}` to change the name, the URL, the
events, or `is_active`. While an endpoint is inactive, the system records no deliveries for it. When
you activate it again, the deliveries that are already queued resume. `DELETE /webhooks/{id}` removes
the endpoint and its queued deliveries. `GET /webhooks` lists your subscriptions.

## The envelope

Every delivery, in either tier, is one JSON body:

```json
{
  "id": "0192a1b2-3c4d-7e5f-8a9b-0c1d2e3f4a5b",
  "type": "call.answered",
  "version": "2026-09-22",
  "created_at": "2026-09-22T10:00:05.120000+00:00",
  "occurred_at": "2026-09-22T10:00:05.004000+00:00",
  "data": { }
}
```

| Key           | Meaning                                                                                                |
| ------------- | ------------------------------------------------------------------------------------------------------ |
| `id`          | The id is the same for retries and replays. Deduplicate on it                                          |
| `type`        | One of the event names above                                                                           |
| `version`     | The contract that the body follows. It changes only for a change that an existing parser cannot accept |
| `created_at`  | The time when the system created this delivery                                                         |
| `occurred_at` | The time when the fact that the event describes happened                                               |
| `data`        | The payload of the event                                                                               |

A new key in `data` or a new event type does not change `version`. Read the keys that you need and
ignore the others.

## Verify a delivery

Every request has a signature header:

```http
X-CCC-Signature: t=1787913600,v1=3f9c…
```

`v1` is `HMAC-SHA256("<t>.<raw body>")` with the secret of the endpoint as the key. It is hex
encoded. Compute it over the raw bytes that you received. Do not use a re-serialized body. Compare
with a constant-time comparison. Reject a `t` that is older than your tolerance. The timestamp is in
the signed string, so nobody can replay a captured request later.

```python
import hashlib, hmac, time

def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    signed = f"{parts['t']}.".encode() + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    fresh = abs(time.time() - int(parts["t"])) <= tolerance
    return fresh and hmac.compare_digest(expected, parts["v1"])
```

Answer with a `2xx` status quickly. Do the work after you answer. Any other status, or a timeout,
counts as a failed attempt in the guaranteed tier.

## Payloads

The `data` of every `call.*` lifecycle event starts with the same 5 keys:

```json
{
  "call_id": "0192...",
  "direction": "inbound",
  "from": { "number": "+2010000000", "type": "external" },
  "to": { "number": "1000", "type": "did" },
  "started_at": "2026-09-22T10:00:00+00:00"
}
```

`direction` is `inbound` or `outbound`. In `call.completed`, it can also be `internal`. A party `type` is `external`, `did`, or `extension`. For a call
that the API places, the live events show `from` as the extension and `to` as the dialed number.
`call_id` is the `id` of the call in call history. `call.completed` shows the numbers of the sealed record. See [Outbound calls](/cloud-call-center/api/outbound-calls).

A best-effort event for a campaign call also has the field `campaign_id`, after the 5 keys. The event `call.completed` does not have it. See [Campaigns](/cloud-call-center/campaigns).

Each event then adds its own fields.

### `call.started`

| Field     | Meaning                                                          |
| --------- | ---------------------------------------------------------------- |
| `flow_id` | The call flow that the call entered. `null` for an outbound call |

### `call.answered`

| Field         | Meaning                                                                                                                      |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `answered_at` | The time when the party answered                                                                                             |
| `party`       | `{ kind, extension, user_id, name, destination }`. `kind` is `agent` or `external`. A field is `null` when it does not apply |

An agent party has `extension`. It also has `user_id` and `name` when the extension belongs to a
user. An answer from a queue has the extension only. An external party has `destination`. An example
is the number that an API-placed call dialed.

### `call.transferred`

| Field            | Meaning                                                                                                                             |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `status`         | `completed` or `failed`                                                                                                             |
| `failure_reason` | `chain_cap` (too many transfers on one call) or `unresolved` (the target does not exist). `null` when the transfer completed        |
| `from_extension` | The agent who asked for the transfer, when known                                                                                    |
| `target`         | `{ kind, number }`. `kind` is `extension` or `queue`. If the transfer failed, `kind` is `null` and `number` is the requested number |
| `mode`           | `blind` or `attended`                                                                                                               |

### `call.ended`

| Field         | Meaning                                                                                                             |
| ------------- | ------------------------------------------------------------------------------------------------------------------- |
| `status`      | `completed`, `interrupted`, or `failed`                                                                             |
| `outcome`     | `answered`, `no_answer`, `busy`, `abandoned`, `canceled`, `failed`, or `voicemail`. The sealed record can refine it |
| `answered_at` | The time when the call was first answered, or `null`                                                                |
| `ended_at`    | The time when the call ended                                                                                        |

### `call.completed`

This event is the sealed record. It has the core, then the outcome and the durations that reports use:

```json
{
  "call_id": "0192...",
  "direction": "inbound",
  "from": { "number": "+2010000000", "type": "external" },
  "to": { "number": "1000", "type": "did" },
  "outcome": "answered",
  "outcome_detail": "queue",
  "started_at": "2026-09-22T10:00:00+00:00",
  "answered_at": "2026-09-22T10:00:09+00:00",
  "ended_at": "2026-09-22T10:02:12+00:00",
  "total_seconds": 132,
  "talk_seconds": 118,
  "queue_wait_seconds": 9,
  "agent": { "user_id": "0190...", "extension": "2001", "name": "Ada" }
}
```

`outcome_detail` is `external` when a trunk leg to an external system decided the call. A call that a
voice agent handled has `outcome: answered` and `outcome_detail: voice_agent`. See
[Call history and recordings](/cloud-call-center/api/call-history-and-recordings) for all values.

### `call.recording_ready`

This event is not a call lifecycle event, so it does not carry the core. `call_id` is `null` when the
system cannot match the recording to a call.

```json
{
  "recording_id": "0192...",
  "call_id": "0192...",
  "duration_ms": 118420,
  "size_bytes": 1894720,
  "recording_url": "https://telephony.hams.ai/api/v1/recordings/public/<token>/audio",
  "recording_url_expires_at": "2026-09-23T10:04:00+00:00"
}
```

| Field                      | Meaning                                                                                                         |
| -------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `recording_url`            | A public link to the stereo WAV. It needs no credentials. It is `null` when the service has no public base URL  |
| `recording_url_expires_at` | The time when the link expires. It is `null` when `recording_url` is `null`. The link lasts 24 hours by default |

> **Warning**
>
> Download the recording before `recording_url_expires_at`. After that time, the link returns 404.

A replay sends the original link without a change. After the link expires, use
`GET /recordings/{recording_id}/audio` with your API key. See
[Call history and recordings](/cloud-call-center/api/call-history-and-recordings).

### `call.analysis_completed`

This event is not a call lifecycle event, so it does not carry the core. The system sends it when the
post-call analysis of a call gives a result. A failed or skipped analysis sends no event.

```json
{
  "call_id": "0192...",
  "analysis_id": "0192...",
  "attempt": 1,
  "profile": { "id": "0192...", "name": "Support calls" },
  "summary": "The customer asked about a late order. The agent gave a new delivery date.",
  "outcome": "resolved",
  "sentiment_start": "negative",
  "sentiment_end": "positive",
  "contact_reason": "The customer called about a late order.",
  "agent_score": 4,
  "agent_score_reason": "The agent was polite and solved the problem quickly.",
  "labels": ["Delivery"]
}
```

| Field                              | Meaning                                                                                                                                                                             |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `attempt`                          | The number of the analysis of this call. Each new analysis of the same call sends a new event with a higher `attempt`. Keep the event with the highest `attempt` for each `call_id` |
| `profile`                          | The profile that made the result. `id` is `null` when someone deleted the profile                                                                                                   |
| `summary`                          | 2 or 3 sentences about the call, in the language of the profile                                                                                                                     |
| `outcome`                          | `resolved`, `unresolved`, `escalated`, or `customer_silent`                                                                                                                         |
| `sentiment_start`, `sentiment_end` | The mood of the customer at the start and at the end: `very_negative`, `negative`, `neutral`, `positive`, or `very_positive`                                                        |
| `contact_reason`                   | 1 sentence that tells why the customer called                                                                                                                                       |
| `agent_score`                      | 1 to 5 for the human agent. `null` when no human agent spoke                                                                                                                        |
| `agent_score_reason`               | 1 sentence that gives the reason for the score. `null` when `agent_score` is `null`                                                                                                 |
| `labels`                           | The labels from the profile that apply to the call. The list can be empty                                                                                                           |

`occurred_at` in the envelope is the time when the analysis completed.

## Deliveries, retries, and replay

The guaranteed deliveries are in **Developer** › **Events** and at
`GET /webhooks/deliveries`. The newest delivery is first. Each delivery shows the body that the
system sent, the attempt count, the next attempt time, and the last error. You can filter by endpoint
and by status: `pending`, `delivered`, or `dead`.

![The Events page shows the status, attempt count, next attempt, and last error of each guaranteed delivery.](/_fern-img/589b9a43481b775209b1bd890a1dcead6a068779300e0ef8716c3646651a6eb8.webp)

The system retries a failed attempt with a longer delay each time. After the last attempt, the system marks
the delivery as `dead` and keeps it. To send it again, click **Replay** in the drawer, or use
`POST /webhooks/deliveries/{id}/replay`. The system sends the original body again with the same event
`id`. If you deduplicate on `id`, you see 1 event and not 2.

Best-effort events never show in this list. An endpoint that subscribes only to best-effort events has
an empty Events page. The page is empty on purpose.

## Related

* [Call history and recordings](/cloud-call-center/api/call-history-and-recordings)
* [Post-call analysis](/cloud-call-center/post-call-analysis)
* [Quickstart](/cloud-call-center/api/quickstart)
* [Errors](/cloud-call-center/api/errors)
* [API Reference](/cloud-call-center/reference)