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

# Webhooks

> Get a signed HTTPS request the moment a call ends, a message arrives or an agent is published. Verify it, dedupe it, replay it.

Webhooks push events to your server as they happen, so you never have to poll. Kallabot signs every delivery with the [Standard Webhooks](https://www.standardwebhooks.com) scheme, retries failures for about 27 hours, and keeps every event for 30 days so you can replay what you missed.

> [card] [Event catalogue](https://docs.kallabot.com/developers/events.md)
>
> Every event type, with a payload example.

> [card] [Set up in the dashboard](https://docs.kallabot.com/guides/webhooks.md)
>
> No code: add an endpoint in Settings and send a test.

## Create an endpoint

```bash curl theme={"dark"}
curl https://api.kallabot.com/v1/webhooks \
  -H "Authorization: Bearer $KALLABOT_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "url": "https://example.com/webhooks/kallabot",
    "name": "CRM sync",
    "events": ["call.ended", "call.analyzed", "conversation.*"],
    "agent_ids": [],
    "include_content": false
  }'
```

```typescript Node SDK theme={"dark"}
const endpoint = await kallabot.webhooks.create({
  url: "https://example.com/webhooks/kallabot",
  name: "CRM sync",
  events: ["call.ended", "call.analyzed", "conversation.*"],
});
console.log(endpoint.secret); // whsec_… shown once
```

```python Python SDK theme={"dark"}
endpoint = client.webhooks.create(
    url="https://example.com/webhooks/kallabot",
    name="CRM sync",
    events=["call.ended", "call.analyzed", "conversation.*"],
)
print(endpoint.secret)  # whsec_… shown once
```

| Field | Notes |
| - | - |
| `url` | Public HTTPS on port 443. Private, local and internal addresses are refused, including hostnames that resolve to them. |
| `events` | Event types, wildcards like `call.*` or `call.transfer.*`, or `*` for everything. 1 to 60 entries. A wildcard also covers events added later. |
| `agent_ids` | Only events about these agents. Empty means every agent. |
| `include_content` | `false` by default. When `true`, payloads include conversation content: message text, summaries and unmasked phone numbers. See [Content](#content-and-privacy). |
| `name`, `description` | For you. |

The response is `201` with the endpoint and its signing **`secret`** (`whsec_…`). The secret is returned only now and when you rotate it; store it somewhere safe.

```json theme={"dark"}
{
  "id": "3f2e1d0c-9b8a-4c7d-a6e5-f4d3c2b1a090",
  "url": "https://example.com/webhooks/kallabot",
  "name": "CRM sync",
  "status": "active",
  "events": ["call.analyzed", "call.ended", "conversation.*"],
  "agent_ids": [],
  "include_content": false,
  "api_version": "2026-11-01",
  "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
  "secret_hint": "…aSw",
  "secret_format": "standard",
  "revision": 1,
  "created_at": "2026-11-07T10:20:00Z",
  "updated_at": "2026-11-07T10:20:00Z"
}
```

## What a delivery looks like

Each event is one `POST` with a JSON body:

```http theme={"dark"}
POST /webhooks/kallabot HTTP/1.1
Content-Type: application/json
User-Agent: Kallabot-Webhooks/2.0 (+https://kallabot.com/docs/webhooks)
webhook-id: evt_4b1f0c9e2d8a4e57b6c3a1f09e7d2c48
webhook-timestamp: 1762511591
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

```json theme={"dark"}
{
  "id": "evt_4b1f0c9e2d8a4e57b6c3a1f09e7d2c48",
  "type": "call.analyzed",
  "api_version": "2026-11-01",
  "timestamp": "2026-11-07T10:33:11.402Z",
  "data": {
    "call_id": "call_5f0a1c9e2b7d4a8e3c6f1b0d",
    "agent_id": "8f0e4c1a-3b7d-4e2f-9a61-2c5d8b7e1f30",
    "disposition": "booked",
    "sentiment_label": "positive",
    "callback_at": null,
    "needs_review": false
  }
}
```

| Field | Meaning |
| - | - |
| `id` | The event id, the same as the `webhook-id` header. Stable across retries: **dedupe on it**. |
| `type` | The event type. |
| `api_version` | The payload version, currently `2026-11-01`. See [Versioning](https://docs.kallabot.com/developers/versioning.md). |
| `timestamp` | When it happened, in UTC. |
| `data` | The event's payload. See the [catalogue](https://docs.kallabot.com/developers/events.md). |
| `test` | `true` only on test deliveries. |

## Verify the signature

Always verify before trusting a delivery. The signature is an HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of your secret after `whsec_`, then base64-encoded and prefixed `v1,`.

> [!WARNING]
>
> Verify against the **raw** request body, exactly as received. Parsing the JSON and re-serializing it changes the bytes and breaks the signature.

### With the Kallabot SDK

The [SDKs](https://docs.kallabot.com/developers/sdks.md) verify the signature and timestamp and return a typed event.

```typescript Node SDK theme={"dark"}
import { Webhooks, WebhookVerificationError } from "@kallabot/sdk";

export async function POST(request: Request) {
  const raw = await request.text(); // the raw body
  try {
    const event = await Webhooks.verify(raw, request.headers, process.env.KALLABOT_WEBHOOK_SECRET!);
    if (event.type === "call.ended") await saveCall(event.data.call_id, event.data.talk_ms);
    return new Response(null, { status: 204 });
  } catch (err) {
    if (err instanceof WebhookVerificationError) return new Response(err.message, { status: 400 });
    throw err;
  }
}
```

```python Python SDK theme={"dark"}
import kallabot
from fastapi import FastAPI, Request, Response
from kallabot.webhooks import CallEndedEvent

app = FastAPI()
client = kallabot.Kallabot()  # reads KALLABOT_WEBHOOK_SECRET too

@app.post("/webhooks/kallabot")
async def kallabot_webhook(request: Request) -> Response:
    try:
        event = client.webhooks.unwrap(await request.body(), request.headers)
    except kallabot.WebhookVerificationError:
        return Response(status_code=400)
    if isinstance(event, CallEndedEvent):
        save_call(event.data.call_id, event.data.talk_ms)
    return Response(status_code=204)
```

### With the Standard Webhooks library

Standard Webhooks publishes libraries for most languages. They check the signature and the timestamp for you.

```javascript Node theme={"dark"}
// npm install standardwebhooks
import { Webhook } from "standardwebhooks";
import express from "express";

const wh = new Webhook(process.env.KALLABOT_WEBHOOK_SECRET); // "whsec_…"
const app = express();

app.post("/webhooks/kallabot", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = wh.verify(req.body, {
      "webhook-id": req.header("webhook-id"),
      "webhook-timestamp": req.header("webhook-timestamp"),
      "webhook-signature": req.header("webhook-signature"),
    });
  } catch {
    return res.sendStatus(400);
  }
  queue.add(event); // do the work later
  res.sendStatus(204);
});
```

```python Python theme={"dark"}
# pip install standardwebhooks
import os
from fastapi import FastAPI, Request, Response
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

wh = Webhook(os.environ["KALLABOT_WEBHOOK_SECRET"])  # "whsec_…"
app = FastAPI()

@app.post("/webhooks/kallabot")
async def kallabot(request: Request):
    body = await request.body()
    try:
        event = wh.verify(body, dict(request.headers))
    except WebhookVerificationError:
        return Response(status_code=400)
    queue.put(event)  # do the work later
    return Response(status_code=204)
```

### Without a library

```javascript Node theme={"dark"}
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verifyKallabot(rawBody, headers, secret) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const header = headers["webhook-signature"];
  if (!id || !timestamp || !header) throw new Error("Missing signature headers");

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) throw new Error("Timestamp too old");

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.`).update(rawBody).digest();

  // During a secret rotation the header holds two signatures, space separated.
  const valid = header.split(" ").some((part) => {
    const [version, signature] = part.split(",");
    if (version !== "v1" || !signature) return false;
    const given = Buffer.from(signature, "base64");
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
  if (!valid) throw new Error("Bad signature");
  return JSON.parse(rawBody.toString("utf8"));
}
```

```python Python theme={"dark"}
import base64, hashlib, hmac, json, time

TOLERANCE_SECONDS = 5 * 60

def verify_kallabot(raw_body: bytes, headers, secret: str) -> dict:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    header = headers.get("webhook-signature")
    if not (msg_id and timestamp and header):
        raise ValueError("Missing signature headers")

    if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
        raise ValueError("Timestamp too old")

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    # During a secret rotation the header holds two signatures, space separated.
    for part in header.split(" "):
        version, _, signature = part.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return json.loads(raw_body)
    raise ValueError("Bad signature")
```

Reject deliveries older than five minutes: it stops someone replaying a captured request later.

## Respond fast, work later

* Answer with any **2xx** status within **15 seconds**. Anything else, a timeout or a redirect counts as a failure. Redirects are not followed.
* Put the event on a queue and return straight away. Don't make the call to your CRM before responding.
* The same event can arrive more than once (after a timeout, for example). Store `webhook-id` and skip ids you've already handled.
* Events can arrive out of order. Use `timestamp`, or the `revision` in call events, to keep the newest state.

## Retries

A failed delivery is retried on this schedule, about 27 hours in all:

| Attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| - | - | - | - | - | - | - | - | - |
| Wait before it | now | 5 s | 5 min | 30 min | 2 h | 5 h | 10 h | 10 h |

* If you answer `429` or `503` with a `Retry-After` header, the next attempt waits that long instead (up to an hour).
* Answering **`410 Gone`** switches the endpoint off straight away. Use it when an endpoint is retired for good.
* An endpoint that has failed continuously for **five days** is switched off (`status: auto_disabled`), and the workspace owners get an email.
* After the last attempt, the delivery is marked `dead`. Nothing is lost: events are kept for 30 days.

## Replay and recover

Every delivery and every attempt (status code, timing, the first 2 KB of your response) is kept and readable:

| Endpoint | Use |
| - | - |
| `GET /v1/webhooks/deliveries` | Recent deliveries, newest first. Filter by `endpoint_id`, `status` (`pending`, `retrying`, `delivered`, `dead`, `canceled`) and `event_type`. |
| `GET /v1/webhooks/deliveries/{delivery_id}` | One delivery with all its attempts. |
| `POST /v1/webhooks/deliveries/{delivery_id}/retry` | Send one delivery again now. |
| `POST /v1/webhooks/{endpoint_id}/recover` | Re-send every failed delivery since a time: `{"since": "2026-11-06T00:00:00Z"}`. Up to 1,000 at once. |
| `POST /v1/webhooks/{endpoint_id}/enable` | Turn a switched-off endpoint back on, with its failure count reset. |

After an outage: fix the endpoint, call `enable`, then `recover` from when it started failing.

## Test an endpoint

`POST /v1/webhooks/{endpoint_id}/test` sends one signed event now and returns the delivery with its attempt, so you can see exactly what your server answered. Pass `{"event_type": "call.ended"}` to get that event's sample payload; leave it out for a plain `webhook.test`. Test deliveries have `"test": true` and are never retried.

## Rotate the secret

`POST /v1/webhooks/{endpoint_id}/rotate-secret` returns a new `secret`. For `grace_hours` (default 24, up to 72) **both** secrets sign every delivery, so the `webhook-signature` header has two entries. Deploy the new secret within that window and nothing breaks. Send `{"grace_hours": 0}` to drop the old secret at once if it leaked.

> [!NOTE]
>
> Endpoints created before Standard Webhooks show `secret_format: "legacy"`. Rotate their secret once so the official libraries can load it.

## Content and privacy

By default, webhook payloads carry ids, statuses and masked numbers, and **no conversation content**. Each event lists its content fields in the catalogue, such as the message `text`, a call `summary`, or `from_number` and `to_number`. They're only sent to endpoints with `include_content: true`.

Turn content on only for endpoints you control, over a connection you trust. Changes to `include_content` are recorded in the workspace's audit log.

## Manage endpoints

| Endpoint | Use |
| - | - |
| `GET /v1/webhooks` | Every endpoint, with its health: `consecutive_failures`, `failing_since`, `last_success_at`. |
| `PATCH /v1/webhooks/{endpoint_id}` | Change `url`, `events`, `agent_ids`, `include_content`, `name`, or set `status` to `active` or `disabled`. Include the endpoint's current `revision` in the body. |
| `DELETE /v1/webhooks/{endpoint_id}` | Delete the endpoint with its delivery history. |
| `GET /v1/webhooks/event-types` | The live event catalogue with JSON Schemas and samples. |

Reading needs `webhook:read`; every change needs `webhook:write`.


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