Skip to main content

Node.js SDK

The official Devotel Node.js SDK provides a fully typed client for the Devotel API. Built with TypeScript, it supports all Devotel services — messaging, voice, agents, flows, numbers, and verify.

Installation

npm install @devotel/orbit-sdk
Or with other package managers:
pnpm add @devotel/orbit-sdk
yarn add @devotel/orbit-sdk

Quick Start

import { Devotel } from '@devotel/orbit-sdk';

const orbit = new Devotel({
  apiKey: 'dv_live_sk_your_key_here',
});

// Send an SMS
const message = await orbit.messages.send({
  channel: 'sms',
  to: '+14155552671',
  body: 'Hello from Orbit!',
});

console.log(message.data.id); // msg_abc123

Messaging

// Send a simple SMS via the generic helper (body-only channels).
const sms = await orbit.messages.send({
  channel: 'sms',
  to: '+14155552671',
  body: 'Hello from Orbit!',
});

// Send WhatsApp template — use the typed per-channel method.
const waMessage = await orbit.messages.sendWhatsApp({
  to: '+14155552671',
  type: 'template',
  template: {
    name: 'order_confirmation',
    language: { code: 'en' },
    components: [
      { type: 'body', parameters: [{ type: 'text', text: 'ORD-12345' }] },
    ],
  },
});

// Send Email — use the typed per-channel method.
const email = await orbit.messages.sendEmail({
  to: 'user@example.com',
  from: 'hello@yourdomain.com',
  subject: 'Welcome!',
  html: '<h1>Welcome to Acme</h1>',
});

Voice

The Voice resource exposes call control, recordings, transcripts, conferences, IVR, dialer campaigns, SIP trunks, and analytics. See Voice operations below for the full surface.
// Make an outbound call
const call = await orbit.voice.calls.create({
  to: '+14155552671',
  from: '+18005551234',
  record: true,
});

// Retrieve call details
const callDetails = await orbit.voice.calls.get(call.data.id);
AI-agent / IVR routing and webhooks are not per-call options. Configure them once on the Application / IVR Flow that owns the number — the call then follows that routing. See Voice operations below.

Voice operations

A complete tour of the live call-control surface. Every snippet matches the actual SDK API in packages/sdk-node/src/resources/voice.ts.

Make a call

const call = await orbit.voice.calls.create({
  to: '+14155552671',
  from: '+18005551234',         // optional — falls back to a tenant default
  record: true,                 // record subject to two-party-consent disclaimer
  amd: true,                    // server-side Answering Machine Detection
  campaignId: 'camp_q2_promo',  // spend-cap tracking + call_logs.metadata.campaign_id
  orgTimezone: 'America/New_York', // TCPA federal quiet-hours guard
  metadata: { orderId: 'ord_42' },
});
console.log(call.data.id); // call_abc123
Routing, AI agents, IVR, and webhooks are configured at the Application / IVR-Flow level, not per create() call. voice.calls.create() only accepts to, from, record, amd, campaignId, orgTimezone, and metadata; any other field is silently stripped by the server. Attach the destination number to an Application (or IVR Flow) and the call inherits its agent/IVR routing and webhook configuration.

Get call details

const detail = await orbit.voice.calls.get('call_abc123');
console.log(detail.data.status);   // 'in-progress' | 'completed' | 'failed' | …
console.log(detail.data.duration); // seconds
You can also list with pagination + filters:
const page = await orbit.voice.calls.list({
  direction: 'outbound',
  status: 'completed',
  limit: 50,
});
for (const c of page.data) console.log(c.id, c.duration);

Hang up a call

await orbit.voice.calls.hangup('call_abc123');

Transfer a call

Both cold (blind) and warm (consult-first) transfers are supported on the same transfer() method:
// Cold / blind transfer — REFER straight to the new destination.
await orbit.voice.calls.transfer('call_abc123', {
  to: '+14155552500',
  mode: 'blind',
});

