Vocatech API
Developer guide
Connect your own software to your Vocatech account. Read calls, texts and faxes with their AI summaries, transcripts and recordings. Send texts and faxes. Look up who is calling, have an AI agent place a call, open a support ticket, and get an event the moment something happens.
- Base URL
https://api.vocatech.com/v1- Authentication
Authorization: Bearer <key>- Format
- JSON in and out, UTF-8
- Every field
- The API reference and the OpenAPI file
It is a plain HTTPS and JSON API for your servers. These hold everywhere:
- Server to server only. The API sends no CORS headers, so a web page cannot call it. A key never belongs in a browser, a mobile app or a code repository.
- JSON both ways. Send
Content-Type: application/jsonwith every request that has a body. Every answer is JSON, except204 No Content. - Times are UTC, in ISO 8601, like
2026-09-24T13:02:11.000Z. The dates you ask for are read in your time zone. See Pagination and dates. - Phone numbers are 10 digits in requests and reports, like
7185550142. Formatting and a leading 1 are stripped. Text and fax webhooks write numbers with +1, like+17185550142. - Your account only. A key reads and acts on its own account. Every read of calls, texts, faxes, recordings and caller history is written to your account's HIPAA access log, with the key that made it.
Quickstart
Three steps take you from nothing to today's calls.
1Get a key
In the Vocatech portal, open your company and go to the Integrations page. Under Portal Public API, press Generate and copy the key. This is your main key, and it reaches every route. For texting only, make an API Messaging key instead.
Put the key in an environment variable. The Node, Python and PHP examples in this guide use the small vt() helper below: save it next to your code. curl needs nothing else.
# Put the key in the environment once. Every curl example reads it from there.
export VOCATECH_API_KEY="paste-your-key-here"
# That is all curl needs.// vocatech.mjs: a tiny client. Node 18 or newer, no packages.
const BASE = 'https://api.vocatech.com';
const KEY = process.env.VOCATECH_API_KEY;
export async function vt(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: {
Authorization: `Bearer ${KEY}`,
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { raw: text };
}
if (!res.ok) {
const err = new Error(`${res.status} ${errorText(data)}`);
err.status = res.status;
err.retryAfter = Number(res.headers.get('retry-after')) || null;
err.body = data;
throw err;
}
return data;
}
// The API writes errors in a few shapes. This reads all of them.
export function errorText(body) {
if (!body) return '';
if (typeof body.error === 'string') return body.message || body.error;
if (body.error) return body.error.message || body.error.description || '';
return body.raw || '';
}# vocatech.py: a tiny client. Python 3.8 or newer, and: pip install requests
import os
import requests
BASE = "https://api.vocatech.com"
KEY = os.environ["VOCATECH_API_KEY"]
def error_text(body):
"""The API writes errors in a few shapes. This reads all of them."""
if not isinstance(body, dict):
return ""
err = body.get("error")
if isinstance(err, str):
return body.get("message") or err
if isinstance(err, dict):
return err.get("message") or err.get("description") or ""
return ""
class VocatechError(Exception):
def __init__(self, status, body, retry_after):
super().__init__(f"{status} {error_text(body)}")
self.status = status
self.body = body
self.retry_after = retry_after
def vt(method, path, body=None, params=None):
r = requests.request(
method,
BASE + path,
params=params,
json=body,
headers={"Authorization": f"Bearer {KEY}"},
timeout=60,
)
try:
data = r.json() if r.content else None
except ValueError:
data = {"raw": r.text}
if not r.ok:
retry = r.headers.get("Retry-After")
raise VocatechError(r.status_code, data, int(retry) if retry else None)
return data<?php
// vocatech.php: a tiny client. PHP 8.1 or newer, with the curl extension.
const VT_BASE = 'https://api.vocatech.com';
/** The API writes errors in a few shapes. This reads all of them. */
function vt_error_text(?array $body): string
{
$error = $body['error'] ?? null;
if (is_string($error)) {
return (string) ($body['message'] ?? $error);
}
if (is_array($error)) {
return (string) ($error['message'] ?? $error['description'] ?? '');
}
return '';
}
final class VocatechError extends RuntimeException
{
public function __construct(
public readonly int $status,
public readonly ?array $body,
public readonly ?int $retryAfter
) {
parent::__construct($status . ' ' . vt_error_text($body), $status);
}
}
function vt(string $method, string $path, ?array $body = null): ?array
{
$retryAfter = null;
$headers = ['Authorization: Bearer ' . getenv('VOCATECH_API_KEY')];
$ch = curl_init(VT_BASE . $path);
if ($body !== null) {
$headers[] = 'Content-Type: application/json';
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$retryAfter): int {
if (stripos($line, 'Retry-After:') === 0) {
$retryAfter = (int) trim(substr($line, 12));
}
return strlen($line);
},
]);
$raw = curl_exec($ch);
if ($raw === false) {
throw new RuntimeException('Network error: ' . curl_error($ch));
}
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data = $raw === '' ? null : json_decode($raw, true);
if ($status >= 400) {
throw new VocatechError($status, is_array($data) ? $data : null, $retryAfter);
}
return is_array($data) ? $data : null;
}The Node examples are ES modules: save them as .mjs files and run them with Node 18 or newer.
2Check the connection
GET /v1/whoami answers with your company id and the address the API sees you calling from. Every key may call it.
curl https://api.vocatech.com/v1/whoami \
-H "Authorization: Bearer $VOCATECH_API_KEY"import { vt } from './vocatech.mjs';
const me = await vt('GET', '/v1/whoami');
console.log(me.company_id, me.client_ip, me.ip_restricted);from vocatech import vt
me = vt("GET", "/v1/whoami")
print(me["company_id"], me["client_ip"], me["ip_restricted"])<?php
require __DIR__ . '/vocatech.php';
$me = vt('GET', '/v1/whoami');
echo $me['company_id'], ' ', $me['client_ip'], PHP_EOL;{
"client_ip": "203.0.113.24",
"forwarded_for": ["203.0.113.24"],
"company_id": 1234567890,
"ip_restricted": false,
"allowed_ips": []
}If you plan to lock the key to your server's address, client_ip is the address to allow. See IP allow-list.
3Pull today's calls
GET /v1/calls with no dates returns today, in New York time. Each call carries its journey: every menu, group and person it reached, with the AI summary, the transcript and a recording link when there is one.
# No dates: today, in New York time
curl https://api.vocatech.com/v1/calls \
-H "Authorization: Bearer $VOCATECH_API_KEY"
# One day, in your own time zone
curl "https://api.vocatech.com/v1/calls?start_date=2026-09-23&end_date=2026-09-23&timezone=America/Chicago" \
-H "Authorization: Bearer $VOCATECH_API_KEY"import { vt } from './vocatech.mjs';
const today = await vt('GET', '/v1/calls');
console.log(`${today.meta.total_calls} calls today`);
for (const call of today.calls) {
console.log(call.start_time, call.direction, call.remote_number, call.extension_name);
}from vocatech import vt
today = vt("GET", "/v1/calls")
print(today["meta"]["total_calls"], "calls today")
for call in today["calls"]:
print(call["start_time"], call["direction"], call["remote_number"], call["extension_name"])<?php
require __DIR__ . '/vocatech.php';
$today = vt('GET', '/v1/calls');
echo $today['meta']['total_calls'], " calls today\n";
foreach ($today['calls'] as $call) {
echo "{$call['start_time']} {$call['direction']} {$call['remote_number']} {$call['extension_name']}\n";
}{
"query": {
"company_id": 1234567890,
"start_date": "2026-09-24",
"end_date": "2026-09-24",
"direction": "any",
"status": "any",
"search": "",
"timezone": "America/New_York"
},
"calls": [
{
"call_id": "8a1f3c2e-5b7d-4e9a-9c1b-2d4e6f8a0b1c",
"direction": "incoming",
"status": "answered",
"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,
"journey": [
{
"order": 1,
"type": "auto_attendant",
"extension_name": "Main Menu",
"extension": "900",
"start_time": "2026-09-24T13:02:11.000Z",
"end_time": "2026-09-24T13:02:24.000Z",
"duration": 13,
"summary": null,
"transcription": null,
"recording_url": null,
"recordings": []
},
{
"order": 2,
"type": "user",
"extension_name": "Maya Chen",
"extension": "104",
"start_time": "2026-09-24T13:02:24.000Z",
"end_time": "2026-09-24T13:06:49.000Z",
"duration": 265,
"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_url": "https://api.vocatech.com/v1/media/rec_58213407",
"recordings": [
{
"part": 1,
"parts": 1,
"recording_id": 10592210,
"start_time": "2026-09-24T13:02:25.000Z",
"duration": 264,
"url": "https://api.vocatech.com/v1/media/rec_58213407?part=1"
}
]
}
]
}
],
"meta": {
"page": 1,
"limit": 500,
"total_pages": 1,
"total_calls": 1
}
}Next, walk every page and download the recordings in Pull today's calls, or have events pushed to you in Receive webhooks.
Authentication and keys
Every request carries a key in the Authorization header:
Authorization: Bearer YOUR_API_KEYA key is an opaque 32-character secret. It is not a JWT, and there is nothing inside it to read. Keep it on your server, in an environment variable or a secret store, and out of logs, URLs and code.
No key, a wrong key, or a key that was turned off or regenerated gets 401. If the API cannot check a key for a moment, it answers 503 with Retry-After: 5. That is not a revoked key: wait a few seconds and try again.
{
"error": "Unauthorized",
"message": "Invalid token or inactive user."
}Main and API Messaging keys
The portal makes two kinds of key. The API itself cannot create or change keys.
| Property | Main key | API Messaging keys |
|---|---|---|
| Made in | Integrations page, Portal Public API, Generate | Textdock, API Messaging |
| How many | One per account | Up to 10 per account: one for each integrator, each with its own label |
| Reaches | Every route | Texting only (the messaging scope) |
| Sending texts | Up to 20 a minute and 200 a day through POST /v1/messages | A whole batch is accepted and paced out by a queue: 60 message parts a minute and 5,000 messages a day to start, set per key |
| IP allow-list | Optional | Required |
API Messaging keys are metered. A key can also be narrowed to agents, support or knowledge, for an AI agent, a help desk or a chat bot that should reach nothing else. Ask Vocatech support for one.
Scopes
A key with no scope reaches every route. A narrowed key reaches only the routes of its scopes. Any key may call GET /v1/whoami.
| Scope | Reaches | Notes |
|---|---|---|
messaging | /v1/messages (send, history, queue), /v1/reports/messages, /v1/media, /v1/webhooks | Media: message attachments only, never recordings or faxes. Webhooks: the three message events, on endpoints this key created. |
agents | /v1/agents, /v1/callers, /v1/contacts/lookup | What an AI receptionist needs: who is calling, and your agents. |
support | /v1/support, /v1/knowledge | Open tickets and ask the knowledge base. |
knowledge | /v1/knowledge | Ask the knowledge base, nothing else. |
Outside its scope, a key gets 403:
{
"error": "Forbidden",
"message": "This API key is limited to messaging and cannot be used for /v1/calls.",
"scopes": "messaging",
"path": "/v1/calls"
}IP allow-list
Lock a key to the addresses your servers call from, and a copied key is useless anywhere else.
- Enter exact IPv4 or IPv6 addresses. Ranges are not accepted. An empty list means any address.
- Main key: on the Integrations page, fill Allowed IPs and press Save allowed IPs. It is optional.
- API Messaging keys: the portal requires the list when it makes the key.
Before you lock a key, call GET /v1/whoami from the server that will use it and allow the client_ip it reports. The Connections panel next to the key lists every address the key was called from. Matching is exact: an IPv4 address and its IPv6 form (::ffff:203.0.113.24) count as two different addresses.
A request from any other address gets 403. The answer names the address the API saw, so when calls that worked start failing, you can see at once that your server's address changed:
{
"error": "Forbidden",
"message": "This API key is restricted to specific IP addresses and 203.0.113.24 is not one of them.",
"client_ip": "203.0.113.24"
}Rotating a key
Press Regenerate next to the key and confirm. The new key replaces the old one at once: from that moment the old key gets 401. So rotate in this order:
- Make sure your system can take a new key quickly, from a secret store or an environment variable.
- Regenerate in the portal and copy the new key.
- Put it in place and restart whatever reads it.
Regenerating the main key does not touch your API Messaging keys, and each API Messaging key regenerates on its own. If a key leaks, regenerate it right away and tell us.
Recipes
Short, complete walk-throughs for the jobs integrations do most. The Node, Python and PHP examples use the vt() helper from the Quickstart. The names and numbers in them are samples.
Pull today's calls with transcripts and recordings
- Ask
GET /v1/callswithsort=day_ascandlimit=500, and raisepageuntil it reachesmeta.total_pages. - Read each call's
journey: one entry per leg, for every menu, group and person. A transcribed leg hassummaryandtranscription. A recorded leg hasrecording_url. recording_urlis an API address. Call it with your key: the answer holds a signedurlthat works for 30 minutes with no key. Download from there.- A call parked and picked up again is recorded in parts. Each leg lists its parts in
recordings, oldest first, each with its ownurl.recording_url,summaryandtranscriptionare the latest part's. To keep every part, fetch each entry ofrecordingsthe same way.
# 1. Today's calls, New York time, 500 a page, oldest first
curl "https://api.vocatech.com/v1/calls?sort=day_asc&limit=500&page=1" \
-H "Authorization: Bearer $VOCATECH_API_KEY"
# 2. A recording: ask for a signed link, then download it with no key (uses jq)
url=$(curl -s https://api.vocatech.com/v1/media/rec_58213407 \
-H "Authorization: Bearer $VOCATECH_API_KEY" | jq -r .url)
curl -o rec_58213407.mp3 "$url"import { writeFile } from 'node:fs/promises';
import { vt } from './vocatech.mjs';
// Today, New York time. Add start_date, end_date and timezone for other days.
for (let page = 1, pages = 1; page <= pages; page++) {
const data = await vt('GET', `/v1/calls?sort=day_asc&limit=500&page=${page}`);
pages = data.meta.total_pages;
for (const call of data.calls) {
for (const leg of call.journey) {
if (leg.summary) console.log(call.call_id, leg.extension_name, leg.summary);
if (leg.recording_url) {
// recording_url is an API address: ask it for a signed download link.
const media = await vt('GET', new URL(leg.recording_url).pathname);
const audio = await fetch(media.url); // no key needed, good for 30 minutes
await writeFile(`${media.id}.${media.format}`, Buffer.from(await audio.arrayBuffer()));
}
}
}
}from urllib.parse import urlparse
import requests
from vocatech import vt
# Today, New York time. Add start_date, end_date and timezone for other days.
page = 1
while True:
data = vt("GET", "/v1/calls", params={"sort": "day_asc", "limit": 500, "page": page})
for call in data["calls"]:
for leg in call["journey"]:
if leg["summary"]:
print(call["call_id"], leg["extension_name"], leg["summary"])
if leg["recording_url"]:
# recording_url is an API address: ask it for a signed download link.
media = vt("GET", urlparse(leg["recording_url"]).path)
audio = requests.get(media["url"], timeout=120) # no key needed
with open(f'{media["id"]}.{media["format"]}', "wb") as f:
f.write(audio.content)
if page >= data["meta"]["total_pages"]:
break
page += 1<?php
require __DIR__ . '/vocatech.php';
// Today, New York time. Add start_date, end_date and timezone for other days.
for ($page = 1, $pages = 1; $page <= $pages; $page++) {
$data = vt('GET', '/v1/calls?' . http_build_query(['sort' => 'day_asc', 'limit' => 500, 'page' => $page]));
$pages = $data['meta']['total_pages'];
foreach ($data['calls'] as $call) {
foreach ($call['journey'] as $leg) {
if ($leg['summary']) {
echo $call['call_id'], ' ', $leg['extension_name'], ': ', $leg['summary'], PHP_EOL;
}
if ($leg['recording_url']) {
// recording_url is an API address: ask it for a signed download link.
$media = vt('GET', parse_url($leg['recording_url'], PHP_URL_PATH));
// The signed link needs no key and works for 30 minutes.
file_put_contents("{$media['id']}.{$media['format']}", file_get_contents($media['url']));
}
}
}
}Transcripts come after the call. They usually land minutes after a call ends and can take longer on a busy day, so a call you pull right away may not have one yet. Pull again later, or subscribe to call.transcription and have it pushed to you.
For another day or range, add start_date, end_date (YYYY-MM-DD) and your timezone. Filters for direction, status, extension and a text search are in the Calls reference.
Send a text
Send from a texting number on your account to a US or Canadian number. Try a dry run first: with "test": true the API checks everything and sends nothing. A dry run is never sent or billed, but it counts as a request toward the rate limits.
# 1. Dry run: checks everything, sends nothing
curl -X POST https://api.vocatech.com/v1/messages \
-H "Authorization: Bearer $VOCATECH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"platform": "text",
"from": "7185550100",
"to": "7185550142",
"message": "Hi Jordan, your appointment is confirmed for Friday at 9 AM.",
"test": true
}'
# 2. Send it: the same body without "test"
curl -X POST https://api.vocatech.com/v1/messages \
-H "Authorization: Bearer $VOCATECH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"platform": "text",
"from": "7185550100",
"to": "7185550142",
"message": "Hi Jordan, your appointment is confirmed for Friday at 9 AM."
}'import { vt } from './vocatech.mjs';
const text = {
platform: 'text',
from: '7185550100', // a texting number on your account
to: '7185550142',
message: 'Hi Jordan, your appointment is confirmed for Friday at 9 AM.',
};
// 1. Dry run: checks everything, sends nothing
const check = await vt('POST', '/v1/messages', { ...text, test: true });
console.log('valid:', check.message.valid, 'parts:', check.message.messages_counted);
// 2. Send it
try {
const res = await vt('POST', '/v1/messages', text);
if (res.status === 'queued') {
console.log('queued at position', res.message.position); // 202, API Messaging keys
} else if (res.message.message_sent) {
console.log('sent:', res.status); // 201, or 200 into an existing Webex space
} else {
console.warn('taken but not sent:', res.message);
}
} catch (err) {
if (err.status === 503) console.warn('Not sent. Safe to retry.');
else if (err.status === 504) console.warn('May have gone out. Check GET /v1/messages first.');
else throw err;
}from vocatech import VocatechError, vt
text = {
"platform": "text",
"from": "7185550100", # a texting number on your account
"to": "7185550142",
"message": "Hi Jordan, your appointment is confirmed for Friday at 9 AM.",
}
# 1. Dry run: checks everything, sends nothing
check = vt("POST", "/v1/messages", {**text, "test": True})
print("valid:", check["message"]["valid"], "parts:", check["message"]["messages_counted"])
# 2. Send it
try:
res = vt("POST", "/v1/messages", text)
if res["status"] == "queued":
print("queued at position", res["message"]["position"]) # 202, API Messaging keys
elif res["message"].get("message_sent"):
print("sent:", res["status"]) # 201, or 200 into an existing Webex space
else:
print("taken but not sent:", res["message"])
except VocatechError as err:
if err.status == 503:
print("Not sent. Safe to retry.")
elif err.status == 504:
print("May have gone out. Check GET /v1/messages first.")
else:
raise<?php
require __DIR__ . '/vocatech.php';
$text = [
'platform' => 'text',
'from' => '7185550100', // a texting number on your account
'to' => '7185550142',
'message' => 'Hi Jordan, your appointment is confirmed for Friday at 9 AM.',
];
// 1. Dry run: checks everything, sends nothing
$check = vt('POST', '/v1/messages', $text + ['test' => true]);
echo 'parts: ', $check['message']['messages_counted'], PHP_EOL;
// 2. Send it
try {
$res = vt('POST', '/v1/messages', $text);
if ($res['status'] === 'queued') {
echo 'queued at position ', $res['message']['position'], PHP_EOL; // 202
} elseif (!empty($res['message']['message_sent'])) {
echo 'sent: ', $res['status'], PHP_EOL; // 201, or 200 into an existing Webex space
} else {
echo "taken but not sent\n";
}
} catch (VocatechError $e) {
if ($e->status === 503) {
echo "Not sent. Safe to retry.\n";
} elseif ($e->status === 504) {
echo "May have gone out. Check GET /v1/messages first.\n";
} else {
throw $e;
}
}{
"status": "test",
"message": {
"mode": "api",
"platform": "text",
"from": "7185550100",
"to": "7185550142",
"name": "Vocatech Text",
"email_recipient": null,
"valid": true,
"messages_counted": 1
}
}{
"status": "created",
"message": {
"message_id": "msg_7f3a9c",
"mode": "api",
"platform": "text",
"from": "7185550100",
"to": "7185550142",
"name": "Vocatech Text",
"email_recipient": null,
"message_sent": true,
"email_sent": false,
"created_at": "2026-09-24T09:15:02-04:00",
"messages_counted": 1
}
}Check message.message_sent. A 201 with message_sent: false was taken but did not go out. messages_counted is the number of parts the text is paced and billed as.
What the status means
| Status | Means | Do this |
|---|---|---|
200 | A dry run passed (status: "test"), or a Webex number posted the text into the contact's existing conversation (status: "sent"). | After a dry run, send for real. Otherwise check message_sent. |
201 | Sent (status: "created"). | Check message_sent. |
202 | Queued (status: "queued"). API Messaging keys only: it goes out in order at the key's pace. | Track it with GET /v1/messages/queue. |
400 | A field is wrong: a number, the length, an attachment link. | Fix it. The message names it. |
403 | Refused: from is not a texting number on your account, the recipient opted out, or the text is over the carrier's limit. | Do not retry as is. |
422 | WhatsApp sending through the API is not available. | Send WhatsApp from Webex or the portal. |
429 | A limit was reached. | Wait Retry-After seconds. |
502 | The messaging service answered and did not send. | Read the message. Retry later if it is temporary. |
503 | The messaging service could not be reached. Nothing was sent. | Safe to retry. |
504 | The send was not confirmed. It may have gone out. | Check GET /v1/messages before you retry. |
Pictures and files (MMS)
Add media: up to 10 https links to files your server hosts on a public address. Every file goes out in one MMS, and an MMS counts as one message however long its text. Each link must end in one of these: jpg, jpeg, png, gif, bmp, webp, mp4, 3gp, mov, mp3, wav, amr, ogg, pdf, vcf or txt. Carriers cap MMS size, often between 500 KB and 1 MB, so keep files small.
curl -X POST https://api.vocatech.com/v1/messages \
-H "Authorization: Bearer $VOCATECH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"platform": "text",
"from": "7185550100",
"to": "7185550142",
"message": "Here is the map to our new office.",
"media": ["https://YOUR_DOMAIN/files/office-map.png"]
}'import { vt } from './vocatech.mjs';
const res = await vt('POST', '/v1/messages', {
platform: 'text',
from: '7185550100',
to: '7185550142',
message: 'Here is the map to our new office.',
media: ['https://YOUR_DOMAIN/files/office-map.png'], // up to 10 links
});
console.log(res.message.mms, res.message.attachments_sent);from vocatech import vt
res = vt("POST", "/v1/messages", {
"platform": "text",
"from": "7185550100",
"to": "7185550142",
"message": "Here is the map to our new office.",
"media": ["https://YOUR_DOMAIN/files/office-map.png"], # up to 10 links
})
print(res["message"]["mms"], res["message"]["attachments_sent"])<?php
require __DIR__ . '/vocatech.php';
$res = vt('POST', '/v1/messages', [
'platform' => 'text',
'from' => '7185550100',
'to' => '7185550142',
'message' => 'Here is the map to our new office.',
'media' => ['https://YOUR_DOMAIN/files/office-map.png'], // up to 10 links
]);
echo $res['message']['attachments_sent'], " attachment(s) sent\n";{
"status": "created",
"message": {
"message_id": "msg_7f3a9d",
"mode": "api",
"platform": "text",
"from": "7185550100",
"to": "7185550142",
"mms": true,
"attachments_sent": 1,
"mirrored_to_webex": false,
"message_sent": true,
"created_at": "2026-09-24T09:18:40-04:00",
"messages_counted": 1
}
}Long texts
A text can run to 1,600 characters. Carriers split it into parts: 160 characters fit in one part, and 153 fit in each part once it splits. One character outside the basic set, such as an emoji or a curly quote, moves the whole text to 70 per part, 67 once it splits. messages_counted says how many parts you sent.
Delivery receipts
A 201 means the text left us. Whether the phone got it arrives later as a message.status_updated webhook. GET /v1/messages lists texts as delivered and does not carry carrier receipts.
Sending WhatsApp through the API is not available. "platform": "whatsapp" answers 422, dry runs included. WhatsApp history is in GET /v1/messages, and replies go out from Webex or the portal.
{
"error": {
"code": 422,
"type": "UNPROCESSABLE_ENTITY",
"message": "Sending WhatsApp messages through the API is not available yet. WhatsApp history is in GET /v1/messages; reply from Webex or the portal."
}
}Receive webhooks
Have events pushed to your server instead of polling for them.
1Create an endpoint with the events you want. The answer holds secret_key. It is shown this once, so store it with your other secrets.
curl -X POST https://api.vocatech.com/v1/webhooks \
-H "Authorization: Bearer $VOCATECH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "CRM sync",
"url": "https://YOUR_DOMAIN/webhooks/vocatech",
"event_filters": ["call.ended", "call.transcription", "message.received"]
}'import { vt } from './vocatech.mjs';
const hook = await vt('POST', '/v1/webhooks', {
name: 'CRM sync',
url: 'https://YOUR_DOMAIN/webhooks/vocatech',
event_filters: ['call.ended', 'call.transcription', 'message.received'],
});
// hook.secret_key is shown this once. Put it in your secret store now.
console.log('webhook', hook.id, 'created');from vocatech import vt
hook = vt("POST", "/v1/webhooks", {
"name": "CRM sync",
"url": "https://YOUR_DOMAIN/webhooks/vocatech",
"event_filters": ["call.ended", "call.transcription", "message.received"],
})
# hook["secret_key"] is shown this once. Put it in your secret store now.
print("webhook", hook["id"], "created")<?php
require __DIR__ . '/vocatech.php';
$hook = vt('POST', '/v1/webhooks', [
'name' => 'CRM sync',
'url' => 'https://YOUR_DOMAIN/webhooks/vocatech',
'event_filters' => ['call.ended', 'call.transcription', 'message.received'],
]);
// $hook['secret_key'] is shown this once. Put it in your secret store now.
echo 'webhook ', $hook['id'], " created\n";{
"id": 42,
"company_id": 1234567890,
"name": "CRM sync",
"description": null,
"url": "https://YOUR_DOMAIN/webhooks/vocatech",
"event_filters": ["call.ended", "call.transcription", "message.received"],
"extension_filters": [],
"number_filters": [],
"enabled": true,
"created_at": "2026-09-24 13:40:00",
"updated_at": "2026-09-24 13:40:00",
"secret_key": "4f1c2e6a9b0d3f7e8a5c1b2d6e9f0a3c4b7d8e1f2a5c6b9d0e3f4a7b8c1d2e5f"
}2Verify, answer, then work. Check the X-Vocatech-Signature header, answer 2xx within 5 seconds, and do the work after you answer. The receivers in Capture the raw body do all three.
3Dedupe. A retry repeats the same event id, so skip an id you already have. For message events, data.message_id names the message itself.
4Test it. Send a signed test event to the endpoint. The answer says what your server replied.
curl -X POST https://api.vocatech.com/v1/webhooks/42/test \
-H "Authorization: Bearer $VOCATECH_API_KEY"import { vt } from './vocatech.mjs';
const result = await vt('POST', '/v1/webhooks/42/test');
console.log(result.success, result.status_code, result.error);from vocatech import vt
result = vt("POST", "/v1/webhooks/42/test")
print(result["success"], result["status_code"], result["error"])<?php
require __DIR__ . '/vocatech.php';
$result = vt('POST', '/v1/webhooks/42/test');
echo $result['success'] ? 'delivered' : 'failed: ' . $result['error'], PHP_EOL;{
"success": true,
"status_code": 200,
"error": null
}You can also manage webhooks in the portal, in company settings under Webhooks. Every event and payload is in Webhooks in depth.
Look up a caller
Two routes answer "who is this?". One is rich, one is lean.
| Route | Answers with | Use it for |
|---|---|---|
GET /v1/callers/{number} | Contacts (up to 5), recent calls with who handled them and the AI summary, texts and WhatsApp messages, and the last call each way | An AI agent or a screen that needs context before hello |
GET /v1/contacts/lookup?phone= | The contact records for the number, up to 10 | Matching a caller to a record in your CRM |
# Everything about one number: the last 30 days, up to 5 calls
curl "https://api.vocatech.com/v1/callers/7185550142?days=30&limit=5" \
-H "Authorization: Bearer $VOCATECH_API_KEY"
# Only the contact records
curl "https://api.vocatech.com/v1/contacts/lookup?phone=7185550142" \
-H "Authorization: Bearer $VOCATECH_API_KEY"import { vt } from './vocatech.mjs';
// Everything about one number: the last 30 days, up to 5 calls
const who = await vt('GET', '/v1/callers/7185550142?days=30&limit=5');
const lastCall = who.last_call ? who.last_call.summary : 'no calls';
console.log(who.counts, lastCall);
// Only the contact records, for a screen pop or a CRM match
const hit = await vt('GET', '/v1/contacts/lookup?phone=7185550142');
if (hit.found) console.log(hit.contacts.map((c) => c.fields));from vocatech import vt
# Everything about one number: the last 30 days, up to 5 calls
who = vt("GET", "/v1/callers/7185550142", params={"days": 30, "limit": 5})
print(who["counts"], who["last_call"]["summary"] if who["last_call"] else "no calls")
# Only the contact records, for a screen pop or a CRM match
hit = vt("GET", "/v1/contacts/lookup", params={"phone": "7185550142"})
if hit["found"]:
print([c["fields"] for c in hit["contacts"]])<?php
require __DIR__ . '/vocatech.php';
// Everything about one number: the last 30 days, up to 5 calls
$who = vt('GET', '/v1/callers/7185550142?days=30&limit=5');
echo $who['last_call']['summary'] ?? 'no calls', PHP_EOL;
// Only the contact records, for a screen pop or a CRM match
$hit = vt('GET', '/v1/contacts/lookup?phone=7185550142');
if ($hit['found']) {
print_r(array_column($hit['contacts'], 'fields'));
}{
"number": "7185550142",
"days": 30,
"contacts": [
{ "id": 5012, "fields": { "Name": "Jordan Reyes", "Phone": "7185550142", "Account": "A-1042" } }
],
"calls": [
{
"call_id": "8a1f3c2e-5b7d-4e9a-9c1b-2d4e6f8a0b1c",
"direction": "incoming",
"status": "answered",
"start_time": "2026-09-24T13:02:11.000Z",
"end_time": "2026-09-24T13:06:49.000Z",
"duration": 278,
"remote_name": "Jordan Reyes",
"remote_number": "7185550142",
"our_number": "7185550100",
"handled_by": { "extension": "104", "name": "Maya Chen" },
"summary": "Jordan asked to move the Thursday visit to Friday morning. Maya booked Friday at 9 AM.",
"journey": [
{
"order": 2,
"type": "user",
"extension": "104",
"extension_name": "Maya Chen",
"start_time": "2026-09-24T13:02:24.000Z",
"duration": 265,
"summary": "Jordan asked to move the Thursday visit to Friday morning. Maya booked Friday at 9 AM."
}
]
}
],
"messages": [
{
"channel": "text",
"direction": "outgoing",
"body": "Hi Jordan, your appointment is confirmed for Friday at 9 AM.",
"sent_time": "2026-09-24T13:15:02Z",
"our_number": "7185550100",
"remote_number": "7185550142"
}
],
"last_call": { "call_id": "8a1f3c2e-5b7d-4e9a-9c1b-2d4e6f8a0b1c", "direction": "incoming" },
"last_incoming": { "call_id": "8a1f3c2e-5b7d-4e9a-9c1b-2d4e6f8a0b1c", "direction": "incoming" },
"last_outgoing": null,
"counts": { "contacts": 1, "calls": 1, "messages": 1 }
}{
"phone": "7185550142",
"found": true,
"contacts": [
{ "id": 5012, "fields": { "Name": "Jordan Reyes", "Phone": "7185550142", "Account": "A-1042" } }
]
}- Any formatting works in the number, and a leading 1 is dropped. It needs at least 7 digits.
dayslooks back 1 to 365 days, 90 by default.limitcaps the calls, and the messages, at 1 to 50, 10 by default.last_call,last_incomingandlast_outgoingrepeat an entry fromcalls, or arenull.- Transcripts stay out unless you add
include=transcripts. Every read is written to your HIPAA access log.
Place an AI agent call
Have one of your AI agents call one person: a reminder, a confirmation, a callback, a notice.
- Pick an agent.
GET /v1/agentslists them. It must beactive, withoutgoing_callson (the Outgoing calls switch on the agent's Persona tab). - Place the call with who to call and why. The answer is
202once the call is placed. - Get the outcome. The agent greets, says the purpose, and handles the answer with its own jobs. It reports how it went the way it reports every message: through the message delivery and message action set on the agent in the portal, marked with your
reference.
# 1. Find an agent that may place calls
curl https://api.vocatech.com/v1/agents \
-H "Authorization: Bearer $VOCATECH_API_KEY"
# 2. Have it call one person
curl -X POST https://api.vocatech.com/v1/agents/7/calls \
-H "Authorization: Bearer $VOCATECH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "7185550142",
"purpose": "confirm the Friday 9 AM visit",
"person_name": "Jordan Reyes",
"reference": "visit-20260925-0900"
}'import { vt } from './vocatech.mjs';
// 1. An agent that is on and may place calls
const { agents } = await vt('GET', '/v1/agents');
const agent = agents.find((a) => a.active && a.outgoing_calls);
// 2. One call, to one person
const { call } = await vt('POST', `/v1/agents/${agent.id}/calls`, {
to: '7185550142',
purpose: 'confirm the Friday 9 AM visit',
person_name: 'Jordan Reyes',
reference: 'visit-20260925-0900',
});
console.log(call.call_sid, call.state); // 202: placedfrom vocatech import vt
# 1. An agent that is on and may place calls
agents = vt("GET", "/v1/agents")["agents"]
agent = next(a for a in agents if a["active"] and a["outgoing_calls"])
# 2. One call, to one person
call = vt("POST", f"/v1/agents/{agent['id']}/calls", {
"to": "7185550142",
"purpose": "confirm the Friday 9 AM visit",
"person_name": "Jordan Reyes",
"reference": "visit-20260925-0900",
})["call"]
print(call["call_sid"], call["state"]) # 202: placed<?php
require __DIR__ . '/vocatech.php';
// 1. An agent that is on and may place calls
$agents = vt('GET', '/v1/agents')['agents'];
$agent = current(array_filter($agents, fn ($a) => $a['active'] && $a['outgoing_calls']));
// 2. One call, to one person
$call = vt('POST', "/v1/agents/{$agent['id']}/calls", [
'to' => '7185550142',
'purpose' => 'confirm the Friday 9 AM visit',
'person_name' => 'Jordan Reyes',
'reference' => 'visit-20260925-0900',
])['call'];
echo $call['call_sid'], ' ', $call['state'], PHP_EOL; // 202: placed{
"agents": [
{
"id": 7,
"name": "Front desk",
"extension": "850",
"active": true,
"outgoing_calls": true,
"provisioned": true
}
],
"meta": { "total": 1 }
}{
"call": {
"call_sid": "5d7e9a1c-3b2f-4c8e-a6d0-9f1b2c3d4e5f",
"agent_id": 7,
"from": "7185550100",
"to": "7185550142",
"state": "trying",
"kind": "notice",
"purpose": "confirm the Friday 9 AM visit",
"reference": "visit-20260925-0900"
}
}- One call per request. This is not a bulk dialer.
- The agent calls inside its calling hours only: 8 AM to 9 PM in the agent's time zone, unless you changed them on its Persona tab. Outside them the call is refused with
422. Send"allow_quiet_hours": trueto place it anyway. tois a 10-digit US or Canadian number, or an extension of 2 to 6 digits.- It never dials 911 or another N11 service code, 933 or 988, a premium number (900 or 976), or an area code that belongs to another country, such as those in the Caribbean. US territories are fine.
- Each key may place 10 agent calls a minute and 500 a day.
Open a support ticket
Open a ticket with Vocatech support from your help desk, an automation or an AI agent's action. It lands in our support inbox and is worked like an email or a text.
curl -X POST https://api.vocatech.com/v1/support/tickets \
-H "Authorization: Bearer $VOCATECH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subject": "Fax line not receiving",
"message": "Our fax line has not received anything since 8 AM. Please call back.",
"contact_name": "Maya Chen",
"contact_phone": "7185550100",
"reply_by": "call",
"reference": "helpdesk-4821",
"source": "Office help desk"
}'import { vt } from './vocatech.mjs';
const { ticket } = await vt('POST', '/v1/support/tickets', {
subject: 'Fax line not receiving',
message: 'Our fax line has not received anything since 8 AM. Please call back.',
contact_name: 'Maya Chen',
contact_phone: '7185550100',
reply_by: 'call',
reference: 'helpdesk-4821', // the same reference within 7 days adds to this ticket
source: 'Office help desk',
});
console.log(ticket.key, ticket.appended ? 'added to the open ticket' : 'opened');from vocatech import vt
ticket = vt("POST", "/v1/support/tickets", {
"subject": "Fax line not receiving",
"message": "Our fax line has not received anything since 8 AM. Please call back.",
"contact_name": "Maya Chen",
"contact_phone": "7185550100",
"reply_by": "call",
"reference": "helpdesk-4821", # the same reference within 7 days adds to this ticket
"source": "Office help desk",
})["ticket"]
print(ticket["key"], "added" if ticket["appended"] else "opened")<?php
require __DIR__ . '/vocatech.php';
$ticket = vt('POST', '/v1/support/tickets', [
'subject' => 'Fax line not receiving',
'message' => 'Our fax line has not received anything since 8 AM. Please call back.',
'contact_name' => 'Maya Chen',
'contact_phone' => '7185550100',
'reply_by' => 'call',
'reference' => 'helpdesk-4821', // the same reference within 7 days adds to this ticket
'source' => 'Office help desk',
])['ticket'];
echo $ticket['key'], $ticket['appended'] ? ' added' : ' opened', PHP_EOL;{
"ticket": {
"key": "case-20260924-133000-7d2f0c9a1b3e4d5f6a7b8c9d0e1f2a3b",
"subject": "Fax line not receiving",
"status": "open",
"created_at": "2026-09-24T13:30:00+00:00",
"appended": false
}
}messageis required: what the person needs, in their own words, up to 4,000 characters.- The same
referencewithin 7 days adds to the ticket it opened, and answers200withappended: true. 409means the ticket is busy right now. Wait forRetry-After(60 seconds) and send it again.- Each key may open 10 tickets a minute and 200 a day.
Sync contacts
Keep the contacts that pop up on calls in step with your own system.
GET /v1/contacts/fieldsreturns your field names. Fields withis_matchdecide which contact a row updates. Fields withis_phonehold numbers.POST /v1/contactstakes up to 500 contacts per request. A row updates the contact whose match fields all equal it, or creates a new one.DELETE /v1/contactsremoves the ids you deleted on your side, up to 500 per request.
# 1. Your field names
curl https://api.vocatech.com/v1/contacts/fields \
-H "Authorization: Bearer $VOCATECH_API_KEY"
# 2. Create or update, up to 500 per request
curl -X POST https://api.vocatech.com/v1/contacts \
-H "Authorization: Bearer $VOCATECH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contacts": [
{ "fields": { "Name": "Jordan Reyes", "Phone": "7185550142", "Account": "A-1042" } },
{ "fields": { "Name": "Riley Morgan", "Phone": "7185550143;7185550144", "Account": "A-1043" } }
]
}'
# 3. Delete, up to 500 ids per request
curl -X DELETE https://api.vocatech.com/v1/contacts \
-H "Authorization: Bearer $VOCATECH_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "ids": [5031, 5032] }'import { vt } from './vocatech.mjs';
// 1. Your field names, and which ones decide "same contact"
const { fields } = await vt('GET', '/v1/contacts/fields');
console.log(fields.map((f) => (f.is_match ? `${f.name} (match)` : f.name)));
// 2. Create or update, up to 500 per request. Use your own field names.
const rows = [
{ fields: { Name: 'Jordan Reyes', Phone: '7185550142', Account: 'A-1042' } },
{ fields: { Name: 'Riley Morgan', Phone: '7185550143;7185550144', Account: 'A-1043' } },
];
for (let i = 0; i < rows.length; i += 500) {
const res = await vt('POST', '/v1/contacts', { contacts: rows.slice(i, i + 500) });
console.log(res.summary);
for (const e of res.errors) console.warn('row', i + e.index, e.message);
}
// 3. Remove the ones you deleted on your side
await vt('DELETE', '/v1/contacts', { ids: [5031, 5032] });from vocatech import vt
# 1. Your field names, and which ones decide "same contact"
fields = vt("GET", "/v1/contacts/fields")["fields"]
print([f["name"] + (" (match)" if f["is_match"] else "") for f in fields])
# 2. Create or update, up to 500 per request. Use your own field names.
rows = [
{"fields": {"Name": "Jordan Reyes", "Phone": "7185550142", "Account": "A-1042"}},
{"fields": {"Name": "Riley Morgan", "Phone": "7185550143;7185550144", "Account": "A-1043"}},
]
for i in range(0, len(rows), 500):
res = vt("POST", "/v1/contacts", {"contacts": rows[i:i + 500]})
print(res["summary"])
for e in res["errors"]:
print("row", i + e["index"], e["message"])
# 3. Remove the ones you deleted on your side
vt("DELETE", "/v1/contacts", {"ids": [5031, 5032]})<?php
require __DIR__ . '/vocatech.php';
// 1. Your field names, and which ones decide "same contact"
$fields = vt('GET', '/v1/contacts/fields')['fields'];
foreach ($fields as $f) {
echo $f['name'], $f['is_match'] ? ' (match)' : '', PHP_EOL;
}
// 2. Create or update, up to 500 per request. Use your own field names.
$rows = [
['fields' => ['Name' => 'Jordan Reyes', 'Phone' => '7185550142', 'Account' => 'A-1042']],
['fields' => ['Name' => 'Riley Morgan', 'Phone' => '7185550143;7185550144', 'Account' => 'A-1043']],
];
foreach (array_chunk($rows, 500) as $n => $batch) {
$res = vt('POST', '/v1/contacts', ['contacts' => $batch]);
foreach ($res['errors'] as $e) {
echo 'row ', $n * 500 + $e['index'], ': ', $e['message'], PHP_EOL;
}
}
// 3. Remove the ones you deleted on your side
vt('DELETE', '/v1/contacts', ['ids' => [5031, 5032]]);{
"summary": { "total": 2, "created": 1, "updated": 1, "errors": 0 },
"contacts": [
{
"contact": { "id": 5012, "fields": { "Name": "Jordan Reyes", "Phone": "7185550142", "Account": "A-1042" } },
"action": "updated"
},
{
"contact": { "id": 5033, "fields": { "Name": "Riley Morgan", "Phone": "7185550143;7185550144", "Account": "A-1043" } },
"action": "created"
}
],
"errors": []
}- Use the field names exactly as
GET /v1/contacts/fieldsreturns them. Unknown names are ignored. - Phone fields keep digits only and drop a leading 1. Put several numbers in one field with semicolons:
7185550143;7185550144. - Values over 255 characters are cut.
- A batch answers
200with a summary, and one entry inerrorsfor each row that failed, by its index in your batch. A single contact sent as{"fields": {...}}answers201when created and200when updated.
Reference by area
The short version of each area. Every field, parameter and response is in the API reference. Answers can carry more fields than the examples here show.
Calls
| Method | Path | What it does |
|---|---|---|
| GET | /v1/calls | Calls with their journey, summaries, transcripts and recording links |
| GET | /v1/reports/calls | The old name for the same list. It still answers: move to /v1/calls |
| Parameter | Meaning |
|---|---|
start_date, end_date | YYYY-MM-DD, both or neither. Neither means today. At most 366 days. |
timezone | An IANA name. The default is America/New_York. |
page, limit | Pages from 1. limit is 1 to 500, 500 by default. |
sort | day_desc (default), day_asc, duration_desc or duration_asc |
direction | incoming, outgoing or voicemail |
status | A leg status, such as answered or missed. missed never returns an outgoing call. |
extension | Calls with a leg on this extension |
type | Calls with a leg of this type, such as user, hunt_group, auto_attendant or call_center |
search | Part of the call id, the caller's name, an extension, the caller's number or your number |
status, extension and type filter legs: a call comes back when one of its legs matches, and its journey then shows the matching legs only.
Each call has call_id, direction, status (its first leg's), extension and extension_name (the person who took or placed it), remote_name, remote_number, group_number (your number on the call), start_time, end_time, duration in seconds, and journey. Each leg has order, type, extension, extension_name, its times and duration, summary, transcription, recording_url and recordings. A call an employee places through the dial-out portal reads outgoing.
Recordings in parts. A call parked and picked up again is recorded in parts: part 1 up to the park, part 2 after the pick-up, rarely a part 3. recordings lists them oldest first, each with part, parts, recording_id, start_time, duration and url (GET /v1/media/rec_<n>?part=N). recording_url, summary and transcription are the latest part's. Nearly every leg has one part, and a leg that was not recorded has an empty list.
Messages
| Method | Path | What it does |
|---|---|---|
| POST | /v1/messages | Send a text or an MMS |
| GET | /v1/messages | Text and WhatsApp history |
| GET | /v1/messages/queue | What an API Messaging key has waiting, and what is left of today |
| DELETE | /v1/messages/queue | Cancel everything still waiting |
| DELETE | /v1/messages/queue/{id} | Cancel one waiting message |
| GET | /v1/reports/messages | The old name for GET /v1/messages. It still answers |
Send: POST /v1/messages
| Field | Meaning |
|---|---|
platform | text. whatsapp answers 422. |
from | A texting number on your account |
to | A 10-digit US or Canadian number |
message | Up to 1,600 characters |
media | Up to 10 https links, for an MMS. media_url also works. |
name | Optional. The contact's name, for a new conversation. |
members | Webex numbers only. The email addresses of the people to add to a new conversation space. Leave it out to use the number's default members. |
test | true for a dry run |
The answer depends on how the number is set up in Textdock. A Webex number posts the text into the contact's conversation space in Webex, and opens one if needed (mode: "webex"). An API or email number sends it straight to the carrier (mode: "api" or "email"); an email number also sends a copy by email. Either way, message_sent says whether it left. The message_id a send returns is not guaranteed to match data.message_id on the webhook events for that text.
History: GET /v1/messages
| Parameter | Meaning |
|---|---|
start_date, end_date, timezone, page, limit | As for calls |
sort | day_desc (default) or day_asc |
direction | incoming or outgoing |
channel | text or whatsapp |
status | Texts always read delivered in this list, so other values return WhatsApp messages only |
search | Part of either number or of the text |
Each message has message_id, direction, status, remote_number, group_number, sent_time, channel, type (media when it carries attachments), body and attachments. Fetch an attachment by its att_ id through Media.
The API Messaging queue
An API Messaging key never gets a per-minute 429 on sends. Its whole batch is accepted and sent in order at the key's pace:
- A send that fits the pace goes out at once (
201). Once anything is waiting, new sends join the back of the line (202). - The pace counts message parts in the last 60 seconds, 60 to start. The day counts messages in the last 24 hours, the waiting ones included, 5,000 to start. Vocatech sets both per key.
- Only the day can refuse a send:
429when today's allowance, with what is waiting, is spoken for. - Messages to T-Mobile numbers are also held to 960 a day per account (
limits.tmobile_day_allowance). When those are spent, T-Mobile messages wait for the next day while the rest keep flowing. - The queue works through the line about once a minute. A message gets up to 3 tries. Finished messages stay in the list for 7 days.
# What this key has waiting, and what is left of today
curl https://api.vocatech.com/v1/messages/queue \
-H "Authorization: Bearer $VOCATECH_API_KEY"
# Cancel one waiting message
curl -X DELETE https://api.vocatech.com/v1/messages/queue/48213 \
-H "Authorization: Bearer $VOCATECH_API_KEY"
# Cancel everything still waiting: the wrong batch went in
curl -X DELETE https://api.vocatech.com/v1/messages/queue \
-H "Authorization: Bearer $VOCATECH_API_KEY"import { vt } from './vocatech.mjs';
const { queue, limits } = await vt('GET', '/v1/messages/queue');
console.log(queue.pending, 'waiting,', limits.remaining_today, 'left today');
await vt('DELETE', '/v1/messages/queue/48213'); // one message
const all = await vt('DELETE', '/v1/messages/queue'); // everything still waiting
console.log(all.canceled, 'canceled');from vocatech import vt
q = vt("GET", "/v1/messages/queue")
print(q["queue"]["pending"], "waiting,", q["limits"]["remaining_today"], "left today")
vt("DELETE", "/v1/messages/queue/48213") # one message
done = vt("DELETE", "/v1/messages/queue") # everything still waiting
print(done["canceled"], "canceled")<?php
require __DIR__ . '/vocatech.php';
$q = vt('GET', '/v1/messages/queue');
echo $q['queue']['pending'], ' waiting, ', $q['limits']['remaining_today'], " left today\n";
vt('DELETE', '/v1/messages/queue/48213'); // one message
$done = vt('DELETE', '/v1/messages/queue'); // everything still waiting
echo $done['canceled'], " canceled\n";{
"status": "queued",
"message": {
"queue_id": 48213,
"position": 61,
"from": "7185550100",
"to": "7185550142",
"messages_counted": 1,
"estimated_wait_minutes": 2,
"info": "Accepted and queued. It goes out automatically, in order, as sending capacity frees up. Track it with GET /v1/messages/queue or cancel it with DELETE /v1/messages/queue/48213."
}
}{
"queue": {
"pending": 2,
"items": [
{
"queue_id": 48213,
"to": "7185550142",
"from": "7185550100",
"status": "queued",
"messages_counted": 1,
"carrier": "tmobile",
"attempts": 0,
"error": null,
"message_id": null,
"created_at": "2026-09-24 13:15:02",
"sent_at": null
}
]
},
"limits": {
"per_minute": 60,
"per_day": 5000,
"used_today": 812,
"remaining_today": 4186,
"tmobile_day_allowance": 960
}
}The list shows up to 200 items: everything waiting first, then finished ones, newest first. An item's status is queued, sending, sent, failed or canceled, and its times are UTC. Only a queued message can be canceled: one already sending or finished answers 409, and an id that is not this key's answers 404.
Faxes
| Method | Path | What it does |
|---|---|---|
| POST | /v1/faxes | Send a fax |
| GET | /v1/faxes | Fax history, sent and received |
| Field | Meaning |
|---|---|
from | One of your active fax lines |
to | A 10-digit US or Canadian number. Premium numbers (900, 976) and other countries' area codes are refused. |
file | The document, a base64 PDF of at most 25 pages. Up to 20 MB. |
filename | Optional. document.pdf by default. |
recipient_name, subject | Optional |
test | true for a dry run |
# Remove "test": true to send it for real.
# base64 -w0 is GNU coreutils; on a Mac use: base64 -i intake-form.pdf
curl -X POST https://api.vocatech.com/v1/faxes \
-H "Authorization: Bearer $VOCATECH_API_KEY" \
-H "Content-Type: application/json" \
-d @- <<EOF
{
"from": "7185550101",
"to": "7185550143",
"filename": "intake-form.pdf",
"subject": "Signed intake form",
"test": true,
"file": "$(base64 -w0 intake-form.pdf)"
}
EOFimport { readFile } from 'node:fs/promises';
import { vt } from './vocatech.mjs';
const res = await vt('POST', '/v1/faxes', {
from: '7185550101', // one of your fax lines
to: '7185550143',
file: (await readFile('intake-form.pdf')).toString('base64'),
filename: 'intake-form.pdf',
subject: 'Signed intake form',
test: true, // remove to send it for real
});
console.log(res.status, res.fax);import base64
from vocatech import vt
with open("intake-form.pdf", "rb") as f:
document = base64.b64encode(f.read()).decode()
res = vt("POST", "/v1/faxes", {
"from": "7185550101", # one of your fax lines
"to": "7185550143",
"file": document,
"filename": "intake-form.pdf",
"subject": "Signed intake form",
"test": True, # remove to send it for real
})
print(res["status"], res["fax"])<?php
require __DIR__ . '/vocatech.php';
$res = vt('POST', '/v1/faxes', [
'from' => '7185550101', // one of your fax lines
'to' => '7185550143',
'file' => base64_encode(file_get_contents('intake-form.pdf')),
'filename' => 'intake-form.pdf',
'subject' => 'Signed intake form',
'test' => true, // remove to send it for real
]);
echo $res['status'], PHP_EOL;{
"status": "test",
"fax": {
"from": "7185550101",
"to": "7185550143",
"filename": "intake-form.pdf",
"fax_line": "Front office fax",
"valid": true
}
}{
"status": "queued",
"fax": {
"fax_id": "fax_90412",
"from": "7185550101",
"to": "7185550143",
"filename": "intake-form.pdf",
"subject": "Signed intake form",
"created_at": "2026-09-24T09:20:41-04:00"
}
}- Limits: a PDF of at most 25 pages and 20 MB (
400for another file type or more pages,413above 20 MB), 3 faxes a minute and 50 faxes sent a day per account (the day runs midnight to midnight Eastern), and the account's monthly outbound limit, 1,000 unless Vocatech set another. The day and monthly limits answer429when reached. The fax API is for sending your own documents, not for marketing or bulk faxing. - Faxing must be on for the account. Otherwise both fax routes answer
403, as does afromthat is not one of your active fax lines. - If the fax service refuses the job, the send answers
500with its reason in the message. created_atin the send answer carries New York time with its offset. The other times in the API are UTC.
History takes start_date, end_date, timezone, page and limit as for calls, sort (day_desc or day_asc), direction (incoming or outgoing), status (in_progress, completed, failed or restricted) and search (part of the other number or the subject). Each fax has fax_id, direction, status, from, to, pages, subject, has_document and sent_at. When has_document is true, fetch the PDF with GET /v1/media/{fax_id}.
Media
| Method | Path | What it does |
|---|---|---|
| GET | /v1/media/{id} | A signed download link for one attachment, recording or fax |
| Id starts with | What it is | Where the id comes from |
|---|---|---|
att_ | A message attachment | attachments in GET /v1/messages and in message webhooks |
rec_ | A call recording | recording_url, or a part's url in recordings, on a call leg |
fax_ | A fax document | fax_id in GET /v1/faxes and in fax webhooks |
{
"id": "rec_58213407",
"type": "recording",
"content_type": "audio/mp3",
"format": "mp3",
"duration": 264,
"url": "<a signed link, good for 30 minutes>",
"expires_at": "2026-09-24T14:05:12.000Z",
"part": 1,
"parts": 1,
"recording_id": 10592210,
"start_time": "2026-09-24T13:02:25.000Z"
}{
"id": "att_902114",
"type": "attachment",
"source": "sms",
"content_type": "image/jpeg",
"filename": "side-entrance.jpg",
"size": 184220,
"url": "<a signed link, good for 30 minutes>",
"expires_at": "2026-09-24T14:05:12.000Z"
}- The
urlworks for 30 minutes and needs no key. Ask again for a fresh one. - An attachment on a text you sent answers with the link you sent it with, and no
expires_at. - A recording answers with its latest part. Add
?part=Nfor part N of a call that was parked and picked up again (from 1, oldest first). The answer names itspartofparts, itsrecording_idandstart_time. A part that does not exist answers404, and the message says how many parts there are. - A fax answers with
content_type: application/pdfand itspages. - API Messaging keys may fetch attachments only. A recording or a fax answers them
403. 404: not found, not yours, or not ready yet.410: the attachment is gone.503: storage trouble on our side. The file exists, so tell us.
Callers
| Method | Path | What it does |
|---|---|---|
| GET | /v1/callers/{number} | Everything the account knows about one number |
| Parameter | Meaning |
|---|---|
days | How far back, 1 to 365. 90 by default. |
limit | Up to how many calls, and messages, 1 to 50. 10 by default. |
include | Any of contacts, calls, messages (the default is all three) and transcripts |
The answer has number, days, contacts (up to 5), calls (each with handled_by, summary and its journey), messages (text cut at 400 characters), last_call, last_incoming, last_outgoing and counts. The calls come from the same data as GET /v1/calls. Transcripts, when asked for, are cut at 6,000 characters. The agents scope reaches this route.
Contacts
| Method | Path | What it does |
|---|---|---|
| GET | /v1/contacts/fields | Your contact fields, with is_match, is_phone and their order |
| GET | /v1/contacts | The contacts, 100 a page by default and up to 500, with search across every value |
| POST | /v1/contacts | Create or update one ({"fields": {...}}) or up to 500 ({"contacts": [...]}) |
| DELETE | /v1/contacts | Delete one ({"id": 5031}) or up to 500 ({"ids": [...]}) |
| GET | /v1/contacts/lookup?phone= | The contacts a number belongs to, up to 10 |
Deleting one contact answers {"deleted": 5031}, or 404 when it does not exist. A batch delete answers a summary with the ids it deleted and one error for each id it could not. The lookup needs at least 7 digits (400 otherwise). Matching and phone rules are in Sync contacts.
AI agents
| Method | Path | What it does |
|---|---|---|
| GET | /v1/agents | Your AI agents: id, name, extension, active, outgoing_calls, provisioned |
| POST | /v1/agents/{id}/calls | Have one agent place one call |
| Field | Meaning |
|---|---|
to | Required. A 10-digit number, or an extension of 2 to 6 digits. |
purpose | Why the agent is calling, in a few words, up to 200 characters. subject also works. |
instructions | What to do on the call, up to 2,000 characters. Send purpose, instructions or both. |
person_name | Who to ask for, up to 80 characters. |
reference | Your id for the matter: letters, digits, . _ : and -, up to 80. It comes back with the outcome. |
kind | notice (default), a short message with a question, or callback, the office returning their call. |
job | The name of one of the agent's jobs to start with, such as a reschedule job. |
allow_quiet_hours | true to call outside the agent's calling hours. |
404: no agent with that id on your account. 422: the call cannot be placed, and the message says why (a missing field, a refused number, the agent or its outgoing calls switched off, its line not ready yet, or outside its calling hours). 502: the agent platform could not be reached, so retry in a minute. 503: outgoing calls are not available right now.
AI agents in the API reference
Support
| Method | Path | What it does |
|---|---|---|
| POST | /v1/support/tickets | Open a ticket, or add to one by reference |
| Field | Meaning |
|---|---|
message | Required. Up to 4,000 characters. |
subject | Optional. One line, up to 120 characters. |
contact_name, contact_phone, contact_email | Who to get back to. A nested contact object with name, phone and email works too. |
contact_company, contact_company_phone | The business the person is calling about and its main number, as they gave them (company and company_phone in the contact object). Support finds the account on the business number first. A business number that is not a 10-digit US number is left out. |
reply_by | call, text or email |
caller_id | The number the person called from, when it is not contact_phone. Anything that is not a 10-digit US number (an extension, a withheld number) is left out and the ticket still opens. |
reference | Your id: letters, digits, . _ : and -, up to 80. The same one within 7 days adds to its ticket. |
source | The system that sent it, up to 80 characters |
201 opened, 200 added to an open ticket, 409 busy (wait for Retry-After: 60), 422 a field is wrong, 502 or 503 the desk cannot take it right now.
Knowledge
| Method | Path | What it does |
|---|---|---|
| GET | /v1/knowledge/answer?q= | One question about Vocatech's service, answered |
| POST | /v1/knowledge/answer | The same, with {"q": "..."} or {"question": "..."} |
The answer comes from Vocatech's published facts only, in one to three short sentences, the way a person at our front desk would say it. Nothing about your account is in it. When the knowledge base does not cover the question, found is false and nothing is guessed.
curl -G https://api.vocatech.com/v1/knowledge/answer \
-H "Authorization: Bearer $VOCATECH_API_KEY" \
--data-urlencode "q=Is there a contract?"import { vt } from './vocatech.mjs';
const q = new URLSearchParams({ q: 'Is there a contract?' });
const a = await vt('GET', `/v1/knowledge/answer?${q}`);
console.log(a.found ? a.answer : a.note);from vocatech import vt
a = vt("GET", "/v1/knowledge/answer", params={"q": "Is there a contract?"})
print(a["answer"] if a["found"] else a["note"])<?php
require __DIR__ . '/vocatech.php';
$a = vt('GET', '/v1/knowledge/answer?' . http_build_query(['q' => 'Is there a contract?']));
echo $a['found'] ? $a['answer'] : $a['note'], PHP_EOL;{
"question": "Is there a contract?",
"found": true,
"answer": "No. Service is month-to-month, and there is no contract to sign.",
"section": "Contracts and billing",
"note": "Answer the caller from this in your own words; state only what it says."
}q is 3 to 500 characters. source, optional, names the system that asks, for our log. Each key may ask 30 questions a minute and 2,000 a day.
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. 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 or WhatsApp message goes out, from the API, Webex or the portal. | 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.
Rate limits
Limits are counted per key, in fixed windows in UTC: each minute, each hour, and each day from 00:00 UTC. Each kind of request has its own counter, so polling reports never uses up your room to send a text.
| Counter | Counts | Per minute | Per hour | Per day |
|---|---|---|---|---|
default | Every request not listed below | 600 | 3,600 | 100,000 |
reports | GET /v1/calls, GET /v1/messages, /v1/reports/* | 300 | 3,000 | 100,000 |
messages | POST /v1/messages on a main key | 20 | None | 200 |
faxes | POST /v1/faxes, per account | 3 | None | 50 faxes sent (Eastern day) |
dial | POST /v1/agents/{id}/calls | 10 | None | 500 |
tickets | POST /v1/support/tickets | 10 | None | 200 |
knowledge | GET or POST /v1/knowledge/answer | 30 | None | 2,000 |
- Every request counts, including requests that were refused.
- A request without a live key (no key, a wrong key, a key turned off) counts against its source address instead: 60 a minute and 600 an hour.
- API Messaging keys are not held to the 20 and 200 on sends. Their queue paces them (see The API Messaging queue), under a backstop of 100,000 send requests a day.
- Faxes are counted per account, not per key. The 50 a day counts faxes actually sent: a dry run or a refused request does not use one.
- Faxes also stop at the account's monthly outbound limit, 1,000 unless Vocatech set another.
Over a limit, the API answers 429 with a Retry-After header: the seconds until the window resets. The body names the window, the limit and the counter:
{
"error": "Rate limit exceeded",
"period": "minute",
"limit": 20,
"counter": "messages",
"retry_after": 37
}When an API Messaging key's day allowance refuses a send, the body says so in words:
{
"error": "Rate limit exceeded",
"message": "This key is limited to 5000 messages per day. 4990 have been sent in the last 24 hours and 10 are waiting in the queue, so this send does not fit today.",
"period": "day",
"limit": 5000,
"used": 4990,
"queued": 10,
"retry_after": 36120
}Back off and retry
Wait for Retry-After when it is there. Without it, back off exponentially with a little jitter. Retry 429 and 503. Never retry a text send that answered 504 without checking GET /v1/messages first, and stop rather than sleep for hours on a day limit.
# curl retries 408, 429, 500, 502, 503 and 504 by itself and honors
# Retry-After (curl 7.66 or newer). Use it on reads, never on text sends.
curl --retry 5 --retry-max-time 120 \
-H "Authorization: Bearer $VOCATECH_API_KEY" \
https://api.vocatech.com/v1/callsimport { vt } from './vocatech.mjs';
const sleep = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000));
export async function withRetry(call, tries = 5) {
for (let attempt = 1; ; attempt++) {
try {
return await call();
} catch (err) {
const retryable = err.status === 429 || err.status === 503;
if (!retryable || attempt >= tries) throw err;
const wait = err.retryAfter ?? Math.min(60, 2 ** attempt) + Math.random();
if (wait > 120) throw err; // a day window: stop, do not sleep for hours
await sleep(wait);
}
}
}
const calls = await withRetry(() => vt('GET', '/v1/calls'));import random
import time
from vocatech import VocatechError, vt
def with_retry(call, tries=5):
for attempt in range(1, tries + 1):
try:
return call()
except VocatechError as err:
if err.status not in (429, 503) or attempt == tries:
raise
wait = err.retry_after or min(60, 2 ** attempt) + random.random()
if wait > 120: # a day window: stop, do not sleep for hours
raise
time.sleep(wait)
calls = with_retry(lambda: vt("GET", "/v1/calls"))<?php
require __DIR__ . '/vocatech.php';
function vt_with_retry(callable $call, int $tries = 5): mixed
{
for ($attempt = 1; ; $attempt++) {
try {
return $call();
} catch (VocatechError $e) {
if (!in_array($e->status, [429, 503], true) || $attempt >= $tries) {
throw $e;
}
$wait = $e->retryAfter ?? min(60, 2 ** $attempt) + mt_rand(0, 1000) / 1000;
if ($wait > 120) {
throw $e; // a day window: stop, do not sleep for hours
}
usleep((int) ($wait * 1000000));
}
}
}
$calls = vt_with_retry(fn () => vt('GET', '/v1/calls'));To stay well under the limits, use webhooks instead of polling, and ask for 500 rows a page.
Errors
Every error has a status code and a JSON body. Read the status first: it tells you what to do. The body comes in one of four shapes. The vt() helpers in the Quickstart read all four.
From an endpoint
Most errors. The message says what to fix.
{
"error": {
"code": 400,
"type": "BAD_REQUEST",
"message": "Ask for at most 366 days at a time (this request covers 400). For more history, walk it a year at a time."
}
}From the router
An unknown path, a method the path does not take, some not-found answers, and faults we did not expect.
{
"statusCode": 404,
"error": {
"type": "RESOURCE_NOT_FOUND",
"description": "Webhook not found."
}
}From the key check
401, 403 for a scope or an IP allow-list, and 503 when a key cannot be checked. Examples are in Authentication and keys.
{
"error": "Service Unavailable",
"message": "The API could not check this key just now. Retry in a few seconds."
}From a rate limit
429, with retry_after in the body and a Retry-After header. See Rate limits.
Status codes
| Status | When | What to do |
|---|---|---|
400 | A value is wrong or missing: a date, a number, a file, a window over 366 days, an end date before the start. | Fix the request. The message names the field. |
401 | No key, a wrong key, or a key that was turned off or regenerated. | Check the key. Retrying will not help. |
403 | The key's scope or IP allow-list refuses the request, or the action is not allowed: from is not your number, the recipient opted out, faxing is off. | Read the message. Do not retry as is. |
404 | Not found, not yours, or an unknown path. | Check the id and the path. |
405 | The path does not take that method. | Use the method in the reference. |
409 | A queued message is already sending or done, or a support ticket is busy (with Retry-After: 60). | Treat it as done, or wait and send again. |
410 | A message attachment is no longer available. | There is nothing to fetch. |
413 | A fax document over 20 MB. | Send a smaller file. |
422 | Validation: a webhook URL that is not public https, a missing event or message, WhatsApp sending, an AI agent call that cannot be placed. | Fix the request. The message says what. |
429 | A rate limit, a day allowance, or the monthly fax limit. | Wait Retry-After seconds. |
500 | Something failed on our side, or the fax service refused a fax. | Retry later with backoff. If it repeats, tell us the time and the path. |
502 | A service behind the API answered and refused: the messaging service did not send, or the support desk or the AI agent platform could not take it. | Read the message. Retry in a minute if it says so. |
503 | The key could not be checked (Retry-After: 5), a text could not reach the messaging service and was not sent, a desk is not available, or storage had trouble on our side. | Safe to retry after a pause. |
504 | A text send was not confirmed. It may have gone out. | Check GET /v1/messages before retrying. |
Pagination and dates
List routes take page, from 1, and limit, and answer with meta:
{
"meta": {
"page": 2,
"limit": 500,
"total_pages": 4,
"total_calls": 1873
}
}| Route | limit | Total in meta |
|---|---|---|
GET /v1/calls | 1 to 500, default 500 | total_calls |
GET /v1/messages | 1 to 500, default 500 | total_messages |
GET /v1/faxes | 1 to 500, default 500 | total_faxes |
GET /v1/contacts | 1 to 500, default 100 | total_contacts |
GET /v1/webhooks/failures | 1 to 100, default 20 | total |
GET /v1/callers/{number} | 1 to 50 calls, default 10, no pages | counts |
GET /v1/messages/queue | Up to 200 items, no pages | queue.pending |
Dates and time zones
start_dateandend_dateareYYYY-MM-DD, and you send both or neither. Neither, or only one, means today.- They are read in
timezone, an IANA name such asAmerica/Chicago. The default isAmerica/New_York. A name the API does not know falls back to New York, so checkquery.timezonein the answer. - A request covers at most 366 days, and
end_datemay not come beforestart_date. Either answers400. For more history, walk a year at a time. - The answer echoes what it used in
query: the local dates and the time zone. - Every time in an answer is UTC, except
created_atin the send answers ofPOST /v1/messagesandPOST /v1/faxes, which carries New York time with its offset. Webhook endpoint, failure and queue listings write times as2026-09-24 13:06:53, also UTC.
Stable order
Rows that tie on the sort key come back in a fixed order, so a page never repeats or skips a row while the data stands still. New calls land while you page, though. To walk a range cleanly, sort day_asc, or walk a window that has closed, such as yesterday.
# Every page, oldest first, until page reaches meta.total_pages (uses jq)
page=1
while :; do
body=$(curl -s -H "Authorization: Bearer $VOCATECH_API_KEY" \
"https://api.vocatech.com/v1/messages?start_date=2026-09-01&end_date=2026-09-23&sort=day_asc&limit=500&page=$page")
echo "$body" | jq -c '.messages[]'
[ "$page" -ge "$(echo "$body" | jq '.meta.total_pages')" ] && break
page=$((page + 1))
doneimport { vt } from './vocatech.mjs';
// Walks every page of /v1/calls, /v1/messages or /v1/faxes, oldest first.
async function* walk(path, listKey, query = {}) {
for (let page = 1; ; page++) {
const qs = new URLSearchParams({ sort: 'day_asc', limit: '500', ...query, page: String(page) });
const data = await vt('GET', `${path}?${qs}`);
yield* data[listKey];
if (page >= data.meta.total_pages) return;
}
}
for await (const msg of walk('/v1/messages', 'messages', { start_date: '2026-09-01', end_date: '2026-09-23' })) {
console.log(msg.sent_time, msg.direction, msg.remote_number);
}from vocatech import vt
def walk(path, list_key, **query):
"""Every page of /v1/calls, /v1/messages or /v1/faxes, oldest first."""
page = 1
while True:
data = vt("GET", path, params={"sort": "day_asc", "limit": 500, **query, "page": page})
yield from data[list_key]
if page >= data["meta"]["total_pages"]:
return
page += 1
for msg in walk("/v1/messages", "messages", start_date="2026-09-01", end_date="2026-09-23"):
print(msg["sent_time"], msg["direction"], msg["remote_number"])<?php
require __DIR__ . '/vocatech.php';
// Every page of /v1/calls, /v1/messages or /v1/faxes, oldest first.
function vt_walk(string $path, string $listKey, array $query = []): Generator
{
for ($page = 1; ; $page++) {
$qs = http_build_query(array_merge(['sort' => 'day_asc', 'limit' => 500], $query, ['page' => $page]));
$data = vt('GET', $path . '?' . $qs);
yield from $data[$listKey];
if ($page >= $data['meta']['total_pages']) {
return;
}
}
}
foreach (vt_walk('/v1/messages', 'messages', ['start_date' => '2026-09-01', 'end_date' => '2026-09-23']) as $msg) {
echo $msg['sent_time'], ' ', $msg['direction'], ' ', $msg['remote_number'], PHP_EOL;
}For a nightly sync, walk yesterday in full each night. For a first import, walk a year at a time.
Changelog
Changes that affect integrations, newest first.
-
Recordings in parts
- A call that is parked and picked up again is recorded in parts. Each leg in
GET /v1/callslists its parts in the newrecordingsfield, oldest first, each withpart,parts,recording_id,start_time,durationand its ownurl. GET /v1/media/rec_<n>?part=Nfetches one part. Without?part, the answer is the latest part, and it now names it:part,parts,recording_id,start_time.recording_url,summaryandtranscriptionalways come from the same recording, the latest part. On a parked call the link used to play the first part while the words came from the second.call.transcriptionevents carryrecording_id,part,parts,recording_start_timeandrecording_duration, so the events of a call recorded in parts can be told apart.
- A call that is parked and picked up again is recorded in parts. Each leg in
-
Separate limits, clearer errors, safer sending
- Support tickets take the business the person is calling about:
contact_companyandcontact_company_phone. Support finds the account on that number first. Acaller_idor business number that is not a 10-digit US number is left out instead of refusing the ticket. - Each kind of request has its own rate-limit counter per key: reports, texts, faxes, AI agent calls, support tickets, knowledge questions and everything else. They used to share one, so a key that polled reports could run out of room to send a text. The
429body now names the counter. - New counters: faxes 10 a minute and 500 a day, AI agent calls 10 and 500, support tickets 10 and 200, knowledge questions 30 and 2,000.
- A request without a live key is limited per source address: 60 a minute and 600 an hour.
- When the API cannot check a key, it answers
503withRetry-After: 5. It used to answer401. - Text sends that fail say what happened:
503(not sent, safe to retry),504(not confirmed, checkGET /v1/messagesbefore retrying), the messaging service's own4xx, or502. They were all500. - WhatsApp sending through
POST /v1/messagesanswers422, dry runs included. WhatsApp history stays inGET /v1/messages. GET /v1/calls,GET /v1/messagesandGET /v1/faxescover at most 366 days a request (400otherwise). Messages and faxes echo the local dates you asked for, and rows that tie on the sort key page in a stable order.- Webhook URLs must be
httpson a public internet address (422otherwise). Deliveries never follow redirects: a3xxis a failed delivery. POST /v1/usersno longer creates or changes keys. It answers403. Keys are made in the portal.- Faxes: documents over 20 MB answer
413, the account's monthly outbound limit is enforced (429), and premium and foreign numbers are refused. - AI agent calls never dial 911 or other service codes, 988, premium numbers or other countries' area codes.
GET /v1/mediaanswers an attachment on a text you sent with the link you sent it with.- Removed:
GET /v1/settings, which never worked, and the stage docs at/st-docs. - The API reference is new, with a request console.
- Support tickets take the business the person is calling about:
-
Knowledge
GETandPOST /v1/knowledge/answer: a question about Vocatech's service, answered from our knowledge base. Newknowledgescope. Thesupportscope reaches it too. -
Callers and AI agents
GET /v1/callers/{number},GET /v1/contacts/lookup,GET /v1/agentsandPOST /v1/agents/{id}/calls. Newagentsscope. -
Support tickets
POST /v1/support/ticketsopens a ticket with Vocatech support. Newsupportscope. -
Texting pace on main keys
POST /v1/messageson a main key: up to 20 a minute and 200 a day. API Messaging keys carry more. -
Calls page by call
A call's journey is never split across two pages of
GET /v1/calls. -
Fax history fixed
GET /v1/faxesno longer fails with500. -
Webhooks by number
number_filterslimits an endpoint to chosen numbers. An API Messaging key manages its own webhooks, for message events only. -
One API Messaging key per integrator
Up to 10 API Messaging keys per account, each with its own label, pace and IP allow-list.
-
Signed attachment links
GET /v1/mediaanswers message attachments with a signed link good for 30 minutes, like recordings and faxes. -
Parts counted the carriers' way
messages_countedfollows the carriers' rules for basic and extended characters. -
Real numbers only
A text's
tomust be a valid 10-digit US or Canadian number. -
The queue
API Messaging keys hand over a whole batch (
202) and it goes out in order at the key's pace.GETandDELETE /v1/messages/queue. An MMS carries every attachment in one message. -
Scopes, API Messaging and MMS
Keys can be narrowed to a scope. API Messaging keys, with their own pace.
mediaonPOST /v1/messages, andmessages_countedin send answers. -
IP allow-list
Lock a key to exact addresses.
GET /v1/whoamishows the address the API sees. -
Cleaner events
call.transcriptioncarries the clean, speaker-labeled transcript. Message events carryerror.POST /v1/messagesreturnsmessage_id. -
Faxes
POSTandGET /v1/faxes,fax_media and thefax.*events. -
One media route
GET /v1/media/{id}for message attachments and call recordings. -
Contacts
List, create or update, and delete contacts through the API.
-
Message events
message.sent,message.receivedandmessage.status_updatedwebhooks. -
Flat paths and dry runs
/v1/calls,/v1/messagesand/v1/webhooks, and dry runs with"test": true.
Support
Questions, a key that leaked, or an endpoint that does not behave: reach us, and a person picks up.
Tell us the time, the path and the status you got. Never send us your key.