# Webhook endpoints

Webhooks push events to your server as they happen. Register an endpoint with `POST /v1/webhooks` (or in the portal: company settings, Webhooks), then answer each delivery with a `2xx`. The payload of each event is under **Webhooks** (the events) in the menu.

**Delivery.** Each event is an HTTPS `POST` with a JSON body:

```json
{
  "id": "evt_6703a1f25c9e8.21736450",
  "event_type": "call.ended",
  "timestamp": "2026-09-24T14:09:41+00:00",
  "company_id": 1234567890,
  "data": {}
}
```

with these headers:

- `X-Vocatech-Signature: t=<unix seconds>,v1=<hex>`
- `X-Vocatech-Event`: the event type, the same as `event_type`
- `User-Agent: Vocatech-Webhook/1.0`
- `Content-Type: application/json`

**Answer fast.** Answer `2xx` within 5 seconds, then do the work. Scheduled deliveries and retries time out at 5 seconds. Any other status, no answer in time, or a redirect is a failed delivery. We never follow redirects: a `3xx` counts as a failure.

**Retries.** Failed deliveries are retried by a job that runs every 5 minutes. The first retry comes at its next run. Each later retry waits at least 5, then 30, then 120 minutes after the one before. That makes four retries and 5 attempts in all, the last one about 2 hours 40 minutes after the first failure. A retry carries the same body (the same `id`) with a new signature. It goes to the URL that failed, even if you change or pause the endpoint later. Deleting the endpoint stops its retries. A failed delivery is listed at `GET /v1/webhooks/failures` from its first failure; once it has used every attempt it stays listed for 7 days. A delivery that succeeds on a retry leaves the list. There is no replay route: to catch up, read the data with `GET /v1/calls`, `GET /v1/messages` or `GET /v1/faxes`.

**Duplicates and order.** A delivery can arrive more than once, and events can arrive out of order. Keep the `id` of each event you handle and skip one you have already seen.

**Verify every delivery.** `v1` is the HMAC-SHA256, in lowercase hex, of `t=<t>.<raw body>`, keyed with your endpoint's `secret_key` exactly as it was given to you (64 characters, used as text). Compute it over the raw bytes you received, before any JSON parsing. Reject the delivery when `t` is more than 300 seconds from your clock, or when the signature does not match. Compare in constant time.

Node:

```js
import crypto from "node:crypto";

// rawBody: the exact bytes received, as a Buffer
// (for example with express.raw({ type: "application/json" })).
export function verifyVocatech(rawBody, header, secret) {
  const parts = Object.fromEntries(
    String(header).split(",").map((kv) => kv.trim().split("=")),
  );
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`t=${parts.t}.`)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(String(parts.v1 || ""));
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Python:

```python
import hashlib
import hmac
import time

