Working with the API

TypeScript SDK

@pendral/vocalis wraps every public endpoint in typed methods, follows calls live, verifies webhooks and retries safely. It also ships the vocalis CLI.

Install

npm install @pendral/vocalis

Runs on Node.js 20 or later, Bun, Deno and edge runtimes: anything with fetch and Web Crypto. ESM and CommonJS, no runtime dependencies. TypeScript 5 or later if you use TypeScript.

Authenticate

Create a key under Settings → API Keys and set it as VOCALIS_API_KEY. The client reads it from the environment. A key acts in one organization and can be narrowed to the scopes your integration needs; see authentication.

import Vocalis from "@pendral/vocalis";
const vocalis = new Vocalis(); // reads VOCALIS_API_KEY

Keep keys on your server

The client refuses to start in a browser unless you pass dangerouslyAllowBrowser: true. A key in a page is readable by anyone who opens it.

Place a call

Trigger a call from an API campaign. The response comes back as soon as the call is queued.

const call = await vocalis.campaigns.trigger("cmp_...", {
phoneNumber: "+14155551234",
calleeName: "Dr. Jane Smith",
variables: { memberId: "M-10293" },
});
console.log(call.callId, call.status); // "queued"

The request fields are the same as the HTTP API's; see triggering calls.

Follow a call live

Stream a call's events while it happens. The stream resumes from the last event if the connection drops, and narrowing on event.type types event.data.

const call = await vocalis.campaigns.trigger(campaignId, { phoneNumber: "+14155551234" });
const { callId } = call;
for await (const event of vocalis.calls.events(callId, { until: (e) => e.type === "call.analysis.completed" })) {
switch (event.type) {
case "call.started":
console.log("connected", event.data.direction);
break;
case "call.transcript.updated":
console.log("transcript", event.data);
break;
case "call.ended":
console.log("ended after", event.data.durationSeconds, "seconds");
break;
}
}
const transcript = await vocalis.calls.transcript(callId);
const details = await vocalis.calls.get(callId);

To follow everything in your organization instead:

for await (const event of vocalis.events.stream({ types: ["call.ended", "helpdesk.conversation.created"] })) {
console.log(event.type, event.id);
}

Store event.id and pass it back as lastEventId to pick up exactly where you left off after a restart.

Webhooks

Create an endpoint once and keep its secret. The secret is returned once, at creation.

const endpoint = await vocalis.webhookEndpoints.create({
name: "Production",
url: "https://example.com/webhooks/vocalis",
events: ["call.ended", "call.analysis.completed"],
});
// endpoint.secret is shown once. Store it as VOCALIS_WEBHOOK_SECRET.

createWebhookHandler returns a (Request) => Promise<Response> handler for Next.js route handlers, Hono, Bun, Deno and Workers. It verifies the signature before your code runs.

app/webhooks/vocalis/route.ts
import { createWebhookHandler } from "@pendral/vocalis/webhooks";
export const POST = createWebhookHandler({
secret: process.env.VOCALIS_WEBHOOK_SECRET!,
on: {
"call.analysis.completed": async (event) => {
const call = await vocalis.calls.get(event.callId!);
await saveOutcome(call);
},
},
});
400
Unsigned, wrongly signed or stale deliveries. They never reach your code.
204
Your handler resolved, or the event type has no handler.
500
Your handler threw, so the delivery is retried.

For Express or anything else, verify with constructEvent. Pass the raw request body, not parsed JSON: re-serializing changes the bytes and breaks the signature.

import { constructEvent, WebhookVerificationError } from "@pendral/vocalis/webhooks";
app.post("/webhooks/vocalis", express.raw({ type: "application/json" }), async (req: Request, res: Response) => {
try {
const event = await constructEvent(req.body, req.headers, process.env.VOCALIS_WEBHOOK_SECRET!);
res.sendStatus(204);
} catch (error) {
if (error instanceof WebhookVerificationError) return res.status(400).send(error.message);
throw error;
}
});

Use the X-Vocalis-Event-Id header (or event.id) to skip deliveries you have already processed. While rotating a secret, pass both: secret: [newSecret, oldSecret]. The signature scheme itself is described in the webhooks guide.

Pagination

