# Webhooks in depth

Webhooks push events to your server as they happen: calls, transcripts, texts and faxes. Each delivery is a signed `POST` with a JSON body.

## Endpoints and filters

| Method | Path | What it does |
| --- | --- | --- |
| GET | `/v1/webhooks` | List your endpoints |
| POST | `/v1/webhooks` | Create one. The answer holds `secret_key`, this once. |
| GET | `/v1/webhooks/{id}` | Read one |
| PATCH | `/v1/webhooks/{id}` | Change its name, URL, events, filters or `enabled` |
| DELETE | `/v1/webhooks/{id}` | Delete it. Answers `204`. |
| POST | `/v1/webhooks/{id}/test` | Send a signed `webhook.test` event and report what your server answered |
| GET | `/v1/webhooks/failures` | Deliveries that failed, newest first |

| Field | Meaning |
| --- | --- |
| `url` | Required. `https` on a public internet address. See the rules below. |
| `event_filters` | Required. At least one of the [events](#webhook-events). On create, `events` also works. |
| `name` | Optional. The URL's host by default. |
| `description` | Optional |
| `number_filters` | Optional. Only events that involve these numbers, 10 digits each, up to 200. Empty means every number. |
| `extension_filters` | Optional. Call events only: only these extensions. |
| `enabled` | On `PATCH`: `false` stops new events, `true` starts them again. |

The URL rules, checked when you save the URL (`422` when it fails) and again at every delivery:

- It uses `https` and carries no user name or password.
- Its host resolves only to public internet addresses. Private, loopback, link-local and internal names are refused.
- We never follow a redirect. A `3xx` answer counts as a failed delivery, so point the webhook at the final URL.

A main key sees every endpoint on the account. An API Messaging key sees and changes only the endpoints it created, and may subscribe them to the three message events only.

**Number filters and call events.** On `call.started`, `call.answered` and `call.ended`, `group_number` is one main number of your account, not always the number that was dialed. A number filter matches those events on the caller's number or that main number, so leave `number_filters` empty on an endpoint that needs every call.

**curl**

```bash
# Change what an endpoint receives
curl -X PATCH https://api.vocatech.com/v1/webhooks/42 \
  -H "Authorization: Bearer $VOCATECH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "event_filters": ["call.ended", "call.transcription"] }'

# Pause it: new events stop
curl -X PATCH https://api.vocatech.com/v1/webhooks/42 \
  -H "Authorization: Bearer $VOCATECH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": false }'

# Deliveries that failed, newest first
curl "https://api.vocatech.com/v1/webhooks/failures?limit=20" \
  -H "Authorization: Bearer $VOCATECH_API_KEY"

# Delete it: its pending retries stop too
curl -X DELETE https://api.vocatech.com/v1/webhooks/42 \
  -H "Authorization: Bearer $VOCATECH_API_KEY"
```

**Node**

```javascript
import { vt } from './vocatech.mjs';

// Change what an endpoint receives
await vt('PATCH', '/v1/webhooks/42', { event_filters: ['call.ended', 'call.transcription'] });

// Pause it: new events stop
await vt('PATCH', '/v1/webhooks/42', { enabled: false });

// Deliveries that failed, newest first
const { failures, meta } = await vt('GET', '/v1/webhooks/failures?limit=20');
console.log(meta.total, failures.map((f) => [f.event_type, f.attempts, f.error_message]));

// Delete it: its pending retries stop too
await vt('DELETE', '/v1/webhooks/42');
```

**Python**

```python
from vocatech import vt

# Change what an endpoint receives
vt("PATCH", "/v1/webhooks/42", {"event_filters": ["call.ended", "call.transcription"]})

# Pause it: new events stop
vt("PATCH", "/v1/webhooks/42", {"enabled": False})

# Deliveries that failed, newest first
failed = vt("GET", "/v1/webhooks/failures", params={"limit": 20})
for f in failed["failures"]:
    print(f["event_type"], f["attempts"], f["error_message"])

# Delete it: its pending retries stop too
vt("DELETE", "/v1/webhooks/42")
```

**PHP**

```php
<?php
require __DIR__ . '/vocatech.php';

// Change what an endpoint receives
vt('PATCH', '/v1/webhooks/42', ['event_filters' => ['call.ended', 'call.transcription']]);

// Pause it: new events stop
vt('PATCH', '/v1/webhooks/42', ['enabled' => false]);

// Deliveries that failed, newest first
$failed = vt('GET', '/v1/webhooks/failures?limit=20');
foreach ($failed['failures'] as $f) {
    echo $f['event_type'], ' ', $f['attempts'], ' ', $f['error_message'], PHP_EOL;
}

// Delete it: its pending retries stop too
vt('DELETE', '/v1/webhooks/42');
```

## Events

| Event | Fires when | Arrives |
| --- | --- | --- |
| `call.started` | A call reaches one of your lines (a person, a group or a menu), or one of your lines places a call. | Seconds after |
| `call.answered` | That leg is answered. | Seconds after |
| `call.ended` | That leg ends. | Seconds after |
| `call.transcription` | A recorded leg is transcribed and summarized: one event per recording, so a call parked and picked up again sends one per part. | Minutes after the call. Up to about 6 hours when transcription runs behind. |
| `message.received` | A text or WhatsApp message comes in. | Seconds, at most about a minute |
| `message.sent` | A text goes out through the API or an automation: a send on a number in `api` or `email` mode, a picture message through the API, or a mass or group text. A text sent from Webex, an email reply or a WhatsApp reply does not fire it; those are in `GET /v1/messages`. | Seconds to about a minute |
| `message.status_updated` | A carrier or WhatsApp reports a new status, such as `delivered`, `failed` or `read`. | Seconds after the report |
| `fax.sent` | An outgoing fax is logged while it is still sending. | About a minute |
| `fax.received` | An incoming fax is logged while it is still arriving. | About a minute |
| `fax.delivered` | A fax is logged already complete. | About a minute |
| `fax.failed` | A fax is logged already failed or restricted. | About a minute |

- **Calls come in legs.** Each leg sends its own events, so a call that rings a group can send several `call.started`. Only recorded legs send `call.transcription`.
- **A fax sends one event**, from its status the first time we see it. A fax that finishes later does not send a second event. For an outgoing fax's final status, read `GET /v1/faxes`. An incoming fax arrives as `fax.received`, or as `fax.delivered` when it was already complete, so subscribe to both.
- **Timing** is how it runs today, not a promise. Build for an event that arrives late, twice, or out of order.

## Payloads

Every event has the same envelope:

| Field | Meaning |
| --- | --- |
| `id` | This event's id on this endpoint. A retry repeats it. |
| `event_type` | One of the events above, or `webhook.test` |
| `timestamp` | When the event was built, ISO 8601 with an offset |
| `company_id` | Your account |
| `data` | The event itself |

**call.endedCopy**

```json
{
  "id": "evt_6ab52ce04f1a23.81920744",
  "event_type": "call.ended",
  "timestamp": "2026-09-24T13:06:52+00:00",
  "company_id": 1234567890,
  "data": {
    "call_id": "3c9e7a14-2b6d-4f80-9a1e-5d7c2b8f4e60",
    "direction": "incoming",
    "extension": "104",
    "extension_name": "Maya Chen",
    "remote_name": "Jordan Reyes",
    "remote_number": "7185550142",
    "group_number": "7185550100",
    "start_time": "2026-09-24T13:02:24.000Z",
    "end_time": "2026-09-24T13:06:49.000Z",
    "duration": 262
  }
}
```

`call.started` and `call.answered` carry the same fields, with `end_time` and `duration` set to `null`. On `call.ended`, `duration` counts the seconds from answer to hang-up, and is `null` when the leg was never answered. `direction` is the leg's: `incoming` when it received the call, `outgoing` when it placed it.

**call.transcriptionCopy**

```json
{
  "id": "evt_6ab52d4a9c0e17.40211586",
  "event_type": "call.transcription",
  "timestamp": "2026-09-24T13:11:30+00:00",
  "company_id": 1234567890,
  "data": {
    "call_id": "5e2b9d71-0c4a-4f3e-8b6a-1d9c7e2f4a08",
    "direction": "incoming",
    "extension": "104",
    "extension_name": "Maya Chen",
    "remote_name": "Jordan Reyes",
    "remote_number": "7185550142",
    "group_number": "7185550100",
    "start_time": "2026-09-24T13:02:11.000Z",
    "end_time": "2026-09-24T13:06:49.000Z",
    "duration": 278,
    "summary": "Jordan asked to move the Thursday visit to Friday morning. Maya booked Friday at 9 AM.",
    "transcription": "Maya: Good morning, how can I help?\nJordan: Hi, I need to move my Thursday visit.\nMaya: Sure. Friday at 9 AM works.",
    "recording_id": 10592210,
    "part": 1,
    "parts": 1,
    "recording_start_time": "2026-09-24T13:02:25.000Z",
    "recording_duration": 264
  }
}
```

`call.transcription` adds `summary` and `transcription` (speaker by speaker). Its `direction` and `duration` are the whole call's, and its extension is the recorded leg's.

It comes once per recording. A call parked and picked up again is recorded in parts and sends one event per part, and these fields tell them apart: `recording_id`, `part` of `parts`, `recording_start_time` and `recording_duration`. Part N is `GET /v1/media/rec_<n>?part=N` and the same entry of `recordings` in `GET /v1/calls`. `parts` counts the parts saved when the event was sent. `part` is `null` for a recording of another call leg linked to the same leg of the report.

**message.receivedCopy**

```json
{
  "id": "evt_6ab52e11d07b45.12098876",
  "event_type": "message.received",
  "timestamp": "2026-09-24T13:20:05+00:00",
  "company_id": 1234567890,
  "data": {
    "message_id": "sms_4821937",
    "direction": "incoming",
    "channel": "text",
    "from": "+17185550142",
    "to": "+17185550100",
    "body": "Friday at 9 works. Photo of the side entrance attached.",
    "status": "delivered",
    "error": null,
    "sent_at": "2026-09-24T13:20:03.000Z",
    "attachments": [
      {
        "url": "https://api.vocatech.com/v1/media/att_902114",
        "content_type": "image/jpeg",
        "filename": "side-entrance.jpg",
        "size": 184220
      }
    ]
  }
}
```

**message.status_updatedCopy**

```json
{
  "id": "evt_6ab52e3f8a1c02.55310947",
  "event_type": "message.status_updated",
  "timestamp": "2026-09-24T13:15:09+00:00",
  "company_id": 1234567890,
  "data": {
    "message_id": "sms_4821930",
    "direction": "outgoing",
    "channel": "text",
    "from": "+17185550100",
    "to": "+17185550142",
    "body": "",
    "status": "delivered",
    "error": null,
    "sent_at": "2026-09-24T13:15:02.000Z",
    "attachments": []
  }
}
```

Message events write numbers as `+1` and ten digits. `channel` is `text` or `whatsapp`. `error` says why a send failed and is otherwise `null`. Each attachment's `url` is a [Media](https://vocatech.com/developers/guide/media) address: call it with your key for a download link. A status update may not repeat the text.

**fax.receivedCopy**

```json
{
  "id": "evt_6ab52f02c4d9e8.90417736",
  "event_type": "fax.received",
  "timestamp": "2026-09-24T13:31:40+00:00",
  "company_id": 1234567890,
  "data": {
    "fax_id": "fax_90398",
    "direction": "incoming",
    "from": "+17185550144",
    "to": "+17185550101",
    "pages": 3,
    "subject": null,
    "status": "in_progress",
    "sent_at": "2026-09-24T13:30:52.000Z"
  }
}
```

Fax events write numbers with `+1` too. `status` is `in_progress`, `completed`, `failed` or `restricted`. Fetch the document with `GET /v1/media/{fax_id}`.

**webhook.testCopy**

```json
{
  "id": "test_6ab53001a2b3c4.11223344",
  "event_type": "webhook.test",
  "timestamp": "2026-09-24T13:40:00+00:00",
  "company_id": 1234567890,
  "data": {
    "message": "This is a test webhook delivery from Vocatech."
  }
}
```

**Two ids for one call.** On call events, `call_id` is the id the phone system gave that call or leg. It is not guaranteed to equal the `call_id` in `GET /v1/calls`. To join the two, match `remote_number` and the start time.

## Headers and signature

**Request headersCopy**

```http
POST /webhooks/vocatech HTTP/1.1
Content-Type: application/json
User-Agent: Vocatech-Webhook/1.0
X-Vocatech-Event: call.ended
X-Vocatech-Signature: t=1790258812,v1=3f6a1c9e0b7d4e2a8c5f1b3d9e7a2c4f6b8d0e1a3c5f7b9d2e4a6c8f0b1d3e5a
```

The signature proves the event came from us and was not changed on the way:

**How v1 is madeCopy**

```text
signed = "t=" + t + "." + raw_body
v1     = hex( HMAC-SHA256( key = secret_key, message = signed ) )
```

- `t` is the Unix time the delivery was signed. Reject a delivery more than 300 seconds away from your clock.
- The key is your webhook's `secret_key`, used as the text you were given. Do not decode it.
- The message is `t=`, the time, a period, then the raw body exactly as it arrived. Parsing and re-encoding the JSON changes the bytes and breaks the match.
- Compare in constant time.
- Each retry is signed again, with a new `t`. The body, and its `id`, stay the same.

## Verify the signature

**Node**

```javascript
// verify.mjs
import crypto from 'node:crypto';

// header:  the X-Vocatech-Signature value
// rawBody: the request body as a Buffer, exactly as it arrived
// secret:  the webhook's secret_key, as the text you were given
export function verifyVocatech(header, rawBody, secret, toleranceSec = 300) {
  const parts = {};
  for (const piece of String(header || '').split(',')) {
    const at = piece.indexOf('=');
    if (at > 0) parts[piece.slice(0, at).trim()] = piece.slice(at + 1).trim();
  }
  if (!/^\d+$/.test(parts.t || '') || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) return false;

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

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(parts.v1, 'utf8');
  // timingSafeEqual throws when the lengths differ, so check them first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

**Python**

```python
# verify.py
import hashlib
import hmac
import time


def verify_vocatech(header, raw_body, secret, tolerance=300):
    """header: X-Vocatech-Signature. raw_body: bytes as received. secret: str."""
    parts = {}
    for piece in (header or "").split(","):
        key, sep, value = piece.strip().partition("=")
        if sep:
            parts[key] = value
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or not v1:
        return False
    if abs(time.time() - int(t)) > tolerance:
        return False

    signed = b"t=" + t.encode() + b"." + raw_body  # bytes, never a decoded str
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), v1.encode())
```

**PHP**

```php
<?php
// verify.php
// $header: X-Vocatech-Signature. $raw: the body as received. $secret: secret_key.
function vt_verify(string $header, string $raw, string $secret, int $tolerance = 300): bool
{
    $parts = [];
    foreach (explode(',', $header) as $piece) {
        [$key, $value] = array_pad(explode('=', trim($piece), 2), 2, '');
        $parts[$key] = $value;
    }
    $t = $parts['t'] ?? '';
    $v1 = $parts['v1'] ?? '';
    if (!ctype_digit($t) || $v1 === '' || abs(time() - (int) $t) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', "t={$t}.{$raw}", $secret);
    return hash_equals($expected, $v1); // constant time
}
```

In Node, `crypto.timingSafeEqual` throws when the two lengths differ, so compare the lengths first. In Python, build the signed message from bytes, never from a decoded string. In PHP, compare with `hash_equals`.

## Capture the raw body

Web frameworks parse JSON before your code sees it, and a parsed body no longer matches the signature. Take the raw bytes, verify, answer, then work:

**Node**

```javascript
// Express: npm install express
import express from 'express';
import { verifyVocatech } from './verify.mjs';

const app = express();
const SECRET = process.env.VOCATECH_WEBHOOK_SECRET;
const seen = new Set(); // use your database in production

// express.raw keeps the exact bytes. express.json() would parse them,
// and a re-encoded body no longer matches the signature.
app.post('/webhooks/vocatech', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.get('X-Vocatech-Signature');
  if (!Buffer.isBuffer(req.body) || !verifyVocatech(signature, req.body, SECRET)) {
    return res.sendStatus(401);
  }
  res.sendStatus(200); // answer within 5 seconds, then work

  const event = JSON.parse(req.body.toString('utf8'));
  if (seen.has(event.id)) return; // a retry of one you already have
  seen.add(event.id);
  setImmediate(() => handle(event));
});