def verify_vocatech(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(kv.strip().split("=", 1) for kv in header.split(",") if "=" in kv)
    t = parts.get("t", "")
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    signed = b"t=" + t.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

PHP:

```php
// $rawBody = file_get_contents('php://input');
function verifyVocatech(string $rawBody, string $header, string $secret): bool
{
    $parts = [];
    foreach (explode(',', $header) as $kv) {
        [$k, $v] = array_pad(explode('=', trim($kv), 2), 2, '');
        $parts[$k] = $v;
    }
    $t = $parts['t'] ?? '';
    if (!ctype_digit($t) || abs(time() - (int) $t) > 300) {
        return false;
    }
    $expected = hash_hmac('sha256', "t={$t}.{$rawBody}", $secret);
    return hash_equals($expected, $parts['v1'] ?? '');
}
```

**Filters.** `event_filters` picks the events. `extension_filters` narrows call events (`call.*` and `call.transcription`) to those extensions; message and fax events are not narrowed by extension. `number_filters` narrows every event to the numbers you list, matched against the event's phone numbers. An event with no phone number is never sent to an endpoint that has number filters. Known issue: on `call.started`, `call.answered` and `call.ended`, `group_number` is one fixed number of your account, not always the number that was dialed, so a filter on your own numbers can miss those events. A filter on the other party's number works.

**Test.** `POST /v1/webhooks/{id}/test` sends a signed `webhook.test` event right away. The hosted tool at https://api.vocatech.com/test/ gives you a temporary receiver URL and a live log of events for 30 minutes.

**Scoped keys.** A key with the `messaging` scope may subscribe to message events only, and sees only the endpoints it created.


## List webhook endpoints

`GET /v1/webhooks`

Your webhook endpoints, newest first. A scoped key lists only the endpoints it created. The signing secret is never shown here.

### Request

**curl**

```bash
curl https://api.vocatech.com/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/webhooks", {
  headers: { Authorization: "Bearer YOUR_API_KEY" },
});

console.log(res.status, await res.json());
```

**Python**

```python
import requests

r = requests.get(
    "https://api.vocatech.com/v1/webhooks",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    timeout=30,
)
print(r.status_code, r.json())
```

**PHP**

```php
<?php
require 'vendor/autoload.php';

$client = new GuzzleHttp\Client([
    'base_uri' => 'https://api.vocatech.com',
    'headers' => ['Authorization' => 'Bearer YOUR_API_KEY'],
    'http_errors' => false,
]);

$res = $client->get('/v1/webhooks');

echo $res->getStatusCode(), PHP_EOL, $res->getBody(), PHP_EOL;
```

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `company_id` | query | integer |  | Optional. If you send it, it must be your own company id, or the request is refused with `403`. The key already decides the company. |

### Answers

#### 200

Your endpoints.

**Example**

```json
[
  {
    "id": 318,
    "company_id": 1234567890,
    "name": "CRM sync",
    "description": "Calls and texts into the CRM",
    "url": "https://hooks.example.com/vocatech",
    "event_filters": [
      "call.ended",
      "message.received"
    ],
    "extension_filters": [],
    "number_filters": [
      "+17185550100"
    ],
    "enabled": true,
    "created_at": "2026-09-24 14:05:09",
    "updated_at": "2026-09-24 14:05:09"
  }
]
```

#### 401

No key, or a key that is wrong or turned off.

#### 403

Refused. The key is scoped away from this route, the request came from an address not on the key's allow-list, or the route refused the request (the message says why).

#### 429

A rate limit was reached. Wait `Retry-After` seconds. The body names the counter, the window and the limit.

#### 503

Try again shortly. The key could not be checked just now (`Retry-After: 5`), or a service this route needs could not be reached.

## Create a webhook endpoint

`POST /v1/webhooks`

Registers an endpoint for one or more events. The answer carries `secret_key` once. Store it to verify signatures: it is never shown again.

**URL.** `https` on a public internet address: every address the name resolves to must be public. Private, loopback and link-local addresses, internal names, and a username or password in the URL are refused with `422`. We never follow redirects.

**Filters.**

- `event_filters` (required): the events to send. `events` is accepted as another name.
- `extension_filters`: only call events (`call.*` and `call.transcription`) for these extensions. Message and fax events are not narrowed by extension.
- `number_filters`: only events that involve one of these numbers: 10-digit US or Canadian numbers in any format, stored as E.164, up to 200. Leave it out for every number. An event with no phone number is never sent to a filtered endpoint.

A key with the `messaging` scope may subscribe to message events only. You can also create endpoints in the portal (company settings, Webhooks).

### Request

**curl**

```bash
curl -X POST https://api.vocatech.com/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/vocatech",
    "event_filters": [
      "call.ended",
      "message.received"
    ],
    "number_filters": [
      "7185550100"
    ],
    "name": "CRM sync",
    "description": "Calls and texts into the CRM"
  }'
```

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/webhooks", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://hooks.example.com/vocatech",
    event_filters: ["call.ended", "message.received"],
    number_filters: ["7185550100"],
    name: "CRM sync",
    description: "Calls and texts into the CRM",
  }),
});

console.log(res.status, await res.json());
```

**Python**

```python
import requests

r = requests.post(
    "https://api.vocatech.com/v1/webhooks",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "url": "https://hooks.example.com/vocatech",
        "event_filters": ["call.ended", "message.received"],
        "number_filters": ["7185550100"],
        "name": "CRM sync",
        "description": "Calls and texts into the CRM",
    },
    timeout=30,
)
print(r.status_code, r.json())
```

**PHP**

```php
<?php
require 'vendor/autoload.php';

$client = new GuzzleHttp\Client([
    'base_uri' => 'https://api.vocatech.com',
    'headers' => ['Authorization' => 'Bearer YOUR_API_KEY'],
    'http_errors' => false,
]);

