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:

ResourceEndpoints
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

  1. 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.

    shell
    export VT_API_KEY="vt_live_…"
  2. List your agents

    The quickest way to check the key works. You will use an id from the response in the next steps.

    curl
    curl "https://hubvocal.com/v1/agents" \
      -H "Authorization: Bearer $VT_API_KEY"
  3. Create an agent

    Engine, language, voice and instructions in one object. For valid voice names and limits, call GET /v1/voices.

    curl
    curl -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"
      }
    }'
  4. Start a web call

    You connect from the browser with the server_url and token in the response; details below.

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

Authentication

Send the key as a Bearer token in the Authorization header on every request:

HTTP
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:

  1. The visitor presses a button and your page calls your own server.
  2. Your server calls POST /v1/web-calls with the API key and passes the response (server_url, token, room, max_seconds) back to the page.
  3. The page connects to the room with livekit-client and turns on the microphone. The agent joins on its own and says its greeting.

1. Server: get a token

Node (Express)
// 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);

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.

index.html
<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
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"
}'

Response

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

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.

  1. Edit the agent with PUT /v1/agents/{id}. This only changes the draft; draft_changed becomes true.
  2. Test the draft: start a web call with version: "draft".
  3. Publish with POST /v1/agents/{id}/publish and an optional note. The draft becomes a new numbered version and goes live.
  4. If something is wrong, POST /v1/agents/{id}/rollback with an earlier version number publishes it again as a new version.
curl
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"
}'

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

  1. Write a scenario: who is calling (persona), what they want (goal) and up to 20 turns.
  2. Add rules a good call must meet (expect), for example "asks for the caller's name before booking".
  3. Give sample results for tools (mocks); no real tool or webhook is called.
  4. Run the scenarios against the draft. A separate model checks every rule and explains why it passed or failed.
JSON
{
  "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

NameWhat it does
end_callThe 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_messageTakes name, phone, message and callback_requested and writes them to outcomes[] in the call record. It does not ask for confirmation.
search_knowledgeAdded automatically when the agent has documents in knowledge. Searches them and records the sources in the call record.
check_availabilityRead. Returns free slots between date_from and date_to, within the agent's working hours. Added when calendar is set.
book_appointmentWrite, behind the confirmation step. Books start for name and phone, with an optional note. The booking goes to outcomes[].
cancel_appointmentWrite, 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
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…"
}'
  • name: lowercase letters, digits and underscores, 3 to 41 characters, starting with a letter, unique in the organization. end_call and take_message are reserved.
  • description tells the model when to use the tool. Write it like an instruction to a colleague.
  • parameters is a JSON Schema object with up to 20 fields of type string, number, integer or boolean (enum allowed).
  • kind is read or write; timeout_s is 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:

HTTP
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

Node (Express)
// 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);

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

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
curl -X POST "https://hubvocal.com/v1/knowledge" \
  -H "Authorization: Bearer $VT_API_KEY" \
  -F "file=@price-list-2026.pdf"
  • 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
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"
}'

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).

PUT /v1/agents/front-desk
{
  "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
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_…"
}'
  • 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.
  • write tools 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 Code
claude mcp add --transport http hubvocal https://hubvocal.com/mcp \
  --header "Authorization: Bearer vt_live_…"

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

ToolScopeWhat it does
list_agentsreadList your agents.
get_agentreadRead one agent.
list_callsreadList calls, filtered by since, outcome or agent_id (up to 50).
get_callreadRead a call with its transcript and summary.
list_numbersreadList your phone numbers.
list_knowledgereadList knowledge base documents.
search_knowledgereadSearch the knowledge base.
get_usagereadCost and minutes for this month.
create_agentwriteCreate an agent.
update_agentwriteChange an agent's draft.
publish_agentwritePublish an agent's draft.
add_knowledge_textwriteAdd a text document to the knowledge base.
start_outbound_callcallsPlace 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:

HTML
<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.

JSON
{
  "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.

JSON
{
  "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.

JSON
{
  "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
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"
  ]
}'

The event

data is the same call object that GET /v1/calls/{room} returns, with the transcript and the summary:

HTTP
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:

  1. after 1 minute
  2. after 5 minutes
  3. after 30 minutes
  4. 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.

Node (Express)
// 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);
});

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 1Gemini LiveOpenAI RealtimeClassic
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.

402
{
  "detail": "Monthly spending limit reached"
}
CodeMeaningWhen
400Bad requestThe 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).
401UnauthorizedNo Authorization header, the key does not start with vt_live_, or it has been revoked.
402Limit reachedThe organization has reached its monthly spending limit; no new calls can start.
403SuspendedThe organization is suspended; no new calls can start.
404Not foundNo such agent, call, number or endpoint.
409ConflictPOST /v1/agents was sent with an id that already exists.
429Rate limitedMore than 120 requests per minute on one key. Wait for the number of seconds in Retry-After.
502Provider errorThe telephony provider rejected the outbound call or did not respond.
500Server errorSomething 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.

Retrying with Retry-After
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;
}