Voxa public API

Manage assistants, read call logs and recordings, and start browser calls from your own server. All endpoints live under /api/v1 on the same origin as the console and return JSON.

Download OpenAPI 3.1 spec · Postman collection

Authentication

Create a private key under API keys in the console. The full key (sk_live_…, 40 characters) is shown once; only a SHA-256 hash is stored. Send it as a bearer token on every request:

Authorization: Bearer sk_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Keep keys on your server. In browsers and mobile apps use the organization's public key with the widget or POST /api/voice/token instead. Keys created before hashing existed show as "Legacy, cannot authenticate" in the console and must be replaced.

Requests without a valid key get 401. Revoking a key in the console makes it fail immediately. A key may be limited to reading (see Key scopes). Only organization admins can create, change or revoke keys.

export VOXA_URL=http://localhost:3000
export VOXA_KEY=sk_live_...

curl -s "$VOXA_URL/api/v1/me" -H "Authorization: Bearer $VOXA_KEY"

Conventions

  • Request bodies are JSON (content-type: application/json). Responses are JSON except the recording endpoint, which returns audio/wav (or a 302 to it on bucket storage, see Recording).
  • List endpoints return { "data": [...], "count": n, "total": n, "hasMore": bool, "nextCursor": string | null } and page with limit and cursor (see Pagination).
  • Create returns 201 with the full record; delete returns 204 with no body; other methods on a path return 405 with an Allow header.
  • Every response carries permissive CORS headers (Access-Control-Allow-Origin: *) and answers OPTIONS preflights, so the API can also be called from a trusted browser environment that holds a key.
  • Records are scoped to the organization the key belongs to. id, updatedAt and orgId are read-only; cost, latency and lat are computed by the server from the assistant's providers and ignored when sent.
  • Timestamps are ISO-8601 strings in UTC.

Errors

Errors are JSON with an error message. Validation errors add a fields object keyed by field name.

{ "error": "Validation failed", "fields": { "name": "is required", "temperature": "must be a number" } }
StatusMeaning
400Malformed JSON, unknown or read-only field, invalid value or query parameter.
401Missing, malformed, unknown or revoked API key.
402The organization cannot start calls right now (no credits left or monthly limit reached); POST /web-calls only.
403The key lacks the scope the method needs (for example a read-only key sending PATCH).
404No record with that id (or it belongs to another organization); no recording for the call.
405Method not supported on this path. Allow lists the supported ones.
429Rate limit exceeded. Wait Retry-After seconds.
500Unexpected server error; it has been logged.

Rate limits

Each key may make 120 requests per minute (a token bucket that refills continuously, two requests per second). Every response includes X-RateLimit-Limit and X-RateLimit-Remaining. When the bucket is empty the API answers 429 with a Retry-After header (seconds).

HTTP/1.1 429 Too Many Requests
Retry-After: 1
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0

{ "error": "Rate limit of 120 requests per minute exceeded" }

Key scopes

Each key carries one or both scopes, chosen when it is created in the console (an admin can change them later):

ScopeAllows
readGET and HEAD on every endpoint.
writePOST, PATCH and DELETE (creating, changing and deleting assistants, starting web calls).

A key created before scopes existed has both. A request whose method needs a scope the key lacks gets 403:

{ "error": "This API key does not have the \"write\" scope" }

GET /api/v1/me returns the key's effective scopes.

Test mode

Build and test without spending credits. When you create a key in the console, pick Test to get a test key (sk_test_…, same length and scopes as a live key). Every organization also has a test public key (pk_test_…, shown next to the public key on the API keys page) for the widget and the web SDK. A key's mode is fixed when it is created; keys created before test mode are live.

