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

# Webhook Subscription API

Public API for managing webhook subscriptions. Use these endpoints to create, retrieve, update, and delete webhook subscriptions that deliver event notifications to your own HTTP endpoints.

* **Base URL:** `https://api.verbex.ai/v1/public/webhook/subscription`
* **Auth:** Bearer token (API key) in the `Authorization` header
* **Content type:** `application/json` (for `POST` and `PUT`)

<Note>
  **Response convention — null/unset fields are omitted.** Optional fields that have no value (e.g. `headers`, `description`, `agent_ids`, `workspace_ids`) are **left out of the response body entirely** rather than returned as `null`. Treat a missing key as "not set". The `… | null` notation in the tables below indicates such a field; in practice the key is simply absent when unset.
</Note>

***

## Authentication

Every request must include your API key as a Bearer token:

```
Authorization: Bearer <YOUR_API_KEY>
```

<Warning>
  **Security note:** Your API key is a credential. Never commit it to source control, share it in tickets, or expose it client-side. If a key is ever exposed, rotate it immediately.
</Warning>

***

## Endpoints

### 1. Create a subscription

**Description:** Registers a new webhook subscription so Verbex delivers selected event notifications to your endpoint as HTTP `POST` requests. Optionally scope it to specific agents or workspaces, filter the `events` it receives, and enable HMAC-signed delivery. Returns the created subscription — including a one-time signing `secret` when `enable_signed_webhooks` is `true`. Use that `secret` to verify the authenticity of incoming deliveries: Verbex signs each request (HMAC) so your endpoint can confirm it genuinely originated from Verbex and reject forged or tampered payloads. The `secret` (prefixed `whsec_`) is returned **only once** at creation, so store it securely; if it's lost or leaked, rotate it via the rotate-secret endpoint.

