SDK
The Node.js / TypeScript SDK (voicast on npm): managing agents, tenants and calls, verifying webhooks and tool calls, and starting Web voice sessions in the browser.
The SDK is about to be released (0.1.0). Until it is on npm, call the API directly.
- No dependencies; it uses
fetchand Web Crypto. - Runs on Node.js 18+, Bun, Deno and Cloudflare Workers.
voicast/webruns in the browser. - ESM and CommonJS, with TypeScript types.
Install#
npm install voicastQuick start#
import { Voicast } from 'voicast';
const voicast = new Voicast(process.env.VOICAST_API_KEY); // or omit it to read VOICAST_API_KEY
// What the AI says and asks
const agent = await voicast.agents.create({
name: 'Reception',
config: {
greeting: 'お電話ありがとうございます。さくら歯科です。',
persona: '丁寧で落ち着いた受付',
fields: [
{ key: 'name', label: 'お名前', required: true },
{ key: 'phone', label: '電話番号', type: 'phone' },
],
handoff_when: ['急患', '担当者と話したいと言われた'],
closing: '承りました。担当者から折り返しご連絡します。',
voice: 'Kore',
},
});
// The business that takes the calls (your customer)
const tenant = await voicast.tenants.create({ external_id: 'clinic-123', name: 'Sakura Dental', agent_id: agent.id });
// tenant.secret (vts_…) is returned only here: pass it to your Twilio <Stream> as the `secret` parameterUse the API key on your server only. Never ship it to a browser or an app.
Talking from a browser or an app#
Create a session on your server and pass only client_token, webrtc_url and ice_servers to the browser.
// server
const session = await voicast.sessions.create({ tenant_id: tenant.id }); // output: 'audio' (default) or 'text'
return { client_token: session.client_token, webrtc_url: session.webrtc_url, ice_servers: session.ice_servers };// browser
import { VoiceSession } from 'voicast/web';
const session = new VoiceSession({ clientToken, webrtcUrl, iceServers }); // iceServers: the session's ice_servers
session.on('user-transcript', ({ text, final }) => showUser(text, final));
session.on('bot-output', ({ text }) => showBot(text)); // what the AI said (output: 'audio')
session.on('closed', ({ reason }) => console.log('closed', reason));
await session.start(); // asks for the microphone, then connects
hangupButton.onclick = () => session.stop();With output: 'text' voicast does not speak: each sentence arrives as a reply event, ready for an avatar such as avacast.
session.on('reply', ({ text }) => avatar.speak(text));
session.on('interrupted', () => avatar.interrupt()); // the user started talking
session.on('end', ({ outcome }) => console.log('ended', outcome));| Event | Payload | When |
|---|---|---|
ready |
{} |
The AI is ready |
reply |
{ text } |
A sentence of the reply (output: 'text') |
interrupted |
{} |
The user started talking: stop the current reply |
end |
{ outcome } |
The AI ended the conversation |
user-transcript |
{ text, final } |
What the user said |
bot-output |
{ text } |
A sentence the AI spoke (output: 'audio') |
bot-started-speaking, bot-stopped-speaking |
{} |
The AI started or stopped speaking |
track |
{ stream } |
The AI's audio track. Played automatically unless playAudio: false |
state |
{ state } |
RTCPeerConnection.connectionState changed |
message |
RTVI message | Every message from the voice server |
error |
{ error } |
|
closed |
{ reason } |
ended, hangup, failed, disconnected or error. Emitted once |
Options: mediaStream (your own microphone stream), audioElement, playAudio, iceServers, iceGatheringTimeout, disconnectTimeout, fetch. setMuted(true) mutes the microphone.
After the call, get the result with voicast.calls.getByProvider(session.id).
Verifying webhooks#
import { verifyWebhook } from 'voicast';
const event = await verifyWebhook(rawBody, headers, process.env.VOICAST_WEBHOOK_SECRET!); // whsec_…
switch (event.type) {
case 'call.ended':
console.log(event.data.outcome, event.data.fields, event.data.tenant.external_id);
break;
case 'call.summary_ready':
console.log(event.data.summary, event.data.category);
break;
case 'billing.balance_low':
console.log(event.data.balance_yen);
break;
}- Pass the raw body (string or bytes), not a parsed object.
- Throws
WebhookVerificationErrorwhen the signature does not match or the timestamp is more than 5 minutes off (change it with{ tolerance: 300 }as the fourth argument). - Pass an array of secrets while you switch to a new one.
Express:
app.post('/webhooks/voicast', express.raw({ type: 'application/json' }), async (req, res) => {
try {
const event = await verifyWebhook(req.body, req.headers, secret);
// handle event
res.sendStatus(204);
} catch {
res.sendStatus(400);
}
});Hono or Next.js (App Router):
const event = await verifyWebhook(await req.text(), req.headers, secret);Verifying tool calls#
import { verifyToolCall } from 'voicast';
app.post('/voicast/tools', express.raw({ type: 'application/json' }), async (req, res) => {
const call = await verifyToolCall<{ date: string }>(req.body, req.headers, process.env.VOICAST_TOOLS_SECRET!);
if (call.tool === 'find_free_slots') {
res.json({ slots: await findSlots(call.arguments.date, call.call.tenant.external_id) });
}
});call.tool_call_id (also sent as the idempotency-key header) is unique per tool call; use it so a retried booking is not made twice (Tools).
Methods#
| Method | API |
|---|---|
agents.create({ name, config }) |
POST /v1/agents |
agents.list() |
GET /v1/agents |
agents.get(id, { version? }) |
GET /v1/agents/{id} |
agents.update(id, { name?, config }) |
PUT /v1/agents/{id} (adds a version) |
agents.versions(id) |
GET /v1/agents/{id}/versions |
agents.delete(id) |
DELETE /v1/agents/{id} |
tenants.create({ external_id, name?, agent_id? }) |
POST /v1/tenants |
tenants.list() |
GET /v1/tenants |
tenants.get(id) |
GET /v1/tenants/{id} |
tenants.update(id, { name?, agent_id?, status? }) |
PATCH /v1/tenants/{id} |
tenants.rotateSecret(id) |
POST /v1/tenants/{id}/secret |
tenants.delete(id) |
DELETE /v1/tenants/{id} |
tenants.listConnections(id) |
GET /v1/tenants/{id}/connections |
tenants.createConnectLink(id, { connectors?, frame_origin? }) |
POST /v1/tenants/{id}/connect-links |
calls.list({ tenant_id?, cursor?, limit? }) |
GET /v1/calls (before is still accepted for the first page) |
calls.get(id) |
GET /v1/calls/{id} |
calls.getByProvider(providerCallId) |
GET /v1/calls/by-provider/{providerCallId} |
calls.transcript(id) |
GET /v1/calls/{id}/transcript |
sessions.create({ tenant_id, agent_id?, output? }) |
POST /v1/sessions |
usage.get({ from?, to?, group_by? }) |
GET /v1/usage |
connectors.list() |
GET /v1/connectors |
connections.list({ tenant_id? }) |
GET /v1/connections |
connections.create({ connector, secret, input?, tenant_id? }) |
POST /v1/connections |
connections.get(id) |
GET /v1/connections/{id} |
connections.update(id, { config }) |
PATCH /v1/connections/{id} |
connections.options(id, query?) |
GET /v1/connections/{id}/options |
connections.delete(id) |
DELETE /v1/connections/{id} |
webhooks.createPortalLink({ frame_origin?, locale? }) |
POST /v1/webhooks/portal |
billing.get() |
GET /v1/billing |
billing.createTopup({ amount_yen }) |
POST /v1/billing/topups |
tools.getSecret() |
GET /v1/tools/secret |
tools.rotateSecret() |
POST /v1/tools/secret/rotate |
Every method takes request options as the last argument: { timeout?, maxRetries?, signal? }. billing.get() returns the organization's balance, auto-recharge settings, per-minute price and payment method; billing.createTopup() returns a top-up URL to open in a browser and pay. Auto-recharge, the monthly cap and the card are changed in the dashboard.
Paging through calls#
await gives one page ({ data, has_more, next_cursor }). Pass next_cursor as cursor for the next page, or let for await walk every call, newest first.
const { data, has_more } = await voicast.calls.list({ tenant_id: 'ten_…', limit: 100 });
for await (const call of voicast.calls.list({ tenant_id: 'ten_…' })) {
console.log(call.id, call.outcome);
}Errors#
Every error extends VoicastError and has status, code (such as invalid_request, in_use, balance_depleted), message and details.
| Class | When |
|---|---|
AuthenticationError |
401: the API key is missing, wrong or revoked |
PaymentRequiredError |
402: new calls are refused (balance_depleted, payment_overdue, spending_cap_reached) |
PermissionError |
403 |
NotFoundError |
404 |
ConflictError |
409: duplicate external_id, in_use, tenant_disabled, needs_reauth |
ValidationError |
400, 422. See details |
RateLimitError |
429, after retries. retryAfter in seconds |
ApiError |
Other responses, including 5xx |
ConnectionError, TimeoutError |
No response, or timeout reached |
WebhookVerificationError |
verifyWebhook or verifyToolCall failed |
import { PaymentRequiredError } from 'voicast';
try {
await voicast.sessions.create({ tenant_id });
} catch (e) {
if (e instanceof PaymentRequiredError && e.code === 'balance_depleted') showTopUpNotice();
else throw e;
}Retries and timeouts#
The defaults are new Voicast(key, { timeout: 30_000, maxRetries: 2 }). 429 is retried for every request, waiting retry-after (up to one minute). 5xx, timeouts and network errors are retried only for requests that are safe to repeat (GET, PATCH, DELETE). Creating agents, tenants, sessions and connections, updating an agent and rotating a secret are not retried on those errors, so they never run twice.
Other languages#
SDKs for Python and other languages are planned. Until then, call the API directly and verify signatures with the official Standard Webhooks libraries (Results).