Calls started with test credentials are test calls:

  • They are never charged: credits and the monthly usage do not change, and they are left out of usage reports, the cost breakdown and the console's Overview. The Calls page lists them under the Test calls filter with a Test tag, and the API returns them with "test": true.
  • Their minutes count against the organization's test budget (60 minutes per month unless an admin changes it on the API keys page; the counter starts over every month). When it is used up, new test calls are refused with 402 and "reason": "test_budget".
  • Each test call ends after 5 minutes at most (or sooner when the assistant's maximum duration is shorter).
  • Video agents run in sandbox mode: a Lite agent always shows the sandbox avatar; a Full-mode agent must have sandbox mode on, else the start is refused with 403 and "reason": "test_mode_video".
  • Test keys cannot drive phone numbers or outbound campaigns: such writes answer 403 with "reason": "test_mode_phone".
  • Test keys read everything, create and edit assistants and start test web calls, but never make a change that cannot be taken back or that reaches live traffic: any DELETE, personal-data erasure (POST /api/v1/privacy/erase) and do-not-call list changes (/api/v1/dnc) answer 403 with "reason": "test_mode_scope". Use a live key for those.
  • Webhooks of test calls go only to the organization's Test webhook URL (API keys page), never to the live server URLs of the organization or the assistant, and every payload carries "test": true. Without a test webhook URL test calls send no webhooks.
curl -s -X POST "$VOXA_URL/api/v1/web-calls" \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{ "assistantId": "asst_7f3a91c2" }'

{ "token": "eyJhc3Npc3RhbnRJZCI6...", "url": "ws://localhost:3001", "expiresIn": 300, "assistantId": "asst_7f3a91c2", "test": true }
{ "error": "This organization used its test call minutes for this month. Raise the test budget on the API keys page, or use a live key.", "reason": "test_budget" }

Pagination

Every list endpoint pages the same way.

QueryMeaning
limitItems per page, 1–100 (default 50). /calls accepts up to 200.
cursorThe nextCursor of the previous page. Opaque: pass it back unchanged, with the same filters.

The response adds total (items matching across all pages), hasMore, and nextCursor (null on the last page). A cursor stays valid when the item it points at is deleted; a cursor this API did not issue gets 400.

curl -s "$VOXA_URL/api/v1/assistants?limit=2" -H "Authorization: Bearer $VOXA_KEY"
{ "data": [ ... ], "count": 2, "total": 5, "hasMore": true, "nextCursor": "eyJpZCI6ImFzc3RfYjIiLCJpIjoxfQ" }

curl -s "$VOXA_URL/api/v1/assistants?limit=2&cursor=eyJpZCI6ImFzc3RfYjIiLCJpIjoxfQ" -H "Authorization: Bearer $VOXA_KEY"

Assistants

An assistant is the full configuration of a voice agent: model, prompt, voice, transcriber, call rules and tools. The record has the same shape as the console's Assistant type; the fields most often set through the API are listed below.

FieldTypeNotes
namestringRequired on create. Up to 80 characters.
status"Published" | "Draft"New assistants start as Draft.
provider, modelstringOpenAI, Anthropic or Google, and a model id such as gpt-4o.
firstMessage, promptstringWhat the assistant says first, and its system prompt.
temperature, maxTokensnumber0–2 and 1–32000. maxTokens is accepted and stored but not applied to live calls.
voiceProvider, voice, speedstring, string, numberCartesia, ElevenLabs, Deepgram or OpenAI; speed 0.5–2 (accepted, not applied to live calls).
tProvider, tModel, languagestringTranscriber: Deepgram, AssemblyAI, Google or OpenAI. Deepgram models (tModel): nova-3 (default), nova-3-multi, flux-general-en, flux-general-multi, nova-2.
recording, hipaa, voicemailbooleanCall behaviour switches.
denoise, backchannel, endpointbooleanAccepted and stored, not applied to live calls (the speech runtime exposes no such controls yet).
waitSeconds, interruptWordsnumber, integer0–5 and 0–20. Accepted and stored, not applied to live calls.
silenceTimeout, maxDurationintegerSeconds of silence before hanging up (1–600); maximum call length in minutes (1–180).
endPhrasesstring[]Phrases that end the call.
serverUrlstringReceives webhooks and tool calls for this assistant.
toolsobjectBooleans for book, transfer, end, order, query. Partial objects are merged.
summaryPrompt, evaluationstringPost-call analysis; evaluation is pass, numeric or descriptive.
cost, latency, latstring, string, numberRead-only. Estimated price per minute and end-to-end latency, recomputed from model, voiceProvider and tProvider on every write; values you send are ignored.
GET/api/v1/assistants

Every assistant in the key's organization, paginated (limit, cursor).

curl -s "$VOXA_URL/api/v1/assistants" -H "Authorization: Bearer $VOXA_KEY"
{ "data": [ { "id": "asst_7f3a91c2", "name": "Riley — Front desk", "status": "Published", "provider": "OpenAI", "model": "gpt-4o", ... } ],
  "count": 4, "total": 4, "hasMore": false, "nextCursor": null }
POST/api/v1/assistants

Create an assistant from a partial body. Anything you leave out takes the console's defaults (the settings of your first assistant, as a Draft with an empty prompt). Returns 201 with the full record.

curl -s -X POST "$VOXA_URL/api/v1/assistants" \
  -H "Authorization: Bearer $VOXA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Taylor — Bookings",
    "provider": "Anthropic",
    "model": "claude-sonnet-4",
    "firstMessage": "Hi, you have reached Acme Dental. How can I help?",
    "prompt": "You book, move and cancel appointments for Acme Dental.",
    "tools": { "book": true, "transfer": true }
  }'
GET/api/v1/assistants/{id}

One assistant. 404 when the id is unknown.

curl -s "$VOXA_URL/api/v1/assistants/asst_7f3a91c2" -H "Authorization: Bearer $VOXA_KEY"
PATCH/api/v1/assistants/{id}

Change any writable fields. Validation is the same as on create; tools is merged with the current value. Returns the updated record.

curl -s -X PATCH "$VOXA_URL/api/v1/assistants/asst_7f3a91c2" \
  -H "Authorization: Bearer $VOXA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "Published", "maxDuration": 15, "tools": { "query": true } }'
DELETE/api/v1/assistants/{id}

Delete the assistant. Returns 204. Same cleanup as the console: phone numbers it answered become unassigned, squads drop it from members and handoff rules, and workflows, campaigns and test suites lose their reference to it. Calls already logged for it are kept.

curl -s -X DELETE "$VOXA_URL/api/v1/assistants/asst_7f3a91c2" -H "Authorization: Bearer $VOXA_KEY" -i

