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
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.
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
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 deploymentmaxRetries: 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 happennpx -p @pendral/vocalis vocalis tail --call <callId> --jsonnpx -p @pendral/vocalis vocalis listen --forward-to http://localhost:3000/webhooks/vocalisnpx -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.--jsonprints each event in full. - request
- Calls any endpoint with your key, e.g. to try one out before writing code for it.