> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Node.js SDK

> Official Devotel Node.js/TypeScript SDK quickstart

# 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

```bash theme={null}
npm install @devotel/orbit-sdk
```

Or with other package managers:

```bash theme={null}
pnpm add @devotel/orbit-sdk
yarn add @devotel/orbit-sdk
```

## Quick Start

```typescript theme={null}
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

```typescript theme={null}
// 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](#voice-operations) below for the full surface.

```typescript theme={null}
// 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](#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`](https://github.com/devotel/orbit/blob/main/packages/sdk-node/src/resources/voice.ts).

### Make a call

```typescript theme={null}
const call = await orbit.voice.calls.create({
  to: '+14155552671',
  from: '+18005551234',         // optional — falls back to a tenant default
  agent_id: 'agt_support_bot',// optional — dispatch the call to a named voice agent
  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
```

> **The AI voice agent is bindable per call via `agent_id`.** When set, the call
> is dispatched to that named, tenant-scoped voice agent — the server validates
> it exists and is not archived (the same first-class field shown in the
> [Quickstart](/quickstart#voice)). `voice.calls.create()` accepts `to`, `from`,
> `record`, `amd`, `campaignId`, `orgTimezone`, `agent_id`, and `metadata`; any
> other field is silently stripped by the server.
>
> IVR flows and webhooks remain Application / IVR-Flow level configuration, not
> per `create()` overrides. Attach the destination number to an Application (or
> IVR Flow) and the call inherits its IVR routing and webhook configuration.

### Get call details

```typescript theme={null}
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:

```typescript theme={null}
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

```typescript theme={null}
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:

```typescript theme={null}
// 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

```typescript theme={null}
// 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):

```typescript theme={null}
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:

```typescript theme={null}
// 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:

```typescript theme={null}
// 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:

```typescript theme={null}
// 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

```typescript theme={null}
// 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

```typescript theme={null}
// 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:

```typescript theme={null}
// 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

```typescript theme={null}
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)

```typescript theme={null}
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](/webhooks/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.

```typescript theme={null}
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.transcription.ready':/* call.transcript.get(id) for full text */ break;
    default:                     /* forward-compat */ break;
  }
  res.status(200).json({ received: true });
});
```

## Verify

```typescript theme={null}
// 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

```typescript theme={null}
// 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`).

```typescript theme={null}
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](/sdks#error-hierarchy) for the full error tree shared
across every Orbit SDK.

## Configuration

| Option    | Type   | Default                               | Description                     |
| --------- | ------ | ------------------------------------- | ------------------------------- |
| `apiKey`  | string | —                                     | Your Devotel API key (required) |
| `baseUrl` | string | `https://api.orbit.devotel.io/api/v1` | API base URL                    |

## Requirements

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