Calls

A call record holds who called, which assistant answered, duration, cost, the end-of-call summary and extracted data; the single-call endpoint adds the transcript. recordingUrl is set when a recording exists and points at /api/v1/calls/{id}/recording.

A call made with a video agent also carries video: { "provider": "heygen", "mode": "lite" | "full", "videoAgentId", "avatarId", "avatarName", "sessionId"?, "sandbox"?, "error"? }. mode says who ran the conversation (lite: the assistant through the voice server, full: HeyGen's own speech, model and voice); error is set when the avatar could not start or dropped and a Lite call continued as audio. For a Full-mode call assistantId and asst name the video agent itself (there is no assistant), and data holds mode and avatar entries.

Calls are listed from the moment they start. status tells where a call is in its life; it is derived from reason and kind:

statusMeaning
liveThe call is in progress (reason is "In progress"). secs, costN, the summary and the transcript are filled in when it ends; poll the call or wait for the end-of-call-report webhook.
interruptedThe voice process handling the call stopped mid-call (reason is "Interrupted", kind is "fail"). It is billed up to the last moment it was known to be running; its transcript may be incomplete.
completedThe call ended normally (kind is "ok"); reason says how.
failedThe call did not connect or ended in an error (kind is "fail"), for example busy, no answer, or a phone call that hung up before reaching the assistant.
transferredThe assistant transferred the caller to a person or another number (kind is "transfer").
voicemailThe number was closed and the caller reached its voicemail (kind is "voicemail", type "Inbound"): secs is the message length, the transcript is one "Caller" line when it was transcribed, and reason is "No message left" when the caller hung up first.
GET/api/v1/calls

Calls, newest first, without transcripts.

QueryMeaning
assistantIdOnly calls answered by this assistant.
sinceOnly calls started at or after this time. ISO-8601 (2026-10-01T00:00:00Z) or a unix timestamp in seconds or milliseconds.
limit1–200, default 50. The response's total and hasMore tell you whether more matched.
cursorThe previous page's nextCursor (see Pagination).
curl -s "$VOXA_URL/api/v1/calls?assistantId=asst_7f3a91c2&limit=20&since=2026-10-01T00:00:00Z" \
  -H "Authorization: Bearer $VOXA_KEY"
{
  "data": [
    { "id": "call_9a1", "assistantId": "asst_7f3a91c2", "asst": "Riley — Front desk", "customer": "+1 415 555 0134",
      "type": "Inbound", "reason": "Booking", "kind": "ok", "status": "completed", "secs": 192, "startedAt": "2026-10-03T14:33:10.000Z",
      "costN": 0.21, "summary": "...", "data": [ { "k": "Appointment", "v": "Tue 10:30" } ], "evalPass": true,
      "recordingUrl": "/api/v1/calls/call_9a1/recording" }
  ],
  "count": 1, "total": 1, "hasMore": false, "nextCursor": null
}
GET/api/v1/calls/{id}

One call including transcript: an array of { "who": "Assistant" | "Caller", "text": "..." }.

curl -s "$VOXA_URL/api/v1/calls/call_9a1" -H "Authorization: Bearer $VOXA_KEY"
GET/api/v1/calls/{id}/recording

The call recording as audio/wav (16-bit PCM, mono, 24 kHz). Supports HEAD. 404 when the call has no recording. Where the recording lives decides the answer:

  • Bucket storage (STORAGE_BUCKET set): 302 Found with a Location of a signed URL on the bucket, valid for at least 15 minutes (twice the call's duration for long calls). Follow the redirect (curl -L; fetch follows by default) and do not store the signed URL. Range requests are served by the bucket. If the console cannot sign a URL it streams the file itself instead.
  • Local disk: the console streams the file itself, with single byte-range (Range) support.

?download=1 asks for an attachment named {id}.wav (Content-Disposition: attachment, also on the signed URL).

curl -sL "$VOXA_URL/api/v1/calls/call_9a1/recording" -H "Authorization: Bearer $VOXA_KEY" -o call_9a1.wav
A browser client that follows the redirect (an <audio> element with crossorigin, or fetch from your own trusted tool) reads the file from the bucket's origin, so the bucket needs a CORS policy that allows your origins, GET/HEAD and the Range header: see "File storage" in the console's README.md. Server-side clients such as curl need no CORS. The signed URL carries its own authorization: do not resend your API key to the bucket (curl -L and browsers drop the Authorization header on a redirect to another host; HTTP clients configured to keep it should not).
POST/api/v1/web-calls

Start a browser call from your own backend: returns a short-lived call token and the voice server's WebSocket URL. Hand both to the browser, which connects with src/lib/voice/web-call.ts or the widget. The token is valid for expiresIn seconds (300). Needs the write scope; answers 402 when the organization has no credits left or has reached its monthly limit. With a test key the call is a free test call ("test": true, see Test mode) and 402 means the test budget is used up.

curl -s -X POST "$VOXA_URL/api/v1/web-calls" \
  -H "Authorization: Bearer $VOXA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "assistantId": "asst_7f3a91c2", "customerId": "cus_8f2k1", "variableValues": { "name": "Sam", "phone": "+14155550177" } }'
{ "token": "eyJhc3Npc3RhbnRJZCI6...", "url": "ws://localhost:3001", "expiresIn": 300, "assistantId": "asst_7f3a91c2" }
customerId (optional, 1 to 128 characters of A-Z a-z 0-9 . _ : @ -) names the caller in your own system. variableValues (optional, strings, at most 20 keys and 2 KB) fill the assistant's {{name}} placeholders. Both come from your server, so the token is marked trusted: caller verification (send_verification_code with to: "on_file") may text the number under phone, phone_number, mobile, mobile_number, cell, customer_phone, caller_phone or callback_number. Variables a web page sends with the public key (widget, web SDK) are never texted, nor anything the caller says during the call.
GET/api/video/agents/{id}?publicKey=pk_...

Video agents (Build → Video agents in the console) show a HeyGen avatar to the caller. They are started from the browser with the organization's public key, not with a secret key: the routes below live outside /api/v1, answer CORS preflights and follow the widget's rules (Published agents only, the site must be among the organization's allowed origins or the list left empty, rate limits per address and per key, 402 without credits). The web SDK (guide, voxa.startVideo(id, { element })) wraps all of them; call them directly only when you cannot use it.

RouteWhat it does
GET /api/video/agents/{id}?publicKey=The agent's public facts: { "id", "name", "mode": "lite" | "full", "sandbox", "avatarName", "avatarPreviewUrl"?, "interactivity"? ("conversational" | "push_to_talk", Full only), "status": "Published" }. 404 for unknown, foreign or draft agents. Tells a client which of the two paths below to take.
POST /api/voice/token
{ "publicKey", "videoAgentId", "via": "sdk", "variableValues"? }
Lite mode. A call token for the voice server exactly as for an audio call, plus videoAgentId and video: { "avatarName", "sandbox", "mode": "lite" }; the token names the avatar. The browser streams its microphone to url as usual; once the avatar is up the voice server sends { "type": "VideoReady", "sessionId", "livekitUrl", "livekitToken", "avatarName", "sandbox", "maxSec"? } on the call socket and stops sending PCM audio: the avatar's video and lip-synced voice come from that LiveKit room. VideoError { "message" } means the call continues as audio; VideoEnded that the avatar left. A Full-mode agent answers 400 with reason: "full_mode".
POST /api/video/sessions
{ "publicKey", "videoAgentId", "via": "sdk" }
Full mode. HeyGen runs speech recognition, the model and the voice. Answers 201 { "callId", "sessionId", "sessionToken", "avatarName", "sandbox", "maxSec"?, "interactivity", "openingText", "ratePerMinute", "auth", "mode": "full" }: start the session in the browser with HeyGen's web SDK (new LiveAvatarSession(sessionToken)), which joins the room and publishes the microphone itself. The call record is written "In progress" at this point. 400 for a Lite agent, 409 when video agents are not enabled on the deployment or the agent's prompt is not saved at HeyGen, 402 without credits (sandbox agents are free and exempt).
POST /api/video/sessions/{callId}/aliveHeartbeat, every 30 seconds while the session runs, with header x-voxa-session-auth: <auth>. A call that stops heartbeating for three minutes is closed as interrupted and billed up to its last heartbeat.
POST /api/video/sessions/{callId}/end
{ "reason": "user" | "stopped" | "error", "stopReason"?, "transcript"? }
Ends the call (same header; auth may travel in the body for navigator.sendBeacon, which sends text/plain). The console stops the HeyGen session, imports the transcript from HeyGen (your transcript lines are the fallback), charges the organization and finishes the record; answers 200 { "call" } and is idempotent.
curl -s "$VOXA_URL/api/video/agents/vide_a88d176f?publicKey=pk_..."
{ "id": "vide_a88d176f", "name": "Sam (Full mode sandbox)", "mode": "full", "sandbox": true, "avatarName": "Wayne (sandbox)", "interactivity": "conversational", "status": "Published" }
Pricing follows the mode: a Lite call costs the assistant's rate plus the avatar's per-minute charge, a Full call one all-in per-minute rate; sandbox agents (the sandbox avatar, about one minute per session) are free. maxSec is the session length HeyGen granted for the deployment's plan; the avatar leaves when it is reached and a Lite call continues as audio. Phone calls and POST /api/v1/web-calls stay audio-only.

Resources

Lists of the other objects configured in the console, plus the campaign actions. Each list returns { "data": [...], "count", "total", "hasMore", "nextCursor" } and pages with limit and cursor.

GET/api/v1/files

Knowledge files: id, name, size, kb (knowledge base), status, added, chars. Metadata only; the content is not returned.

curl -s "$VOXA_URL/api/v1/files" -H "Authorization: Bearer $VOXA_KEY"
GET/api/v1/tools

Tools assistants can call: id, name, type, desc, used, updated, parameters, url, method, timeoutMs. Request headers often hold credentials and are never returned; headerCount says how many are set.

curl -s "$VOXA_URL/api/v1/tools" -H "Authorization: Bearer $VOXA_KEY"
GET/api/v1/numbers

Phone numbers: id, num, label, asst (what answers it), prov.

curl -s "$VOXA_URL/api/v1/numbers" -H "Authorization: Bearer $VOXA_KEY"
GET/api/v1/campaigns

Outbound campaigns: id, name, asst, assistantId, from, done, total, status, when. The contact list (personal data) is not returned; contactCount says how many contacts the campaign has. Calling hours: callingWindow ({ start, end, days }, wall-clock HH:MM, days 0 = Sunday … 6, or null for any time), timeZoneSource (contact or campaign), timeZone; the dialer's counters skippedOutsideHours, skippedDnc and nextWindowAt.

curl -s "$VOXA_URL/api/v1/campaigns" -H "Authorization: Bearer $VOXA_KEY"
POST/api/v1/campaigns/{id}/contacts

Appends contacts to a Paused or Scheduled campaign (write scope, live keys only; test keys answer 403 test_mode_phone). Body: { "contacts": [{ "phone", "vars"? }] }, at most 10,000. Phones are cleaned like the console's; numbers your organization may not dial (dial policy), repeats and phones already on the campaign are skipped. Answers { "added", "skipped", "total" }; 409 when the campaign is Running or Completed. The numbers stay out of the activity log (counts only).

curl -s -X POST "$VOXA_URL/api/v1/campaigns/camp_3c1/contacts" -H "Authorization: Bearer $VOXA_KEY" \
  -H "content-type: application/json" -d '{"contacts":[{"phone":"+14155550100","vars":{"name":"Ada"}}]}'
POST/api/v1/campaigns/{id}/start

Starts the campaign's outbound calls now, with the console's checks: Twilio connected, credits and the monthly limit (402), the assistant and the caller ID (400); per contact the calling window (08:00 to 21:00 callee time) and the do-not-call list. Every dial spends credits. Answers { "campaign": { "id", "name", "status", "total", "done" }, "simulated" }. Live keys only.

curl -s -X POST "$VOXA_URL/api/v1/campaigns/camp_3c1/start" -H "Authorization: Bearer $VOXA_KEY"
GETPOST/api/v1/dnc

The organization's do-not-call list. Campaigns never dial a listed number (the contact ends as dnc). Entries: id, number (E.164), reason, source (manual, import, api, assistant), addedBy, at. GET lists newest first (?q= matches digits or the reason; limit/cursor paging). POST { number, reason? } (write scope) answers 201 with the entry, 200 with the stored entry when the number is already listed, 400 for a number that is not a phone number. GET /api/v1/dnc/{id} reads one; DELETE /api/v1/dnc/{id} removes it (204).

curl -s -X POST "$VOXA_URL/api/v1/dnc" -H "Authorization: Bearer $VOXA_KEY" \
  -H "content-type: application/json" -d '{"number":"+14155550100","reason":"Opted out by email"}'
GET/api/v1/knowledge-bases

Knowledge bases: id, name and the other stored fields, plus fileCount (files whose kb is this base's name).

curl -s "$VOXA_URL/api/v1/knowledge-bases" -H "Authorization: Bearer $VOXA_KEY"
{ "data": [ { "id": "kb_front", "name": "Front desk", "fileCount": 3 } ], "count": 1, "total": 1, "hasMore": false, "nextCursor": null }
GET/api/v1/me

The key making the request and its organization. Handy for checking a key works.

curl -s "$VOXA_URL/api/v1/me" -H "Authorization: Bearer $VOXA_KEY"
{ "key": { "id": "keys_3f9c", "name": "Production server", "prefix": "sk_live_", "created": "Oct 4, 2026", "lastUsedAt": "2026-10-04T15:18:03.871Z", "scopes": ["read", "write"] },
  "org": { "id": "org_1", "name": "Acme Dental" } }
GET/api/v1/analytics/summary

Call totals of the organization for a window: from / to (ISO-8601 or unix time; default the last 30 days, at most 366) and an optional assistantId. Counts, minutes, cost (dollars), success rate, statuses, call types, end reasons, evaluation pass rate, sentiment and per-assistant totals. Test calls are left out and counted in testCalls; calls still in progress count toward calls only. Reads at most 20,000 calls (then truncated: true). Scope read.

curl -s "$VOXA_URL/api/v1/analytics/summary?from=2026-10-01" -H "Authorization: Bearer $VOXA_KEY"
{ "from": "2026-10-01T00:00:00.000Z", "to": "2026-10-16T12:00:00.000Z", "assistantId": null, "testCalls": 3,
  "calls": 412, "finished": 410, "minutes": 1285.4, "avgDurationSec": 188, "cost": 154.25, "successRate": 93.2,
  "status": { "live": 2, "interrupted": 1, "completed": 371, "failed": 28, "transferred": 9, "voicemail": 1 },
  "byType": { "Inbound": 301, "Outbound": 88, "Web": 23 },
  "evaluation": { "evaluated": 390, "passed": 351, "passRate": 90 }, "sentiment": { "scored": 380, "avg": 0.41 },
  "topReasons": [{ "reason": "Customer ended call", "calls": 290 }],
  "assistants": [{ "id": "asst_7f3a91c2", "name": "Front desk", "calls": 300, "minutes": 960.2, "cost": 115.1 }] }
GET/api/v1/logs

The organization's log entries (the console's Logs page), newest first: callId, since, until, level and kind (comma lists) and limit (1..500, default 100). Contexts are redacted (secrets, phone numbers, card numbers, query strings); the person behind a console request is never named. Page back with until = nextUntil; follow new entries with since and de-duplicate by id (what voxa logs tail does). Scope read.

curl -s "$VOXA_URL/api/v1/logs?callId=call_123&level=warn,error" -H "Authorization: Bearer $VOXA_KEY"
{ "data": [{ "id": "log_9a1", "ts": "2026-10-16T10:00:04.120Z", "level": "warn", "kind": "event", "message": "call.tool_failed", "source": "voice", "context": { "callId": "call_123", "tool": "book" } }],
  "count": 1, "hasMore": false, "nextUntil": null }

Developer tools

Packages in the repository's packages/ folder build on this API. Each has its own README.

MCP server (@voxa/mcp)

Manage assistants from Claude, Cursor or any Model Context Protocol client: list, create, update and publish assistants, start test calls, read calls, analytics and logs, add campaign contacts and start campaigns. It runs locally over stdio with a secret key; the API enforces the key's scopes and test-key limits, and destructive tools (update_assistant, start_campaign) carry destructiveHint. Start with a test key.

claude mcp add voxa -e VOXA_API_KEY=sk_test_... -e VOXA_BASE_URL=$VOXA_URL -- npx -y @voxa/mcp

CLI (@voxa/cli)

voxa login stores the key in the OS config directory (readable by you only; never echoed). voxa assistants deploy -f assistants.yaml creates or updates assistants by id or name after showing a diff (--yes in CI); voxa calls list|get, voxa logs tail --call <id> and voxa test-call <assistant> (prints the web call URL).

Python (voxa)

Sync and asyncio clients on httpx with typed models, pagination (list_all()), one exception per error status (fields, reason, retry_after) and voxa.webhooks.construct_event() for signatures.

from voxa import Voxa
client = Voxa(api_key="sk_live_...", base_url="https://console.example.com")
call = client.web_calls.create("asst_7f3a91c2")

Mobile SDKs (preview)

React Native (@voxa/react-native, with a pluggable PCM audio module and a react-native-audio-api adapter), iOS (Swift Package, AVAudioEngine) and Android (Kotlin, AudioRecord / AudioTrack). They speak the web SDK's protocol: a token from POST /api/voice/token with the public key, or a ticket your server mints with POST /api/v1/web-calls (startWithTicket), then the voice WebSocket with 16 kHz PCM16 up and 24 kHz PCM16 down. Apps send no Origin: when the organization restricts allowed origins, configure the SDK's origin option.

Webhooks

Voxa POSTs JSON events about calls to the assistant's serverUrl and to the organization's server URL (Organization settings), when set. Destinations must be public http(s) URLs; private and internal addresses are skipped. Each event is tried up to 3 times (after 1 s and 3 s) until your endpoint answers 2xx; a 3xx or 4xx answer stops the retries, and redirects are not followed. Every attempt shows on the console's Webhooks log.

Every body has event, timestamp (ISO-8601) and assistantId, plus the event's fields. Requests carry these headers:

HeaderValue
x-voxa-eventThe event name.
x-voxa-timestampUnix time in seconds when the request was signed.
x-voxa-signaturesha256= followed by the hex HMAC-SHA256 of ${timestamp}.${rawBody}, keyed with your organization's webhook secret.

Event payloads

EventWhenFields
status-updateA call connects.callId, status ("in-progress"), type (Inbound, Outbound, Web), customer.
transcriptNew transcript lines during the call.callId, lines: [{ "who": "Assistant" | "Caller", "text": "..." }].
tool-callsThe assistant called one of its tools. (The request to the tool's own URL is described under Tool requests.)callId, name, arguments, result.
end-of-call-reportThe call ended and was saved.call: the call record as GET /calls/{id} returns it (customer redacted for HIPAA assistants).
analysisThe post-call analysis finished (after end-of-call-report).callId, summary, data (an object keyed by the assistant's analysis schema keys; a value the model could not find is null), evaluation ({ "type": "pass" | "numeric" | "descriptive", "pass": bool, "score"?, "text"? }), analyzedAt (ISO-8601).
billing.thresholdThis month's usage reached one of the organization's usage alert thresholds (default 80% and 100% of the monthly limit; once per month each). Sent to the organization's server URL only.threshold (percent), monthUsed and monthlyLimit (dollars), month (YYYY-MM).
suite.finishedA test suite run finished (completed, or failed after at least one case), started from the console, by a publish of the suite's assistant, or through the API.suiteId, name, run: { "id", "at", "trigger": "manual" | "publish" | "api", "status": "done" | "failed", "passed", "failed", "total", "passRate" (0..1), "previousPassRate"?, "regressed", "fingerprint" (the assistant's configuration at run start), "assistantId", "charged" (dollars), "error"? }.
voicemail.receivedA caller left a voicemail on a closed number (business hours with the voicemail after-hours rule). Sent to the server URL of the number's assistant (when it has one) and the organization's; the transcript needs the organization's own Deepgram key.callId, from (the caller), to (your number, E.164), durationSec, transcript (text or null), recordingUrl (/api/v1/calls/{id}/recording, or null when the recording could not be fetched with the organization's Twilio credentials).
call.endedAutomation hooks only (see Automation): a call ended. Not for test calls.callId, assistantId, assistantName, type, customer (redacted under HIPAA), status, endedReason, startedAt, durationSeconds, summary, recordingUrl, consoleUrl. No transcript.
booking.createdAutomation hooks only: the assistant booked an appointment in a connected calendar.callId, booking: { "provider", "integrationId", "eventId", "start", "end", "title", "timeZone", "link"? }.
campaign.finishedAutomation hooks only: every contact of a campaign settled.campaignId, name, total, dialed, outcomes (outcome to count), finishedAt. Never the numbers.
lead.createdAutomation hooks only: the CRM automation or the assistant's CRM tool created a lead.callId, provider (hubspot, salesforce, pipedrive), recordId, recordType, leadId? (Pipedrive), via (post-call or tool). Read the person from your CRM.
report.readyA scheduled daily or weekly report was built (console: Organization settings, Reports, with the webhook option on). Sent to the organization's server URL only; no assistantId.reportId, period: { "from", "to", "timeZone", "kind": "daily" | "weekly", "label" } (ISO-8601 local midnights; to is exclusive), summary: { "calls", "minutes", "cost" (dollars), "passRate" (0..1 or null) }, roi? (when the organization set its ROI values: { "settings", "current", "previous", "pct" }). Finished, non-test calls only.
payment.succeededA payment during a call was paid: a payment link the assistant sent (collect_payment, your own Stripe account, reported by your Stripe webhook to Voxa) or keypad card entry (collect_card_by_keypad, Twilio <Pay>). Never carries a card detail, the link or the caller's number. Test calls: test URL only.callId, payment: { "id": "pay_…", "amount": 4500, "currency": "usd", "status": "paid", "method": "link" | "keypad", "description", "confirmation", "testMode", "at" } (amount in minor units).
payment.failedA payment link expired unpaid or its payment failed, or keypad card entry ended without a payment.callId, payment as above with status failed or expired and reason (e.g. expired, payment_failed, too-many-failed-attempts, cancelled).
sms.text_backA phone number with missed-call text-back on texted a caller nobody answered (hung up before the agent answered, not answered, or closed after hours), or the send failed. Sent from the number through the organization's own Twilio account; never for HIPAA numbers, withheld callers, numbers on the do-not-call list, during quiet hours (21:00 to 08:00 caller time) or twice to a caller within 24 hours.callId, numberId, from (your number, E.164), to (the caller), trigger (abandoned, unanswered, after_hours), status (sent or failed), sid and sentAt (sent) or error (failed).
POST /your/webhook HTTP/1.1
content-type: application/json
x-voxa-event: analysis
x-voxa-timestamp: 1791043200
x-voxa-signature: sha256=5f1c0e...

{ "event": "analysis", "timestamp": "2026-10-03T14:40:00.000Z", "assistantId": "asst_7f3a91c2",
  "callId": "call_9a1", "summary": "Booked a cleaning for Tuesday 10:30.",
  "data": { "appointment": "Tue 10:30", "insurance": null }, "evaluation": { "type": "pass", "pass": true },
  "analyzedAt": "2026-10-03T14:39:58.120Z" }

Tool requests

When the assistant calls a custom tool (Function / API request tools on the console's Tools page), Voxa sends the tool's URL (or the assistant's server URL) a request with the headers x-voxa-event: tool-calls and x-voxa-tool. A POST tool gets the JSON body { "tool", "arguments", "callId", "assistantId", "orgId", "verified" }; a GET tool gets the arguments as query parameters followed by tool, callId, assistantId, orgId and verified (JSON), which an argument of the same name cannot replace. The response body (at most 2000 characters) is what the assistant is told.

verified is the caller's verified identity on this call, or null when the caller has not been verified: { "at" (ISO-8601), "method": "sms", "to" (masked, "•••0123"), "number" (E.164; absent on HIPAA calls), "source": "caller" | "contact" | "variables" }. arguments come from the model, which repeats what the caller says: bind account lookups and changes to verified.number, never to an account number, name or phone number in the arguments alone, and refuse a request whose verified is null when the action needs a verified caller (marking the tool "Requires a verified caller" keeps the assistant from calling it before verification, but your server is the one that must check). The code goes only to a number from a trusted source: the caller's own line on phone calls, or variables your server supplied (campaigns, outbound calls, POST /web-calls).

Verifying signatures

Each organization has its own signing secret: whsec_ followed by 32 letters and digits. It is shown, and can be rotated, on the console's API keys page. From a signed-in console session (header x-voxa-org), GET /api/keys/webhook-secret returns { "secret": "whsec_…" }, creating the secret on first use, and POST /api/keys/webhook-secret rotates it: the new secret signs every request from then on, so update your receiver right away. The platform secret configured on the server (WEBHOOK_SECRET) is only a fallback for organizations that never revealed or rotated theirs; without either, requests are sent unsigned.

  1. Read the raw request body as bytes, before any JSON parsing.
  2. Compute the hex HMAC-SHA256 of x-voxa-timestamp, a literal ., and the raw body, with your secret.
  3. Compare it with the hex after sha256= in x-voxa-signature in constant time.
  4. Reject requests whose timestamp is more than 5 minutes from your clock, so a captured request cannot be replayed later. Retries are signed again with a fresh timestamp; deduplicate on callId plus event if you need exactly-once handling.
import crypto from "node:crypto";

export function verifyVoxa(rawBody, headers, secret) {
  const ts = headers["x-voxa-timestamp"];
  const sig = (headers["x-voxa-signature"] || "").replace(/^sha256=/, "");
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(ts + "." + rawBody).digest("hex");
  const a = Buffer.from(sig, "hex"), b = Buffer.from(expected, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Automation: Zapier, Make, n8n

No app to install: automation platforms use REST hooks. A zap, scenario or workflow subscribes a URL to one event with an API key; Voxa POSTs each event there, signed like every webhook (x-voxa-signature, see Verifying signatures), with the same retries and retry queue. Turning the automation off unsubscribes it. Events: call.ended, booking.created, voicemail.received, campaign.finished, lead.created (payloads under Event payloads). Test calls never reach hooks. Admins see and remove subscriptions on the console's Providers page (Automation).

Actions an automation can take with the same key: add a contact to a campaign, start a campaign's calls, start a browser call, add a number to the do-not-call list, find calls and change an assistant. Use a live key with the scopes it needs; test keys cannot subscribe or dial.

GETPOSTDELETE/api/v1/hooks

POST { "event", "url" } (write scope) subscribes and answers 201 { "id", "event", "url", "createdAt" }; the same URL and event again answers 200 with the existing subscription. The URL must be public https (checked again at every delivery); at most 50 subscriptions per organization (409). GET lists them (limit/cursor), GET /api/v1/hooks/{id} reads one. DELETE /api/v1/hooks/{id} unsubscribes one ({ "id", "deleted": true }); DELETE /api/v1/hooks with { "id" } or { "url", "event"? } (body or query) removes by id or URL ({ "deleted": n }).

curl -s -X POST "$VOXA_URL/api/v1/hooks" -H "Authorization: Bearer $VOXA_KEY" \
  -H "content-type: application/json" -d '{"event":"call.ended","url":"https://hooks.zapier.com/hooks/standard/123/abc/"}'
{ "id": "hook_4f1c9a0b2d3e5f6a7b8c9d0e", "event": "call.ended", "url": "https://hooks.zapier.com/hooks/standard/123/abc/", "createdAt": "2026-10-15T09:00:00.000Z" }

curl -s -X DELETE "$VOXA_URL/api/v1/hooks/hook_4f1c9a0b2d3e5f6a7b8c9d0e" -H "Authorization: Bearer $VOXA_KEY"

Setup examples

Zapier (a private app in the Zapier Developer Platform): authentication API Key sent as Authorization: Bearer {{bundle.authData.apiKey}}, tested with GET /api/v1/me. For each trigger choose REST Hook:

// performSubscribe
const r = await z.request({ method: "POST", url: `${bundle.authData.baseUrl}/api/v1/hooks`,
  body: { event: "call.ended", url: bundle.targetUrl } });
return r.data;                              // keeps { id } for the unsubscribe
// performUnsubscribe
await z.request({ method: "DELETE", url: `${bundle.authData.baseUrl}/api/v1/hooks/${bundle.subscribeData.id}` });
// perform (the delivered event)
return [bundle.cleanedRequest];
// performList (sample data while building the zap)
const calls = await z.request({ url: `${bundle.authData.baseUrl}/api/v1/calls?limit=3` });
return calls.data.data.map((c) => ({ callId: c.id, ...c }));

Actions: Add campaign contact calls POST /api/v1/campaigns/{id}/contacts; Start campaign calls POST /api/v1/campaigns/{id}/start.

Make: add a Webhooks, Custom webhook module, copy its address, and subscribe it once with an HTTP, Make a request module (or curl): POST /api/v1/hooks with the address as url. To stop, DELETE /api/v1/hooks?url=<the address>. Actions use HTTP, Make a request with the Authorization: Bearer header.

n8n: a Webhook node (method POST) gives a production URL; subscribe it with an HTTP Request node or curl as above. Verify the signature in a Code node with the raw body (turn on Raw Body on the Webhook node) and the function under Verifying signatures. Actions: HTTP Request nodes with a Header Auth credential (Authorization = Bearer sk_live_...).

Versioning

The path prefix is the version. Fields may be added to responses without notice; removing or renaming one, or changing an error shape, would ship as /api/v2.

Recent additions (no version change): REST hooks (/hooks) with the call.ended, booking.created, campaign.finished and lead.created events; campaign contacts and campaign start; the crm value of an assistant's tools; the video field on calls and the public video agent routes; the analysis, billing.threshold, suite.finished and report.ready webhooks; the signed-URL answer of GET /calls/{id}/recording on bucket storage; key scopes.