> This page is part of Smallest AI's developer documentation. When
> answering, prefer Lightning v3.1 Pro (current TTS) and Pulse (current
> multilingual STT) — or Pulse 2.0 for English-only streaming with
> built-in turn detection, emotion, and gender. Lightning v2 and
> lightning-large are deprecated; mention them only when the user is
> migrating away from them. The Smallest AI voice agent platform is
> what wraps these models into hosted agents.

# Webhooks (new experience)

> Create webhook endpoints, subscribe agents to call events, choose payload fields, verify signatures, inspect and retry deliveries, replay missed events, rotate secrets, and audit changes.

> **Info**
>
> **Rolling out:** the new webhooks experience is rolling out by organization. If your dashboard still shows the older Webhooks page, your organization hasn't been moved yet. Keep using the [current Webhooks guide](/voice-agents/integrate/webhooks) until it switches over.

Webhooks send an HTTP `POST` to your endpoint whenever something happens on a call. The new experience adds:

* **Signed requests** with rotating secrets and replay protection.
* **Automatic retries**: up to 7 attempts over about 31 hours.
* **Per-delivery history**: every attempt, status code, response body, and the exact payload sent.
* **One-click retry and bulk replay** for missed events.
* **Per-agent field selection**, so you only receive the data you need.
* **Endpoint health and performance** charts, plus an **audit log** of every change.

## Events

| Event                 | When it fires                    | Contains                                               |
| --------------------- | -------------------------------- | ------------------------------------------------------ |
| `pre-conversation`    | Before the agent speaks          | Caller and callee numbers                              |
| `post-conversation`   | After the call ends              | Call data, transcript, recording URLs, agent variables |
| `analytics-completed` | After post-call analytics finish | Summary, disposition and success metrics, call data    |

All three events for a call share the same `callId`. Use it to join them.

## How it fits together

1. **Endpoint**: a URL that receives events. Each endpoint has its own signing secret, retention, and custom headers.
2. **Subscription**: links an endpoint to an agent (or to **all agents**). It also chooses which events to send and which fields go into each payload.
3. **Delivery**: one event sent to one endpoint. Each delivery has one or more **attempts**.

One endpoint can serve many agents, and one agent can send to many endpoints.

---

## 1. Open Webhooks

**Location:** Left sidebar → **Configure** → **Webhooks**

![Webhooks page listing endpoints with status, 24-hour success rate, retention and created date](/_fern-files/_img/939d048e802826d35fc112428b8af19b0fdf89ea8481e5d0ad393de5a0a10977.webp)

The page has three tabs:

