Webhooks
Webhook endpoints
Read from the API’s own reference file every hour.
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:
{
"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 asevent_typeUser-Agent: Vocatech-Webhook/1.0Content-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:
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:
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:
// $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/
Your webhook endpoints, newest first. A scoped key lists only the endpoints it created. The signing secret is never shown here.
Request
curl https://api.vocatech.com/v1/webhooks \
-H "Authorization: Bearer YOUR_API_KEY"const res = await fetch("https://api.vocatech.com/v1/webhooks", {
headers: { Authorization: "Bearer YOUR_API_KEY" },
});
console.log(res.status, await res.json());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
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 | 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
A value of type array of objects.
[
{
"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"
}
]Retry-After seconds. The body names the counter, the window and the limit.| Header | Type | Description |
|---|---|---|
Retry-After | integer | Seconds to wait before you try again. |
Retry-After: 5), or a service this route needs could not be reached.| Header | Type | Description |
|---|---|---|
Retry-After | integer | Seconds to wait before you try again. |
Create a webhook endpoint
POST/v1/
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.eventsis accepted as another name.extension_filters: only call events (call.*andcall.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 -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"
}'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());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
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 | Description |
|---|---|---|
url required | string (uri) | 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 required | array of strings | 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. |
{
"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
secret_key now.| Field | Type | Description |
|---|---|---|
id required | integer | |
company_id required | integer | |
name required | string | Defaults to the URL's host. |
description required | string or null | |
url required | string (uri) | |
event_filters required | array of strings | |
extension_filters required | array of strings | Only call events for these extensions. Empty means every extension. |
number_filters required | 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. |
enabled required | boolean | False pauses deliveries. |
created_at required | string | UTC, written YYYY-MM-DD HH:MM:SS. |
updated_at required | string | UTC, written YYYY-MM-DD HH:MM:SS. |
secret_key required | string | The signing secret, 64 characters. Shown only in this answer. Store it now. |
{
"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"
}company_id is not yours, or the key's scopes or address.Example body
{
"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."
}
}Example bodies
{
"error": {
"code": 422,
"type": "UNPROCESSABLE_ENTITY",
"message": "The URL must point at a public internet address. hooks.internal does not resolve to one."
}
}{
"error": {
"code": 422,
"type": "UNPROCESSABLE_ENTITY",
"message": "The URL must use HTTPS."
}
}{
"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"
}
}{
"error": {
"code": 422,
"type": "UNPROCESSABLE_ENTITY",
"message": "Not a valid phone number in number_filters: 555-0142"
}
}Retry-After seconds. The body names the counter, the window and the limit.| Header | Type | Description |
|---|---|---|
Retry-After | integer | Seconds to wait before you try again. |
Retry-After: 5), or a service this route needs could not be reached.| Header | Type | Description |
|---|---|---|
Retry-After | integer | Seconds to wait before you try again. |
List failed deliveries
GET/v1/
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 -G https://api.vocatech.com/v1/webhooks/failures \
-H "Authorization: Bearer YOUR_API_KEY" \
--data-urlencode "page=1" \
--data-urlencode "limit=20"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());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
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 | 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
| Field | Type | Description |
|---|---|---|
failures required | array of objects | |
failures[]. required | integer | |
failures[]. required | integer or null | |
failures[]. required | string | The URL the delivery went to. |
failures[]. required | string | 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[]. required | object or null | The event exactly as we sent it. |
failures[]. required | string or null | HTTP and the status your server answered, or the connection error. |
failures[]. required | integer | Attempts so far, 1 to 5. From 1 to 5. |
failures[]. required | string or null | The last retry. UTC, written YYYY-MM-DD HH:MM:SS. |
failures[]. required | string | The first failure. UTC, written YYYY-MM-DD HH:MM:SS. |
meta required | object | |
meta. required | integer | |
meta. required | integer | |
meta. required | integer | |
meta. required | integer |
{
"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
}
}Retry-After seconds. The body names the counter, the window and the limit.| Header | Type | Description |
|---|---|---|
Retry-After | integer | Seconds to wait before you try again. |
Retry-After: 5), or a service this route needs could not be reached.| Header | Type | Description |
|---|---|---|
Retry-After | integer | Seconds to wait before you try again. |
Get a webhook endpoint
GET/v1/
One endpoint. The signing secret is not shown. A scoped key can read only the endpoints it created.
Request
curl https://api.vocatech.com/v1/webhooks/318 \
-H "Authorization: Bearer YOUR_API_KEY"const res = await fetch("https://api.vocatech.com/v1/webhooks/318", {
headers: { Authorization: "Bearer YOUR_API_KEY" },
});
console.log(res.status, await res.json());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
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 | Description |
|---|---|---|---|
id required | path | integer | 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
| Field | Type | Description |
|---|---|---|
id required | integer | |
company_id required | integer | |
name required | string | Defaults to the URL's host. |
description required | string or null | |
url required | string (uri) | |
event_filters required | array of strings | |
extension_filters required | array of strings | Only call events for these extensions. Empty means every extension. |
number_filters required | 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. |
enabled required | boolean | False pauses deliveries. |
created_at required | string | UTC, written YYYY-MM-DD HH:MM:SS. |
updated_at required | string | UTC, written YYYY-MM-DD HH:MM:SS. |
{
"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"
}Retry-After seconds. The body names the counter, the window and the limit.| Header | Type | Description |
|---|---|---|
Retry-After | integer | Seconds to wait before you try again. |
Retry-After: 5), or a service this route needs could not be reached.| Header | Type | Description |
|---|---|---|
Retry-After | integer | Seconds to wait before you try again. |
Update a webhook endpoint
PATCH/v1/
Changes only the fields you send.
enabled: falsepauses 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
namekeeps the current one. event_filtersmust keep at least one event.eventsis 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 -X PATCH https://api.vocatech.com/v1/webhooks/318 \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"enabled": false
}'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());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
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 | Description |
|---|---|---|---|
id required | path | integer | The webhook endpoint's id. For example 318. |
Body
| Field | Type | 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. |
{
"enabled": false
}{
"event_filters": [
"call.ended",
"call.transcription"
]
}Answers
| Field | Type | Description |
|---|---|---|
id required | integer | |
company_id required | integer | |
name required | string | Defaults to the URL's host. |
description required | string or null | |
url required | string (uri) | |
event_filters required | array of strings | |
extension_filters required | array of strings | Only call events for these extensions. Empty means every extension. |
number_filters required | 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. |
enabled required | boolean | False pauses deliveries. |
created_at required | string | UTC, written YYYY-MM-DD HH:MM:SS. |
updated_at required | string | UTC, written YYYY-MM-DD HH:MM:SS. |
{
"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"
}company_id is not yours, or the key's scopes or address.Example body
{
"error": {
"code": 403,
"type": "FORBIDDEN",
"message": "Unauthorized: company_id mismatch."
}
}Example bodies
{
"error": {
"code": 422,
"type": "UNPROCESSABLE_ENTITY",
"message": "event_filters must be a non-empty array."
}
}{
"error": {
"code": 422,
"type": "UNPROCESSABLE_ENTITY",
"message": "The URL must use HTTPS."
}
}Retry-After seconds. The body names the counter, the window and the limit.| Header | Type | Description |
|---|---|---|
Retry-After | integer | Seconds to wait before you try again. |
Retry-After: 5), or a service this route needs could not be reached.| Header | Type | Description |
|---|---|---|
Retry-After | integer | Seconds to wait before you try again. |
Delete a webhook endpoint
DELETE/v1/
Deletes the endpoint and stops its pending retries. The answer has no body.
Request
curl -X DELETE https://api.vocatech.com/v1/webhooks/318 \
-H "Authorization: Bearer YOUR_API_KEY"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 workedimport 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
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 workedParameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | integer | 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
Retry-After seconds. The body names the counter, the window and the limit.| Header | Type | Description |
|---|---|---|
Retry-After | integer | Seconds to wait before you try again. |
Retry-After: 5), or a service this route needs could not be reached.| Header | Type | Description |
|---|---|---|
Retry-After | integer | Seconds to wait before you try again. |
Send a test delivery
POST/v1/
Sends one signed test event to the endpoint now and tells you what came back. The event is webhook.test:
{
"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 -X POST https://api.vocatech.com/v1/webhooks/318/test \
-H "Authorization: Bearer YOUR_API_KEY"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());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
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 | Description |
|---|---|---|---|
id required | path | integer | The webhook endpoint's id. For example 318. |
Answers
| Field | Type | Description |
|---|---|---|
success required | boolean | True when your endpoint answered 2xx within 10 seconds. |
status_code required | integer or null | The status your endpoint answered. Null when it could not be reached. |
error required | string or null | Why it failed. |
{
"success": true,
"status_code": 200,
"error": null
}{
"success": false,
"status_code": 500,
"error": "HTTP 500"
}{
"success": false,
"status_code": null,
"error": "Destination does not resolve to a public internet address"
}Retry-After seconds. The body names the counter, the window and the limit.| Header | Type | Description |
|---|---|---|
Retry-After | integer | Seconds to wait before you try again. |
Retry-After: 5), or a service this route needs could not be reached.| Header | Type | Description |
|---|---|---|
Retry-After | integer | Seconds to wait before you try again. |
