In depth
Webhooks in depth
Written beside the API’s code, and read from it every hour.
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. 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
httpsand 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
3xxanswer 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.
# 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"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');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
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 sendcall.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 asfax.received, or asfax.deliveredwhen 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 |
{
"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.
{
"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.
{
"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
}
]
}
}{
"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 address: call it with your key for a download link. A status update may not repeat the text.
{
"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}.
{
"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
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=3f6a1c9e0b7d4e2a8c5f1b3d9e7a2c4f6b8d0e1a3c5f7b9d2e4a6c8f0b1d3e5aThe signature proves the event came from us and was not changed on the way:
signed = "t=" + t + "." + raw_body
v1 = hex( HMAC-SHA256( key = secret_key, message = signed ) )tis 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 itsid, stay the same.
Verify the signature
// 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);
}# 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
// 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:
// 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);# 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
// 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/failureslists 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.limitis 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/messagesandGET /v1/faxes. - Deliveries can repeat, so dedupe as in Receive webhooks.
{
"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}/testsends a signedwebhook.testevent 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.