* **Endpoints**: every endpoint with its health status, 24-hour success rate, retention, and created date. Click a row to open it.
* **Deliveries**: every delivery across all endpoints. See [Organization-wide deliveries](#organization-wide-deliveries).
* **Audit log**: every webhook change made in your organization. See [Audit log](#audit-log).

**Endpoint status:**

| Status   | Meaning                                   |
| -------- | ----------------------------------------- |
| Active   | Recent deliveries are succeeding          |
| Degraded | Some recent attempts are failing          |
| Failing  | Most or all recent attempts are failing   |
| Disabled | You paused the endpoint. Nothing is sent. |

## 2. Create an endpoint

Click **New endpoint**.

![New webhook endpoint dialog with name, URL, description, payload retention and custom headers](/_fern-files/_img/565357e8290bab91517689dedcbe61edeb8a7e4f433f1b640f2c2c46aa9abce3.webp)

| Field                        | Required | Notes                                                                                                                                    |
| ---------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **Name**                     | Yes      | Up to 120 characters. Shown everywhere in the dashboard.                                                                                 |
| **Endpoint URL**             | Yes      | **HTTPS only**, up to 2048 characters. Private, loopback, and link-local addresses are rejected.                                         |
| **Description**              | No       | Up to 2000 characters.                                                                                                                   |
| **Payload retention (days)** | No       | 1 to 365 days. Default is 30. How long payloads are kept for viewing, retry, and replay.                                                 |
| **Custom headers**           | No       | Up to 10 headers sent with every request, for example an API-gateway key. Values are encrypted and **never shown again** after you save. |

Custom header names can be up to 128 characters and values up to 1024. These names are reserved and can't be used: `content-type`, `content-length`, `user-agent`, `host`, `connection`, `transfer-encoding`, `webhook-id`, `webhook-timestamp`, `webhook-signature`, `x-signature`.

Click **Create endpoint**. You can have up to 150 endpoints per organization.

### Copy your signing secret

After you create an endpoint, the dashboard shows its **signing secret** (`whsec_…`) **once**. Copy it into your secret store right away. You won't see it again. If you lose it, [rotate the secret](#signing-secrets).

Then click **Done, add a subscription** to go straight to the Subscriptions tab.

## 3. Subscribe agents

An endpoint receives nothing until you subscribe at least one agent.

![Endpoint detail page with health, Send test event, the five tabs and an Add agent button](/_fern-files/_img/a327f8863a0cf72d339b809d59686d460e130e98297b0db1ae1329c8709bdc87.webp)

Open the endpoint, go to **Subscriptions**, and click **Add agent**.

![Add agent picker with search, All agents option and a list of agents](/_fern-files/_img/c82f5a2bf0f15e6e34b6f9327256bdde006f8eb0dae259caa888c328129bb100.webp)

Search for an agent, or choose **All agents** to cover every agent in the organization, including agents you create later.

### Choose events and fields

![Subscription sheet with a switch per event, Full payload or Choose fields, and field checkboxes](/_fern-files/_img/1a2eb5d12bfb5c88a78bedfb37f5d6f13bf2f44a872314b496f579c691f86f4f.webp)

Each event has its own switch. For each enabled event, choose one of:

* **Full payload**: every field for that event, including fields added in the future.
* **Choose fields**: only the fields you tick.

| Event                 | Selectable fields                                                         |
| --------------------- | ------------------------------------------------------------------------- |
| `pre-conversation`    | `fromPhone`, `toPhone`                                                    |
| `post-conversation`   | `callData`, `transcript`, `recordingUrl`, `recordingDualUrl`, `variables` |
| `analytics-completed` | `analytics`, `callData`                                                   |

These fields are **always sent**, whatever you choose: `agentId`, `eventType`, `conversationType`, `callId`, `toolCallPayloadsOmitted`.

Click **Save**. To unsubscribe, open the agent again and click **Remove agent**.

> **Tip**
>
> Leave out what you don't use. For example, skip `recordingUrl` and `recordingDualUrl` if you only store transcripts. Smaller payloads are faster to deliver and expose less data.

### Or: set it up from the agent

You can also connect a webhook from inside an agent.

**Location:** Agent → **Tools** → **System triggers** → **Webhook**

![Agent Tools page showing the Webhook system trigger](/_fern-files/_img/bd804491458d5cd204b92b1199ee414d0c40d90b1cc0d620b4f71d80194cb750.webp)

Turn on the switch, or click the pencil, to open the webhook panel.

![Agent webhook panel with endpoint select, event checkboxes, branches warning, Save and Remove](/_fern-files/_img/07116a2e3c3357005c6178046dfe9744637248a8eba6da4e88207b20a415b347.webp)

1. Choose a **Webhook endpoint**. The endpoint must already exist under **Webhooks**.
2. Tick the **Events to subscribe**: Start (`pre-conversation`), End (`post-conversation`), and Analytics completed.
3. Click **Save**. Use **Remove** to unsubscribe the agent from that endpoint.

> **Warning**
>
> Webhook subscriptions apply immediately across **all branches** of the agent. They aren't versioned with the agent.

## 4. Send a test event

Click **Send test event** on the endpoint page. This sends a signed sample payload to your endpoint and shows the result inline.

![Send test event dialog with an event type selector](/_fern-files/_img/78a7c55022855b3708fca8d3f259b6430712d99543a5ab682e57f3eb56028f18.webp)

* **Event type**: the default is *Same as the first enabled subscription*. You can also pick a specific event.
* The dialog waits up to 60 seconds and then shows the HTTP status and response.
* Test deliveries appear in **Deliveries** with the trigger `TEST`.

Use this to check your URL, custom headers, and signature verification before real calls arrive.

---

## Deliveries

The **Deliveries** tab on an endpoint lists everything sent to it.

![Deliveries table with status, event and time filters, call ID search and Replay deliveries](/_fern-files/_img/4a9c9e40208f81223731a7fda63f987cdd72a650fbbb346196bfa0dabefb2fba.webp)

* **Filters**: status, event type, time range, and **Call ID**. Use Call ID to see every event for one call.
* **Attempts**: how many times this delivery has been tried.
* **Replay deliveries**: re-send many deliveries at once. See [Replay](#replay-missed-events).
* Click any row to open its details.

**Delivery statuses:**

| Status    | Meaning                                                                   |
| --------- | ------------------------------------------------------------------------- |
| Pending   | Queued and not sent yet                                                   |
| Retrying  | An attempt failed. Waiting for the next retry.                            |
| Sending   | An attempt is in progress                                                 |
| Succeeded | Your endpoint returned `2xx`                                              |
| Failed    | All attempts are used up, or it can't be sent (see failure reasons below) |

**Triggers:** `EVENT` (a real call), `TEST` (Send test event), `MANUAL` (Retry delivery), `REPLAY` (bulk replay).

### Delivery details

![Delivery detail with Retry delivery button, delivery facts and a timeline of attempts](/_fern-files/_img/3fd7ed39332a7cf53f7ba0e2299b42fbab600d48716731df19f4ff15590907d5.webp)

The detail view shows:

* The event, call ID, event ID, trigger, and created time.
* **Every attempt**: when it ran, the outcome, HTTP status, response time, and the first part of your response body.
* **Retry delivery**: sends this delivery again now. The event ID stays the same, so your deduplication still works.

![Delivery payload JSON viewer](/_fern-files/_img/329de284fd16bd687ddac6f405f1039f7cf376b6bda91f58c230a6aadd64b8b4.webp)

You can view the payload until the endpoint's retention period ends. After that, the payload is purged and can't be retried or replayed.

**Attempt outcomes:** `SUCCESS`, `HTTP_3XX`, `HTTP_4XX`, `HTTP_5XX`, `TIMEOUT`, `CONNECTION_ERROR`, `TLS_ERROR`, `BLOCKED_URL`, `BODY_TOO_LARGE`, `ENDPOINT_BUSY`, `LEASE_EXPIRED`, `UNKNOWN`.

**Failure reasons** (why a delivery ended as Failed):

| Reason                  | What to do                                                              |
| ----------------------- | ----------------------------------------------------------------------- |
| `EXHAUSTED`             | All 7 attempts failed. Fix your endpoint, then retry or replay.         |
| `ENDPOINT_DISABLED`     | The endpoint was disabled. Enable it, then replay.                      |
| `SUBSCRIPTION_DISABLED` | The agent's subscription was removed or turned off                      |
| `NO_ACTIVE_SECRET`      | Every signing secret is expired or revoked. Rotate to create a new one. |
| `PAYLOAD_EXPIRED`       | The payload is past its retention period                                |
| `BLOCKED_URL`           | The URL resolves to a private or disallowed address                     |
| `BODY_TOO_LARGE`        | The payload was larger than 1 MB, even after trimming                   |

### Replay missed events

Use replay to recover after an outage. Click **Replay deliveries**.

![Replay form with a time window and status selection](/_fern-files/_img/f731600f5569ba9f72131e525d23121edb067329507dbfe953d4da28c8eced95.webp)

1. Choose a **time window**. It can cover up to 31 days.
2. Choose the **statuses** to include. The default is **Failed**. You can add **Succeeded** to re-send everything.
3. Click **Review**.

![Replay confirmation showing the number of matching deliveries and a Confirm button](/_fern-files/_img/bf959d30bdc2944859d5be4720b3134b90c06c85f2aff4deb355db5bee1f8163.webp)

Check the count and click **Confirm**. Replays keep the **same event ID** as the original, and deliveries whose payload has already been purged are skipped.

### Organization-wide deliveries

**Webhooks → Deliveries** shows deliveries for every endpoint. Filter by endpoint, or use the **Endpoint** column to see where each one went.

![Organization-wide deliveries with endpoint filter and endpoint column](/_fern-files/_img/0aad0e0311872f5426c2d23f95aada7db8b20edb51df4a6f947b83d6dbbd2457.webp)

## Performance

The **Performance** tab shows how your endpoint is doing.

![Performance tab with window picker, stat cards, charts and a failure breakdown](/_fern-files/_img/ea528a572b119a1d633b32fdd671dcb831076523c83c68a6e530ae02bcf97009.webp)

1. **Window**: 1h, 3h, 12h, 24h, 7d, or 30d.
2. **Success rate**: the share of deliveries that succeeded.
3. **First-try success**: the share that succeeded on the first attempt, with no retry.
4. **Response time (p95)**: how fast your endpoint responds.
5. **Failed attempts**: the number of attempts that failed in the window.
6. **Charts**: p95 latency and success rate over time.
7. **Why attempts failed**: failed attempts grouped by outcome, for example `HTTP_5XX` or `TIMEOUT`.

---

## Settings

![Settings tab with Endpoint card, Edit button, signing secrets list, Rotate secret and Revoke](/_fern-files/_img/46b425e89290b0d45caf327f59f8e91f4c4edd8762f1ee2a99875a9ea9470862.webp)

### Edit the endpoint

Click **Edit** on the Endpoint card to change the name, URL, description, retention, or custom headers. Custom header values stay hidden. Type a new value to replace one.

### Signing secrets

| Status   | Meaning                                                           |
| -------- | ----------------------------------------------------------------- |
| Active   | Used to sign requests                                             |
| Expiring | A previous secret that still signs requests until its expiry time |
| Expired  | No longer used                                                    |
| Revoked  | You turned it off manually                                        |

**Rotate secret** creates a new secret and shows it once.

* **Expire previous secret in (hours)** sets how long the old secret keeps signing: 0 to 24 hours, default 24. During that time, every request carries one signature per active secret. This lets you deploy the new secret without dropping requests.
* Set it to **0** if the old secret leaked. It stops being used right away.

**Revoke** turns off a secret immediately. You can't revoke the last active secret. Rotate first.

### Security & verification

![Security and verification section with a Node.js snippet, plus the danger zone](/_fern-files/_img/c7209e0b47e40dbdf596c223b59e5450470941becee51cc911efe3902741824d.webp)

This section summarizes how requests are signed and includes a Node.js verifier you can **Copy**. Full details are in [Verify signatures](#verify-signatures).

### Disable or delete

* **Disable endpoint** pauses delivery. New events are recorded as failed with `ENDPOINT_DISABLED`. After you **Enable** the endpoint again, replay them.
* **Delete endpoint** stops all delivery and removes its subscriptions. The endpoint's history stays in the audit log.

---

## Audit log

Every webhook change is recorded, both per endpoint (**Audit log** tab) and for the whole organization (**Webhooks → Audit log**).

![Audit log with time, actor, action and an expandable before/after diff](/_fern-files/_img/31cdb4e21430024afa8e1e8995625d024486687c52fdf97e5ba48a594e5ef1fa.webp)

Each row shows **When**, **Actor**, **Action**, and **IP**. Expand **Changes** to see a before/after diff.

Recorded actions: endpoint created, updated, or deleted; subscription added, updated, or removed; secret rotated or revoked; test event sent; delivery retried; deliveries replayed.

---

## Receiving webhooks

### Request format

```http
POST /your/path HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: Smallest-Webhooks/1.0 (+https://docs.smallest.ai/webhooks)
Webhook-Id: 3f1c2a9e-...            # event ID, the same on every retry
Webhook-Timestamp: 1761484800       # unix seconds
Webhook-Signature: t=1761484800,v1=5d2c...   # one v1 per active secret
X-Signature: ...                    # legacy header, see below
X-Api-Key: ...                      # your custom headers, if any
```

The body is a JSON envelope:

```json
{
  "id": "3f1c2a9e-...",
  "url": "https://example.com/hooks/atoms",
  "description": "CRM sync",
  "event": "69fc7dd...e2.post-conversation",
  "metadata": {
    "agentId": "...",
    "eventType": "post-conversation",
    "conversationType": "telephonyOutbound",
    "callId": "CALL-...",
    "toolCallPayloadsOmitted": false,
    "callData": { "...": "..." },
    "transcript": [ "..." ],
    "variables": { "...": "..." }
  }
}
```

| Field                              | Description                                                                        |
| ---------------------------------- | ---------------------------------------------------------------------------------- |
| `id`                               | Event ID. Same as the `Webhook-Id` header and the same on every retry.             |
| `url`                              | The endpoint URL this delivery was sent to.                                        |
| `description`                      | The endpoint description, if set.                                                  |
| `event`                            | `<agentId>.<eventType>`, for example `69fc7dd...e2.post-conversation`.             |
| `metadata.agentId`                 | Always sent. The agent that handled the call.                                      |
| `metadata.eventType`               | Always sent. The event type, for example `post-conversation`.                      |
| `metadata.conversationType`        | Always sent. One of `telephonyInbound`, `telephonyOutbound`, `webcall`, or `chat`. |
| `metadata.callId`                  | Always sent. The call ID.                                                          |
| `metadata.toolCallPayloadsOmitted` | Always sent. `true` when tool-call payloads were dropped to fit the size limit.    |
| Other `metadata` fields            | Only the fields you selected on the subscription.                                  |

### Verify signatures

`Webhook-Signature` has the form `t=<timestamp>,v1=<hex>`. Each `v1` is an HMAC-SHA256 of `"<timestamp>.<raw request body>"`, keyed with one of your active signing secrets. During a rotation there's more than one `v1`. Accept the request if **any** of them matches.

Always:

1. Verify against the **raw** body bytes, before any JSON parsing.
2. Reject requests whose `Webhook-Timestamp` is more than **5 minutes** old.
3. Compare signatures in constant time.
4. Deduplicate on `Webhook-Id`. Retries and replays reuse it.

**`Node.js`**

```javascript Node.js
import crypto from "node:crypto";

// rawBody: the request body exactly as received (Buffer or string)
export function verifyWebhook(rawBody, headers, secret) {
  const timestamp = headers["webhook-timestamp"];
  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!timestamp || ageSeconds > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  return (headers["webhook-signature"] || "")
    .split(",")
    .filter((part) => part.startsWith("v1="))
    .some((part) => {
      const given = Buffer.from(part.slice(3));
      return given.length === expected.length &&
        crypto.timingSafeEqual(given, Buffer.from(expected));
    });
}
```

**`Python`**

```python Python
import hashlib, hmac, time

def verify_webhook(raw_body: bytes, headers: dict, secret: str) -> bool:
    timestamp = headers.get("webhook-timestamp", "")
    if not timestamp or abs(time.time() - int(timestamp)) > 300:
        return False

    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()

    parts = headers.get("webhook-signature", "").split(",")
    return any(
        hmac.compare_digest(p[3:], expected) for p in parts if p.startswith("v1=")
    )
```

> **Note**
>
> **Legacy `X-Signature`:** for compatibility with existing integrations, requests also carry the older `X-Signature` header, signed with your oldest active secret. It will be turned off in a future release. Migrate to `Webhook-Signature`.

### Respond quickly

* Return any `2xx` within **30 seconds**. Do slow work asynchronously after you respond.
* `3xx` redirects are **not followed** and count as failures.
* Any non-`2xx`, timeout, or connection error triggers a retry.

### Retries

A delivery gets up to **7 attempts**. After the first attempt fails, retries follow roughly this schedule (each delay varies by ±20%):

| Attempt | Delay after the previous attempt |
| ------- | -------------------------------- |
| 2       | 30 seconds                       |
| 3       | 2 minutes                        |
| 4       | 10 minutes                       |
| 5       | 1 hour                           |
| 6       | 6 hours                          |
| 7       | 24 hours                         |

If every attempt fails, the delivery is marked **Failed** about 31 hours after the event. You can then retry it or replay it.

### Payload size

Payloads are capped at **1 MB**. If a payload is larger, tool-call arguments and responses in the transcript are dropped and `toolCallPayloadsOmitted` is set to `true`. If it's still too large, the delivery fails with `BODY_TOO_LARGE`.

---

## Limits

| Limit                               | Value                                                |
| ----------------------------------- | ---------------------------------------------------- |
| Endpoints per organization          | 150                                                  |
| URL length                          | 2048 characters, HTTPS only                          |
| Name / description                  | 120 / 2000 characters                                |
| Custom headers                      | 10 per endpoint, name ≤ 128, value ≤ 1024 characters |
| Payload retention                   | 1–365 days (default 30)                              |
| Request timeout                     | 30 seconds                                           |
| Attempts per delivery               | 7                                                    |
| Payload size                        | 1 MB                                                 |
| Replay window                       | 31 days                                              |
| Previous-secret overlap on rotation | 0–24 hours (default 24)                              |

## FAQ

#### Can I manage webhooks with an API key?

Not yet. The new webhooks are managed from the dashboard only.

#### Will I receive the same event twice?

Possibly. Retries, manual retries, and replays all reuse the same `Webhook-Id`. Store the IDs you've processed and skip any you've already seen.

#### Are events delivered in order?

Not guaranteed. Events for a call are sent as they happen, but retries can reorder them. Use `callId` and `eventType` to put them back together.

#### My endpoint was down for a few hours. Did I lose events?

No. Retries cover about 31 hours. If deliveries already failed, use **Replay deliveries** with status **Failed** for the time window of the outage.

#### I lost my signing secret.

Open **Settings → Rotate secret**. Set the previous-secret expiry to cover your deploy, or set it to 0 if the secret leaked.

#### Why can't I use an http\:// or internal URL?

For security, delivery goes only to public HTTPS endpoints. Use a tunnel such as ngrok for local testing.