$res = $client->post('/v1/webhooks', [
    'json' => [
        'url' => 'https://hooks.example.com/vocatech',
        'event_filters' => ['call.ended', 'message.received'],
        'number_filters' => ['7185550100'],
        'name' => 'CRM sync',
        'description' => 'Calls and texts into the CRM',
    ],
]);

echo $res->getStatusCode(), PHP_EOL, $res->getBody(), PHP_EOL;
```

### Body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | string (uri) | yes | An https URL on a public internet address. Every address the name resolves to must be public. No username or password in the URL. Up to 2,048 characters. |
| `event_filters` | array of strings | yes | The events to send. At least one. At least 1 item. |
| `events` | array of strings |  | Another name for `event_filters`, accepted on create only. |
| `extension_filters` | array of strings |  | Only call events (`call.*` and `call.transcription`) for these extensions. |
| `number_filters` | array of strings |  | Only events that involve one of these numbers: 10-digit US or Canadian numbers in any format, stored and shown as E.164 (`+17185550142`). Up to 200. Empty means every number on the account. Up to 200 items. |
| `name` | string |  | A name you will recognize. Defaults to the URL's host. |
| `description` | string |  |  |
| `company_id` | integer |  | Optional. If you send it, it must be your own company id. |

**Example**

```json
{
  "url": "https://hooks.example.com/vocatech",
  "event_filters": [
    "call.ended",
    "message.received"
  ],
  "number_filters": [
    "7185550100"
  ],
  "name": "CRM sync",
  "description": "Calls and texts into the CRM"
}
```

### Answers

#### 201

Created. Store `secret_key` now.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | integer | yes |  |
| `company_id` | integer | yes |  |
| `name` | string | yes | Defaults to the URL's host. |
| `description` | string or null | yes |  |
| `url` | string (uri) | yes |  |
| `event_filters` | array of strings | yes |  |
| `extension_filters` | array of strings | yes | Only call events for these extensions. Empty means every extension. |
| `number_filters` | array of strings | yes | Only events that involve one of these numbers: 10-digit US or Canadian numbers in any format, stored and shown as E.164 (`+17185550142`). Up to 200. Empty means every number on the account. Up to 200 items. |
| `enabled` | boolean | yes | False pauses deliveries. |
| `created_at` | string | yes | UTC, written `YYYY-MM-DD HH:MM:SS`. |
| `updated_at` | string | yes | UTC, written `YYYY-MM-DD HH:MM:SS`. |
| `secret_key` | string | yes | The signing secret, 64 characters. Shown only in this answer. Store it now. |

**Example**

```json
{
  "id": 318,
  "company_id": 1234567890,
  "name": "CRM sync",
  "description": "Calls and texts into the CRM",
  "url": "https://hooks.example.com/vocatech",
  "event_filters": [
    "call.ended",
    "message.received"
  ],
  "extension_filters": [],
  "number_filters": [
    "+17185550100"
  ],
  "enabled": true,
  "created_at": "2026-09-24 14:05:09",
  "updated_at": "2026-09-24 14:05:09",
  "secret_key": "9f4c2e7a1b8d3f6c0e5a9d2b7f4c1e8a3d6b0f9c2e5a8d1b4f7c0e3a6d9b2f5c"
}
```

#### 401

No key, or a key that is wrong or turned off.

#### 403

The key's scope does not allow an event you asked for, `company_id` is not yours, or the key's scopes or address.

**Example**

```json
{
  "error": {
    "code": 403,
    "type": "FORBIDDEN",
    "message": "This API key cannot subscribe to call.ended. It may subscribe to: message.sent, message.received, message.status_updated."
  }
}
```

#### 422

A URL we refuse, no events or an unknown event, or a bad number filter.

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "The URL must point at a public internet address. hooks.internal does not resolve to one."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "The URL must use HTTPS."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "Invalid event(s): call.missed. Allowed: call.started, call.started, call.ended, call.answered, call.transcription, message.sent, message.received, message.status_updated, fax.sent, fax.received, fax.delivered, fax.failed"
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "Not a valid phone number in number_filters: 555-0142"
  }
}
```

#### 429

A rate limit was reached. Wait `Retry-After` seconds. The body names the counter, the window and the limit.

