Desarrolladores
Gestiona agentes, números y llamadas desde tus propios sistemas, y añade una llamada de voz a tu sitio web.
Resumen
Es una API REST. Los cuerpos de solicitud y respuesta son JSON (UTF-8), las marcas de tiempo van en segundos unix y los importes en USD. La URL base es:
https://hubvocal.com/v1
Una clave de API pertenece a una sola organización y actúa con permisos de administrador dentro de ella. Todo lo que puedes hacer en el panel, puedes hacerlo con la API:
| Recurso | Endpoints |
|---|---|
| Agentes | /v1/agents, /v1/agents/{id} |
| Base de conocimiento | /v1/knowledge, /v1/knowledge/{id}, /v1/knowledge/search |
| Herramientas | /v1/tools, /v1/tools/{id}, /v1/tools/{id}/test |
| Servidores MCP | /v1/mcp-servers, /v1/mcp-servers/{id}, /v1/mcp-servers/{id}/sync |
| Webhooks | /v1/webhooks, /v1/webhooks/{id}, /v1/webhooks/{id}/deliveries |
| Voces y motores | /v1/voices |
| Llamadas web | /v1/web-calls |
| Llamadas y llamadas salientes | /v1/calls, /v1/calls/{room} |
| Números | /v1/numbers, /v1/numbers/{number} |
| Uso | /v1/usage |
Hay un documento OpenAPI 3.1 legible por máquina en GET /v1/openapi.json, y no necesita clave. Para ver cada endpoint en detalle, consulta la referencia de la API.
Inicio rápido
-
Crea una clave de API
En el panel, abre Configuración > Claves de API y ponle a la clave un nombre que indique para qué sirve. Solo los propietarios y administradores de la organización pueden crear claves. El secreto (
vt_live_…) se muestra una sola vez; guárdalo enseguida en un gestor de secretos.export VT_API_KEY="vt_live_…" -
Lista tus agentes
La forma más rápida de comprobar que la clave funciona. En los siguientes pasos usarás un id de la respuesta.
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()) -
Crea un agente
Motor, idioma, voz e instrucciones en un solo objeto. Para ver los nombres de voz válidos y los límites, llama a
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()) -
Inicia una llamada web
Te conectas desde el navegador con el
server_urly eltokende la respuesta; tienes los detalles más abajo.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())
Autenticación
Envía la clave como token Bearer en el encabezado Authorization de cada solicitud:
Authorization: Bearer vt_live_… - La clave actúa con permisos de administrador en tu organización: crea agentes, asigna números y hace llamadas. Trátala como una contraseña.
- Nunca pongas la clave en código del navegador, en una app móvil ni en un repositorio. Para llamadas desde el navegador, obtén un token de corta duración en tu servidor.
- La lista de Configuración > Claves de API muestra cuándo se usó cada clave por última vez. Una clave revocada deja de funcionar de inmediato.
- Usa una clave por integración, para que revocar una no afecte a las demás.
Integrar una llamada web
Tus visitantes pueden hablar con el agente directamente en tu sitio, desde el navegador. El flujo tiene tres pasos:
- El visitante pulsa un botón y tu página llama a tu propio servidor.
- Tu servidor llama a
POST /v1/web-callscon la clave de API y devuelve la respuesta (server_url,token,room,max_seconds) a la página. - La página se conecta a la sala con
livekit-clienty activa el micrófono. El agente entra solo y dice su saludo.
1. Servidor: obtén un token
// server.js (Node 18+, Express)
import express from "express";
const app = express();
app.post("/voice/session", async (req, res) => {
// Aplica aquí tu propia autenticación y limitación de solicitudes.
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():
# Aplica aquí tu propia autenticación y limitación de solicitudes.
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. Navegador: conéctate
Si usas un bundler, instala npm install livekit-client@2.23.0 e importa los mismos nombres. El ejemplo de abajo carga una versión fija desde un CDN.
<button id="talk" type="button">Empezar a hablar</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;
// Tu servidor devuelve tal cual la respuesta de POST /v1/web-calls.
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();
// Conecta el audio del agente a un elemento <audio> cuando llegue.
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 = "Empezar a hablar";
});
await room.connect(session.server_url, session.token);
await room.localParticipant.setMicrophoneEnabled(true);
button.disabled = false;
button.textContent = "Colgar";
});
</script> Inicia la conexión a partir de un gesto del usuario (un clic): los navegadores vinculan a él el permiso del micrófono y la reproducción automática de audio. La página debe servirse por HTTPS.
Una llamada dura como máximo max_seconds; después, el agente cuelga. Cuando termine, obtén su registro con GET /v1/calls/{room}.
Llamadas salientes
Llama a un número desde uno de los números de tu organización; el agente habla en cuanto contestan. El número from debe aparecer en GET /v1/numbers. Sin agent_id, se usa el agente asignado a ese número. Los números van en formato E.164 (+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()) Respuesta
{
"room": "tel-out-9c41e0b7",
"provider": "didww",
"sip_call_id": "SCL_7mVt2kQb9xRa"
} El room de la respuesta identifica la llamada. Cuando la llamada termina, su registro (transcripción, coste, quién colgó) está disponible en GET /v1/calls/{room}. Las llamadas sin respuesta terminan con ended_by en no_answer.
Llamadas entrantes
Las llamadas entrantes a tus números las atiende el agente asignado a cada número. Los números llegan a través de DIDWW, Twilio o tu propio SIP trunk (Asterisk y otros), y el equipo de la plataforma los añade a tu organización. Tú eliges qué agente contesta:
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()) Versiones y publicación segura
Al editar un agente cambias su borrador. Las llamadas a tus números, las llamadas web y las salientes usan la versión publicada, así que puedes trabajar en el borrador sin afectar a las llamadas reales.
- Edita el agente con
PUT /v1/agents/{id}. Esto solo cambia el borrador;draft_changedpasa a true. - Prueba el borrador: inicia una llamada web con
version: "draft". - Publica con
POST /v1/agents/{id}/publishy una nota opcional. El borrador se convierte en una nueva versión numerada y pasa a estar activo. - Si algo va mal,
POST /v1/agents/{id}/rollbackcon un número de versión anterior la vuelve a publicar como una versión nueva.
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 y POST /v1/calls aceptan version: "live" (por defecto), "draft" o un número de versión. Un agente que nunca se ha publicado usa su borrador.
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"
}' Prueba antes de publicar
El laboratorio de pruebas ejecuta conversaciones de texto entre un llamante simulado y tu agente, para que puedas comprobar un cambio antes de que lo oigan llamantes reales.
- Escribe un escenario: quién llama (
persona), qué quiere (goal) y hasta 20 turnos. - Añade las reglas que debe cumplir una buena llamada (
expect), por ejemplo "pide el nombre del llamante antes de reservar". - Indica resultados de ejemplo para las herramientas (
mocks); no se llama a ninguna herramienta ni webhook real. - Ejecuta los escenarios con el borrador. Un modelo independiente comprueba cada regla y explica por qué se cumplió o no.
{
"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"
} Las conversaciones se ejecutan en modo texto, así que prueban las instrucciones y el uso de herramientas, no la voz ni la latencia. Las simulaciones se ejecutan desde el panel; todavía no hay un endpoint /v1 para ellas.
Herramientas
Las herramientas permiten que el agente actúe durante una llamada. La misma definición funciona en los cuatro motores; en GPT-Live las ejecuta el modelo en segundo plano. Las herramientas se activan por agente en el campo tools.
Herramientas integradas
| Nombre | Qué hace |
|---|---|
end_call | El agente se despide y cuelga 0,75 s después de terminar de hablar. Si el llamante habla mientras tanto, no cuelga. El registro de llamada muestra ended_by: "agent". |
take_message | Recoge name, phone, message y callback_requested y los guarda en outcomes[] del registro de llamada. No pide confirmación. |
search_knowledge | Se añade automáticamente cuando el agente tiene documentos en knowledge. Busca en ellos y anota las fuentes en el registro de llamada. |
check_availability | Lectura. Devuelve los huecos libres entre date_from y date_to, dentro del horario laboral del agente. Se añade cuando calendar está configurado. |
book_appointment | Escritura, con paso de confirmación. Reserva start a nombre de name con phone, y una note opcional. La reserva se guarda en outcomes[]. |
cancel_appointment | Escritura, con paso de confirmación. Cancela una reserva por su booking_ref. |
Herramientas webhook
Una herramienta webhook es un endpoint HTTPS de tu sistema. Defínela una sola vez para la organización en el panel, en Herramientas, o con POST /v1/tools, y después añade su id a tools.webhooks de un agente (hasta 16 por agente).
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: letras minúsculas, dígitos y guiones bajos, de 3 a 41 caracteres; debe empezar por una letra y ser único en la organización.end_callytake_messageestán reservados.descriptionle indica al modelo cuándo usar la herramienta. Escríbela como si dieras una instrucción a un compañero.parameterses un objeto JSON Schema con hasta 20 campos de tipo string, number, integer o boolean (se admite enum).kindesreadowrite;timeout_sva de 2 a 15 segundos.
La solicitud que enviamos
Cuando el modelo llama a la herramienta, la API envía un POST a tu URL. El cuerpo incluye el nombre de la herramienta, los argumentos que eligió el modelo y algo de contexto sobre la llamada:
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"
}
} Cada solicitud va firmada con el secreto de la herramienta. X-VT-Timestamp es la hora unix; X-VT-Signature es sha256= seguido del HMAC-SHA256 en hexadecimal de timestamp + "." + body. Compruébala con el cuerpo sin procesar antes de parsearlo y rechaza las marcas de tiempo de hace más de unos minutos.
Verificar la firma
// server.js (Node 18+, Express)
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.VT_TOOL_SECRET;
// La firma cubre los bytes exactos: lee el cuerpo sin procesar.
app.post("/hooks/order", express.raw({ type: "*/*" }), (req, res) => {
const ts = req.get("X-VT-Timestamp") ?? "";
const sig = req.get("X-VT-Signature") ?? "";
// Rechaza las solicitudes antiguas (protección contra repetición).
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"));
// Responde breve: al modelo solo le llegan los primeros 4 KB.
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", "")
# Rechaza las solicitudes antiguas (protección contra repetición).
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
abort(401)
# La firma cubre los bytes exactos: lee el cuerpo sin procesar.
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"]
# Responde breve: al modelo solo le llegan los primeros 4 KB.
return jsonify(order_id=args["order_id"], status="shipped", eta="2026-11-07") Tu respuesta
Responde con un estado 2xx y JSON o texto plano. Al modelo solo le llegan los primeros 4 KB, así que devuelve solo lo que el agente necesita decir. Un estado distinto de 2xx o un tiempo de espera agotado le indica al modelo que la herramienta falló, y el agente lo gestiona en la conversación.
Herramientas de lectura y escritura
- read: herramientas que se ejecutan de inmediato, para consultas como el estado de un pedido o el horario de apertura.
- write: herramientas que pasan por un paso de confirmación. La primera llamada a la herramienta no se ejecuta; el agente repite los detalles al llamante y le pide un sí o un no. Solo se ejecuta si en su siguiente turno el llamante da un sí claro en el idioma de la llamada.
- La misma llamada a la herramienta (nombre y argumentos) se ejecuta como máximo una vez por conversación.
Seguridad
- Las URL deben usar
https://. Se rechazan las direcciones locales y de redes privadas. - El secreto se guarda cifrado y la API nunca lo devuelve (
has_secretindica si hay uno configurado). El agente nunca lo ve; la solicitud la hace la API. - Los argumentos y las respuestas no se guardan. El registro de llamada conserva
tools[]con nombre, tipo, resultado y duración, y una pista TOOL en la línea de tiempo.
Usa POST /v1/tools/{id}/test para enviar una solicitud real con argumentos de ejemplo y ver el estado, el tiempo de respuesta y el cuerpo de la respuesta.
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()) Base de conocimiento
La base de conocimiento guarda los documentos de tu organización. Cuando un agente tiene documentos seleccionados, recibe la herramienta search_knowledge y responde con lo que encuentra. Tiene instrucciones de no inventarse lo que no esté en los documentos.
Añadir un documento
Envía un archivo como multipart (file), texto plano como JSON (title, text) o una sola página web como JSON (url). El procesamiento se hace en segundo plano: el documento empieza en processing y pasa a ready o 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()) - Archivos: pdf, docx, txt, md o html, de hasta 10 MB cada uno.
- Hasta 200 documentos y 200 MB en total por organización.
- URL: una sola página, solo https; se rechazan las direcciones locales y privadas.
Asignarlos a un agente
Indica los ids de los documentos en knowledge.doc_ids del agente (hasta 50). Prueba algunas búsquedas con POST /v1/knowledge/search antes de pasar a producción.
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()) En el registro de llamada, cada búsqueda aparece en tools[] como search_knowledge, con sus sources (documento, título y fragmento).
Calendario y citas
Con un calendario conectado, el agente puede consultar la disponibilidad y reservar o cancelar citas durante la llamada.
Los calendarios se conectan desde el panel, en Integraciones (Google Calendar). No hay ningún endpoint /v1 para las integraciones; en la API solo indicas qué integración usa un agente.
Configuración del agente
Configura el objeto calendar del agente: qué integración y qué calendario, duración de la cita (15–240 min), margen entre citas (0–60 min), zona horaria, horario laboral, antelación mínima (0–72 h) y con cuánto tiempo se puede reservar como máximo (1–90 días).
{
"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
}
} El agente recibe entonces tres herramientas integradas: check_availability (lectura), book_appointment y cancel_appointment (ambas de escritura). Las de escritura pasan por el paso de confirmación: el agente repite la hora, el nombre y el teléfono, y solo reserva tras un sí claro.
Cada reserva se guarda en el registro de llamada, en outcomes[], como {kind: "appointment", start, name, phone, ref}.
MCP
HubVocal admite el Model Context Protocol de dos formas: tus agentes pueden usar herramientas de tus propios servidores MCP, y los clientes MCP pueden gestionar la plataforma.
Conecta un servidor MCP a tus agentes
Añade tu servidor con POST /v1/mcp-servers o desde el panel. Obtenemos su lista de herramientas (tools/list); las nuevas llegan desactivadas. Activa las que quieras, marca cada una como read o write e inclúyelas en tools.mcp del agente.
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()) - La conexión la hace la API, no el agente, así que el agente nunca ve el secreto de tu servidor.
- Las URL deben ser https; se rechazan las direcciones locales y privadas, también en el momento de conectar.
- Las herramientas
writepasan por el paso de confirmación: el agente repite los datos y ejecuta la herramienta solo tras un sí claro, como máximo una vez por llamada. - Cada invocación expira a los 10 segundos; al modelo llegan como máximo 4 KB del resultado.
- Hasta 10 servidores por organización y 50 herramientas por servidor. Las herramientas MCP cuentan para el límite de 16 herramientas del agente.
En la conversación y en el registro de llamada, una herramienta MCP se llama <server>.<tool>, por ejemplo crm.find_customer.
Gestiona la plataforma desde un cliente MCP
HubVocal también es un servidor MCP en https://hubvocal.com/mcp (streamable HTTP). Autentícate con una clave de API de la organización como token Bearer. El límite de solicitudes se comparte con /v1.
Conecta un cliente
Sustituye vt_live_… por tu clave. Usa una clave que tenga solo los ámbitos que el cliente necesita.
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) En la app de ChatGPT, los conectores MCP personalizados se añaden en el modo desarrollador; que puedas usar un encabezado con la clave de API depende de tu plan y de la configuración del conector. Con la API de OpenAI puedes pasar la clave directamente, como se muestra abajo.
Herramientas
| Herramienta | Ámbito | Qué hace |
|---|---|---|
list_agents | read | Lista tus agentes. |
get_agent | read | Lee un agente. |
list_calls | read | Lista llamadas, filtradas por since, outcome o agent_id (hasta 50). |
get_call | read | Lee una llamada con su transcripción y su resumen. |
list_numbers | read | Lista tus números de teléfono. |
list_knowledge | read | Lista los documentos de la base de conocimiento. |
search_knowledge | read | Busca en la base de conocimiento. |
get_usage | read | Coste y minutos de este mes. |
create_agent | write | Crea un agente. |
update_agent | write | Modifica el borrador de un agente. |
publish_agent | write | Publica el borrador de un agente. |
add_knowledge_text | write | Añade un documento de texto a la base de conocimiento. |
start_outbound_call | calls | Realiza una llamada saliente (agent_id, to). Se aplican las reglas y los límites habituales de las llamadas salientes. |
Recursos y prompts
agent://{id}: la configuración de un agente.call://{id}: la transcripción de una llamada en texto.- Prompt
daily_call_report: un resumen de las llamadas del día.
Ámbitos y registro de auditoría
Cada clave de API tiene ámbitos, que eliges al crearla en el panel:
read: herramientas y endpoints de solo lectura.write: además, crear y modificar agentes y documentos.calls: además, realizar llamadas salientes.
Las claves creadas antes de que existieran los ámbitos tienen los tres. Cada llamada a una herramienta MCP queda en el registro de auditoría con la clave, la herramienta, la hora y el resultado.
Widget web
El widget añade un botón de llamada a tu sitio web. Los visitantes hablan con tu agente en el navegador; no necesitas código de servidor por tu parte.
Crea una clave pública
En el panel, abre el agente y crea una clave pública (pk_…). Una clave pública pertenece a un solo agente y puede ir en tu página sin riesgo, porque solo permite llamadas web con ese agente y solo desde los dominios que autorices.
- Dominios permitidos (
allowed_origins): se rechazan las llamadas desde otros sitios. - Un límite de tiempo por llamada.
- Como máximo 10 llamadas por hora desde la misma dirección IP.
Añade el script
Pégalo antes de la etiqueta de cierre </body>:
<script src="https://hubvocal.com/widget.js" data-agent="pk_…" async></script> El color, la posición y el texto del botón (en el idioma del visitante) se toman de los ajustes widget del agente: color, position, label.
{
"widget": {
"color": "#2563eb",
"position": "bottom-right",
"label": "Talk to us"
}
} El widget llama a POST /api/public/web-calls con la clave pública; nunca ve tu clave de API. Las claves públicas se gestionan en el panel.
Campos estructurados hacia tu CRM
Los campos estructurados convierten una conversación en datos que tus sistemas pueden usar, por ejemplo la fecha de la cita, el producto por el que alguien preguntó o si quiere que le devuelvan la llamada.
Define los campos
Añade hasta 24 campos en post_call.fields del agente. Cada uno tiene un name (letras minúsculas, dígitos y guiones bajos), un type (string, number, boolean, enum o date), una description que indica al modelo qué buscar y, opcionalmente, valores enum y 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?"
}
]
}
} Tras la llamada, el mismo modelo que redacta el resumen rellena los campos. Los valores que no se mencionaron son null. Los recibes como extracted en GET /v1/calls/{room} y en la lista de llamadas, y en el cuerpo del webhook call.ended.
{
"extracted": {
"appointment_date": "2026-11-12",
"service": "cleaning",
"callback": true
}
} La exportación CSV incluye cada campo como una columna.
Webhooks de llamada
Un webhook envía un evento call.ended a tu sistema cuando una llamada ha terminado y su registro, resumen incluido, está completo. Úsalo para crear un ticket, actualizar un contacto en tu CRM o enviar un seguimiento.
Si las puntuaciones de calidad de llamada están activadas, también puedes suscribirte a call.quality_low. Se envía una vez por llamada cuando la puntuación cae por debajo del umbral de tu organización.
Añade hasta 5 webhooks por organización desde el panel o con POST /v1/webhooks. Si no envías un secret, se genera uno y se devuelve solo en esa respuesta. Las URL deben ser https; se rechazan las direcciones locales y privadas.
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()) El evento
data es el mismo objeto de llamada que devuelve GET /v1/calls/{room}, con la transcripción y el resumen:
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
}
]
}
} Las solicitudes se firman igual que las solicitudes de herramientas: X-VT-Timestamp y X-VT-Signature. Se añaden dos encabezados más: X-VT-Event (el tipo de evento) y X-VT-Delivery (un id único para esta entrega).
Reintentos
Cualquier respuesta 2xx en menos de 10 segundos cuenta como entregada. Si no, reintentamos:
- al cabo de 1 minuto
- al cabo de 5 minutos
- al cabo de 30 minutos
- al cabo de 2 horas
Tras 5 intentos en total, la entrega se marca como failed. Puedes ver cada intento con GET /v1/webhooks/{id}/deliveries y volver a poner en cola una entrega fallida con POST /v1/webhooks/deliveries/{id}/retry.
Gestiona los duplicados
Un reintento puede llegar aunque tu respuesta anterior se haya perdido por el camino, así que el mismo evento puede llegar más de una vez. Guarda el id de X-VT-Delivery y descarta los que ya hayas procesado. Responde rápido y deja el trabajo lento para segundo plano.
// Primero verifica la firma (el mismo código que para las herramientas).
app.post("/hooks/voice", express.raw({ type: "*/*" }), async (req, res) => {
if (!verify(req)) return res.sendStatus(401);
// ¿Ya procesaste esta entrega? Confírmala y no sigas.
const delivery = req.get("X-VT-Delivery");
if (await db.deliveries.exists(delivery)) return res.sendStatus(200);
await db.deliveries.insert(delivery);
// Responde rápido; haz el trabajo lento en segundo plano.
res.sendStatus(200);
const event = JSON.parse(req.body.toString("utf8"));
queue.add("call-ended", event.data);
}); # Primero verifica la firma (el mismo código que para las herramientas).
@app.post("/hooks/voice")
def voice_hook():
if not verify(request):
abort(401)
# ¿Ya procesaste esta entrega? Confírmala y no sigas.
delivery = request.headers["X-VT-Delivery"]
if db.deliveries.exists(delivery):
return "", 200
db.deliveries.insert(delivery)
# Responde rápido; haz el trabajo lento en segundo plano.
event = json.loads(request.get_data())
queue.enqueue("call_ended", event["data"])
return "", 200 POST /v1/webhooks/{id}/test envía un evento call.ended de ejemplo y devuelve el estado, el tiempo de respuesta y el cuerpo de la respuesta.
Idiomas de conversación compatibles
En qué idiomas puede conversar cada motor. El idioma de un agente se define con el campo lang, usando los códigos de abajo.
- Compatible
- Experimental El motor no incluye oficialmente este idioma. Funciona si se le indica en las instrucciones, pero no hemos medido la calidad.
| Idioma | lang | GPT-Live 1 | Gemini Live | OpenAI Realtime | Clásico |
|---|---|---|---|---|---|
Türkçetr | tr | Experimental | Compatible | Experimental | Compatible |
Englishen | en | Compatible | Compatible | Experimental | Compatible |
Deutschde | de | Compatible | Compatible | Experimental | Compatible |
Françaisfr | fr | Compatible | Compatible | Experimental | Compatible |
Españoles | es | Experimental | Compatible | Experimental | Compatible |
Italianoit | it | Experimental | Compatible | Experimental | Compatible |
Portuguêspt | pt | Compatible | Compatible | Experimental | Compatible |
Nederlandsnl | nl | Experimental | Compatible | Experimental | Compatible |
Русскийru | ru | Experimental | Compatible | Experimental | Compatible |
Українськаuk | uk | Experimental | Compatible | Experimental | Compatible |
Polskipl | pl | Experimental | Compatible | Experimental | Compatible |
Românăro | ro | Experimental | Compatible | Experimental | Compatible |
Ελληνικάel | el | Experimental | Compatible | Experimental | Compatible |
Българскиbg | bg | Experimental | Compatible | Experimental | Compatible |
Češtinacs | cs | Experimental | Compatible | Experimental | Compatible |
Slovenčinask | sk | Experimental | Compatible | Experimental | Compatible |
Magyarhu | hu | Experimental | Compatible | Experimental | Compatible |
Hrvatskihr | hr | Experimental | Compatible | Experimental | Compatible |
Српскиsr | sr | Experimental | Compatible | Experimental | Compatible |
Bosanskibs | bs | Experimental | Compatible | Experimental | Compatible |
Slovenščinasl | sl | Experimental | Compatible | Experimental | Compatible |
Македонскиmk | mk | Experimental | Compatible | Experimental | Compatible |
Svenskasv | sv | Experimental | Compatible | Experimental | Compatible |
Danskda | da | Experimental | Compatible | Experimental | Compatible |
Norskno | no | Experimental | Compatible | Experimental | Compatible |
Suomifi | fi | Experimental | Compatible | Experimental | Compatible |
Eestiet | et | Experimental | Compatible | Experimental | Compatible |
Latviešulv | lv | Experimental | Compatible | Experimental | Compatible |
Lietuviųlt | lt | Experimental | Compatible | Experimental | Compatible |
Catalàca | ca | Experimental | Compatible | Experimental | Compatible |
Prueba los idiomas experimentales en el navegador antes de llevarlos a una línea telefónica.
Límites y errores
El límite es de 120 solicitudes por minuto por clave. Si lo superas, recibes 429 con un encabezado Retry-After en segundos. Los errores llegan con un código de estado HTTP y siempre con el mismo cuerpo; detail es un mensaje legible.
{
"detail": "Monthly spending limit reached"
} | Código | Significado | Cuándo |
|---|---|---|
| 400 | Solicitud incorrecta | No se pudo leer el cuerpo, falta un campo obligatorio o un valor no es válido (por ejemplo, un número que no está en E.164 o borrar el último agente). |
| 401 | No autorizado | Falta el encabezado Authorization, la clave no empieza por vt_live_ o ha sido revocada. |
| 402 | Límite alcanzado | La organización alcanzó su límite de gasto mensual; no se pueden iniciar llamadas nuevas. |
| 403 | Suspendida | La organización está suspendida; no se pueden iniciar llamadas nuevas. |
| 404 | No encontrado | No existe ese agente, llamada, número o endpoint. |
| 409 | Conflicto | Se envió POST /v1/agents con un id que ya existe. |
| 429 | Demasiadas solicitudes | Más de 120 solicitudes por minuto con una misma clave. Espera los segundos que indica Retry-After. |
| 502 | Error del proveedor | El proveedor de telefonía rechazó la llamada saliente o no respondió. |
| 500 | Error del servidor | Ocurrió algo inesperado. Vuelve a intentarlo; si persiste, escríbenos. |
Reintenta solo las respuestas 429 y 5xx. Un 4xx indica que algo falla en la propia solicitud; enviarla de nuevo no cambiará el resultado.
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;
}