For developers
Connect Voxiora to the systems you already use
Read calls, transcripts, appointments and contacts over a REST API, get signed webhooks the moment something happens, let the assistant check live data in your system during a call, and put a voice assistant on your website with one line of code.
The REST API and webhooks are included in every plan, starting with Essential; higher plans have higher limits. During the free trial you get test access: 1 API key, 20 requests per minute.
https://voxiora.com/api/v1curl https://voxiora.com/api/v1/me \
-H "Authorization: Bearer tf_live_…"{
"object": "account",
"id": "1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a1b",
"name": "Smile Dental",
"timezone": "Europe/Warsaw",
"locale": "en",
"country": "PL",
"currency": "PLN",
"status": "active",
"plan": {
"id": "team",
"billing_cycle": "month",
"trial_ends_at": null,
"period_start": "2026-10-01T00:00:00Z",
"period_end": "2026-11-01T00:00:00Z",
"recording_history_days": 365
},
"minutes": {
"included": 3000,
"extra": 100,
"total": 3100,
"used": 412.5,
"remaining": 2687.5
},
"api_key_id": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
"scopes": [
"read:calls",
"read:appointments",
"read:contacts",
"read:tasks"
],
"limits": {
"plan": "team",
"requests_per_minute": 180,
"requests_per_day": 50000,
"max_api_keys": 10,
"max_webhook_endpoints": 10
}
}What you can connect
Seven ways to plug Voxiora into your business
Pick what fits: no-code automations, a few lines of code, or a full two-way integration.
- Every plan
REST API
Read calls with transcripts and recordings, appointments, contacts and tasks. Create and update contacts, cancel or move appointments and close tasks from your own system.
- Every plan
Webhooks
Signed JSON events in real time: call started or completed, appointment booked, canceled or moved, new contact, callback task.
- Every plan
Live data via MCP
Connect your MCP server and the assistant checks stock, availability or order status during the call.
- From Business
Website voice widget
One script tag adds a “Talk to us” button. Visitors speak with your assistant in the browser.
- Every plan
Phone onboarding
Send new sign-ups from your system and the assistant calls them to help them get started.
- Every plan
Calendar & CRM
Google Calendar for bookings, HubSpot and Pipedrive for contacts, calls, meetings and tasks.
- Every plan
Zapier, Make, n8n
No-code flows built on webhooks and the API: Google Sheets, Slack, email, any CRM.
Quick start
Your first request in two minutes
- 1
Create an API key
In the dashboard go to Developers → API keys, name the key and choose its scopes. The key (tf_live_…) is shown only once, so store it in your secrets manager.
- 2
Test the connection
Call GET /me. It works with any valid key and returns your account, the key's scopes and your plan's limits.
- 3
Read data or subscribe to events
List calls, appointments and contacts, or add a webhook endpoint under Developers → Webhooks to get events as they happen.
curl https://voxiora.com/api/v1/me \
-H "Authorization: Bearer tf_live_…"const API = "https://voxiora.com/api/v1";
const headers = { Authorization: `Bearer ${process.env.VOXIORA_API_KEY}` };
// All calls booked since 1 October, page by page
let cursor = null;
while (true) {
const url = new URL(`${API}/calls`);
url.searchParams.set("outcome", "booked");
url.searchParams.set("since", "2026-10-01T00:00:00Z");
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers });
if (res.status === 429) {
// rate limited: wait for the number of seconds in Retry-After, then retry the same page
await new Promise((r) => setTimeout(r, Number(res.headers.get("Retry-After") ?? 1) * 1000));
continue;
}
if (!res.ok) throw new Error((await res.json()).error.message);
const page = await res.json();
for (const call of page.data) console.log(call.started_at, call.from, call.summary);
if (!page.has_more) break;
cursor = page.next_cursor;
}REST API v1
Calls, appointments and contacts over HTTPS
JSON in, JSON out. Every request is authenticated with an API key and limited by your plan. Requests are logged in the dashboard (method, path, status and time — never request or response bodies).
Authentication
Create a key in API keys and send it as a Bearer token. Keys start with tf_live_ and belong to one account.
Authorization: Bearer tf_live_…Pagination
List endpoints return up to limit items (1–100, default 25) in a stable order. When has_more is true, pass next_cursor unchanged as cursor (with the same filters) to get the next page. An invalid cursor returns 400 invalid_request.
Errors
Errors use standard HTTP status codes and a JSON body with a type and a human-readable message.
{ "error": { "type": "…", "message": "…" } }Endpoints
- GET
/meScope: any keyGet the current account
Returns the account the API key belongs to. Handy as a “test connection” call.
- GET
/assistantsScope: any keyList assistants
Your assistants with their language, status and assigned phone numbers. Use an id as assistant_id when filtering calls.
- GET
/assistants/{id}Scope: any keyRetrieve an assistant
One assistant with language, status and phone numbers. With the read:assistants (or write:assistants) scope it also returns the prompt: instructions, greeting, company_profile and updated_at.
- PATCH
/assistants/{id}Scope: write:assistantsUpdate the assistant's prompt
Changes the instructions, greeting or company profile. Omitted fields stay unchanged. Same limits as the dashboard: instructions up to 40,000 characters, company profile up to 10,000, greeting 5–600 characters and it must say the caller is talking to an AI (otherwise 400 with code ai_disclosure). Every change is saved in the assistant's version history (source API) and applies from the next call. The platform's honesty rules (AI disclosure, recording notice) always apply on top of your instructions.
- GET
/phone-numbersScope: any keyList phone numbers
Your account's phone numbers with country, type, status and the assistant that answers them.
- GET
/callsScope: read:callsList calls
Calls newest first, without transcripts. Demo calls are never included.
- GET
/calls/{id}Scope: read:callsRetrieve a call
One call with the full transcript and the appointments booked during it.
- GET
/calls/{id}/recordingScope: read:callsGet a call recording
Signed links, valid for 1 hour, to play and download the call's MP3 recording (stereo: caller on the left, assistant on the right). A recording older than your plan's recording history returns 403 plan_required with recording_history_days; a call without a recording returns 404.
- GET
/appointmentsScope: read:appointmentsList appointments
Upcoming appointments in start order (from now by default).
- GET
/appointments/{id}Scope: read:appointmentsRetrieve an appointment
One appointment of your account.
- POST
/appointments/{id}/cancelScope: write:appointmentsCancel an appointment
Cancels an upcoming appointment, removes it from the connected Google calendar and sends the appointment.canceled webhook. calendar_sync tells you whether the calendar was updated.
- POST
/appointments/{id}/rescheduleScope: write:appointmentsReschedule an appointment
Moves an upcoming appointment to a new time. Fails with 409 if the time overlaps another appointment or a busy time in the connected calendar. Without ends_at the length stays the same. Sends the appointment.rescheduled webhook.
- GET
/contactsScope: read:contactsList contacts
Your callers, newest first. The assistant remembers them across calls.
- GET
/contacts/{id}Scope: read:contactsRetrieve a contact
One contact with name, email, tags, notes and where the name and email came from (call, system or manual).
- POST
/contactsScope: write:contactsCreate a contact
Adds a contact, for example from your CRM, so the assistant knows the caller's name. Name and email saved through the API are marked manual and the assistant never overwrites them. Returns 409 with contact_id if the phone number already exists. Sends the contact.created webhook.
- PATCH
/contacts/{id}Scope: write:contactsUpdate a contact
Changes the name, email, notes or blocked flag. Omitted fields stay unchanged and null clears a field. A changed name or email is marked manual, so the assistant won't overwrite it. The phone number can't be changed.
- GET
/tasksScope: read:tasksList tasks
Callback requests, messages and other tasks from calls, newest first.
- GET
/tasks/{id}Scope: read:tasksRetrieve a task
One task of your account.
- PATCH
/tasks/{id}Scope: write:tasksUpdate a task
Sets the task status to open, done or dismissed, for example after your team has called the customer back.
- POST
/calls/outboundScope: write:callsCreate an outbound call
Your assistant calls one person from your business number with the goal you give, like a step of an onboarding sequence. The call goes out now or at call_at, always within calling hours (Mon–Sat, 9:00–20:00 in your account's time zone; outside them it moves to the next allowed time and call_at shows when). If nobody answers, it tries up to 3 times, 2 hours apart. Returns 202 with the call in status scheduled. Errors: 400 consent_missing, invalid_phone, number_not_callable, international or invalid_call_at; 409 opted_out (the person asked not to be called), already_scheduled (with existing_id), assistant_not_live, over_quota or no_outbound_number; 429 daily_outbound_limit with limit (200 calls per 24 hours by default). The outcome arrives in the call.completed and outbound_call.finished webhooks with your external_id.
- GET
/calls/outbound/{id}Scope: write:callsRetrieve an outbound call
Status of a scheduled outbound call: scheduled, dialing, done (with call_id of the conversation), failed (last_error, e.g. no_answer after all attempts) or canceled. Also works with the callback_id from webhooks for onboarding and callback calls.
- DELETE
/calls/outbound/{id}Scope: write:callsCancel an outbound call
Cancels an outbound call created through the API that is still scheduled. Repeating it is safe (already canceled returns 200). Returns 409 not_cancelable when the call is dialing or finished, or for onboarding calls (remove the person with POST /api/outreach/unenroll instead).
- GET
/messagesScope: read:messagesList SMS messages
SMS your account sent and customer replies, newest first. phone is the customer's number (recipient or sender of a reply). generated_by: template, ai or manual; charged_from: plan or credits.
- POST
/messagesScope: write:messagesSend an SMS
Sends an SMS to one of your contacts or callers: your own text, or one of your account's templates with vars (date, time, name, service, link, amount). Counts toward your plan's SMS allowance and extra SMS. Errors: 400 invalid_request (consent_missing, invalid_phone, too_long, missing_vars); 409 conflict (opted_out — the customer replied STOP, over_quota, recipient_not_allowed — create the contact first, blocked_destination); 429 rate_limited.
Scopes
Every key has the scopes chosen when it was created. Scopes can't be changed later, so create a new key when you need more. Endpoints answer 403 insufficient_scope when the key lacks the required scope. GET /me, /assistants and /phone-numbers work with any valid key.
read:callsRead calls, summaries and transcriptsread:appointmentsRead appointmentswrite:appointmentsCancel and reschedule appointmentsread:contactsRead contactswrite:contactsCreate and update contactsread:tasksRead tasks and callback requestswrite:tasksUpdate task statuswrite:callsStart outbound calls, check and cancel themread:messagesRead SMS messages and customer replieswrite:messagesSend SMS to your contactsread:assistantsRead the assistant's prompt (instructions, greeting, company profile)write:assistantsChange the assistant's prompt
Errors
400invalid_request — a parameter is missing or invalid401unauthorized — missing, invalid or revoked API key403insufficient_scope — the key lacks the endpoint's scope; plan_required — the account has no active plan, or the recording is older than the plan's recording history404not_found — the object doesn't exist in your account409conflict — the time is already taken, the appointment can't be changed, a contact with this phone number already exists, or an outbound call can't be made (code: opted_out, already_scheduled, assistant_not_live, over_quota, no_outbound_number, not_cancelable)429rate_limited — too many requests; retry after the Retry-After header500server_error — something went wrong on our side503service_unavailable — a temporary problem; retry after the Retry-After header
Full API reference with parameters and example responses is in the dashboard under Developers → API reference.
Webhooks
Events pushed to your server in real time
Add an https endpoint in the dashboard, choose the events, and we POST a signed JSON envelope as soon as something happens. “Send test” delivers a sample of your first event, handy for mapping fields in Zapier or Make.
Events
call.completedCall completedSummary, outcome, sentiment, transcript, contact and bookings; for outbound calls also the sequence or API request (callback_id, external_id, outreach).
call.startedCall startedA call was answered (inbound, outbound or web).
appointment.bookedAppointment bookedThe assistant booked an appointment.
appointment.canceledAppointment canceledAn appointment was canceled by phone or through the API.
appointment.rescheduledAppointment rescheduledAn appointment was moved to a new time (includes the previous time).
task.createdCallback / task createdA caller asked for a callback or left a task.
contact.createdNew contactA first-time caller was saved as a contact.
outbound_call.finishedOutbound call finishedA scheduled outbound call (API, onboarding sequence or callback) is done or failed — also when nobody answered after all attempts.
Headers on every delivery
Voxiora-SignatureSignature: t=<unix time>,v1=<HMAC-SHA256 hex>Voxiora-EventEvent type, e.g. appointment.bookedVoxiora-DeliveryUnique delivery IDUser-AgentVoxiora-Webhooks/1.0 · Identifies our webhook sender
Delivery and retries
- Answer with any 2xx status within 10 seconds. Redirects are not followed.
- Failed deliveries are retried after 1 min, 5 min, 30 min, 2 h, 6 h and 12 h (7 attempts, about 21 hours).
- The same event can arrive more than once. Use the event id to skip duplicates.
- Only public https addresses are accepted. After 50 failures in a row an endpoint is paused and the dashboard tells you why.
- The dashboard keeps a delivery log with status codes and responses, and lets you retry a delivery by hand.
{
"id": "evt_3f9a1c7e5b2d4f6a8c0e1b3d5f7a9c1e",
"type": "call.completed",
"created_at": "2026-10-01T09:17:45Z",
"account_id": "1f2e3d4c-5b6a-4789-8a7b-6c5d4e3f2a1b",
"data": {
"id": "5b1f0c6e-2a8d-4f3e-9d51-7c0a4e2b9f10",
"object": "call",
"assistant_id": "0e6f4c8a-3b2d-4a1e-8f7c-5d9b2a1c3e4f",
"phone_number_id": "4d3c2b1a-0f9e-4d8c-b7a6-958473625140",
"contact_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
"direction": "inbound",
"status": "completed",
"from": "+48601234567",
"to": "+48221234567",
"started_at": "2026-10-01T09:14:03Z",
"ended_at": "2026-10-01T09:17:41Z",
"duration_sec": 218,
"summary": "Anna Kowalska booked a first consultation for Friday 10:00.",
"outcome": "booked",
"sentiment": "positive",
"tags": [
"new-customer"
],
"has_recording": true,
"end_reason": "caller_hangup",
"page_url": null,
"page_title": null,
"continued_from": null,
"callback_id": null,
"external_id": null,
"outreach": null,
"callback_return": false,
"caller_spoke": true,
"caller_words": 6,
"transcript": [
{
"role": "caller",
"text": "I'd like to book a consultation this week.",
"at": 4.1
}
],
"contact": {
"id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
"object": "contact",
"phone": "+48601234567",
"name": "Anna Kowalska",
"name_source": "call",
"email": "anna.kowalska@example.com",
"email_source": "call",
"notes": null,
"tags": [
"customer"
],
"blocked": false,
"calls_count": 3,
"last_call_at": "2026-10-01T09:14:03Z",
"created_at": "2026-08-12T15:02:11Z"
},
"appointments": [
{
"id": "c3d2e1f0-a9b8-4c7d-8e6f-5a4b3c2d1e0f",
"object": "appointment",
"call_id": "5b1f0c6e-2a8d-4f3e-9d51-7c0a4e2b9f10",
"contact_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
"customer_name": "Anna Kowalska",
"customer_phone": "+48601234567",
"customer_email": "anna.kowalska@example.com",
"service": "First consultation",
"starts_at": "2026-10-03T08:00:00Z",
"ends_at": "2026-10-03T08:30:00Z",
"status": "booked",
"external_id": "google:7h3k9s2l1m0n",
"created_at": "2026-10-01T09:16:20Z"
}
]
}
}Verifying signatures. Every delivery has a Voxiora-Signature header in the form t=timestamp,v1=signature. Compute HMAC-SHA256 of “timestamp.raw_body” with your signing secret and compare it to v1. Reject timestamps older than 5 minutes.
import crypto from "node:crypto";
// Express: app.post("/webhook", express.raw({ type: "application/json" }), handler)
export function verify(rawBody, signatureHeader, secret) {
// Voxiora-Signature: t=1727774265,v1=5f2c…
const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
const given = Buffer.from(parts.v1 ?? "");
// timingSafeEqual needs buffers of the same length
return fresh && given.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), given);
}import hmac, hashlib, time
def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts["t"])) < 300
return fresh and hmac.compare_digest(expected, parts.get("v1", ""))import express from "express";
import { verify } from "./verify.js";
const app = express();
const seen = new Set(); // use your database in production
app.post("/voxiora/webhook", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verify(raw, req.get("Voxiora-Signature") ?? "", process.env.VOXIORA_WEBHOOK_SECRET)) return res.sendStatus(401);
const event = JSON.parse(raw);
if (seen.has(event.id)) return res.sendStatus(200); // retried delivery, already handled
seen.add(event.id);
if (event.type === "appointment.booked") {
const a = event.data;
console.log("New booking:", a.customer_name, a.service, a.starts_at);
}
res.sendStatus(200); // answer with 2xx within 10 seconds
});
app.listen(3000);Stable values
These values in calls (API and call.* webhooks) and in outbound calls (outbound_call.finished, /calls/outbound) are a stable contract: we may add new values, but we won't change or remove these. Unanswered, busy or rejected outbound attempts never create a call, so there is no call.completed for them: you get outbound_call.finished with last_error no_answer after the last attempt. A call answered by voicemail counts as answered (usually a short call with outcome other).
Call status
in_progress- The call is going on.
completed- The person talked to the assistant.
missed- Nobody spoke, or the assistant couldn't take the call (see end_reason).
failed- The call couldn't be handled because of an error.
Call outcome
booked- An appointment was booked.
transferred- The call was transferred to a person.
message- A message, callback request, order or follow-up was taken.
info- The person got information; nothing else was needed.
spam- Spam, telemarketing or a blocked number.
other- Anything else, e.g. a very short call or voicemail.
Call end reason (end_reason)
caller_hangup- The other person hung up.
caller_hangup_greeting- Outbound call: the person hung up during or right after the greeting without saying a word (call of up to 45 s). caller_spoke is then false.
agent_hangup- The assistant ended the call after saying goodbye.
transferred- The call was transferred to a person.
silence- Ended after a long silence.
max_duration- The maximum call length was reached.
spam- Ended as spam.
blocked- The number is blocked in contacts.
busy- All of the account's lines were busy.
over_quota- No call minutes left.
trial_expired- The free trial has ended.
inactive- No active plan.
assistant_not_live- The assistant is paused.
no_assistant- No assistant answers this number.
ai_budget- Handled without AI because of a temporary limit; the team was asked to call back.
error- A technical error ended the call.
Outbound call status
scheduled- Waiting for its time (or for the next attempt).
dialing- Calling now.
done- Answered; call_id is the conversation.
failed- Not answered after all attempts, or couldn't be made (see last_error).
canceled- Canceled (API, the person removed from the sequence, or the sequence turned off).
Outbound call last_error
no_answer- Nobody answered (also busy or rejected).
failed- The assistant wasn't available at call time (plan, minutes or pause).
no_result- No result from the call within 15 minutes.
expired- The call was more than 2 hours late, so it wasn't made.
outside_window- Moved to the next calling hours (status stays scheduled).
dial_error- The phone network refused the call (sent as dial_<reason>).
over_quota- No call minutes left.
inactive- No active plan.
assistant_not_live- The assistant is paused.
no_outbound_number- No business number to call from.
canceled_by_api- Canceled with DELETE /calls/outbound/{id}.
unenrolled- The person was removed from the sequence (POST /api/outreach/unenroll).
opt_out- The person asked not to be called.
replaced- Replaced by a newer callback to the same number.
Live data via MCP
Let the assistant look things up in your system during a call
Connect a remote MCP server (Model Context Protocol), such as your shop catalogue, stock, booking or order system. You choose exactly which read-only tools and resources the assistant may use.
Learn in advance
For content that rarely changes, such as price lists, product descriptions or policies. Selected resources and read-only tools are imported into the assistant's knowledge every 1 to 168 hours or on demand. No delay during calls.
Check live in calls
For things that change: stock, availability, order status. The assistant calls the tool during the conversation with a short “one moment, let me check”. If your server doesn't answer within about 2.5 seconds, it says it can't confirm right now and offers a follow-up from your team.
What you need
- An MCP server reachable over https (Streamable HTTP, e.g. https://mcp.example.com/mcp; older HTTP+SSE servers are detected automatically).
- Authentication: none, a Bearer token or a custom header such as X-API-Key. Secrets are encrypted (AES-256-GCM) and never sent to the browser.
- Read-only tools. Tools marked as destructive are blocked; for tools not marked read-only you confirm that they only read data.
- Up to 5 sources and 50 live tools per assistant. Live lookups should answer within about 2 seconds.
- Optional for the website widget: on an “At the start of the call” tool, pick the argument that receives the identity token of the signed-in visitor (see Website voice widget).
Tool arguments are validated against the tool's schema, results are shortened and treated strictly as data (never as instructions), and every lookup is logged without caller data. Private and internal addresses are refused.
MCP server for AI agents
Give your AI agent access to Voxiora
Voxiora runs a remote MCP server (Model Context Protocol). Connect Claude, ChatGPT, Cursor or your own agent and it can read your calls, transcripts, appointments, contacts and tasks, and with your permission add contacts, start outbound calls and send SMS.
- Endpoint: https://voxiora.com/api/mcp (Streamable HTTP, JSON-RPC 2.0, stateless).
- Authentication: the same API key as the REST API (Authorization: Bearer tf_live_…) or a 1-hour OAuth access token from the client credentials grant. Without it the server answers 401 with a pointer to the OAuth metadata.
- The agent only sees tools allowed by the key's scopes, and each call has the same checks, rate limits and request log as the REST API.
- Outbound calls and SMS require consent: true, exactly like the API. Tool results are data from your callers, never instructions.
OAuth for agents
Exchange an API key for a short-lived access token at https://voxiora.com/api/oauth/token: client_id is the key id (api_key_id from GET /me) or its prefix, client_secret is the key. Granted scopes are the requested scopes that the key has. Revoking the key in the dashboard ends every token issued from it.
Agents can also ask for access themselves: POST https://voxiora.com/api/agent-auth/register returns a code, the account owner approves it in the dashboard (Approve an AI agent) and the agent receives its own API key once. Nothing is granted before approval; details in auth.md.
Tools
get_accountany keylist_assistantsany keylist_phone_numbersany keylist_callsread:callsget_callread:callslist_contactsread:contactsget_contactread:contactscreate_contactwrite:contactslist_appointmentsread:appointmentsget_appointmentread:appointmentslist_tasksread:taskslist_messagesread:messagesstart_outbound_callwrite:callsget_outbound_callwrite:callssend_messagewrite:messages
Discovery documents
curl https://voxiora.com/api/mcp \
-H "Authorization: Bearer tf_live_…" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"my-agent","version":"1.0"}}}'curl https://voxiora.com/api/oauth/token \
-u "<api_key_id>:tf_live_…" \
-d grant_type=client_credentials \
-d scope="read:calls read:contacts"Website voice widget
Your assistant on your website, one line of code
Visitors click a button and talk to the same assistant that answers your phone, right in the browser. Web calls use your plan minutes, with no telephony costs.
From Business
- About 21 KB, no dependencies, isolated in Shadow DOM so your site's styles can't break it.
- Speaks the language of your page (<html lang>) or the one you set with data-lang="de".
- Live captions; if the microphone is blocked, visitors can type and the assistant answers by voice.
- Restrict the widget to your own domains and it only starts on those sites.
<script src="https://voxiora.com/widget.js" data-key="tfw_…" async></script>// optional: open the widget from your own button
document.querySelector("#talk-to-us").addEventListener("click", () => window.VoxioraWidget?.open());
// open and start right away: { mode: "voice" } or { mode: "chat" } (text only, no microphone)
window.VoxioraWidget?.open({ mode: "chat" });
window.VoxioraWidget?.close(); // close the panel
window.VoxioraWidget?.destroy(); // remove the widget from the pageStrict Content Security Policy? Allow script-src https://voxiora.com https://cdn.jsdelivr.net, connect-src https://voxiora.com wss://*.livekit.cloud https://*.livekit.cloud and media-src blob:.
Signed-in users (identity token)
When visitors are signed in on your site, the assistant can know who is talking without asking for their e-mail.
- Your server issues a short-lived, signed token for the signed-in user (e.g. HMAC, valid 15 minutes). Never put an e-mail address or user ID in the browser: anyone could type someone else's into the console.
- Call VoxioraWidget.identify({ token }) at any time; a later call replaces the token and identify(null) clears it. Before widget.js has loaded, set window.VoxioraIdentity = { token, expires_at }: it is read when the conversation starts, and an expired token is skipped.
- In the dashboard, choose the identity token argument of any live MCP tool — a lookup, an action or your “At the start of the call” tool. Voxiora passes the token unchanged only to that argument, never stores or shows it and keeps it out of transcripts, summaries, webhooks and logs; the assistant never sees it.
- 8 to 400 characters: letters, digits and . _ ~ + / = - (e.g. base64url). Refresh it before it expires, for example every 10 minutes. Your MCP server verifies it; an invalid or expired token should give the same result as no token. Phone calls and conversations without a token work as before.
// signed-in user: a short-lived, signed token issued by YOUR server (never an e-mail or user ID)
const { token, expires_at } = await fetch("/api/voxiora-identity").then((r) => r.json());
window.VoxioraIdentity = { token, expires_at }; // read when the conversation starts, also before widget.js loads
window.VoxioraWidget?.identify({ token, expires_at }); // replaces the token at any time
// refresh before it expires (e.g. every 10 minutes); on sign-out:
window.VoxioraIdentity = null;
window.VoxioraWidget?.identify(null);Chat mode and your own button
Open the assistant from your own interface — a button, a menu or a reception desk in a 3D scene — by voice or as a text chat.
- data-launcher="none" hides the floating button: the panel opens only from your code with VoxioraWidget.open() and closes with close(). Focus returns to where it was (for example your game canvas). close() only hides the panel and the conversation goes on; VoxioraWidget.endCall() or close({ end: true }) ends it (the widget stays).
- open({ mode: "voice" }) or open({ mode: "chat" }) opens the panel and starts the conversation straight away. data-mode="chat" makes chat the default for the start button.
- Chat mode never asks for the microphone: the visitor types and the assistant answers in text. It is the same assistant with the same knowledge and tools.
- Every method works before the widget has loaded: calls are queued and run once it is ready. Even before widget.js loads, you can queue them in window.VoxioraWidget = { q: [["open", { mode: "chat" }]] }.
<!-- no floating button: the panel opens only from your code -->
<script src="https://voxiora.com/widget.js" data-key="tfw_…" data-launcher="none" data-mode="chat" async></script>
<script>
// calls made before widget.js has loaded are queued and run when it starts
window.VoxioraWidget = window.VoxioraWidget || { q: [] };
const voxiora = (method, ...args) =>
window.VoxioraWidget.q ? window.VoxioraWidget.q.push([method, ...args]) : window.VoxioraWidget[method](...args);
// e.g. the reception desk in your 3D gallery
document.querySelector("#talk").addEventListener("click", () => voxiora("open", { mode: "voice" })); // asks for the microphone
document.querySelector("#chat").addEventListener("click", () => voxiora("open", { mode: "chat" })); // text only, no microphone
// later:
// VoxioraWidget.close(); hide the panel (the conversation continues)
// VoxioraWidget.isOpen(); true | false
// VoxioraWidget.state(); { ready, open, mode, call: "idle" | "connecting" | "active", agent_state }
// VoxioraWidget.init({ launcher: false, mode: "chat", lang: "de" }); // the same options in code
</script>Events
React to the conversation on your page — animate an avatar while the assistant speaks, show your own chat bubbles or log calls.
- Each event is a window CustomEvent named voxiora:<name> and can also be observed with VoxioraWidget.on(name, fn) / off(name, fn).
- ready { key, assistant, available } · open · close · call_started { mode, roomName } · call_ended { reason } · agent_state { state: listening | thinking | speaking } · error { code }
- speaking { speaking, level }: the level of the assistant's voice from 0 to 1, up to 20 times per second while it speaks and whenever speaking changes — also when the visitor prefers reduced motion.
- message { role: agent | user, text, final, id }: text as it arrives (final: false) and complete (final: true), for your own chat UI. tool: see page-side tools below. One utterance = one id: several events arrive with the same id and the final text has final: true — merge by id.
const avatar = document.querySelector("#receptionist");
// { speaking: boolean, level: 0..1 } — up to 20 times per second while the assistant speaks
window.addEventListener("voxiora:speaking", (e) => {
avatar.classList.toggle("talking", e.detail.speaking);
avatar.style.setProperty("--mouth", e.detail.level); // e.g. scale the mouth or a glow
// Unity WebGL: unityInstance.SendMessage("Receptionist", "SetVoiceLevel", e.detail.level);
});
// the same events through the widget API (name with or without the "voxiora:" prefix)
VoxioraWidget.on("call_started", ({ mode, roomName }) => console.log("started", mode, roomName));
VoxioraWidget.on("call_ended", ({ reason }) => console.log("ended", reason)); // user | disconnected | …
VoxioraWidget.on("agent_state", ({ state }) => console.log(state)); // listening | thinking | speaking
VoxioraWidget.on("message", ({ role, text, final }) => final && console.log(role, text)); // your own chat UI
VoxioraWidget.on("error", ({ code }) => console.warn("Voxiora:", code)); // rate_limited, assistant_offline, …Page context
Tell the assistant where the visitor is — which gallery, store or product they are looking at — so it answers without asking.
- VoxioraWidget.setContext({ … }) or data-context='{"gallery":"POL"}' on the script tag; setContext(null) clears it. During a conversation the new context reaches the assistant at once, together with the current page address and title (also after navigation without a page reload); otherwise it applies to the next conversation.
- Up to 20 keys (lowercase letters, digits and _, starting with a letter); values: text up to 200 characters, numbers or true/false, at most 2000 bytes in total. Invalid context is ignored with a console warning and the previous one is kept.
- In the assistant's instructions use {{context.gallery}}, {{context.store}} and so on; the assistant also sees the context as information about the page.
- Your MCP server receives it in the Voxiora-Context header (compact JSON, non-ASCII characters as \uXXXX, up to 1000 bytes — above that only the keys that fit stay, secret_ first) next to Voxiora-Call-Id. Treat it as untrusted input: the visitor's browser sets it. Keys starting with secret_ (e.g. secret_session) go only to your MCP server: the assistant never sees them, they are never stored, logged or shown, and if your server repeats one, it is replaced with [secret].
// what the visitor is looking at; used by the next conversation
VoxioraWidget.setContext({ gallery: "POL", store: "Lumi Shoes", floor: 2, logged_in: true });
VoxioraWidget.setContext(null); // clear
// or on the script tag: data-context='{"gallery":"POL"}'
// assistant instructions:
// The visitor is in gallery {{context.gallery}}, at the store {{context.store}}.
// every live lookup on your MCP server during that conversation carries:
// Voxiora-Call-Id: 5b1f0c6e-…
// Voxiora-Context: {"gallery":"POL","store":"Lumi Shoes","floor":2,"logged_in":true}
// (non-ASCII as \uXXXX — JSON.parse restores it; set by the visitor's browser, so treat it as untrusted)Page-side tools
Let the assistant do things in the visitor's browser — show a store, open a product, read the cart — with functions you register on the page.
- VoxioraWidget.registerTools([...]): up to 10 tools with name (lowercase letters, digits, _), description (what it does and when to use it, up to 500 characters) and parameters as a JSON Schema of type object (up to 4000 bytes). The list applies to the next conversation.
- When the assistant calls a tool, your handler(args, { waitForUser }) runs (it may return a value or a Promise) and a voxiora:tool event is dispatched with name, args, respond(result), reject(error) and waitForUser(). The first answer wins.
- Answer within 9 seconds — otherwise the assistant hears that the tool did not respond. If the action needs the visitor (e.g. to confirm a payment), call waitForUser() first: the assistant says it is waiting and you have up to 29 seconds. Results are limited to 15,000 bytes of JSON; unknown tools are refused.
- The arguments come from the AI model: validate them like any user input and never offer actions that need the visitor's confirmation without asking for it.
VoxioraWidget.registerTools([
{
name: "show_store",
description: "Moves the visitor's view to a store in the gallery. Use it when they ask where a store is.",
parameters: { type: "object", properties: { store_id: { type: "string" } }, required: ["store_id"] },
// runs in the visitor's browser; the returned value (or Promise) goes back to the assistant
handler: async ({ store_id }) => {
await gallery.flyTo(String(store_id)); // your code — validate arguments like any user input
return { shown: true };
},
},
{ name: "cart_count", description: "Number of items in the visitor's cart.", parameters: { type: "object", properties: {} } },
]);
// or answer from an event listener — the first answer wins; no answer within 9 s = "timeout"
window.addEventListener("voxiora:tool", (e) => {
const { name, args, respond, reject } = e.detail;
if (name === "cart_count") respond({ items: cart.length });
});Several assistants on one page
Each assistant has its own widget key. Switch between them without reloading the page — for example between the main reception and a store's own assistant.
- VoxioraWidget.switchTo({ key }) ends the current conversation, removes the widget and starts it again with the other key; an open() called right after it waits until the new assistant is ready. Context (setContext), tools (registerTools) and listeners (on) are kept — no need to register them again; just change the context if the other assistant needs a different one (e.g. place).
- Your event listeners, page context and tools stay; each key keeps its own conversation-resume state. Add your domain to the allowed domains of every key.
- VoxioraWidget.destroy() removes the widget completely: interface, listeners, the conversation and the saved resume state.
const RECEPTION = "tfw_…"; // widget key of the gallery reception
const STORE = "tfw_…"; // widget key of a store's own assistant
document.querySelector("#store-desk").addEventListener("click", () => {
VoxioraWidget.switchTo({ key: STORE }); // ends the conversation, reloads the widget with the other assistant
VoxioraWidget.open({ mode: "voice" }); // queued until the new assistant is ready
});
VoxioraWidget.on("ready", ({ key, assistant }) => console.log("now:", assistant, key === RECEPTION ? "reception" : "store"));
VoxioraWidget.destroy(); // remove the widget completely (interface, listeners, conversation, resume state)Phone onboarding
Your new customers get a welcome call, automatically
When someone signs up in your system, send them to a call sequence. The assistant calls them from your business number, helps them get started and checks in again a few days later. Calls go out within the sequence's call hours: by default Monday to Saturday, 9:00 to 20:00 in your time zone, or the days and hours you set.
POST/api/outreach/enroll
Authenticate with the sequence key (tlo_…) from the dashboard, as a Bearer token or in the X-Voxiora-Key header. Each sequence has its own key.
Request body
- Phone number, ideally in E.164 format. National numbers are read with your account's country code.
phonestringrequired - Must be true: the person agreed to be contacted by phone, e.g. in your sign-up form.
consenttruerequired - Name, up to 120 characters.
namestringoptional - Email address.
emailstringoptional - What the assistant should know for this person, up to 1,000 characters (e.g. the plan they chose).
contextstringoptional - The person's ID in your system (string or number). The same ID is enrolled only once.
external_idstring | numberoptional
curl -X POST "https://voxiora.com/api/outreach/enroll" \
-H "Authorization: Bearer tlo_…" \
-H "Content-Type: application/json" \
-d '{"phone":"+48601234567","name":"Anna Kowalska","email":"anna@example.com","context":"Signed up today, Start plan","external_id":"user_123","consent":true}'{
"ok": true,
"enrollment_id": "8f7e6d5c-4b3a-4c2d-9e1f-0a9b8c7d6e5f",
"external_id": "user_123",
"status": "active",
"next_call_at": "2026-10-09T10:20:00Z",
"limits": {
"per_day": 500,
"remaining_today": 499
}
}Responses
- 201: enrolled. enrollment_id, external_id, status, next_call_at (time of the first call), intro_sms_at (when the SMS before the first call goes out, or null) and limits { per_day, remaining_today }
- 200: already enrolled (same external_id, or this number is in progress in the sequence), with duplicate: true
- 400: invalid_request, invalid_phone or consent_required
- 401: invalid_key
- 409: sequence_disabled, consent_missing or opted_out (the person asked not to be called)
- 429: limit, the sequence's daily enrollment limit was reached (500 new people per 24 hours; the body has limit). rate_limited: more than 120 requests per minute with this key (Retry-After header)
Call hours: set the days and a time range between 7:00 and 21:00 for each sequence in the dashboard. If nobody answers, the assistant tries up to 3 times, 2 hours apart, within those hours.
SMS before the call: when it's on for the sequence (dashboard), the person gets a short text from the number the assistant will call from, by default 20 minutes before the first call (5–120 min). If the first call would come sooner, it moves so the SMS always goes out first, within the call hours. A failed SMS (the person replied STOP, the SMS allowance is used up) never stops the call. Each SMS counts toward your SMS allowance and appears in the dashboard under SMS.
Greeting: in the dashboard you set the first sentence of the call for each step (or a default for the whole sequence) — a fixed text with the variables {first_name}, {first_name_vocative} (Polish vocative, e.g. Krystianie), {assistant_name}, {brand} (the company name for calls from the assistant settings) and {name}, with a separate variant for sign-ups without a name — or a mode in which the assistant composes the first sentence from name, context, the step's goal and the result of the "At the start of the call" tools. That an AI assistant is calling, and that the call is recorded, is always added to the first sentence if the text does not say it.
Remove a person from the sequence
POST/api/outreach/unenroll
When someone no longer needs the calls (for example after their first sale, or they cancelled), stop their sequence with the same key. Scheduled calls are cancelled at once. A call already in progress isn't interrupted, but no further calls follow it.
- Exactly one of: external_id (as sent when enrolling), enrollment_id (from the enroll response) or phone.
external_id | enrollment_id | phonerequired - Optional reason, up to 200 characters, kept in your account's audit log.
reasonoptional
- 200: stopped (changed: true) or already finished: status stopped, completed or failed (changed: false). Safe to repeat.
- 400: invalid_request (no identifier or more than one) or invalid_phone
- 401: invalid_key
- 404: not_found, no such enrollment in this sequence
- 429: rate_limited
curl -X POST "https://voxiora.com/api/outreach/unenroll" \
-H "Authorization: Bearer tlo_…" \
-H "Content-Type: application/json" \
-d '{"external_id":"user_123","reason":"first_sale"}'{
"ok": true,
"enrollment_id": "8f7e6d5c-4b3a-4c2d-9e1f-0a9b8c7d6e5f",
"external_id": "user_123",
"status": "stopped",
"changed": true
}Call results in webhooks
call.started and call.completed for calls from a sequence carry callback_id, external_id and outreach { sequence_id, enrollment_id, external_id, step } (all null for inbound calls). Unanswered attempts don't create a call, so there is no call.completed for them: after the last attempt you get outbound_call.finished with status failed and last_error no_answer. Retries of one step count as one result. call.completed also includes caller_spoke and caller_words (how many words the person said). When someone answered and hung up during or right after the greeting without a word, end_reason is caller_hangup_greeting. When the person calls the business number back within 3 days of our attempt, the inbound call has callback_return: true and the same callback_id, external_id and outreach as the call they are returning. Calls from the website widget have direction widget, page_url and page_title (the page of the call) and continued_from (the previous part after a page reload); a browser test from the panel is direction web. Every request to your MCP server during a call carries the Voxiora-Call-Id header (same as the call id), Voxiora-Caller on phone calls and Voxiora-Outreach-External-Id on sequence or API calls.
{
"id": "5b1f0c6e-2a8d-4f3e-9d51-7c0a4e2b9f10",
"object": "call",
"assistant_id": "0e6f4c8a-3b2d-4a1e-8f7c-5d9b2a1c3e4f",
"phone_number_id": "4d3c2b1a-0f9e-4d8c-b7a6-958473625140",
"contact_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
"direction": "outbound",
"status": "completed",
"from": "+48732127980",
"to": "+48601234567",
"started_at": "2026-10-01T09:14:03Z",
"ended_at": "2026-10-01T09:17:41Z",
"duration_sec": 218,
"summary": "Jan added the company name and tax ID; asked for a call tomorrow at 10:00 about payments.",
"outcome": "message",
"sentiment": "positive",
"tags": [],
"has_recording": true,
"end_reason": "agent_hangup",
"page_url": null,
"page_title": null,
"continued_from": null,
"callback_id": "0f1e2d3c-4b5a-4968-8776-655443322110",
"external_id": "gapli-evt-1842",
"outreach": {
"sequence_id": "11111111-1111-4111-8111-111111111111",
"enrollment_id": "8f7e6d5c-4b3a-4c2d-9e1f-0a9b8c7d6e5f",
"external_id": "gapli-evt-1842",
"step": 1
},
"callback_return": false,
"caller_spoke": true,
"caller_words": 112
}Calendar, CRM and automation
Native connectors, no code needed
Google Calendar
The assistant checks real availability and books, moves and cancels appointments in your calendar. It only reads busy times, never event titles.
HubSpot
Every call is logged on the contact, bookings become meetings and callbacks become tasks. Optionally creates contacts and deals.
Pipedrive
Calls as activities or notes, bookings as meetings, callbacks as activities. Optionally creates people, deals or leads.
Zapier, Make and n8n
Step-by-step guides in the dashboard. Built on webhooks and the API, which are included in every plan.
Plans and limits
Which plan you need
Limits per account: requests per minute (also per key, sliding window) and per day (UTC). Above a limit you get 429 rate_limited with a Retry-After header.
Website voice widget: from the Business plan.
Need higher limits? Contact us and we can raise them for your account.
Start today
Ready to connect?
Start the free trial, create a test API key and send your first request in minutes. The API is included in every plan, from Essential.