#### 503

Try again shortly. The key could not be checked just now (`Retry-After: 5`), or a service this route needs could not be reached.

## List failed deliveries

`GET /v1/webhooks/failures`

Deliveries that failed, newest first, with the event exactly as we sent it and the error we got.

A failure is listed from its first failed attempt, while we retry. One that used all 5 attempts stays listed for 7 days. One that succeeds on a retry leaves the list, and deleting an endpoint drops its failures. A scoped key sees only failures of endpoints it created.

There is no replay route. To catch up, read the data with `GET /v1/calls`, `GET /v1/messages` or `GET /v1/faxes`.

### Request

**curl**

```bash
curl -G https://api.vocatech.com/v1/webhooks/failures \
  -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "page=1" \
  --data-urlencode "limit=20"
```

**Node**

```javascript
const params = new URLSearchParams({
  page: "1",
  limit: "20",
});

const res = await fetch(`https://api.vocatech.com/v1/webhooks/failures?${params}`, {
  headers: { Authorization: "Bearer YOUR_API_KEY" },
});

console.log(res.status, await res.json());
```

**Python**

```python
import requests

r = requests.get(
    "https://api.vocatech.com/v1/webhooks/failures",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    params={
        "page": 1,
        "limit": 20,
    },
    timeout=30,
)
print(r.status_code, r.json())
```

**PHP**

```php
<?php
require 'vendor/autoload.php';

$client = new GuzzleHttp\Client([
    'base_uri' => 'https://api.vocatech.com',
    'headers' => ['Authorization' => 'Bearer YOUR_API_KEY'],
    'http_errors' => false,
]);

$res = $client->get('/v1/webhooks/failures', [
    'query' => [
        'page' => 1,
        'limit' => 20,
    ],
]);

echo $res->getStatusCode(), PHP_EOL, $res->getBody(), PHP_EOL;
```

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `page` | query | integer |  | Page number, from 1. Default `1`. At least 1. |
| `limit` | query | integer |  | Failures per page, 1 to 100. A value outside that range is brought inside it. Default `20`. From 1 to 100. |
| `company_id` | query | integer |  | Optional. If you send it, it must be your own company id, or the request is refused with `403`. The key already decides the company. |

### Answers

#### 200

A page of failures.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `failures` | array of objects | yes |  |
| `failures[].id` | integer | yes |  |
| `failures[].endpoint_id` | integer or null | yes |  |
| `failures[].endpoint_url` | string | yes | The URL the delivery went to. |
| `failures[].event_type` | string | yes | A webhook event. One of `call.started`, `call.answered`, `call.ended`, `call.transcription`, `message.sent`, `message.received`, `message.status_updated`, `fax.sent`, `fax.received`, `fax.delivered` or `fax.failed`. |
| `failures[].payload` | object or null | yes | The event exactly as we sent it. |
| `failures[].error_message` | string or null | yes | `HTTP` and the status your server answered, or the connection error. |
| `failures[].attempts` | integer | yes | Attempts so far, 1 to 5. From 1 to 5. |
| `failures[].retried_at` | string or null | yes | The last retry. UTC, written `YYYY-MM-DD HH:MM:SS`. |
| `failures[].created_at` | string | yes | The first failure. UTC, written `YYYY-MM-DD HH:MM:SS`. |
| `meta` | object | yes |  |
| `meta.page` | integer | yes |  |
| `meta.limit` | integer | yes |  |
| `meta.total` | integer | yes |  |
| `meta.total_pages` | integer | yes |  |

**Example**

```json
{
  "failures": [
    {
      "id": 20417,
      "endpoint_id": 318,
      "endpoint_url": "https://hooks.example.com/vocatech",
      "event_type": "call.ended",
      "payload": {
        "id": "evt_6703a1f8e0c46.73920518",
        "event_type": "call.ended",
        "timestamp": "2026-09-24T14:09:43+00:00",
        "company_id": 1234567890,
        "data": {
          "call_id": "8a1f3c2e-5b7d-4e9a-9c1b-2d4e6f8a0b1c",
          "direction": "incoming",
          "extension": "104",
          "extension_name": "Maya Chen",
          "remote_name": "Jordan Reyes",
          "remote_number": "7185550142",
          "group_number": "7185550100",
          "start_time": "2026-09-24T14:03:12.000Z",
          "end_time": "2026-09-24T14:09:40.000Z",
          "duration": 369
        }
      },
      "error_message": "HTTP 503",
      "attempts": 2,
      "retried_at": "2026-09-24 14:15:02",
      "created_at": "2026-09-24 14:09:43"
    }
  ],
  "meta": {
    "page": 1,
    "limit": 20,
    "total": 1,
    "total_pages": 1
  }
}
```

#### 401

No key, or a key that is wrong or turned off.

#### 403

Refused. The key is scoped away from this route, the request came from an address not on the key's allow-list, or the route refused the request (the message says why).

#### 429

A rate limit was reached. Wait `Retry-After` seconds. The body names the counter, the window and the limit.

#### 503

Try again shortly. The key could not be checked just now (`Retry-After: 5`), or a service this route needs could not be reached.

## Get a webhook endpoint

`GET /v1/webhooks/{id}`

One endpoint. The signing secret is not shown. A scoped key can read only the endpoints it created.

### Request

**curl**

```bash
curl https://api.vocatech.com/v1/webhooks/318 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/webhooks/318", {
  headers: { Authorization: "Bearer YOUR_API_KEY" },
});

