Developers
Manage agents, numbers and calls from your own systems, and put a voice call on your own website.
Overview
The API is a REST API. Request and response bodies are JSON (UTF-8), timestamps are unix seconds and amounts are in USD. The base URL is:
https://hubvocal.com/v1
An API key belongs to a single organization and acts with admin permissions inside it. Anything you can do in the dashboard, you can do with the API:
| Resource | Endpoints |
|---|---|
| Agents | /v1/agents, /v1/agents/{id} |
| Knowledge base | /v1/knowledge, /v1/knowledge/{id}, /v1/knowledge/search |
| Tools | /v1/tools, /v1/tools/{id}, /v1/tools/{id}/test |
| MCP servers | /v1/mcp-servers, /v1/mcp-servers/{id}, /v1/mcp-servers/{id}/sync |
| Webhooks | /v1/webhooks, /v1/webhooks/{id}, /v1/webhooks/{id}/deliveries |
| Voices and engines | /v1/voices |
| Web calls | /v1/web-calls |
| Calls and outbound calls | /v1/calls, /v1/calls/{room} |
| Numbers | /v1/numbers, /v1/numbers/{number} |
| Usage | /v1/usage |
A machine-readable OpenAPI 3.1 document is available at GET /v1/openapi.json and needs no key. For every endpoint in detail, see the API reference.
Quickstart
-
Create an API key
In the dashboard, open Settings > API keys and give the key a name that says what it is for. Only organization owners and admins can create keys. The secret (
vt_live_…) is shown once; store it in a secrets manager right away.export VT_API_KEY="vt_live_…" -
List your agents
The quickest way to check the key works. You will use an id from the response in the next steps.
curlcurl "https://hubvocal.com/v1/agents" \ -H "Authorization: Bearer $VT_API_KEY"JavaScriptconst res = await fetch("https://hubvocal.com/v1/agents", { headers: { Authorization: `Bearer ${process.env.VT_API_KEY}`, }, }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status}: ${data.detail}`); console.log(data);Pythonimport os import requests resp = requests.get( "https://hubvocal.com/v1/agents", headers={"Authorization": f"Bearer {os.environ['VT_API_KEY']}"}, timeout=30, ) if not resp.ok: raise RuntimeError(f"{resp.status_code}: {resp.json()['detail']}") print(resp.json()) -
Create an agent
Engine, language, voice and instructions in one object. For valid voice names and limits, call
GET /v1/voices.curlcurl -X POST "https://hubvocal.com/v1/agents" \ -H "Authorization: Bearer $VT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Front desk", "lang": "en", "engine": "live", "instructions": "You are the front desk assistant for a dental clinic. Help callers book appointments and answer general questions briefly. Never make things up; offer a callback from the team instead.", "greeting": "Hello, thanks for calling the clinic. How can I help you today?", "voice": { "voice": "marin", "speed": 1 }, "tools": { "builtins": [ "end_call", "take_message" ], "webhooks": [ "tl_8f3a2c" ] }, "turn": { "first_speaker": "agent", "idle_check_s": 30 }, "recording": { "enabled": false, "announce": true }, "post_call": { "summary": true, "fields": [ { "name": "appointment_date", "type": "date", "description": "Requested appointment date, if any" }, { "name": "callback", "type": "boolean", "description": "Does the caller want a callback?" } ] }, "knowledge": { "doc_ids": [ "kb_7d21e4" ], "mode": "tool" } }'JavaScriptconst res = await fetch("https://hubvocal.com/v1/agents", { method: "POST", headers: { Authorization: `Bearer ${process.env.VT_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "name": "Front desk", "lang": "en", "engine": "live", "instructions": "You are the front desk assistant for a dental clinic. Help callers book appointments and answer general questions briefly. Never make things up; offer a callback from the team instead.", "greeting": "Hello, thanks for calling the clinic. How can I help you today?", "voice": { "voice": "marin", "speed": 1 }, "tools": { "builtins": [ "end_call", "take_message" ], "webhooks": [ "tl_8f3a2c" ] }, "turn": { "first_speaker": "agent", "idle_check_s": 30 }, "recording": { "enabled": false, "announce": true }, "post_call": { "summary": true, "fields": [ { "name": "appointment_date", "type": "date", "description": "Requested appointment date, if any" }, { "name": "callback", "type": "boolean", "description": "Does the caller want a callback?" } ] }, "knowledge": { "doc_ids": [ "kb_7d21e4" ], "mode": "tool" } }), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status}: ${data.detail}`); console.log(data);Pythonimport os import requests resp = requests.post( "https://hubvocal.com/v1/agents", headers={"Authorization": f"Bearer {os.environ['VT_API_KEY']}"}, json={ "name": "Front desk", "lang": "en", "engine": "live", "instructions": "You are the front desk assistant for a dental clinic. Help callers book appointments and answer general questions briefly. Never make things up; offer a callback from the team instead.", "greeting": "Hello, thanks for calling the clinic. How can I help you today?", "voice": { "voice": "marin", "speed": 1, }, "tools": { "builtins": [ "end_call", "take_message", ], "webhooks": [ "tl_8f3a2c", ], }, "turn": { "first_speaker": "agent", "idle_check_s": 30, }, "recording": { "enabled": False, "announce": True, }, "post_call": { "summary": True, "fields": [ { "name": "appointment_date", "type": "date", "description": "Requested appointment date, if any", }, { "name": "callback", "type": "boolean", "description": "Does the caller want a callback?", }, ], }, "knowledge": { "doc_ids": [ "kb_7d21e4", ], "mode": "tool", }, }, timeout=30, ) if not resp.ok: raise RuntimeError(f"{resp.status_code}: {resp.json()['detail']}") print(resp.json()) -
Start a web call
You connect from the browser with the
server_urlandtokenin the response; details below.curlcurl -X POST "https://hubvocal.com/v1/web-calls" \ -H "Authorization: Bearer $VT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "agent_id": "front-desk" }'JavaScriptconst res = await fetch("https://hubvocal.com/v1/web-calls", { method: "POST", headers: { Authorization: `Bearer ${process.env.VT_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "agent_id": "front-desk" }), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status}: ${data.detail}`); console.log(data);Pythonimport os import requests resp = requests.post( "https://hubvocal.com/v1/web-calls", headers={"Authorization": f"Bearer {os.environ['VT_API_KEY']}"}, json={ "agent_id": "front-desk", }, timeout=30, ) if not resp.ok: raise RuntimeError(f"{resp.status_code}: {resp.json()['detail']}") print(resp.json())
Authentication
Send the key as a Bearer token in the Authorization header on every request:
Authorization: Bearer vt_live_… - The key acts with admin permissions in your organization: it creates agents, assigns numbers and places calls. Treat it like a password.
- Never put the key in browser code, a mobile app or a repository. For browser calls, get a short-lived token on your server.
- The list under Settings > API keys shows when each key was last used. A revoked key stops working immediately.
- Use one key per integration, so revoking one does not break the others.
Embed a web call
Your visitors can talk to the agent right on your site, in the browser. The flow has three steps:
- The visitor presses a button and your page calls your own server.
- Your server calls
POST /v1/web-callswith the API key and passes the response (server_url,token,room,max_seconds) back to the page. - The page connects to the room with
livekit-clientand turns on the microphone. The agent joins on its own and says its greeting.
1. Server: get a token
// server.js (Node 18+, Express)
import express from "express";
const app = express();
app.post("/voice/session", async (req, res) => {
// Apply your own auth and rate limiting here.
const r = await fetch("https://hubvocal.com/v1/web-calls", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ agent_id: "front-desk" }),
});
res.status(r.status).json(await r.json());
});
app.listen(3000); # app.py (Flask)
import os
import requests
from flask import Flask, jsonify
app = Flask(__name__)
@app.post("/voice/session")
def voice_session():
# Apply your own auth and rate limiting here.
r = requests.post(
"https://hubvocal.com/v1/web-calls",
headers={"Authorization": f"Bearer {os.environ['VT_API_KEY']}"},
json={"agent_id": "front-desk"},
timeout=15,
)
return jsonify(r.json()), r.status_code 2. Browser: connect
If you use a bundler, install npm install livekit-client@2.23.0 and import the same names. The example below loads a pinned version from a CDN.
<button id="talk" type="button">Start talking</button>
<script type="module">
import { Room, RoomEvent, Track } from "https://cdn.jsdelivr.net/npm/livekit-client@2.23.0/dist/livekit-client.esm.mjs";
const button = document.getElementById("talk");
let room;
button.addEventListener("click", async () => {
if (room) {
await room.disconnect();
return;
}
button.disabled = true;
// Your server returns the POST /v1/web-calls response as is.
const res = await fetch("/voice/session", { method: "POST" });
const session = await res.json();
if (!res.ok) {
button.disabled = false;
alert(session.detail);
return;
}
room = new Room();
// Attach the agent's audio to an <audio> element when it arrives.
room.on(RoomEvent.TrackSubscribed, (track) => {
if (track.kind === Track.Kind.Audio) document.body.appendChild(track.attach());
});
room.on(RoomEvent.Disconnected, () => {
room?.remoteParticipants.forEach((p) =>
p.audioTrackPublications.forEach((pub) => pub.track?.detach().forEach((el) => el.remove())),
);
room = undefined;
button.textContent = "Start talking";
});
await room.connect(session.server_url, session.token);
await room.localParticipant.setMicrophoneEnabled(true);
button.disabled = false;
button.textContent = "Hang up";
});
</script> Start the connection from a user gesture (a click): browsers tie microphone permission and audio autoplay to it. The page must be served over HTTPS.
A call lasts at most max_seconds, then the agent hangs up. When it ends, fetch its record with GET /v1/calls/{room}.
Outbound calls
Call a number from one of your organization's numbers; the agent speaks once the call is answered. The from number must be in GET /v1/numbers. Without agent_id, the agent assigned to that number is used. Numbers are in E.164 format (+90…).
curl -X POST "https://hubvocal.com/v1/calls" \
-H "Authorization: Bearer $VT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "+908501234567",
"to": "+905321234567",
"agent_id": "front-desk"
}' const res = await fetch("https://hubvocal.com/v1/calls", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"from": "+908501234567",
"to": "+905321234567",
"agent_id": "front-desk"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status}: ${data.detail}`);
console.log(data); import os
import requests
resp = requests.post(
"https://hubvocal.com/v1/calls",
headers={"Authorization": f"Bearer {os.environ['VT_API_KEY']}"},
json={
"from": "+908501234567",
"to": "+905321234567",
"agent_id": "front-desk",
},
timeout=30,
)
if not resp.ok:
raise RuntimeError(f"{resp.status_code}: {resp.json()['detail']}")
print(resp.json()) Response
{
"room": "tel-out-9c41e0b7",
"provider": "didww",
"sip_call_id": "SCL_7mVt2kQb9xRa"
} The room in the response identifies the call. Once the call ends, its record (transcript, cost, who hung up) is available from GET /v1/calls/{room}. Unanswered calls end with ended_by set to no_answer.
Inbound calls
Incoming calls on your numbers are answered by the agent assigned to that number. Numbers come in through DIDWW, Twilio or your own SIP trunk (Asterisk and others) and are added to your organization by the platform team. You choose which agent answers:
curl -X PUT "https://hubvocal.com/v1/numbers/%2B908501234567" \
-H "Authorization: Bearer $VT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "front-desk",
"label": "Main line"
}' const res = await fetch("https://hubvocal.com/v1/numbers/%2B908501234567", {
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.VT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"agent_id": "front-desk",
"label": "Main line"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status}: ${data.detail}`);
console.log(data); import os
import requests
resp = requests.put(
"https://hubvocal.com/v1/numbers/%2B908501234567",
headers={"Authorization": f"Bearer {os.environ['VT_API_KEY']}"},
json={
"agent_id": "front-desk",
"label": "Main line",
},
timeout=30,
)
if not resp.ok:
raise RuntimeError(f"{resp.status_code}: {resp.json()['detail']}")
print(resp.json()) Versions and safe publishing
Editing an agent changes its draft. Calls on your numbers, web calls and outbound calls use the published version, so you can work on the draft without affecting live calls.
- Edit the agent with
PUT /v1/agents/{id}. This only changes the draft;draft_changedbecomes true. - Test the draft: start a web call with
version: "draft". - Publish with
POST /v1/agents/{id}/publishand an optional note. The draft becomes a new numbered version and goes live. - If something is wrong,
POST /v1/agents/{id}/rollbackwith an earlier version number publishes it again as a new version.
curl -X POST "https://hubvocal.com/v1/agents/front-desk/publish" \
-H "Authorization: Bearer $VT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"note": "Shorter greeting"
}' const res = await fetch("https://hubvocal.com/v1/agents/front-desk/publish", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"note": "Shorter greeting"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status}: ${data.detail}`);
console.log(data); import os
import requests
resp = requests.post(
"https://hubvocal.com/v1/agents/front-desk/publish",
headers={"Authorization": f"Bearer {os.environ['VT_API_KEY']}"},
json={
"note": "Shorter greeting",
},
timeout=30,
)
if not resp.ok:
raise RuntimeError(f"{resp.status_code}: {resp.json()['detail']}")
print(resp.json()) POST /v1/web-calls and POST /v1/calls accept version: "live" (default), "draft" or a version number. An agent that has never been published uses its draft.
curl -X POST "https://hubvocal.com/v1/web-calls" \
-H "Authorization: Bearer $VT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "front-desk",
"version": "draft"
}' Test before you publish
The test lab runs text conversations between a simulated caller and your agent, so you can check a change before real callers hear it.
- Write a scenario: who is calling (
persona), what they want (goal) and up to 20 turns. - Add rules a good call must meet (
expect), for example "asks for the caller's name before booking". - Give sample results for tools (
mocks); no real tool or webhook is called. - Run the scenarios against the draft. A separate model checks every rule and explains why it passed or failed.
{
"scenarios": [
{
"name": "Booking without a name",
"persona": "A caller in a hurry who gives short answers",
"goal": "Book an appointment tomorrow afternoon",
"max_turns": 12,
"expect": [
"Asks for the caller's name before booking",
"Reads the time back and asks for confirmation"
],
"mocks": {
"check_availability": {
"ok": true,
"slots": [
"2026-11-06T14:00",
"2026-11-06T15:30"
]
}
}
}
],
"version": "draft"
} The conversations run in text mode, so they test instructions and tool use, not voice or latency. Simulations are run from the dashboard; there is no /v1 endpoint for them yet.
Tools
Tools let the agent act during a call. The same definition works on all four engines; on GPT-Live the background model runs them. Tools are switched on per agent in the tools field.
Built-in tools
| Name | What it does |
|---|---|
end_call | The agent says goodbye and hangs up 0.75 s after it stops speaking. If the caller speaks in the meantime, hanging up is cancelled. The call record shows ended_by: "agent". |
take_message | Takes name, phone, message and callback_requested and writes them to outcomes[] in the call record. It does not ask for confirmation. |
search_knowledge | Added automatically when the agent has documents in knowledge. Searches them and records the sources in the call record. |
check_availability | Read. Returns free slots between date_from and date_to, within the agent's working hours. Added when calendar is set. |
book_appointment | Write, behind the confirmation step. Books start for name and phone, with an optional note. The booking goes to outcomes[]. |
cancel_appointment | Write, behind the confirmation step. Cancels a booking by its booking_ref. |
Webhook tools
A webhook tool is an HTTPS endpoint on your side. Define it once for the organization in the dashboard under Tools or with POST /v1/tools, then add its id to an agent's tools.webhooks (up to 16 per agent).
curl -X POST "https://hubvocal.com/v1/tools" \
-H "Authorization: Bearer $VT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "check_order",
"description": "Looks up the shipping status of an order by its number.",
"kind": "read",
"url": "https://crm.example.com/hooks/order",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "Order number"
}
},
"required": [
"order_id"
]
},
"timeout_s": 8,
"enabled": true,
"secret": "whsec_2b7f…"
}' const res = await fetch("https://hubvocal.com/v1/tools", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"name": "check_order",
"description": "Looks up the shipping status of an order by its number.",
"kind": "read",
"url": "https://crm.example.com/hooks/order",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "Order number"
}
},
"required": [
"order_id"
]
},
"timeout_s": 8,
"enabled": true,
"secret": "whsec_2b7f…"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status}: ${data.detail}`);
console.log(data); import os
import requests
resp = requests.post(
"https://hubvocal.com/v1/tools",
headers={"Authorization": f"Bearer {os.environ['VT_API_KEY']}"},
json={
"name": "check_order",
"description": "Looks up the shipping status of an order by its number.",
"kind": "read",
"url": "https://crm.example.com/hooks/order",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "Order number",
},
},
"required": [
"order_id",
],
},
"timeout_s": 8,
"enabled": True,
"secret": "whsec_2b7f…",
},
timeout=30,
)
if not resp.ok:
raise RuntimeError(f"{resp.status_code}: {resp.json()['detail']}")
print(resp.json()) name: lowercase letters, digits and underscores, 3 to 41 characters, starting with a letter, unique in the organization.end_callandtake_messageare reserved.descriptiontells the model when to use the tool. Write it like an instruction to a colleague.parametersis a JSON Schema object with up to 20 fields of type string, number, integer or boolean (enum allowed).kindisreadorwrite;timeout_sis 2 to 15 seconds.
The request we send
When the model calls the tool, the API sends a POST to your URL. The body holds the tool name, the arguments the model chose and some context about the call:
POST /hooks/order HTTP/1.1
Host: crm.example.com
Content-Type: application/json
X-VT-Timestamp: 1793832421
X-VT-Signature: sha256=5f0c9a…
{
"tool": "check_order",
"arguments": {
"order_id": "A-10234"
},
"call": {
"room": "tel-6b1f04d2",
"org_id": "6f1c2a40-3b7e-4d0c-9a51-2f8e7c1d9b03",
"agent": "front-desk",
"channel": "phone_in",
"caller": "+905321234567",
"called": "+908501234567",
"lang": "en"
}
} Every request is signed with the tool's secret. X-VT-Timestamp is the unix time; X-VT-Signature is sha256= followed by the hex HMAC-SHA256 of timestamp + "." + body. Check it against the raw body before parsing, and reject timestamps older than a few minutes.
Verifying the signature
// server.js (Node 18+, Express)
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.VT_TOOL_SECRET;
// The signature covers the exact bytes: read the raw body.
app.post("/hooks/order", express.raw({ type: "*/*" }), (req, res) => {
const ts = req.get("X-VT-Timestamp") ?? "";
const sig = req.get("X-VT-Signature") ?? "";
// Reject old requests (replay protection).
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);
const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(ts + ".").update(req.body).digest("hex");
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
const { tool, arguments: args, call } = JSON.parse(req.body.toString("utf8"));
// Keep the answer short: only the first 4 KB reach the model.
res.json({ order_id: args.order_id, status: "shipped", eta: "2026-11-07" });
});
app.listen(3000); # app.py (Flask)
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, jsonify, request
app = Flask(__name__)
SECRET = os.environ["VT_TOOL_SECRET"].encode()
@app.post("/hooks/order")
def order():
ts = request.headers.get("X-VT-Timestamp", "")
sig = request.headers.get("X-VT-Signature", "")
# Reject old requests (replay protection).
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
abort(401)
# The signature covers the exact bytes: read the raw body.
body = request.get_data()
expected = "sha256=" + hmac.new(SECRET, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
payload = json.loads(body)
args = payload["arguments"]
# Keep the answer short: only the first 4 KB reach the model.
return jsonify(order_id=args["order_id"], status="shipped", eta="2026-11-07") Your response
Answer with a 2xx status and JSON or plain text. Only the first 4 KB reach the model, so return just what the agent needs to say. A non-2xx status or a timeout tells the model that the tool failed, and the agent handles it in the conversation.
Read and write tools
- read tools run immediately, for lookups such as order status or opening hours.
- write tools go through a confirmation step. The first call is not executed; the agent reads the details back to the caller and asks for a yes or no. It runs only if the caller's next turn contains a clear yes in the language of the call.
- The same call (name and arguments) runs at most once per conversation.
Security
- URLs must use
https://. Local and private network addresses are rejected. - The secret is stored encrypted and is never returned by the API (
has_secretshows whether one is set). The agent never sees it; the API makes the request. - Arguments and responses are not stored. The call record keeps
tools[]with name, kind, result and duration, and a TOOL lane on the timeline.
Use POST /v1/tools/{id}/test to send a real request with sample arguments and see the status, timing and response body.
curl -X POST "https://hubvocal.com/v1/tools/tl_8f3a2c/test" \
-H "Authorization: Bearer $VT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"arguments": {
"order_id": "A-10234"
}
}' const res = await fetch("https://hubvocal.com/v1/tools/tl_8f3a2c/test", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"arguments": {
"order_id": "A-10234"
}
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status}: ${data.detail}`);
console.log(data); import os
import requests
resp = requests.post(
"https://hubvocal.com/v1/tools/tl_8f3a2c/test",
headers={"Authorization": f"Bearer {os.environ['VT_API_KEY']}"},
json={
"arguments": {
"order_id": "A-10234",
},
},
timeout=30,
)
if not resp.ok:
raise RuntimeError(f"{resp.status_code}: {resp.json()['detail']}")
print(resp.json()) Knowledge base
The knowledge base holds your organization's documents. When an agent has documents selected, it gets the search_knowledge tool and answers from what it finds. It is instructed not to make up what is not in the documents.
Add a document
Send a file as multipart (file), plain text as JSON (title, text) or a single web page as JSON (url). Processing runs in the background: the document starts as processing and becomes ready or failed.
curl -X POST "https://hubvocal.com/v1/knowledge" \
-H "Authorization: Bearer $VT_API_KEY" \
-F "file=@price-list-2026.pdf" import fs from "node:fs";
const form = new FormData();
form.append("file", new Blob([await fs.promises.readFile("price-list-2026.pdf")]), "price-list-2026.pdf");
const res = await fetch("https://hubvocal.com/v1/knowledge", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.VT_API_KEY}` },
body: form,
});
console.log(await res.json()); import os
import requests
with open("price-list-2026.pdf", "rb") as f:
resp = requests.post(
"https://hubvocal.com/v1/knowledge",
headers={"Authorization": f"Bearer {os.environ['VT_API_KEY']}"},
files={"file": ("price-list-2026.pdf", f, "application/pdf")},
timeout=60,
)
print(resp.json()) - Files: pdf, docx, txt, md or html, up to 10 MB each.
- Up to 200 documents and 200 MB in total per organization.
- URLs: one page, https only; local and private addresses are rejected.
Give it to an agent
List the document ids in the agent's knowledge.doc_ids (up to 50). Try searches with POST /v1/knowledge/search before you go live.
curl -X POST "https://hubvocal.com/v1/knowledge/search" \
-H "Authorization: Bearer $VT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "how much is a scale and polish"
}' const res = await fetch("https://hubvocal.com/v1/knowledge/search", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"query": "how much is a scale and polish"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status}: ${data.detail}`);
console.log(data); import os
import requests
resp = requests.post(
"https://hubvocal.com/v1/knowledge/search",
headers={"Authorization": f"Bearer {os.environ['VT_API_KEY']}"},
json={
"query": "how much is a scale and polish",
},
timeout=30,
)
if not resp.ok:
raise RuntimeError(f"{resp.status_code}: {resp.json()['detail']}")
print(resp.json()) In the call record, each search appears in tools[] as search_knowledge with its sources (document, title and chunk).
Calendar and appointments
With a calendar connected, the agent can check availability and book or cancel appointments during the call.
Calendars are connected in the dashboard under Integrations (Google Calendar). There is no /v1 endpoint for integrations; in the API you only point an agent at one.
Agent settings
Set the agent's calendar object: which integration and calendar, appointment length (15–240 min), buffer (0–60 min), time zone, working hours, minimum notice (0–72 h) and how far ahead bookings are allowed (1–90 days).
{
"calendar": {
"integration_id": "int_4a7c10",
"calendar_id": "primary",
"duration_min": 30,
"buffer_min": 10,
"timezone": "Europe/Istanbul",
"working_hours": {
"mon": [
[
"09:00",
"18:00"
]
],
"tue": [
[
"09:00",
"18:00"
]
],
"wed": [
[
"09:00",
"18:00"
]
],
"thu": [
[
"09:00",
"18:00"
]
],
"fri": [
[
"09:00",
"17:00"
]
]
},
"min_notice_h": 2,
"max_days_ahead": 30
}
} The agent then gets three built-in tools: check_availability (read), book_appointment and cancel_appointment (both write). Writes go through the confirmation step: the agent reads the time, name and phone back and books only after a clear yes.
Each booking is saved in the call record under outcomes[] as {kind: "appointment", start, name, phone, ref}.
MCP
HubVocal supports the Model Context Protocol in two ways: your agents can use tools from your own MCP servers, and MCP clients can manage the platform.
Connect an MCP server to your agents
Add your server with POST /v1/mcp-servers or in the dashboard. We fetch its tool list (tools/list); new tools arrive switched off. Turn on the ones you want, mark each as read or write, and list them in the agent's tools.mcp.
curl -X POST "https://hubvocal.com/v1/mcp-servers" \
-H "Authorization: Bearer $VT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "crm",
"url": "https://mcp.example.com/mcp",
"auth": "bearer",
"secret": "sk_live_…"
}' const res = await fetch("https://hubvocal.com/v1/mcp-servers", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"name": "crm",
"url": "https://mcp.example.com/mcp",
"auth": "bearer",
"secret": "sk_live_…"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status}: ${data.detail}`);
console.log(data); import os
import requests
resp = requests.post(
"https://hubvocal.com/v1/mcp-servers",
headers={"Authorization": f"Bearer {os.environ['VT_API_KEY']}"},
json={
"name": "crm",
"url": "https://mcp.example.com/mcp",
"auth": "bearer",
"secret": "sk_live_…",
},
timeout=30,
)
if not resp.ok:
raise RuntimeError(f"{resp.status_code}: {resp.json()['detail']}")
print(resp.json()) - The connection is made by the API, not the agent, so the agent never sees your server's secret.
- URLs must be https; local and private addresses are rejected, also at connection time.
writetools go through the confirmation step: the agent reads the details back and runs the tool only after a clear yes, at most once per call.- Each call times out after 10 seconds; at most 4 KB of the result reaches the model.
- Up to 10 servers per organization and 50 tools per server. MCP tools count towards the agent's limit of 16 tools.
In the conversation and in the call record, an MCP tool is named <server>.<tool>, for example crm.find_customer.
Manage the platform from an MCP client
HubVocal is also an MCP server at https://hubvocal.com/mcp (streamable HTTP). Authenticate with an organization API key as a Bearer token. The rate limit is shared with /v1.
Connect a client
Replace vt_live_… with your key. Use a key with only the scopes the client needs.
claude mcp add --transport http hubvocal https://hubvocal.com/mcp \
--header "Authorization: Bearer vt_live_…" {
"mcpServers": {
"hubvocal": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://hubvocal.com/mcp",
"--header",
"Authorization:${VT_AUTH}"
],
"env": {
"VT_AUTH": "Bearer vt_live_…"
}
}
}
} {
"mcpServers": {
"hubvocal": {
"url": "https://hubvocal.com/mcp",
"headers": {
"Authorization": "Bearer vt_live_…"
}
}
}
} from openai import OpenAI
client = OpenAI()
resp = client.responses.create(
model="gpt-5.6-luna",
tools=[{
"type": "mcp",
"server_label": "hubvocal",
"server_url": "https://hubvocal.com/mcp",
"headers": {"Authorization": "Bearer vt_live_…"},
"require_approval": "always",
}],
input="Summarise yesterday's calls.",
)
print(resp.output_text) In the ChatGPT app, custom MCP connectors are added in developer mode; whether you can use an API key header depends on your plan and the connector settings. With the OpenAI API you can pass the key directly, as below.
Tools
| Tool | Scope | What it does |
|---|---|---|
list_agents | read | List your agents. |
get_agent | read | Read one agent. |
list_calls | read | List calls, filtered by since, outcome or agent_id (up to 50). |
get_call | read | Read a call with its transcript and summary. |
list_numbers | read | List your phone numbers. |
list_knowledge | read | List knowledge base documents. |
search_knowledge | read | Search the knowledge base. |
get_usage | read | Cost and minutes for this month. |
create_agent | write | Create an agent. |
update_agent | write | Change an agent's draft. |
publish_agent | write | Publish an agent's draft. |
add_knowledge_text | write | Add a text document to the knowledge base. |
start_outbound_call | calls | Place an outbound call (agent_id, to). The usual outbound rules and limits apply. |
Resources and prompts
agent://{id}: an agent's settings.call://{id}: a call's transcript as text.- Prompt
daily_call_report: a summary of the day's calls.
Scopes and audit log
Each API key has scopes, chosen when you create it in the dashboard:
read: read-only tools and endpoints.write: also create and change agents and documents.calls: also place outbound calls.
Keys created before scopes existed have all three. Every MCP tool call is written to the audit log with the key, tool, time and result.
Web widget
The widget puts a call button on your website. Visitors talk to your agent in the browser; no server code is needed on your side.
Create a public key
In the dashboard, open the agent and create a public key (pk_…). A public key belongs to one agent and is safe to put in your page, because it only allows web calls with that agent and only from the domains you allow.
- Allowed domains (
allowed_origins): calls from other sites are refused. - A time limit per call.
- At most 10 calls per hour from the same IP address.
Add the script
Paste this before the closing </body> tag:
<script src="https://hubvocal.com/widget.js" data-agent="pk_…" async></script> Color, position and button text (in the visitor's language) come from the agent's widget settings: color, position, label.
{
"widget": {
"color": "#2563eb",
"position": "bottom-right",
"label": "Talk to us"
}
} The widget calls POST /api/public/web-calls with the public key; it never sees your API key. Public keys are managed in the dashboard.
Structured fields to your CRM
Structured fields turn a conversation into data your systems can use, for example the appointment date, the product someone asked about or whether they want a callback.
Define the fields
Add up to 24 fields to the agent's post_call.fields. Each has a name (lowercase letters, digits and underscores), a type (string, number, boolean, enum or date), a description that tells the model what to look for, and optionally enum values and required.
{
"post_call": {
"summary": true,
"fields": [
{
"name": "appointment_date",
"type": "date",
"description": "Requested appointment date"
},
{
"name": "service",
"type": "enum",
"enum": [
"checkup",
"cleaning",
"whitening"
],
"description": "Service the caller asked about"
},
{
"name": "callback",
"type": "boolean",
"required": true,
"description": "Does the caller want a callback?"
}
]
}
} After the call, the same model that writes the summary fills the fields. Values that were not mentioned are null. You get them as extracted in GET /v1/calls/{room} and the call list, and in the call.ended webhook body.
{
"extracted": {
"appointment_date": "2026-11-12",
"service": "cleaning",
"callback": true
}
} The CSV export includes each field as a column.
Call webhooks
A webhook delivers a call.ended event to your system once a call is finished and its record, including the summary, is complete. Use it to create a ticket, update a CRM contact or send a follow-up.
If call quality scores are on, you can also subscribe to call.quality_low. It is sent once per call when the score falls below your organization's threshold.
Add up to 5 webhooks per organization in the dashboard or with POST /v1/webhooks. If you do not send a secret, one is generated and returned only in that response. URLs must be https; local and private addresses are rejected.
curl -X POST "https://hubvocal.com/v1/webhooks" \
-H "Authorization: Bearer $VT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://crm.example.com/hooks/voice",
"events": [
"call.ended"
]
}' const res = await fetch("https://hubvocal.com/v1/webhooks", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"url": "https://crm.example.com/hooks/voice",
"events": [
"call.ended"
]
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status}: ${data.detail}`);
console.log(data); import os
import requests
resp = requests.post(
"https://hubvocal.com/v1/webhooks",
headers={"Authorization": f"Bearer {os.environ['VT_API_KEY']}"},
json={
"url": "https://crm.example.com/hooks/voice",
"events": [
"call.ended",
],
},
timeout=30,
)
if not resp.ok:
raise RuntimeError(f"{resp.status_code}: {resp.json()['detail']}")
print(resp.json()) The event
data is the same call object that GET /v1/calls/{room} returns, with the transcript and the summary:
POST /hooks/voice HTTP/1.1
Host: crm.example.com
Content-Type: application/json
X-VT-Event: call.ended
X-VT-Delivery: dl_51b7a9
X-VT-Timestamp: 1793832440
X-VT-Signature: sha256=9b1e44…
{
"id": "evt_8a41f0c2",
"type": "call.ended",
"created_at": "2026-11-05T09:47:20Z",
"data": {
"room": "demo-8585522d",
"org_id": "6f1c2a40-3b7e-4d0c-9a51-2f8e7c1d9b03",
"org_name": "Example Clinic",
"channel": "web",
"profile": "Front desk",
"engine": "live",
"lang": "en",
"voice": "marin",
"started_at": 1793832409.1,
"ended_at": 1793832434.2,
"duration_s": 25.1,
"total_usd": 0.021215,
"ended_by": "caller",
"outcome": "message_taken",
"summary_text": "The caller asked for an appointment on Thursday afternoon; a message was taken for a callback.",
"extracted": {
"appointment_date": "2026-11-12",
"callback": true
},
"first_audio_s": 2.43,
"turns": 4,
"source_gaps": {
"count": 2,
"total_ms": 230,
"max_ms": 141,
"agent_audio_s": 31.2,
"events": [
[
16.4,
141
],
[
40.1,
89
]
]
},
"quality": {
"verdict": "Clean: no loss, no gaps.",
"protocol": "UDP",
"loss_pct": 0,
"conceal_pct": 0,
"jitter_ms": 4,
"rtt_ms": 38
},
"cost": {
"live": {
"billed_s": 25.1,
"reported": true,
"voice_usd": 0.020917,
"backend": {
"in": 1840,
"cached": 1024,
"out": 96
},
"backend_usd": 0.000298
},
"llm": {
"input_tokens": 1840,
"cached_tokens": 1024,
"output_tokens": 96,
"usd": 0.000298
},
"tts": {
"input_tokens": 0,
"audio_tokens": 0,
"audio_s": 25.1,
"usd": 0.020917
},
"stt": {
"audio_s": 0,
"usd": 0
}
},
"tools": [
{
"t": 14.2,
"name": "check_order",
"kind": "read",
"ok": true,
"ms": 412,
"status": 200
}
],
"outcomes": [],
"summary": {
"text": "The caller asked for an appointment on Thursday afternoon; a message was taken for a callback.",
"language": "en",
"outcome": "message_taken",
"sentiment": "neutral",
"topics": [
"appointment"
],
"action_items": [
"Call the caller back on Thursday morning"
]
},
"recording": {
"available": true,
"duration_s": 25.1,
"size_bytes": 201344
},
"supervisors": [
{
"user": "ayse@example.com",
"joined_at": 1793832420.4,
"left_at": 1793832431,
"modes": [
"listen",
"whisper"
]
}
],
"greeting": {
"connect_s": 0.15,
"session_started_s": 0.05,
"reply_requested_s": 0.05,
"first_audio_s": 2.4,
"reply_done_s": 7.1
},
"transcript": [
{
"role": "assistant",
"text": "Hello, thanks for calling the clinic. How can I help you today?",
"t": 2.4,
"interrupted": false
},
{
"role": "user",
"text": "I would like an appointment on Thursday afternoon.",
"t": 9.8
}
]
}
} Requests are signed exactly like tool requests: X-VT-Timestamp and X-VT-Signature. Two more headers are added: X-VT-Event (the event type) and X-VT-Delivery (a unique id for this delivery).
Retries
Any 2xx response within 10 seconds counts as delivered. Otherwise we retry:
- after 1 minute
- after 5 minutes
- after 30 minutes
- after 2 hours
After 5 attempts in total, the delivery is marked failed. You can see every attempt with GET /v1/webhooks/{id}/deliveries and queue a failed one again with POST /v1/webhooks/deliveries/{id}/retry.
Handle duplicates
A retry can arrive even if your earlier response was lost on the way, so the same event may come more than once. Store the X-VT-Delivery id and skip ids you have already processed. Answer quickly and do slow work in the background.
// Verify the signature first (same code as for tools).
app.post("/hooks/voice", express.raw({ type: "*/*" }), async (req, res) => {
if (!verify(req)) return res.sendStatus(401);
// Already processed this delivery? Acknowledge and stop.
const delivery = req.get("X-VT-Delivery");
if (await db.deliveries.exists(delivery)) return res.sendStatus(200);
await db.deliveries.insert(delivery);
// Answer fast; do the slow work in the background.
res.sendStatus(200);
const event = JSON.parse(req.body.toString("utf8"));
queue.add("call-ended", event.data);
}); # Verify the signature first (same code as for tools).
@app.post("/hooks/voice")
def voice_hook():
if not verify(request):
abort(401)
# Already processed this delivery? Acknowledge and stop.
delivery = request.headers["X-VT-Delivery"]
if db.deliveries.exists(delivery):
return "", 200
db.deliveries.insert(delivery)
# Answer fast; do the slow work in the background.
event = json.loads(request.get_data())
queue.enqueue("call_ended", event["data"])
return "", 200 POST /v1/webhooks/{id}/test sends a sample call.ended event and returns the status, timing and response body.
Supported conversation languages
Which languages each engine can hold a conversation in. An agent's language is set with the lang field, using the codes below.
- Supported
- Experimental The engine does not officially list this language. It works when instructed to, but we have not measured the quality.
| Language | lang | GPT-Live 1 | Gemini Live | OpenAI Realtime | Classic |
|---|---|---|---|---|---|
TurkishTürkçetr | tr | Experimental | Supported | Experimental | Supported |
Englishen | en | Supported | Supported | Experimental | Supported |
GermanDeutschde | de | Supported | Supported | Experimental | Supported |
FrenchFrançaisfr | fr | Supported | Supported | Experimental | Supported |
SpanishEspañoles | es | Experimental | Supported | Experimental | Supported |
ItalianItalianoit | it | Experimental | Supported | Experimental | Supported |
PortuguesePortuguêspt | pt | Supported | Supported | Experimental | Supported |
DutchNederlandsnl | nl | Experimental | Supported | Experimental | Supported |
RussianРусскийru | ru | Experimental | Supported | Experimental | Supported |
UkrainianУкраїнськаuk | uk | Experimental | Supported | Experimental | Supported |
PolishPolskipl | pl | Experimental | Supported | Experimental | Supported |
RomanianRomânăro | ro | Experimental | Supported | Experimental | Supported |
GreekΕλληνικάel | el | Experimental | Supported | Experimental | Supported |
BulgarianБългарскиbg | bg | Experimental | Supported | Experimental | Supported |
CzechČeštinacs | cs | Experimental | Supported | Experimental | Supported |
SlovakSlovenčinask | sk | Experimental | Supported | Experimental | Supported |
HungarianMagyarhu | hu | Experimental | Supported | Experimental | Supported |
CroatianHrvatskihr | hr | Experimental | Supported | Experimental | Supported |
SerbianСрпскиsr | sr | Experimental | Supported | Experimental | Supported |
BosnianBosanskibs | bs | Experimental | Supported | Experimental | Supported |
SlovenianSlovenščinasl | sl | Experimental | Supported | Experimental | Supported |
MacedonianМакедонскиmk | mk | Experimental | Supported | Experimental | Supported |
SwedishSvenskasv | sv | Experimental | Supported | Experimental | Supported |
DanishDanskda | da | Experimental | Supported | Experimental | Supported |
NorwegianNorskno | no | Experimental | Supported | Experimental | Supported |
FinnishSuomifi | fi | Experimental | Supported | Experimental | Supported |
EstonianEestiet | et | Experimental | Supported | Experimental | Supported |
LatvianLatviešulv | lv | Experimental | Supported | Experimental | Supported |
LithuanianLietuviųlt | lt | Experimental | Supported | Experimental | Supported |
CatalanCatalàca | ca | Experimental | Supported | Experimental | Supported |
Experimental languages are worth testing in the browser before you put them on a phone line.
Rate limits and errors
The rate limit is 120 requests per minute per key. Above it you get 429 with a Retry-After header in seconds. Errors come with an HTTP status code and always the same body; detail is a human-readable message.
{
"detail": "Monthly spending limit reached"
} | Code | Meaning | When |
|---|---|---|
| 400 | Bad request | The body could not be read, a required field is missing or a value is invalid (for example a number not in E.164, or deleting the last agent). |
| 401 | Unauthorized | No Authorization header, the key does not start with vt_live_, or it has been revoked. |
| 402 | Limit reached | The organization has reached its monthly spending limit; no new calls can start. |
| 403 | Suspended | The organization is suspended; no new calls can start. |
| 404 | Not found | No such agent, call, number or endpoint. |
| 409 | Conflict | POST /v1/agents was sent with an id that already exists. |
| 429 | Rate limited | More than 120 requests per minute on one key. Wait for the number of seconds in Retry-After. |
| 502 | Provider error | The telephony provider rejected the outbound call or did not respond. |
| 500 | Server error | Something unexpected happened. Retry; if it persists, write to us. |
Retry only 429 and 5xx responses. A 4xx means something is wrong with the request itself; sending it again will not change the result.
async function vt(path, init = {}, attempt = 0) {
const res = await fetch(`https://hubvocal.com/v1${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.VT_API_KEY}`,
"Content-Type": "application/json",
...init.headers,
},
});
if (res.status === 429 && attempt < 3) {
const wait = Number(res.headers.get("Retry-After") ?? 60);
await new Promise((r) => setTimeout(r, wait * 1000));
return vt(path, init, attempt + 1);
}
const data = await res.json();
if (!res.ok) throw new Error(`${res.status}: ${data.detail}`);
return data;
}