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

Source: https://voicast.jp/en/docs/sdk

> The SDK is about to be released (0.1.0). Until it is on npm, call the [API](https://voicast.jp/en/docs/api.md) directly.

- No dependencies; it uses `fetch` and Web Crypto.
- Runs on Node.js 18+, Bun, Deno and Cloudflare Workers. `voicast/web` runs in the browser.
- ESM and CommonJS, with TypeScript types.

## Install

```bash
npm install voicast
```

## Quick start

```ts
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` parameter
```

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

```ts
// 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 };
```

```ts
// 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](https://avacast.jp/).

```ts
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

```ts
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 `WebhookVerificationError` when 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:

```ts
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):

```ts
const event = await verifyWebhook(await req.text(), req.headers, secret);
```

## Verifying tool calls

```ts
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](https://voicast.jp/en/docs/tools.md)).

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

```ts
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 |

```ts
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](https://voicast.jp/en/docs/api.md) directly and verify signatures with the official Standard Webhooks libraries ([Results](https://voicast.jp/en/docs/results.md#verifying-signatures)).