console.log(res.status, await res.json());
```

**Python**

```python
import requests

r = requests.get(
    "https://api.vocatech.com/v1/webhooks/318",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    timeout=30,
)
print(r.status_code, r.json())
```

**PHP**

```php
<?php
require 'vendor/autoload.php';

$client = new GuzzleHttp\Client([
    'base_uri' => 'https://api.vocatech.com',
    'headers' => ['Authorization' => 'Bearer YOUR_API_KEY'],
    'http_errors' => false,
]);

$res = $client->get('/v1/webhooks/318');

echo $res->getStatusCode(), PHP_EOL, $res->getBody(), PHP_EOL;
```

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | The webhook endpoint's id. For example `318`. |
| `company_id` | query | integer |  | Optional. If you send it, it must be your own company id, or the request is refused with `403`. The key already decides the company. |

### Answers

#### 200

The endpoint.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | integer | yes |  |
| `company_id` | integer | yes |  |
| `name` | string | yes | Defaults to the URL's host. |
| `description` | string or null | yes |  |
| `url` | string (uri) | yes |  |
| `event_filters` | array of strings | yes |  |
| `extension_filters` | array of strings | yes | Only call events for these extensions. Empty means every extension. |
| `number_filters` | array of strings | yes | Only events that involve one of these numbers: 10-digit US or Canadian numbers in any format, stored and shown as E.164 (`+17185550142`). Up to 200. Empty means every number on the account. Up to 200 items. |
| `enabled` | boolean | yes | False pauses deliveries. |
| `created_at` | string | yes | UTC, written `YYYY-MM-DD HH:MM:SS`. |
| `updated_at` | string | yes | UTC, written `YYYY-MM-DD HH:MM:SS`. |

**Example**

```json
{
  "id": 318,
  "company_id": 1234567890,
  "name": "CRM sync",
  "description": "Calls and texts into the CRM",
  "url": "https://hooks.example.com/vocatech",
  "event_filters": [
    "call.ended",
    "message.received"
  ],
  "extension_filters": [],
  "number_filters": [
    "+17185550100"
  ],
  "enabled": true,
  "created_at": "2026-09-24 14:05:09",
  "updated_at": "2026-09-24 14:05:09"
}
```

#### 401

No key, or a key that is wrong or turned off.

#### 403

Refused. The key is scoped away from this route, the request came from an address not on the key's allow-list, or the route refused the request (the message says why).

#### 404

No endpoint with that id on your account, or not one this key created.

#### 429

A rate limit was reached. Wait `Retry-After` seconds. The body names the counter, the window and the limit.

#### 503

Try again shortly. The key could not be checked just now (`Retry-After: 5`), or a service this route needs could not be reached.

## Update a webhook endpoint

`PATCH /v1/webhooks/{id}`

Changes only the fields you send.

- `enabled: false` pauses deliveries without deleting the endpoint. Retries already scheduled still go out; delete the endpoint to stop them.
- `number_filters: []` widens the endpoint back to every number.
- A blank `name` keeps the current one.
- `event_filters` must keep at least one event. `events` is not accepted here.

The signing secret does not change. To get a new secret, create a new endpoint and delete the old one.

### Request

**curl**

```bash
curl -X PATCH https://api.vocatech.com/v1/webhooks/318 \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": false
  }'