<Tip>
  **Using signed webhooks (client side):** on every delivery, verify the `X-Webhook-Signature` header with your stored `secret` before trusting the payload — see [Verifying webhook signatures (client side)](#verifying-webhook-signatures-client-side).
</Tip>

**Method:** `POST /v1/public/webhook/subscription`

**Headers**

| Header          | Required | Value                   |
| --------------- | -------- | ----------------------- |
| `Authorization` | Yes      | `Bearer <YOUR_API_KEY>` |
| `Content-Type`  | Yes      | `application/json`      |

**Request Body**

| Field                    | Type             | Required | Description                                                                                                                                                                                                     |
| ------------------------ | ---------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`                   | string           | Yes      | Display name for the subscription (non-blank; unique per organization).                                                                                                                                         |
| `url`                    | string           | Yes      | Destination endpoint that receives event POSTs. Must match `^https?://.*`.                                                                                                                                      |
| `active`                 | boolean          | Yes      | Whether deliveries are attempted (`true`) or paused (`false`).                                                                                                                                                  |
| `enable_signed_webhooks` | boolean          | Yes      | HMAC signing toggle. Must be `true` or `false` (omitting it returns `400`). On create, `true` generates a `whsec_` signing secret returned **once** in the response; `false` creates an unsigned subscription.  |
| `events`                 | array of strings | No       | Event types to subscribe to, each in the case-sensitive **`Service.Event`** pattern (e.g. `CallHandler.CallStarted`) — see [Event types](#event-types). An empty array or `["*"]` subscribes to **all** events. |
| `headers`                | object           | No       | Custom HTTP headers sent with each delivery (string→string map; e.g. auth tokens for your receiving endpoint).                                                                                                  |
| `description`            | string           | No       | Free-text description (max 1500 chars).                                                                                                                                                                         |
| `agent_ids`              | array of strings | No       | Scope the subscription to specific agents (`scope = AGENT`).                                                                                                                                                    |
| `workspace_ids`          | array of strings | No       | Scope the subscription to specific workspaces (`scope = WORKSPACE`).                                                                                                                                            |

**Response Body**

| Field                | Type                     | Description                                                                                                                                                   |
| -------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                 | string                   | Generated subscription ID. Required for GET / PUT / DELETE.                                                                                                   |
| `organization_id`    | string                   | Owning organization (resolved from your API key).                                                                                                             |
| `scope`              | string                   | Resolved delivery scope: `ORGANIZATION`, `WORKSPACE`, or `AGENT`.                                                                                             |
| `created_user_id`    | string                   | User that created the subscription (from your API key).                                                                                                       |
| `name`               | string                   | Display name for the subscription.                                                                                                                            |
| `url`                | string                   | Destination endpoint that receives event POSTs.                                                                                                               |
| `events`             | array of strings         | Event types the subscription is subscribed to.                                                                                                                |
| `active`             | boolean                  | Whether deliveries are attempted (`true`) or paused (`false`).                                                                                                |
| `headers`            | object \| null           | Custom HTTP headers forwarded with each delivery; omitted when unset.                                                                                         |
| `description`        | string \| null           | Free-text description; omitted when unset.                                                                                                                    |
| `agent_ids`          | array of strings \| null | Agent scope; omitted when unset.                                                                                                                              |
| `workspace_ids`      | array of strings \| null | Workspace scope; omitted when unset.                                                                                                                          |
| `request_timeout_ms` | integer                  | Per-attempt delivery timeout in ms (default `1000`).                                                                                                          |
| `retry_policy`       | object                   | Retry config (`retryStrategy`, `maxRetryAttempts`, `maxRetryDelayMs`).                                                                                        |
| `signing_enabled`    | boolean                  | Whether the subscription has a signing secret. Always present.                                                                                                |
| `secret`             | string                   | HMAC signing secret, prefixed `whsec_`. Present **only at issuance** when signing is enabled (create with `enable_signed_webhooks: true`). Omitted otherwise. |
| `created_at`         | string (ISO-8601)        | Creation timestamp.                                                                                                                                           |
| `updated_at`         | string (ISO-8601)        | Last-update timestamp.                                                                                                                                        |

<Note>
  **`secret` is view-once.** It is returned in the response body **only at issuance** (create with signing on, a `PUT` that enables signing, or the rotate-secret endpoint) and is **never** returned by read endpoints (`GET` / list). Store it as soon as you receive it; if it's lost, obtain a new one via rotate-secret (the old one stays valid for a 72-hour grace window).
</Note>

**Sample Request**

```bash theme={null}
curl -X 'POST' \
  'https://api.verbex.ai/v1/public/webhook/subscription' \
  -H 'accept: */*' \
  -H 'Authorization: Bearer <YOUR_API_KEY>' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Payment Events Webhook",
    "description": "Receives payment events with custom authentication",
    "url": "https://api.example.com/webhooks/payments",
    "active": true,
    "enable_signed_webhooks": true,
    "events": [],
    "headers": {
      "X-Custom-Header": "custom-value",
      "Authorization": "Bearer your-api-token-here"
    }
  }'
```

**Sample Response** — `201 Created` *(signed subscription; `secret` shown only here)*

```json theme={null}
{
  "id": "6a27a76692d9e3a8ffd7e62d",
  "organization_id": "ddd6f1d8-b232-497e-bf1e-ea7317622d17",
  "scope": "ORGANIZATION",
  "created_user_id": "google-oauth2|101090478854418701727",
  "name": "Payment Events Webhook",
  "url": "https://api.example.com/webhooks/payments",
  "events": [],
  "active": true,
  "headers": {
    "X-Custom-Header": "custom-value",
    "Authorization": "Bearer your-api-token-here"
  },
  "description": "Receives payment events with custom authentication",
  "request_timeout_ms": 1000,
  "retry_policy": {
    "retryStrategy": "FIXED_DELAY",
    "maxRetryAttempts": 3,
    "maxRetryDelayMs": 10000
  },
  "signing_enabled": true,
  "secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "created_at": "2026-06-09T10:15:00.000Z",
  "updated_at": "2026-06-09T10:15:00.000Z"
}
```

**Notes**

* `headers` are forwarded on each delivery to your `url`. Use them to authenticate Verbex → your endpoint (this `Authorization` is *your* receiver's token, separate from the API key used to call this API).
* The response returns the created subscription, including its generated `id`. Save that `id` — it's required for GET / PUT / DELETE.

***

### 2. Update a subscription

**Description:** Replaces the full configuration of an existing subscription (full-replacement `PUT` semantics — send every field you want to keep, or it may be cleared/reset). The required `enable_signed_webhooks` also drives the signing lifecycle (enable or disable signing). Returns the updated subscription — including a **new** one-time signing `secret` only if this call just *enabled* signing on a previously-unsigned subscription.

**Method:** `PUT /v1/public/webhook/subscription/{id}`

**Headers**

| Header          | Required | Value                   |
| --------------- | -------- | ----------------------- |
| `Authorization` | Yes      | `Bearer <YOUR_API_KEY>` |
| `Content-Type`  | Yes      | `application/json`      |

**Path parameters**

| Parameter | Description                    |
| --------- | ------------------------------ |
| `id`      | The subscription ID to update. |

**Request Body**

| Field                    | Type             | Required | Description                                                                                                                                                                                                     |
| ------------------------ | ---------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`                   | string           | Yes      | Display name for the subscription (non-blank; unique per organization).                                                                                                                                         |
| `url`                    | string           | Yes      | Destination endpoint that receives event POSTs. Must match `^https?://.*`.                                                                                                                                      |
| `active`                 | boolean          | Yes      | Whether deliveries are attempted (`true`) or paused (`false`).                                                                                                                                                  |
| `enable_signed_webhooks` | boolean          | Yes      | HMAC signing toggle, and the signing-lifecycle control on update (see table below). Must be `true` or `false`.                                                                                                  |
| `events`                 | array of strings | No       | Event types to subscribe to, each in the case-sensitive **`Service.Event`** pattern (e.g. `CallHandler.CallStarted`) — see [Event types](#event-types). An empty array or `["*"]` subscribes to **all** events. |
| `headers`                | object           | No       | Custom HTTP headers sent with each delivery (string→string map).                                                                                                                                                |
| `description`            | string           | No       | Free-text description (max 1500 chars).                                                                                                                                                                         |
| `agent_ids`              | array of strings | No       | Scope the subscription to specific agents (`scope = AGENT`).                                                                                                                                                    |
| `workspace_ids`          | array of strings | No       | Scope the subscription to specific workspaces (`scope = WORKSPACE`).                                                                                                                                            |

**Response Body**

| Field                | Type                     | Description                                                                                                                                               |
| -------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                 | string                   | Subscription ID.                                                                                                                                          |
| `organization_id`    | string                   | Owning organization (resolved from your API key).                                                                                                         |
| `scope`              | string                   | Resolved delivery scope: `ORGANIZATION`, `WORKSPACE`, or `AGENT`.                                                                                         |
| `created_user_id`    | string                   | User that created the subscription.                                                                                                                       |
| `name`               | string                   | Display name for the subscription.                                                                                                                        |
| `url`                | string                   | Destination endpoint that receives event POSTs.                                                                                                           |
| `events`             | array of strings         | Event types the subscription is subscribed to.                                                                                                            |
| `active`             | boolean                  | Whether deliveries are attempted (`true`) or paused (`false`).                                                                                            |
| `headers`            | object \| null           | Custom HTTP headers forwarded with each delivery; omitted when unset.                                                                                     |
| `description`        | string \| null           | Free-text description; omitted when unset.                                                                                                                |
| `agent_ids`          | array of strings \| null | Agent scope; omitted when unset.                                                                                                                          |
| `workspace_ids`      | array of strings \| null | Workspace scope; omitted when unset.                                                                                                                      |
| `request_timeout_ms` | integer                  | Per-attempt delivery timeout in ms (default `1000`).                                                                                                      |
| `retry_policy`       | object                   | Retry config (`retryStrategy`, `maxRetryAttempts`, `maxRetryDelayMs`).                                                                                    |
| `signing_enabled`    | boolean                  | Whether the subscription has a signing secret. Always present.                                                                                            |
| `secret`             | string                   | HMAC signing secret, prefixed `whsec_`. Returned **only** if this update just *enabled* signing on a previously-unsigned subscription. Omitted otherwise. |
| `created_at`         | string (ISO-8601)        | Creation timestamp.                                                                                                                                       |
| `updated_at`         | string (ISO-8601)        | Last-update timestamp.                                                                                                                                    |

**Signing lifecycle on update** (`enable_signed_webhooks`):

| Value                                    | Effect                                                                                                            |
| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `true` on an unsigned subscription       | Enables signing and returns a **new** `secret` once in the response.                                              |
| `true` on an already-signed subscription | No-op (secret unchanged, not returned). To replace the secret, use [rotate-secret](#3-rotate-the-signing-secret). |
| `false` on a signed subscription         | Disables signing and **clears the secret irrecoverably** (response shows `signing_enabled: false`, no `secret`).  |

**Sample Request**

```bash theme={null}
curl -X 'PUT' \
  'https://api.verbex.ai/v1/public/webhook/subscription/6a27a76692d9e3a8ffd7e62d' \
  -H 'accept: */*' \
  -H 'Authorization: Bearer <YOUR_API_KEY>' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Payment Events Webhook",
    "description": "Receives payment events with custom authentication",
    "url": "https://api.example2.com/webhooks/payments",
    "active": true,
    "enable_signed_webhooks": true,
    "events": [],
    "headers": {
      "X-Custom-Header": "custom-value",
      "Authorization": "Bearer your-api-token-here"
    }
  }'
```

**Sample Response** — `200 OK` *(signing already enabled, so no `secret` returned)*

```json theme={null}
{
  "id": "6a27a76692d9e3a8ffd7e62d",
  "organization_id": "ddd6f1d8-b232-497e-bf1e-ea7317622d17",
  "scope": "ORGANIZATION",
  "created_user_id": "google-oauth2|101090478854418701727",
  "name": "Payment Events Webhook",
  "url": "https://api.example2.com/webhooks/payments",
  "events": [],
  "active": true,
  "headers": {
    "X-Custom-Header": "custom-value",
    "Authorization": "Bearer your-api-token-here"
  },
  "description": "Receives payment events with custom authentication",
  "request_timeout_ms": 1000,
  "retry_policy": {
    "retryStrategy": "FIXED_DELAY",
    "maxRetryAttempts": 3,
    "maxRetryDelayMs": 10000
  },
  "signing_enabled": true,
  "created_at": "2026-06-09T10:15:00.000Z",
  "updated_at": "2026-06-09T11:42:00.000Z"
}
```

**Notes**

* This is a full replacement (`PUT` semantics). Include every field you want to keep, or it may be cleared/reset.
* `updated_at` changes on each successful update; `created_at` stays fixed.

***

### 3. Rotate the signing secret

**Description:** Generates a **new** signing `secret` for a subscription while keeping the **previous** secret valid for a **72-hour** grace window, so in-flight receivers don't break. During that window Verbex signs each delivery with **both** secrets (two `v1=` values in `X-Webhook-Signature`), so a receiver still using the old secret keeps verifying until it switches to the new one. The new `secret` is returned **once** in this response — store it immediately.

**Method:** `POST /v1/public/webhook/subscription/{id}/rotate/secret`

**Headers**

| Header          | Required | Value                   |
| --------------- | -------- | ----------------------- |
| `Authorization` | Yes      | `Bearer <YOUR_API_KEY>` |

No request body.

**Path parameters**

| Parameter | Description                                 |
| --------- | ------------------------------------------- |
| `id`      | The subscription ID whose secret to rotate. |

**Response Body**

Returns **only** the two rotation fields — not the full subscription object:

| Field                        | Type              | Description                                                                                                                                   |
| ---------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `secret`                     | string            | The **new** signing secret (`whsec_…`), returned **once**.                                                                                    |
| `previous_secret_expires_at` | string (ISO-8601) | When the previous secret stops being accepted (rotation time + 72h). The prior secret value itself is **not** returned — you already have it. |

<Note>
  The response does **not** include `id`, `signing_enabled`, or any other subscription fields. To read the rest of the subscription after rotating, call [Get a subscription](#5-get-a-subscription).
</Note>

**Sample Request**

```bash theme={null}
curl -X 'POST' \
  'https://api.verbex.ai/v1/public/webhook/subscription/6a27a76692d9e3a8ffd7e62d/rotate/secret' \
  -H 'accept: */*' \
  -H 'Authorization: Bearer <YOUR_API_KEY>'
```

**Sample Response** — `200 OK`

```json theme={null}
{
  "secret": "whsec_NEWxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "previous_secret_expires_at": "2026-06-12T10:15:00.000Z"
}
```

**Notes**

* Returns `409 Conflict` if the subscription was created without signing enabled (no secret to rotate — re-create or `PUT` with `enable_signed_webhooks: true` first).
* Update your stored secret to the new value before the previous one expires; after the 72-hour window the old secret is rejected.

***

### 4. Delete a subscription

**Description:** Permanently deletes a subscription; Verbex stops delivering its events. This cannot be undone — to temporarily stop deliveries instead, set `active: false` via [Update](#2-update-a-subscription).

**Method:** `DELETE /v1/public/webhook/subscription/{id}`

**Headers**

| Header          | Required | Value                   |
| --------------- | -------- | ----------------------- |
| `Authorization` | Yes      | `Bearer <YOUR_API_KEY>` |

No request body.

**Path parameters**

| Parameter | Description                    |
| --------- | ------------------------------ |
| `id`      | The subscription ID to delete. |

**Response** — `204 No Content` (empty body).

**Sample Request**

```bash theme={null}
curl -X 'DELETE' \
  'https://api.verbex.ai/v1/public/webhook/subscription/6a27a76692d9e3a8ffd7e62d' \
  -H 'accept: */*' \
  -H 'Authorization: Bearer <YOUR_API_KEY>'
```

**Sample Response** — `204 No Content` (empty body).

**Notes**

* Returns `404 Not Found` if no subscription exists for the `id`.
* To pause deliveries without deleting, `PUT` with `"active": false` — this preserves the subscription and its `id`.

***

### 5. Get a subscription

**Description:** Retrieves a single subscription by its ID. Read endpoints never return the signing `secret` — use `signing_enabled` to tell whether signing is on.

**Method:** `GET /v1/public/webhook/subscription/{id}`

**Headers**

| Header          | Required | Value                   |
| --------------- | -------- | ----------------------- |
| `Authorization` | Yes      | `Bearer <YOUR_API_KEY>` |

No request body.

**Path parameters**

| Parameter | Description                               |
| --------- | ----------------------------------------- |
| `id`      | The subscription ID returned at creation. |

**Response Body**

| Field                | Type                     | Description                                                            |
| -------------------- | ------------------------ | ---------------------------------------------------------------------- |
| `id`                 | string                   | Subscription ID.                                                       |
| `organization_id`    | string                   | Owning organization.                                                   |
| `scope`              | string                   | `ORGANIZATION`, `WORKSPACE`, or `AGENT`.                               |
| `created_user_id`    | string                   | User that created the subscription.                                    |
| `name`               | string                   | Display name.                                                          |
| `url`                | string                   | Destination endpoint.                                                  |
| `events`             | array of strings         | Subscribed event types.                                                |
| `active`             | boolean                  | Whether deliveries are attempted.                                      |
| `headers`            | object \| null           | Custom delivery headers; omitted when unset.                           |
| `description`        | string \| null           | Free-text description; omitted when unset.                             |
| `agent_ids`          | array of strings \| null | Agent scope; omitted when unset.                                       |
| `workspace_ids`      | array of strings \| null | Workspace scope; omitted when unset.                                   |
| `request_timeout_ms` | integer                  | Per-attempt delivery timeout (default `1000`).                         |
| `retry_policy`       | object                   | Retry config (`retryStrategy`, `maxRetryAttempts`, `maxRetryDelayMs`). |
| `signing_enabled`    | boolean                  | Whether signing is on. (`secret` is **never** returned by reads.)      |
| `created_at`         | string (ISO-8601)        | Creation timestamp.                                                    |
| `updated_at`         | string (ISO-8601)        | Last-update timestamp.                                                 |

**Sample Request**

```bash theme={null}
curl -X 'GET' \
  'https://api.verbex.ai/v1/public/webhook/subscription/6a27a76692d9e3a8ffd7e62d' \
  -H 'accept: */*' \
  -H 'Authorization: Bearer <YOUR_API_KEY>'
```

**Sample Response** — `200 OK` *(no `secret`)*

```json theme={null}
{
  "id": "6a27a76692d9e3a8ffd7e62d",
  "organization_id": "ddd6f1d8-b232-497e-bf1e-ea7317622d17",
  "scope": "ORGANIZATION",
  "created_user_id": "google-oauth2|101090478854418701727",
  "name": "Payment Events Webhook",
  "url": "https://api.example.com/webhooks/payments",
  "events": [],
  "active": true,
  "headers": {
    "X-Custom-Header": "custom-value",
    "Authorization": "Bearer your-api-token-here"
  },
  "description": "Receives payment events with custom authentication",
  "request_timeout_ms": 1000,
  "retry_policy": {
    "retryStrategy": "FIXED_DELAY",
    "maxRetryAttempts": 3,
    "maxRetryDelayMs": 10000
  },
  "signing_enabled": true,
  "created_at": "2026-06-09T10:15:00.000Z",
  "updated_at": "2026-06-09T10:15:00.000Z"
}
```

**Notes**

* Returns `404 Not Found` if no subscription exists for the `id`.

***

### 6. List subscriptions

**Description:** Lists the subscriptions in your organization (cursor-paginated). Note the **plural** `subscriptions` path — distinct from the singular get-by-id. `secret` is never included.

**Method:** `GET /v1/public/webhook/subscriptions`

**Headers**

| Header          | Required | Value                   |
| --------------- | -------- | ----------------------- |
| `Authorization` | Yes      | `Bearer <YOUR_API_KEY>` |

No request body.

**Query parameters**

| Parameter    | Required | Description                                                                                                              |
| ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------ |
| `page_size`  | Yes      | Max items per page, `1`–`100`. Omitting it returns `400`; a value `> 100` returns `400 — "Page size cannot exceed 100"`. |
| `page_token` | No       | Cursor from a previous response's `next_page_token`; omit for the first page.                                            |

**Response Body**

| Field             | Type           | Description                                                                                                   |
| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------- |
| `data`            | array          | Page of subscription objects, each the same shape as the [get response](#5-get-a-subscription) (no `secret`). |
| `next_page_token` | string \| null | Cursor for the next page; `null` on the last page.                                                            |
| `total_count`     | integer        | Total subscriptions matching the query.                                                                       |

**Sample Request**

```bash theme={null}
curl -X 'GET' \
  'https://api.verbex.ai/v1/public/webhook/subscriptions?page_size=20' \
  -H 'accept: */*' \
  -H 'Authorization: Bearer <YOUR_API_KEY>'
```

**Sample Response** — `200 OK`

```json theme={null}
{
  "data": [
    {
      "id": "6a27a76692d9e3a8ffd7e62d",
      "organization_id": "ddd6f1d8-b232-497e-bf1e-ea7317622d17",
      "scope": "ORGANIZATION",
      "created_user_id": "google-oauth2|101090478854418701727",
      "name": "Payment Events Webhook",
      "url": "https://api.example.com/webhooks/payments",
      "events": [],
      "active": true,
      "request_timeout_ms": 1000,
      "retry_policy": {
        "retryStrategy": "FIXED_DELAY",
        "maxRetryAttempts": 3,
        "maxRetryDelayMs": 10000
      },
      "signing_enabled": true,
      "created_at": "2026-06-09T10:15:00.000Z",
      "updated_at": "2026-06-09T10:15:00.000Z"
    }
  ],
  "next_page_token": null,
  "total_count": 1
}
```

<Note>
  Each `data` item has the same shape as the [get response](#5-get-a-subscription) (no `secret`).
</Note>

***

## Event types

Values for the `events` array use the **`Service.Event`** pattern — the service name and event name joined by a dot (e.g. `CallHandler.CallStarted`). Names are **case-sensitive**; use them exactly as listed below (note `CallHandler` is PascalCase, `callAnalysis` is lower-camelCase). A bare event name without its service prefix (e.g. `CallStarted`) is rejected with `400 — Event type '…' does not exist`.

Currently subscribable:

| Event                       | Description                                        | Sample payload                                                                                                                                                |
| --------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CallHandler.CallStarted`   | Triggered when a call is initiated.                | `{"call_id": "69ef5c4...3c729ab8c83f", "status": "success", "agent_id": "c51ab1b1-...-86a922d694e7"}`                                                         |
| `CallHandler.CallEnded`     | Triggered when a call is ended.                    | `{"call_id": "69ef5c43...29ab8c83f", "status": "success", "agent_id": "c51ab1b1-...-86a922d694e7"}`                                                           |
| `callAnalysis.pcaCompleted` | Triggered when post-call analysis (PCA) completes. | `{"call_id": "698820c3...28d6b98c63", "agent_id": "285aef8f-...-3573855ed4a0", "status": "success", "reason": "success", "analysisTimestamp": 1770529007989}` |

**Notes**

* An empty array (`[]`) or `["*"]` subscribes to **all** subscribable event types.
* Some event types exist but are **internal-only** and cannot be subscribed to via public webhooks — subscribing to them returns `400 — Event type '…' is internal-only and cannot be subscribed to via public webhooks` (e.g. `CallHandler.AnsweringMachineDetected`).

***

## Event Delivery

Every delivery is an HTTP `POST` to your subscription `url` with a JSON body in the following **envelope**. The event-specific data lives under `payload`; the surrounding fields are the same for every event type. Test deliveries use the identical envelope (the only difference is `payload` comes from the event type's sample payload and `traceId` is prefixed `test-`).

**Envelope fields**

| Field            | Type              | Description                                                                                                                                                                                                        |
| ---------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `eventName`      | string            | The full event type in `Service.Event` form (e.g. `CallHandler.CallStarted`). **⚠️ Deprecated** — being phased out and will be **removed** once consumers migrate to `event`. Prefer `event` for new integrations. |
| `event`          | string            | The event name without its service prefix (e.g. `CallStarted`). **Going-forward field** — use this instead of `eventName`.                                                                                         |
| `traceId`        | string            | Correlation/trace ID for this delivery (also sent as the `X-Webhook-TraceId` header).                                                                                                                              |
| `timestamp`      | string (ISO-8601) | When the event was produced.                                                                                                                                                                                       |
| `organizationId` | string            | The organization the event belongs to.                                                                                                                                                                             |
| `payload`        | object            | The event-specific body — see the [Event types](#event-types) table for the shape per event (e.g. `call_id`, `agent_id`, `status`).                                                                                |

**Sample delivery — `CallHandler.CallStarted`**

```json theme={null}
{
  "eventName": "CallHandler.CallStarted",
  "event": "CallStarted",
  "traceId": "9f1c2e7a-...-b3d4",
  "timestamp": "2026-06-09T10:15:00.000Z",
  "organizationId": "ddd6f1d8-b232-497e-bf1e-ea7317622d17",
  "payload": {
    "call_id": "69ef5c4...3c729ab8c83f",
    "status": "success",
    "agent_id": "c51ab1b1-...-86a922d694e7"
  }
}
```

**Sample delivery — `callAnalysis.pcaCompleted`**

```json theme={null}
{
  "eventName": "callAnalysis.pcaCompleted",
  "event": "pcaCompleted",
  "traceId": "9f1c2e7a-...-b3d4",
  "timestamp": "2026-06-09T10:15:00.000Z",
  "organizationId": "ddd6f1d8-b232-497e-bf1e-ea7317622d17",
  "payload": {
    "call_id": "698820c3...28d6b98c63",
    "agent_id": "285aef8f-...-3573855ed4a0",
    "status": "success",
    "reason": "success",
    "analysisTimestamp": 1770529007989
  }
}
```

**Delivery headers**

| Header                | Always sent      | Description                                                                                                                                                                                         |
| --------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`        | Yes              | `application/json`.                                                                                                                                                                                 |
| `X-Webhook-Event`     | Yes              | The full event type, in `Service.Event` form (e.g. `CallHandler.CallStarted`) — same value as the body's `eventName`. Routing hint.                                                                 |
| `X-Webhook-TraceId`   | Yes              | Correlation/trace ID for this delivery — same value as the body's `traceId`.                                                                                                                        |
| `X-Webhook-Timestamp` | Yes              | Event timestamp as an **ISO-8601 string** (e.g. `2026-06-09T10:15:00Z`) — same value as the body's `timestamp`.                                                                                     |
| `X-Webhook-Signature` | Only when signed | HMAC signature, format `t=<unix_seconds>,v1=<hex>[,v1=<hex>]`. Present only if the subscription has signing enabled. See [Verifying webhook signatures](#verifying-webhook-signatures-client-side). |
| *custom headers*      | If configured    | Any `headers` you set on the subscription are forwarded as-is.                                                                                                                                      |

<Warning>
  `X-Webhook-Event`, `X-Webhook-TraceId`, and `X-Webhook-Timestamp` are routing hints and are **not** covered by the HMAC signature — do not rely on them for security. Only the raw request **body** and the `t` value *inside* `X-Webhook-Signature` are signed.

  **Note:** `t` (inside `X-Webhook-Signature`) is a **Unix epoch-seconds** integer and is what the replay check uses; the separate `X-Webhook-Timestamp` header is an **ISO-8601 string**. They are different formats and values — don't conflate them.
</Warning>

### Stable vs. optional fields

Verbex maintains **backward compatibility**: the fields below remain stable, and new optional fields may be **added** over time. Your receiver should **tolerate unknown fields** rather than strictly validate the whole body.

**Stable — safe to rely on:**

* `event`
* `traceId`
* `timestamp`
* `organizationId`
* `payload.call_id`
* `payload.agent_id`
* `payload.status`

<Warning>
  **`eventName` is deprecated** and will be **removed** in a future version — migrate to the `event` field. It is still sent today for backward compatibility, but do not build new integrations on it.
</Warning>

**Optional / may change or be added later:** additional `payload` fields (e.g. `reason`, `analysisTimestamp`, and event-specific extras), and any future top-level fields.

<Note>
  Webhook subscription changes, including deletion, may take up to **10 minutes** to fully propagate due to internal caching.
</Note>

***

## Verifying webhook signatures (client side)

When `enable_signed_webhooks` is `true`, every delivery carries an `X-Webhook-Signature` header. Verify it before trusting the payload.

**Header format**

```
X-Webhook-Signature: t=<unix_seconds>,v1=<hmac_sha256_hex>[,v1=<hmac_sha256_hex>]
```

* `t` — Unix timestamp (seconds) when the delivery was signed.
* `v1` — lowercase hex HMAC-SHA256. During the **72-hour grace window after a secret rotation**, two `v1=` values are sent (new secret first, then previous) — accept the request if **either** matches.

**What is signed:** the raw request body, a literal `.`, then `t`:

```
signed_payload = <raw_request_body> + "." + <t>
```

**Key:** use the full `whsec_…` secret string exactly as returned, as UTF-8 bytes. Do **not** strip the `whsec_` prefix and do **not** base64-decode it.

**Steps**

1. Parse `t` and all `v1` values from `X-Webhook-Signature`.
2. Reject if `|now − t| > 300` seconds (replay protection).
3. Compute `expected = hex(HMAC_SHA256(secret, raw_body + "." + t))`.
4. Accept if `expected` equals **any** `v1` (use a constant-time comparison).
5. Don't rely on `X-Webhook-Event`, `X-Webhook-TraceId`, or `X-Webhook-Timestamp` for security — they are routing hints and are **not** covered by the signature.

<Warning>
  Verify against the **raw** request body bytes exactly as received. Parsing then re-serializing the JSON will change the bytes and break the signature.
</Warning>

**Node.js (Express)**

```js theme={null}
const crypto = require("crypto");

function verifyWebhook(rawBody, sigHeader, secret, toleranceSec = 300) {
  let t;
  const sigs = [];
  for (const seg of sigHeader.split(",")) {
    const i = seg.indexOf("=");
    const k = seg.slice(0, i), v = seg.slice(i + 1);
    if (k === "t") t = parseInt(v, 10);
    else if (k === "v1") sigs.push(v);
  }
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;

  const expected = crypto
    .createHmac("sha256", secret)        // secret includes the whsec_ prefix
    .update(`${rawBody}.${t}`)
    .digest("hex");

  return sigs.some(
    (s) => s.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))
  );
}

// Capture the RAW body so the bytes are unchanged
app.use(express.raw({ type: "application/json" }));
app.post("/webhooks", (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verifyWebhook(raw, req.get("X-Webhook-Signature"), process.env.WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(raw);
  // ... handle event ...
  res.sendStatus(200);
});
```

**Python (Flask)**

```python theme={null}
import hmac, hashlib, time

def verify_webhook(raw_body: bytes, sig_header: str, secret: str, tolerance: int = 300) -> bool:
    t, sigs = None, []
    for seg in sig_header.split(","):
        k, _, v = seg.partition("=")
        if k == "t":
            t = int(v)
        elif k == "v1":
            sigs.append(v)
    if t is None or abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(
        secret.encode("utf-8"),                       # full whsec_... string
        raw_body + b"." + str(t).encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in sigs)

# Flask: request.get_data() returns the raw bytes
# ok = verify_webhook(request.get_data(), request.headers["X-Webhook-Signature"], WEBHOOK_SECRET)
```

***

## Error responses

Errors are returned with a non-`2xx` status code and this JSON body:

```json theme={null}
{
  "status": 400,
  "type": "VALIDATION_ERROR",
  "message": "Validation failed",
  "details": {
    "fields": {
      "enableSignedWebhooks": "enable_signed_webhooks is required (true or false)"
    }
  },
  "timestamp": "2026-06-24T13:24:36.078Z",
  "path": "/v1/public/webhook/subscription",
  "trace_id": "341b5325-a0be-4533-85dd-6f697476dfc3"
}
```

| Field                             | Description                                                                 |
| --------------------------------- | --------------------------------------------------------------------------- |
| `status`                          | HTTP status code.                                                           |
| `type`                            | Machine-readable error type (e.g. `VALIDATION_ERROR`).                      |
| `message`                         | Human-readable summary.                                                     |
| `details`                         | Optional structured detail (e.g. `fields` → per-field validation messages). |
| `timestamp` / `path` / `trace_id` | When/where it happened and a correlation ID for support.                    |

<Note>
  **Authentication errors use a different shape.** The schema above is returned by the webhook service for application-level errors (`400`, `404`, `409`, `500`). Authentication failures rejected at the API gateway (`401`) return a **leaner body with only `error`, `message`, and a camelCase `traceId`** — no `status`, `type`, `path`, or `timestamp`:

  ```json theme={null}
  {
    "error": "Authentication",
    "message": "Authentication failed",
    "traceId": "e679120f-1d1c-41d2-947c-34e194d5a4a1"
  }
  ```

  The `message` text is informational (e.g. `"Authentication failed"` for an invalid token, `"Authentication failure"` when the `Authorization` header is missing). Note `traceId` is **camelCase** here, unlike the snake\_case `trace_id` on service-level errors. Parse error bodies defensively: rely on the HTTP status code, and treat `status`/`type`/`path`/`trace_id` as present only on service-level errors.
</Note>

| Status                      | Meaning                                                                                                                                            |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400 Bad Request`           | Malformed JSON or invalid/missing fields (e.g. missing `enable_signed_webhooks`, invalid `url`).                                                   |
| `401 Unauthorized`          | Missing or invalid `Authorization` Bearer token. Returned by the gateway in the leaner shape above (`error`, `message`, camelCase `traceId` only). |
| `403 Forbidden`             | Token is valid but not permitted for this resource.                                                                                                |
| `404 Not Found`             | No subscription exists for the given `id`.                                                                                                         |
| `409 Conflict`              | Duplicate `name` (unique per organization), or rotate-secret on a subscription without signing enabled.                                            |
| `429 Too Many Requests`     | Rate limit exceeded.                                                                                                                               |
| `500 Internal Server Error` | Unexpected server-side failure.                                                                                                                    |

***

## Quick reference

| Action        | Method   | Path                                                 | Body | Success |
| ------------- | -------- | ---------------------------------------------------- | ---- | ------- |
| Create        | `POST`   | `/v1/public/webhook/subscription`                    | Yes  | `201`   |
| Read          | `GET`    | `/v1/public/webhook/subscription/{id}`               | No   | `200`   |
| Update        | `PUT`    | `/v1/public/webhook/subscription/{id}`               | Yes  | `200`   |
| Delete        | `DELETE` | `/v1/public/webhook/subscription/{id}`               | No   | `204`   |
| List          | `GET`    | `/v1/public/webhook/subscriptions`                   | No   | `200`   |
| Rotate secret | `POST`   | `/v1/public/webhook/subscription/{id}/rotate/secret` | No   | `200`   |

***

## Common headers

| Header          | Used on      | Value                   |
| --------------- | ------------ | ----------------------- |
| `Authorization` | All requests | `Bearer <YOUR_API_KEY>` |
| `Content-Type`  | POST, PUT    | `application/json`      |
| `accept`        | All requests | `*/*`                   |

***

## Tips

* **`events: []`** — an empty array (or `["*"]`) subscribes to **all** event types. To receive only specific events, list them explicitly (see [Event types](#event-types)).
* **Verify delivery** — your `url` should respond `2xx` quickly. Use the `headers` field to let your receiver authenticate inbound calls.
* **Pause vs. delete** — to temporarily stop deliveries, `PUT` with `"active": false` rather than deleting; this preserves the subscription and its ID.