List methods return the first page when awaited, and every item when iterated. The SDK fetches the next page as you go.

const page = await vocalis.calls.list({ status: "completed", pageSize: 50 });
console.log(page.data.length, page.hasNextPage(), page.raw.summary);
for await (const call of vocalis.calls.list({ status: "completed" })) {
console.log(call.id);
}
const recent = await vocalis.people.list().toArray({ max: 500 });

Errors

8 classes

Every failure is a VocalisError carrying status, code, message, requestId and details. The code is the one from the error envelope; subclasses let you branch on the kind of failure.

import { RateLimitError, ValidationError, NotFoundError } from "@pendral/vocalis";
try {
await vocalis.campaigns.trigger(campaignId, { phoneNumber: "not a number" });
} catch (error) {
if (error instanceof ValidationError) console.error(error.details);
else if (error instanceof NotFoundError) console.error("No such campaign");
else if (error instanceof RateLimitError) console.error(`Retry in ${error.retryAfter}s`);
else throw error;
}
400, 422ValidationError
The request failed validation. details lists each problem.
401AuthenticationError
The key is missing, invalid, expired or revoked.
403PermissionDeniedError
The key lacks the scope, or its owner the permission, this call needs.
404NotFoundError
The object does not exist, or belongs to an organization the key cannot reach.
409ConflictError
The object is not in a state that allows this.
429RateLimitError
Over the key’s limits. retryAfter says when to try again.
5xxInternalServerError
Something failed on our side. Quote requestId to support.
noneAPIConnectionError, APITimeoutError
The request never got a response.

Retries and idempotency

Rate limits, server errors and network failures are retried twice with backoff, honouring Retry-After. Every POST sends an Idempotency-Key and its retries reuse it, so the API runs the request once and answers every repeat with the first result. A retried trigger never places a second call.

Pass your own key to make an operation idempotent across your own retries, for example a job that may run twice:

await vocalis.campaigns.trigger(campaignId, { phoneNumber }, { idempotencyKey: `reminder-${appointmentId}` });

How the API treats a repeated key is in retrying safely.

Configuration

const vocalis = new Vocalis({
apiKey: process.env.VOCALIS_API_KEY,
baseUrl: "https://vocalis.io", // only for a dedicated deployment
maxRetries: 2,
timeoutMs: 60_000,
});
apiKey
Your API key.Default: VOCALIS_API_KEY
integrationToken
For sessions, from a contact-center integration.Default: VOCALIS_INTEGRATION_TOKEN
baseUrl
Must be HTTPS. Only set it for a dedicated deployment.Default: VOCALIS_BASE_URL, then https://vocalis.io
maxRetries
Per request. Override per call with { maxRetries }.Default: 2
timeoutMs
Per attempt. Override per call with { timeoutMs }.Default: 60000
logLevel
off, error, warn, info or debug. Never logs keys or bodies.Default: VOCALIS_LOG, then warn
fetch
Supply your own for proxies, instrumentation or tests.Default: global fetch
defaultHeaders
Sent with every request. Cannot replace the credential.Default: none

Every call also takes { signal, timeoutMs, maxRetries, idempotencyKey, headers } as its last argument. Use withResponse() for the raw response, request id and rate-limit state:

const { data, requestId, rateLimit } = await vocalis.calls.get(callId).withResponse();

An endpoint newer than your SDK

vocalis.request("calls.get", { path: { callId } }) calls any operation by id, and vocalis.raw("GET", "/api/v1/...") calls any path, both with the client's credentials, retries and errors.

Command line

The package includes the vocalis CLI. It reads VOCALIS_API_KEY like the client does.

npx -p @pendral/vocalis vocalis tail # print events as they happen
npx -p @pendral/vocalis vocalis tail --call <callId> --json
npx -p @pendral/vocalis vocalis listen --forward-to http://localhost:3000/webhooks/vocalis
npx -p @pendral/vocalis vocalis request GET /api/v1/calls --query status=completed
listen
Forwards live events to a local URL, signed exactly like real webhook deliveries with a secret it prints, so you can build your webhook handler without exposing your machine.
tail
Prints events as they happen, for the organization or for one call with --call. --json prints each event in full.
request
Calls any endpoint with your key, e.g. to try one out before writing code for it.

Where to go next