// Warm transfer — consult the agent first, then bridge. The dedicated
// `warmTransfer()` helper accepts an optional AI context whisper that
// reads the call summary to the receiving agent before the bridge.
await orbit.voice.calls.warmTransfer('call_abc123', {
  destination: '+14155552500',
  from: '+18005551234',
  contextWhisper: true,
  context: 'Customer is calling about order #ord_42, refund request.',
});
warmTransfer() forwards its params to the server verbatim, so the field names below are camelCase exactly as shown — the route validates them under those keys:
  • destination — E.164 number to consult-then-bridge to (required).
  • from — optional caller ID presented on the consultation leg.
  • contextWhisper — when true, a short summary is spoken to the receiving agent on answer, before the bridge. Defaults to false.
  • context — optional custom whisper text (max 220 characters). When omitted while contextWhisper is true, a generic summary is spoken.

Conference call

// 1. Create the conference bridge.
const conf = await orbit.voice.conferences.create({
  name: 'support-room-42',
  region: 'us-east',
});

// 2. Add a live call leg by call_id.
await orbit.voice.conferences.addParticipant(conf.data.id, {
  callId: 'call_abc123',
  muted: false,
});

// 3. …or dial out and add a new participant by phone number.
await orbit.voice.conferences.addParticipant(conf.data.id, {
  phoneNumber: '+14155552671',
  coaching: true, // whisper mode — they hear but aren't heard
});

Conference participant controls

Once a conference is live, the moderator can mute, hold, kick, lock, and record from orbit.voice.conferences.*. The participantId is the Jambonz call_sid of the leg (the leg_call_sid field on each row of the conference detail / listParticipants response):
const confId = conf.data.id;
const participantId = 'leg_call_9f2a';

// Per-participant audio control.
await orbit.voice.conferences.muteParticipant(confId, participantId);
await orbit.voice.conferences.unmuteParticipant(confId, participantId);
await orbit.voice.conferences.holdParticipant(confId, participantId);
await orbit.voice.conferences.unholdParticipant(confId, participantId);

// Kick a participant (returns 204).
await orbit.voice.conferences.removeParticipant(confId, participantId);

// Room-wide mute / unmute in one call.
const { data } = await orbit.voice.conferences.muteAll(confId);
console.log(data.toggled, 'of', data.total, 'legs muted');
await orbit.voice.conferences.unmuteAll(confId);

// Lock the room — subsequent addParticipant() calls return 422 LOCKED.
await orbit.voice.conferences.lock(confId);
await orbit.voice.conferences.unlock(confId);

// Record every leg. consentReceiptId is required in two-party-consent
// jurisdictions (UAE/KR/TR + several EU member states).
await orbit.voice.conferences.startRecording(confId, {
  consent_receipt_id: 'crcpt_abc123',
});
await orbit.voice.conferences.stopRecording(confId);

// Terminate the conference — drops every leg, flips status to completed.
await orbit.voice.conferences.end(confId, { end_reason: 'host-ended' });

// Paginate participants (use for rooms with > 32 legs).
for await (const p of orbit.voice.conferences.iterParticipants(confId)) {
  console.log(p);
}

IVR flows

Trigger a published IVR flow as an outbound session, or browse the flow catalog:
// List the tenant's published IVR flows.
const flows = await orbit.voice.ivr.flows.list();
for (const flow of flows.data) console.log(flow.id, flow.name);

// Fetch one flow's definition.
const flow = await orbit.voice.ivr.flows.get('flow_main_menu');

// Start an outbound IVR session targeted at `to`.
const session = await orbit.voice.ivr.flows.trigger({
  flowId: 'flow_main_menu',
  to: '+14155552671',
  from: '+18005551234',
});
console.log(session.data.call_id, session.data.status);

Conversational IVR intents

Per-tenant NLU buckets the LLM classifier uses to route free-speech caller utterances to an ACD queue or a direct agent:
// List every intent bucket (active + inactive).
const intents = await orbit.voice.ivr.intents.list();

// Create an intent. target_queue_id / target_agent_id are mutually
// exclusive; both omitted is valid for analytics-only intents.
const billing = await orbit.voice.ivr.intents.create({
  name: 'billing',
  description: 'Caller wants to pay, dispute, or ask about an invoice.',
  target_queue_id: 'queue_billing',
});