```

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/webhooks/318", {
  method: "PATCH",
  headers: {
    Authorization: "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    enabled: false,
  }),
});

console.log(res.status, await res.json());
```

**Python**

```python
import requests

r = requests.patch(
    "https://api.vocatech.com/v1/webhooks/318",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "enabled": False,
    },
    timeout=30,
)
print(r.status_code, r.json())
```

**PHP**

```php
<?php
require 'vendor/autoload.php';

$client = new GuzzleHttp\Client([
    'base_uri' => 'https://api.vocatech.com',
    'headers' => ['Authorization' => 'Bearer YOUR_API_KEY'],
    'http_errors' => false,
]);

$res = $client->patch('/v1/webhooks/318', [
    'json' => [
        'enabled' => false,
    ],
]);

echo $res->getStatusCode(), PHP_EOL, $res->getBody(), PHP_EOL;
```

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | The webhook endpoint's id. For example `318`. |

### Body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | string (uri) |  | Checked the same way as on create. |
| `event_filters` | array of strings |  | Replaces the list. At least one. At least 1 item. |
| `extension_filters` | array of strings |  | Replaces the list. Send `[]` for every extension. |
| `number_filters` | array of strings |  | Replaces the list. Send `[]` for every number. Up to 200 items. |
| `enabled` | boolean |  | False pauses deliveries. |
| `name` | string |  | A blank name keeps the current one. |
| `description` | string or null |  |  |
| `company_id` | integer |  | Optional. If you send it, it must be your own company id. |

**Pause deliveries**

```json
{
  "enabled": false
}
```

**Change the events**

```json
{
  "event_filters": [
    "call.ended",
    "call.transcription"
  ]
}
```

### Answers

#### 200

The endpoint as saved.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | integer | yes |  |
| `company_id` | integer | yes |  |
| `name` | string | yes | Defaults to the URL's host. |
| `description` | string or null | yes |  |
| `url` | string (uri) | yes |  |
| `event_filters` | array of strings | yes |  |
| `extension_filters` | array of strings | yes | Only call events for these extensions. Empty means every extension. |
| `number_filters` | array of strings | yes | Only events that involve one of these numbers: 10-digit US or Canadian numbers in any format, stored and shown as E.164 (`+17185550142`). Up to 200. Empty means every number on the account. Up to 200 items. |
| `enabled` | boolean | yes | False pauses deliveries. |
| `created_at` | string | yes | UTC, written `YYYY-MM-DD HH:MM:SS`. |
| `updated_at` | string | yes | UTC, written `YYYY-MM-DD HH:MM:SS`. |

**Example**

```json
{
  "id": 318,
  "company_id": 1234567890,
  "name": "CRM sync",
  "description": "Calls and texts into the CRM",
  "url": "https://hooks.example.com/vocatech",
  "event_filters": [
    "call.ended",
    "message.received"
  ],
  "extension_filters": [],
  "number_filters": [
    "+17185550100"
  ],
  "enabled": false,
  "created_at": "2026-09-24 14:05:09",
  "updated_at": "2026-09-24 15:02:44"
}
```

#### 401

No key, or a key that is wrong or turned off.

#### 403

The key's scope does not allow an event you asked for, `company_id` is not yours, or the key's scopes or address.

**Example**

```json
{
  "error": {
    "code": 403,
    "type": "FORBIDDEN",
    "message": "Unauthorized: company_id mismatch."
  }
}
```

#### 404

No endpoint with that id on your account, or not one this key created.

#### 422

A URL we refuse, an empty or unknown event list, or a bad number filter.

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "event_filters must be a non-empty array."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "The URL must use HTTPS."
  }
}
```

#### 429

A rate limit was reached. Wait `Retry-After` seconds. The body names the counter, the window and the limit.

#### 503

Try again shortly. The key could not be checked just now (`Retry-After: 5`), or a service this route needs could not be reached.

## Delete a webhook endpoint

`DELETE /v1/webhooks/{id}`

Deletes the endpoint and stops its pending retries. The answer has no body.

### Request

**curl**

```bash
curl -X DELETE https://api.vocatech.com/v1/webhooks/318 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/webhooks/318", {
  method: "DELETE",
  headers: { Authorization: "Bearer YOUR_API_KEY" },
});

