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

# Webhooks

> Every event, what it carries, how it's signed and delivered.

A webhook posts the events you choose to your URL as signed JSON. Manage them
in **Developer → Webhooks & callbacks**, or through the API (`/webhooks`: list,
create, change, delete, test, deliveries, redeliver).

```bash theme={null}
curl -X POST https://api.your-host/api/v1/webhooks \
  -H "authorization: Bearer $FIRETONE_KEY" -H 'content-type: application/json' \
  -d '{ "name": "CRM", "url": "https://crm.acme.com.au/firetone/events",
        "events": ["call.answered", "call.ended", "call.missed", "ticket.created"] }'
```

The response includes the webhook's **signing secret**, shown once.

## Every delivery

```json theme={null}
{
  "id": "5b0c…",                    // the delivery id, also X-FireTone-Delivery
  "event": "call.ended",
  "occurred_at": "2026-09-15T13:50:10Z",
  "organisation_id": "f1be…",
  "data": { … }
}
```

**Headers on every request:**

* `X-FireTone-Event`
* `X-FireTone-Delivery`
* `X-FireTone-Signature: t=<unix>,v1=<hex>`
* any headers you configured on the webhook

**Delivery:**

* **Saved before it's sent.** A restart or an outage at your end doesn't lose
  an event.
* **Retries** until your receiver answers `2xx`: after 30 s, 5 min, 30 min,
  2 h and 12 h.
* **Switched off after three days of failures.** The webhook shows why, and
  your admins are emailed.
* **Send again** re-queues a delivery with the same body and the same id.

## The events

| Event                        | `data` carries                                                                                                                                                                                                                                                                           |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `call.started`               | `call_uuid`, `caller`, `destination`, `direction`; `did`/`trunk` inbound, `campaign_id` for a campaign, `reference` for an API call                                                                                                                                                      |
| `call.ringing`               | the above, plus `extension`                                                                                                                                                                                                                                                              |
| `call.answered`              | the above, plus `agent_id` when an agent took it                                                                                                                                                                                                                                         |
| `call.ended`                 | `billsec`, `duration_sec`, `hangup_cause`, `answered`, `recorded`, `sell_amount`, `billing_mode`, `destination_e164`, flow `variables`                                                                                                                                                   |
| `call.missed`                | `did`, `duration_sec`, `hangup_cause`: offered to an extension or queue, nobody answered, no voicemail                                                                                                                                                                                   |
| `voicemail.received`         | `voicemail_id`, `duration_sec`, `extension_id`/`queue_id`, `audio_url`                                                                                                                                                                                                                   |
| `recording.ready`            | `recording_url`, `duration_sec`                                                                                                                                                                                                                                                          |
| `conversation.completed`     | `conversation_id`, `summary`, `turn_count`, `flags` (e.g. a named competitor), tokens                                                                                                                                                                                                    |
| `conference.minutes`         | `session_id`, `room_id`, `room`, `agent`, `started_at`, `ended_at`, `attendees`, `minutes` (plain text), `notes` (count), `delivered_to` (emails). One per session in which a virtual agent wrote minutes; none for a session where nothing was said or the minutes could not be written |
| `callback.requested`         | `ticket_id`, `ticket_ref`, `number`, `question`, `name`, `preferred_time`, `assigned_agent_id`                                                                                                                                                                                           |
| `ticket.created`             | `ticket_id`, `ref`, `subject`, `status`, `priority`, `source`, `contact_id`, `call_uuid`                                                                                                                                                                                                 |
| `ticket.updated`             | the same, plus `change`: `fields` or `update_added` (with the `update`)                                                                                                                                                                                                                  |
| `contact.created`            | `contact_id`, `e164`, `name`, `source` (`call`, `api`, `import`)                                                                                                                                                                                                                         |
| `campaign.contact.completed` | see [Running campaigns](/api/campaigns#4-results)                                                                                                                                                                                                                                        |
| `campaign.completed`         | `contacts`, `outcomes`, `dispositions`, `why` (`finished` or `stopped`)                                                                                                                                                                                                                  |
| `csat.submitted`             | `score`, `comment`, `method`, `call_uuid`, `contact_id`, `agent_id`                                                                                                                                                                                                                      |

A call placed by a test in the IVR designer carries `"test": true`.

## Checking the signature

`v1` is the hex HMAC-SHA256 of `"<t>.<raw body>"`, keyed with the webhook's
secret. Check it against the raw bytes before parsing, and reject a `t` more
than about five minutes old.

<CodeGroup>
  ```js Node theme={null}
  const crypto = require("crypto");
  function verified(secret, header, rawBody) {
    const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
    const mac = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
    return Math.abs(Date.now() / 1000 - Number(t)) < 300 && typeof v1 === "string" &&
      v1.length === mac.length && crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(v1));
  }
  ```

  ```python Python theme={null}
  import hashlib, hmac, time
  def verified(secret: bytes, header: str, raw_body: bytes) -> bool:
      parts = dict(p.split("=", 1) for p in header.split(","))
      mac = hmac.new(secret, f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
      return abs(time.time() - int(parts["t"])) < 300 and hmac.compare_digest(mac, parts["v1"])
  ```

  ```php PHP theme={null}
  function verified(string $secret, string $header, string $rawBody): bool {
      parse_str(str_replace(',', '&', $header), $p);
      $mac = hash_hmac('sha256', $p['t'] . '.' . $rawBody, $secret);
      return abs(time() - (int)$p['t']) < 300 && hash_equals($mac, $p['v1'] ?? '');
  }
  ```
</CodeGroup>

## Webhooks or callbacks?

|                    | Webhook                                                | Callback                                                   |
| ------------------ | ------------------------------------------------------ | ---------------------------------------------------------- |
| **What it covers** | Every event of the kinds you picked, organisation-wide | Only the one call or campaign whose request named the URL  |
| **Where it's set** | Once, in the panel or `/webhooks`                      | On each request (`callback_url`, `result_callback_url`)    |
| **Signed with**    | The webhook's own secret                               | The organisation's [callback secret](/api/calls#callbacks) |

Both use the same format, retries and delivery log.

## Receiving them in order

Events go out in the order they happened, but a retried one can arrive after
later ones. If order matters, use `occurred_at`. Your receiver should also
ignore a delivery `id` it has already processed.