// Partial update — pass null to clear a routing target.
await orbit.voice.ivr.intents.update(billing.data.id, {
  target_agent_id: null,
  active: false,
});

// Delete (returns 204).
await orbit.voice.ivr.intents.remove(billing.data.id);

// Ad-hoc classify an utterance against the tenant's active intents.
const match = await orbit.voice.ivr.intents.test(
  'I need to dispute a charge on my last bill',
  'en',
);
console.log(match.data.matched_intent_id); // null + fallback_reason if no match

Outbound dialer campaigns

Manage power/predictive dialer campaigns. Status changes flow through the server’s campaign state machine — start() and pause() are ergonomic wrappers over the status transition:
// List campaigns.
const campaigns = await orbit.voice.dialer.campaigns.list();

// Create a campaign.
const campaign = await orbit.voice.dialer.campaigns.create({
  name: 'Q3 renewals',
  contactListId: 'list_renewals',
  flowId: 'flow_renewal_pitch',
  concurrency: 8,
  dailyWindow: { start: '09:00', end: '17:00', timezone: 'America/New_York' },
});

// Resume/start — 409 CAMPAIGN_ABORTED on terminal states (do not retry).
await orbit.voice.dialer.campaigns.start(campaign.data.id);

// Pause — in-flight legs drain naturally per the FCC protocol.
await orbit.voice.dialer.campaigns.pause(campaign.data.id);

SIP trunks

// List configured SIP trunks.
const trunks = await orbit.voice.sipTrunks.list();

// Register a new SIP trunk.
const trunk = await orbit.voice.sipTrunks.create({
  name: 'datacenter-east',
  domain: 'sip.acme.example.com',
  ip_addresses: ['203.0.113.10', '203.0.113.11'],
  username: 'acme',
  password: process.env.SIP_TRUNK_PASSWORD!,
});

Voice clones & VAQI analytics

// Phone-enrolled voice clones.
const clones = await orbit.voice.clones.list();
const clone = await orbit.voice.clones.create({
  name: 'agent-aria',
  call_id: 'call_abc123', // enrollment call
});
await orbit.voice.clones.delete(clone.data.id);

// Voice Agent Quality Index rollup for the trailing N days.
const vaqi = await orbit.voice.vaqi.get({ days: 30 });

Record a call

Recording is started by passing record: true at call creation; pause/resume happens via call-control endpoints. After the call ends, fetch the recording metadata and a signed download URL:
// Start recording on call creation:
const call = await orbit.voice.calls.create({
  to: '+14155552671',
  from: '+18005551234',
  record: true,
});

// After the call ends, list recordings for that call:
const recordings = await orbit.voice.recordings.list({ callId: call.data.id });
const recording = recordings.data[0];

// Stop recording is implicit on call hangup; to delete after retrieval:
await orbit.voice.recordings.delete(recording.id);

Listen to a recording

const recording = await orbit.voice.recordings.get('rec_xyz789');
const signed = await orbit.voice.recordings.getDownloadUrl(recording.data.id);
console.log(signed.data.url); // signed GCS URL — valid for ~60 min (1 hour)
console.log(signed.data.expires_at); // ISO 8601 timestamp — refetch once past this

// Download in Node:
const audio = await fetch(signed.data.url).then((r) => r.arrayBuffer());
require('node:fs').writeFileSync('/tmp/call.wav', Buffer.from(audio));

Live transcript stream (Node-only)

const handle = orbit.voice.calls.streamTranscript('call_abc123', {
  onPartial: (frame) => console.log('partial:', frame.speaker, frame.text),
  onFinal: (frame) => console.log('FINAL:', frame.speaker, frame.text),
  onStatus: (s) => console.log('call status:', s.status),
  onError: (e) => console.error(e),
  onEnd: (reason) => console.log('stream ended:', reason),
});
// Tear down when you're done:
// handle.close();

Voice webhook handling