console.log(res.status); // 204 when it worked
```

**Python**

```python
import requests

r = requests.delete(
    "https://api.vocatech.com/v1/webhooks/318",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    timeout=30,
)
print(r.status_code)  # 204 when it worked
```

**PHP**

```php
<?php
require 'vendor/autoload.php';

$client = new GuzzleHttp\Client([
    'base_uri' => 'https://api.vocatech.com',
    'headers' => ['Authorization' => 'Bearer YOUR_API_KEY'],
    'http_errors' => false,
]);

$res = $client->delete('/v1/webhooks/318');

echo $res->getStatusCode(), PHP_EOL; // 204 when it worked
```

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | The webhook endpoint's id. For example `318`. |
| `company_id` | query | integer |  | Optional. If you send it, it must be your own company id, or the request is refused with `403`. The key already decides the company. |

### Answers

#### 204

Deleted.

#### 401

No key, or a key that is wrong or turned off.

#### 403

Refused. The key is scoped away from this route, the request came from an address not on the key's allow-list, or the route refused the request (the message says why).

#### 404

No endpoint with that id on your account, or not one this key created.

#### 429

A rate limit was reached. Wait `Retry-After` seconds. The body names the counter, the window and the limit.

#### 503

Try again shortly. The key could not be checked just now (`Retry-After: 5`), or a service this route needs could not be reached.

## Send a test delivery

`POST /v1/webhooks/{id}/test`

Sends one signed test event to the endpoint now and tells you what came back. The event is `webhook.test`:

```json
{
  "id": "test_6703a7f0c2b19.90417236",
  "event_type": "webhook.test",
  "timestamp": "2026-09-24T14:30:00+00:00",
  "company_id": 1234567890,
  "data": { "message": "This is a test webhook delivery from Vocatech." }
}
```

It carries the same headers and signature as every event. We wait up to 10 seconds. A failed test is not retried and is not listed in failures. The answer is `200` either way: read `success`.

### Request

**curl**

```bash
curl -X POST https://api.vocatech.com/v1/webhooks/318/test \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/webhooks/318/test", {
  method: "POST",
  headers: { Authorization: "Bearer YOUR_API_KEY" },
});

console.log(res.status, await res.json());
```

**Python**

```python
import requests

r = requests.post(
    "https://api.vocatech.com/v1/webhooks/318/test",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    timeout=30,
)
print(r.status_code, r.json())
```

**PHP**

```php
<?php
require 'vendor/autoload.php';

$client = new GuzzleHttp\Client([
    'base_uri' => 'https://api.vocatech.com',
    'headers' => ['Authorization' => 'Bearer YOUR_API_KEY'],
    'http_errors' => false,
]);

$res = $client->post('/v1/webhooks/318/test');

echo $res->getStatusCode(), PHP_EOL, $res->getBody(), PHP_EOL;
```

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | The webhook endpoint's id. For example `318`. |

### Answers

#### 200

What your endpoint answered.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `success` | boolean | yes | True when your endpoint answered `2xx` within 10 seconds. |
| `status_code` | integer or null | yes | The status your endpoint answered. Null when it could not be reached. |
| `error` | string or null | yes | Why it failed. |

**Delivered**

```json
{
  "success": true,
  "status_code": 200,
  "error": null
}
```

**Your server answered 500**

```json
{
  "success": false,
  "status_code": 500,
  "error": "HTTP 500"
}
```

**Not a public address any more**

```json
{
  "success": false,
  "status_code": null,
  "error": "Destination does not resolve to a public internet address"
}
```

#### 401

No key, or a key that is wrong or turned off.

#### 403

Refused. The key is scoped away from this route, the request came from an address not on the key's allow-list, or the route refused the request (the message says why).

#### 404

No endpoint with that id on your account, or not one this key created.

#### 429

A rate limit was reached. Wait `Retry-After` seconds. The body names the counter, the window and the limit.

#### 503

Try again shortly. The key could not be checked just now (`Retry-After: 5`), or a service this route needs could not be reached.
