Vocatech

Webhooks

Webhook endpoints

Read from the API’s own reference file every hour.

Plain text

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:

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:

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 https://api.vocatech.com/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY"

Parameters

NameInTypeDescription
company_idqueryintegerOptional. 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.

A value of type array of objects.

Example
[
  {
    "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.
HeaderTypeDescription
Retry-AfterintegerSeconds to wait before you try again.
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.
HeaderTypeDescription
Retry-AfterintegerSeconds to wait before you try again.

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 -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"
  }'

Body

FieldTypeDescription
url requiredstring (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 requiredarray of stringsThe events to send. At least one. At least 1 item.
eventsarray of stringsAnother name for event_filters, accepted on create only.
extension_filtersarray of stringsOnly call events (call.* and call.transcription) for these extensions.
number_filtersarray of stringsOnly 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.
namestringA name you will recognize. Defaults to the URL's host.
descriptionstring
company_idintegerOptional. If you send it, it must be your own company id.
Example
{
  "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.
FieldTypeDescription
id requiredinteger
company_id requiredinteger
name requiredstringDefaults to the URL's host.
description requiredstring or null
url requiredstring (uri)
event_filters requiredarray of strings
extension_filters requiredarray of stringsOnly call events for these extensions. Empty means every extension.
number_filters requiredarray of stringsOnly 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 requiredbooleanFalse pauses deliveries.
created_at requiredstringUTC, written YYYY-MM-DD HH:MM:SS.
updated_at requiredstringUTC, written YYYY-MM-DD HH:MM:SS.
secret_key requiredstringThe signing secret, 64 characters. Shown only in this answer. Store it now.
Example
{
  "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 body
Example
{
  "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 bodies
Example
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "The URL must point at a public internet address. hooks.internal does not resolve to one."
  }
}
Example
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "The URL must use HTTPS."
  }
}
Example
{
  "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
{
  "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.
HeaderTypeDescription
Retry-AfterintegerSeconds to wait before you try again.
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.
HeaderTypeDescription
Retry-AfterintegerSeconds to wait before you try again.

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 -G https://api.vocatech.com/v1/webhooks/failures \
  -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "page=1" \
  --data-urlencode "limit=20"

Parameters

NameInTypeDescription
pagequeryintegerPage number, from 1. Default 1. At least 1.
limitqueryintegerFailures per page, 1 to 100. A value outside that range is brought inside it. Default 20. From 1 to 100.
company_idqueryintegerOptional. 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.
FieldTypeDescription
failures requiredarray of objects
failures[].id requiredinteger
failures[].endpoint_id requiredinteger or null
failures[].endpoint_url requiredstringThe URL the delivery went to.
failures[].event_type requiredstringA 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 requiredobject or nullThe event exactly as we sent it.
failures[].error_message requiredstring or nullHTTP and the status your server answered, or the connection error.
failures[].attempts requiredintegerAttempts so far, 1 to 5. From 1 to 5.
failures[].retried_at requiredstring or nullThe last retry. UTC, written YYYY-MM-DD HH:MM:SS.
failures[].created_at requiredstringThe first failure. UTC, written YYYY-MM-DD HH:MM:SS.
meta requiredobject
meta.page requiredinteger
meta.limit requiredinteger
meta.total requiredinteger
meta.total_pages requiredinteger
Example
{
  "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.
HeaderTypeDescription
Retry-AfterintegerSeconds to wait before you try again.
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.
HeaderTypeDescription
Retry-AfterintegerSeconds to wait before you try again.

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 https://api.vocatech.com/v1/webhooks/318 \
  -H "Authorization: Bearer YOUR_API_KEY"

Parameters

NameInTypeDescription
id requiredpathintegerThe webhook endpoint's id. For example 318.
company_idqueryintegerOptional. 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.
FieldTypeDescription
id requiredinteger
company_id requiredinteger
name requiredstringDefaults to the URL's host.
description requiredstring or null
url requiredstring (uri)
event_filters requiredarray of strings
extension_filters requiredarray of stringsOnly call events for these extensions. Empty means every extension.
number_filters requiredarray of stringsOnly 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 requiredbooleanFalse pauses deliveries.
created_at requiredstringUTC, written YYYY-MM-DD HH:MM:SS.
updated_at requiredstringUTC, written YYYY-MM-DD HH:MM:SS.
Example
{
  "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.
HeaderTypeDescription
Retry-AfterintegerSeconds to wait before you try again.
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.
HeaderTypeDescription
Retry-AfterintegerSeconds to wait before you try again.

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 -X PATCH https://api.vocatech.com/v1/webhooks/318 \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": false
  }'

Parameters

NameInTypeDescription
id requiredpathintegerThe webhook endpoint's id. For example 318.

Body

FieldTypeDescription
urlstring (uri)Checked the same way as on create.
event_filtersarray of stringsReplaces the list. At least one. At least 1 item.
extension_filtersarray of stringsReplaces the list. Send [] for every extension.
number_filtersarray of stringsReplaces the list. Send [] for every number. Up to 200 items.
enabledbooleanFalse pauses deliveries.
namestringA blank name keeps the current one.
descriptionstring or null
company_idintegerOptional. If you send it, it must be your own company id.
Pause deliveries
{
  "enabled": false
}
Change the events
{
  "event_filters": [
    "call.ended",
    "call.transcription"
  ]
}

Answers

200
The endpoint as saved.
FieldTypeDescription
id requiredinteger
company_id requiredinteger
name requiredstringDefaults to the URL's host.
description requiredstring or null
url requiredstring (uri)
event_filters requiredarray of strings
extension_filters requiredarray of stringsOnly call events for these extensions. Empty means every extension.
number_filters requiredarray of stringsOnly 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 requiredbooleanFalse pauses deliveries.
created_at requiredstringUTC, written YYYY-MM-DD HH:MM:SS.
updated_at requiredstringUTC, written YYYY-MM-DD HH:MM:SS.
Example
{
  "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 body
Example
{
  "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 bodies
Example
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "event_filters must be a non-empty array."
  }
}
Example
{
  "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.
HeaderTypeDescription
Retry-AfterintegerSeconds to wait before you try again.
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.
HeaderTypeDescription
Retry-AfterintegerSeconds to wait before you try again.

Delete a webhook endpoint

DELETE/v1/webhooks/{id}

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"

Parameters

NameInTypeDescription
id requiredpathintegerThe webhook endpoint's id. For example 318.
company_idqueryintegerOptional. 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.
HeaderTypeDescription
Retry-AfterintegerSeconds to wait before you try again.
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.
HeaderTypeDescription
Retry-AfterintegerSeconds to wait before you try again.

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 -X POST https://api.vocatech.com/v1/webhooks/318/test \
  -H "Authorization: Bearer YOUR_API_KEY"

Parameters

NameInTypeDescription
id requiredpathintegerThe webhook endpoint's id. For example 318.

Answers

200
What your endpoint answered.
FieldTypeDescription
success requiredbooleanTrue when your endpoint answered 2xx within 10 seconds.
status_code requiredinteger or nullThe status your endpoint answered. Null when it could not be reached.
error requiredstring or nullWhy it failed.
Delivered
{
  "success": true,
  "status_code": 200,
  "error": null
}
Your server answered 500
{
  "success": false,
  "status_code": 500,
  "error": "HTTP 500"
}
Not a public address any more
{
  "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.
HeaderTypeDescription
Retry-AfterintegerSeconds to wait before you try again.
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.
HeaderTypeDescription
Retry-AfterintegerSeconds to wait before you try again.