function handle(event) {
  console.log(event.event_type, event.data);
}

app.listen(3000);
```

**Python**

```python
# Flask: pip install flask
import json
import os
import threading

from flask import Flask, abort, request
from verify import verify_vocatech

app = Flask(__name__)
SECRET = os.environ["VOCATECH_WEBHOOK_SECRET"]
seen = set()  # use your database in production


@app.post("/webhooks/vocatech")
def vocatech_webhook():
    raw = request.get_data()  # the exact bytes, before any parsing
    if not verify_vocatech(request.headers.get("X-Vocatech-Signature"), raw, SECRET):
        abort(401)

    event = json.loads(raw)
    if event["id"] not in seen:  # skip a retry of one you already have
        seen.add(event["id"])
        threading.Thread(target=handle, args=(event,)).start()
    return "", 200  # answer within 5 seconds, then work


def handle(event):
    print(event["event_type"], event["data"])
```

**PHP**

```php
<?php
// Laravel, routes/api.php (API routes skip the CSRF check)
use App\Jobs\HandleVocatechEvent;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;

Route::post('/webhooks/vocatech', function (Request $request) {
    $raw = $request->getContent(); // the exact bytes, not $request->all()
    $ok = vt_verify(
        $request->header('X-Vocatech-Signature', ''),
        $raw,
        config('services.vocatech.webhook_secret')
    );
    if (!$ok) {
        abort(401);
    }

    $event = json_decode($raw, true);
    // Cache::add is false when the id is already there: a retry you already have.
    if (Cache::add('vocatech-event:' . $event['id'], true, now()->addDays(8))) {
        HandleVocatechEvent::dispatch($event); // your queued job does the work
    }
    return response()->noContent(); // 204, well within 5 seconds
});
```

In plain PHP, `file_get_contents('php://input')` returns the raw body.

## Retries and failures

Answer with any `2xx` within 5 seconds. Anything else fails the delivery: another status, a `3xx` redirect, a timeout or a connection error. A failed delivery is retried up to four times, five attempts in all:

| Attempt | When |
| --- | --- |
| 1 | As the event happens |
| 2 | Within about 5 minutes of the failure |
| 3 | At least 5 minutes after attempt 2 |
| 4 | At least 30 minutes after attempt 3 |
| 5 | At least 2 hours after attempt 4, about 2 hours 40 minutes after the first failure. Then it stops. |

- Retries go to the URL the event was first sent to. Turning an endpoint off stops new events, not retries already under way. Deleting it stops those too.
- `GET /v1/webhooks/failures` lists deliveries that are failing or gave up, newest first, with the full payload, the last error and the number of attempts. A delivery that later succeeds leaves the list. One that gives up stays for 7 days after its last attempt. `limit` is 1 to 100, 20 by default.
- There is no replay route. To recover a missed event, take its payload from the failures list, or read the same data from `GET /v1/calls`, `GET /v1/messages` and `GET /v1/faxes`.
- Deliveries can repeat, so dedupe as in [Receive webhooks](https://vocatech.com/developers/guide/receive-webhooks).

**GET /v1/webhooks/failures response200 OKTrimmedCopy**

```json
{
  "failures": [
    {
      "id": 7781,
      "endpoint_id": 42,
      "endpoint_url": "https://YOUR_DOMAIN/webhooks/vocatech",
      "event_type": "call.ended",
      "payload": {
        "id": "evt_6ab52ce04f1a23.81920744",
        "event_type": "call.ended",
        "timestamp": "2026-09-24T13:06:52+00:00",
        "company_id": 1234567890,
        "data": { "call_id": "3c9e7a14-2b6d-4f80-9a1e-5d7c2b8f4e60" }
      },
      "error_message": "HTTP 500",
      "attempts": 3,
      "retried_at": "2026-09-24 13:17:04",
      "created_at": "2026-09-24 13:06:53"
    }
  ],
  "meta": { "page": 1, "limit": 20, "total": 1, "total_pages": 1 }
}
```

## Test tool

Two ways to watch real deliveries before your own code is done:

- **The hosted test tool** at api.vocatech.com/test. Enter your key and your company id (it is in `GET /v1/whoami`), pick call events, then watch them arrive on the page or send one test event to your own URL. A session lasts 30 minutes, and events can take up to a minute to show on the page. Place a call to one of your numbers to see one.
- **`POST /v1/webhooks/{id}/test`** sends a signed `webhook.test` event to a saved endpoint and answers with the status your server returned. It waits up to 10 seconds.

While a hosted session runs, a temporary endpoint named Test Session appears in your webhook list. It is removed after the session ends.