Verify the signature with the shared SDK helper, then dispatch on event.type: The signing secret is the dashboard-issued whsec_... value from your endpoint’s settings (see Webhook Security) — a customer-side secret you store in your own environment, not an Orbit env.ts variable. Name the env var whatever you like; ORBIT_WEBHOOK_SECRET is used here.
import express from 'express';
import { verifyWebhookSignature } from '@devotel/orbit-sdk';

const app = express();
app.use(express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; } }));

app.post('/webhooks/voice', (req, res) => {
  const ok = verifyWebhookSignature(
    (req as any).rawBody,
    req.headers['x-devotel-signature'] as string,
    process.env.ORBIT_WEBHOOK_SECRET!, // dashboard-issued whsec_... value
  );
  if (!ok) return res.status(401).send('invalid signature');

  const event = req.body as { type: string; data: Record<string, unknown> };
  switch (event.type) {
    case 'call.initiated':       /* dial-out started */ break;
    case 'call.ringing':         /* far-end ringing */ break;
    case 'call.answered':        /* bridged */ break;
    case 'call.completed':       /* normal hangup */ break;
    case 'call.failed':          /* signalling failure, busy, no-answer */ break;
    case 'call.recording.ready': /* recording.id is in event.data */ break;
    case 'call.transcript.ready':/* call.transcript.get(id) for full text */ break;
    default:                     /* forward-compat */ break;
  }
  res.status(200).json({ received: true });
});

Verify

// Send OTP
const verification = await orbit.verify.send('+14155552671', 'sms');

// Check OTP
const result = await orbit.verify.check('+14155552671', '482901');

console.log(result.data.status); // 'approved'

Webhooks

// Verify webhook signature in an Express handler
import { verifyWebhookSignature } from '@devotel/orbit-sdk';

app.post('/webhooks/orbit', (req, res) => {
  const isValid = verifyWebhookSignature(
    req.rawBody,
    req.headers['x-devotel-signature'],
    'whsec_your_secret',
  );

  if (!isValid) return res.status(401).send('Invalid signature');

  const event = req.body;
  switch (event.type) {
    case 'message.delivered':
      // Handle delivery confirmation
      break;
    case 'call.completed':
      // Handle call completion
      break;
  }

  res.status(200).json({ received: true });
});

Error Handling

Every non-2xx response is thrown as an OrbitApiError, but the SDK constructs a status-specific subclass so you can branch on the failure mode. Narrow on the subclass first, then fall back to the OrbitApiError base. All of these names are importable from @devotel/orbit-sdk: OrbitApiError (base), OrbitAuthError (401/403), OrbitNotFoundError (404), OrbitRateLimitError (429), OrbitValidationError (400/422), OrbitServerError (5xx), and OrbitWebhookSignatureError (webhook verification — extends Error, not OrbitApiError).
import {
  Devotel,
  OrbitApiError,
  OrbitValidationError,
  OrbitRateLimitError,
} from '@devotel/orbit-sdk'

try {
  await orbit.messages.send({ channel: 'sms', to: 'invalid', body: 'Hi' });
} catch (e) {
  if (e instanceof OrbitValidationError) {
    // 400/422 — request body failed server-side validation.
    console.error(e.code);       // 'INVALID_PHONE_NUMBER'
    console.error(e.statusCode); // 422
    console.error(e.details);    // field-level validation details
  } else if (e instanceof OrbitRateLimitError) {
    // 429 — back off and retry after `Retry-After` (also surfaced in `details`).
    console.error('rate limited, retry later');
  } else if (e instanceof OrbitApiError) {
    // Any other non-2xx (OrbitAuthError, OrbitNotFoundError, OrbitServerError, …).
    console.error(e.statusCode, e.code, e.message);
  } else {
    throw e; // not an SDK API error — rethrow.
  }
}
See the SDK overview for the full error tree shared across every Orbit SDK.

Configuration

OptionTypeDefaultDescription
apiKeystringYour Devotel API key (required)
baseUrlstringhttps://api.orbit.devotel.io/api/v1API base URL

Requirements

  • Node.js 18 or later
  • TypeScript 5.0+ (recommended but not required)