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

# Web SDK

> Embed Orbit messaging, AI agents, and live softphone into your web app

# Web SDK

The Devotel Web SDK (`@devotel/orbit-web-sdk`) is the browser-side SDK that powers the embeddable Orbit Chat widget, the WebRTC softphone, on-site personalisation, and Web Push (VAPID) re-engagement notifications. It ships as native ES module + CommonJS builds with subpath exports per surface (`./softphone`, `./personalization`, `./push`, `./push/service-worker`, `./widget`) and has no peer-dependency on a framework (works in vanilla, React, Vue, Svelte, Solid).

## Installation

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

Or via CDN:

```html theme={null}
<script type="module" src="https://cdn.jsdelivr.net/npm/@devotel/orbit-web-sdk/dist/index.mjs"></script>
```

The Softphone path additionally requires `jssip` as an optional peer dep:

```bash theme={null}
npm install jssip
```

Chat-only consumers can skip it; the bundle is tree-shaken so you don't pay the cost unless you import `Softphone`.

## Quick Start

The SDK exports several product surfaces — pick only what you need. The Quick Start below wires the four most common (chat, softphone, personalisation, push); three further GA surfaces — the embeddable AI voice-agent widget (`OrbitVoiceAgent`), the CRM CTI bridge (`OrbitCTI`), and the Segment-compatible CDP analytics clients (`OrbitCdpAnalytics` / `OrbitCdpAnalyticsQueue`) — are documented under [Modules](#modules) below.

```html theme={null}
<!doctype html>
<html>
  <head>
    <script type="module">
      import {
        OrbitChat,
        Softphone,
        OrbitPersonalization,
        OrbitPush,
      } from '@devotel/orbit-web-sdk';

      // Chat widget — drop-in inbox-backed widget. It self-mounts as a
      // fixed-position overlay on document.body, so `mount()` takes no
      // selector and you don't need a placeholder element.
      const chat = new OrbitChat({
        publicKey: 'dv_live_pk_your_public_key_here',
        agentName: 'Support',
        greeting: 'Hi! How can we help?',
        position: 'bottom-right',
      });
      chat.mount();

      // Softphone — server mints a short-lived JWT, browser registers a
      // SIP/WebRTC UA against it. NEVER ship a secret key in the browser.
      // `Softphone.fromToken` requires (jwt, opts) — `fromNumber` is the
      // E.164 caller-ID the softphone identifies as on outbound calls.
      const tokenResp = await fetch('/your-backend/softphone-token').then((r) => r.json());
      const softphone = Softphone.fromToken(tokenResp.token, {
        fromNumber: '+15551234567',
      });
      await softphone.register();

      document.querySelector('#call-sales').addEventListener('click', async () => {
        const session = await softphone.call('+18005551234');
        session.on('answered', () => console.log('Answered'));
        session.on('ended', (reason) => console.log('Ended:', reason));
      });

      // Personalisation — fetches contact_scores.segment_label so
      // champions/at-risk/dormant visitors see different copy.
      const personalization = new OrbitPersonalization({
        publicKey: 'dv_live_pk_your_public_key_here',
        apiUrl: 'https://api.orbit.devotel.io',
      });
      await personalization.identify({ email: 'user@example.com', display_name: 'Ada Lovelace' });
      personalization.track({ name: 'checkout_started', properties: { cart_total_cents: 14999 } });
    </script>
  </head>
  <body>
    <button id="call-sales">Call sales</button>
  </body>
</html>
```

## Modules

### Chat widget — `OrbitChat`

Embeds the same Inbox-backed live chat that operators see on the dashboard. The widget supports text, file uploads, typing indicators, AI-agent fallback, and human-handoff escalation.

```ts theme={null}
import { OrbitChat } from '@devotel/orbit-web-sdk';

const chat = new OrbitChat({
  publicKey: 'dv_live_pk_your_public_key_here',
  agentName: 'Support',
  greeting: 'Hi! How can we help?',
  position: 'bottom-right',
});
// The widget self-mounts as a fixed-position overlay — `mount()` takes
// no selector argument and needs no placeholder element on the page.
chat.mount();
```

### Softphone (SIP / WebRTC voice) — `Softphone`

A JsSIP-backed softphone that places outbound PSTN calls or joins inbound voice flows. Call control hooks into Orbit's voice routing rules (IVR, AI agent, human handoff). Two-step auth: your server exchanges a server-issued JWT (or, less commonly, an API key) for short-lived SIP credentials via `POST /voice/softphone/register`, then the browser opens a JsSIP WSS to the Orbit WSS (Kamailio) gateway (the `wssUrl` returned by `POST /voice/softphone/register`).

```ts theme={null}
import { Softphone } from '@devotel/orbit-web-sdk';

// Server hands the browser a short-lived JWT via your backend.
// `fromToken` is synchronous (don't `await` it); it requires a second
// `opts` argument containing at minimum the outbound caller-ID.
const softphone = Softphone.fromToken(jwt, {
  fromNumber: '+15551234567',
});
await softphone.register();

// The outbound caller-ID is fixed at construction via `fromNumber` above —
// `call()` takes only an optional `{ timeout }` (per-call dial timeout, ms).
const session = await softphone.call('+14155552671');
session.on('ringing', () => console.log('Ringing'));
session.on('answered', () => console.log('Answered'));
session.on('ended', (reason) => console.log('Ended:', reason));
```

The SDK auto re-registers \~5 minutes before the SIP credential expires. Tear down with `softphone.unregister()` to stop the renewal loop.

See [Voice operations](#voice-operations) below for the full call-control surface (hangup, hold, mute, DTMF, transfer, conference, recording, webhooks).

### Web Push subscriptions — `OrbitPush`

VAPID + RFC-8030/8291/8292 compliant push registration. Always feature-detect with `OrbitPush.isSupported()` first — Safari pre-16.4 and browsers without `PushManager` will throw otherwise.

```ts theme={null}
import { OrbitPush } from '@devotel/orbit-web-sdk';

if (OrbitPush.isSupported()) {
  const push = new OrbitPush({
    publicKey: 'dv_live_pk_your_public_key_here',
    vapidPublicKey: 'BPa6...your-vapid-public-key',
    // Optional — defaults to '/orbit-push-sw.js' served from your site root.
    serviceWorkerUrl: '/orbit-push-sw.js',
    // Optional — override the API host, dedup reinstalls, or tag a channel.
    apiBaseUrl: 'https://api.orbit.devotel.io',
    appInstallId: 'install_abc123',
    notificationChannel: 'transactional',
  });
  // `register()` returns null if the user denies notification permission.
  const registration = await push.register();
  if (registration) {
    console.log('Device token:', registration.deviceTokenId);
    console.log('Subscribed:', registration.subscription.endpoint);
  }
}
```

The service-worker bootstrap is published at a separate subpath. Import it from your service-worker file with an ES-module `import` — because of that, the worker must be registered as a **module worker** (`navigator.serviceWorker.register('/orbit-push-sw.js', { type: 'module' })`):

```js theme={null}
// public/orbit-push-sw.js
import { OrbitPushServiceWorker } from '@devotel/orbit-web-sdk/push/service-worker';

OrbitPushServiceWorker.install({
  apiBaseUrl: 'https://api.orbit.devotel.io',
  expectedTenantId: 'tenant_abc',
});
```

The SW must run with `scope: "/"` to receive pushes for any path. The `expectedTenantId` guard is compared against each payload's `tenant_id`, so it must be the `tenant_<id>` value your push payloads carry (not an `org_`-prefixed id) — any mismatch is dropped to prevent cross-tenant leakage.

### Personalisation — `OrbitPersonalization`

Identifies a visitor and fetches their `contact_scores.segment_label` server-side. Use the segment to swap copy, gate features, or trigger CTAs.

```ts theme={null}
import { OrbitPersonalization } from '@devotel/orbit-web-sdk';

const personalization = new OrbitPersonalization({
  publicKey: 'dv_live_pk_your_public_key_here',
  apiUrl: 'https://api.orbit.devotel.io',
});

await personalization.identify({
  email: 'user@example.com',
  display_name: 'Ada Lovelace',
  traits: { plan: 'business' },
});

personalization.track({
  name: 'checkout_started',
  properties: { cart_total_cents: 14999 },
});

// Resolve a configured slot (e.g. 'hero', 'cart-empty-cta') for this visitor.
const payload = await personalization.getPersonalization('hero');
// payload.segment ∈ 'champion' | 'at_risk' | 'dormant' | … (or null when unknown)
```

### AI voice-agent widget — `OrbitVoiceAgent`

An embeddable browser WebRTC widget that lets a visitor press **"Talk to AI"** and hold a live voice conversation with one of your configured AI voice agents — the web front-door to Orbit's voice AI (web onboarding + live demos), no inbound PSTN DID required. It self-paints a button and a lightweight waveform into the container you pass, mints a short-lived room token via `POST /widget/voice-agent-session`, and runs the call over the same WebRTC media stack as the softphone. Like `OrbitChat`, it authenticates with your **public key** (today the tenant org id) — never a secret.

```ts theme={null}
import { OrbitVoiceAgent } from '@devotel/orbit-web-sdk';

const agent = OrbitVoiceAgent.init({
  publicKey: 'dv_live_pk_your_public_key_here',
  agentId: 'agent_support',
  container: document.getElementById('orbit-voice-agent')!,
  // Optional — override the API host, button copy, or pin a stable visitor.
  apiBaseUrl: 'https://api.orbit.devotel.io',
  buttonLabel: 'Talk to AI',
  visitorId: 'visitor_abc123',
});

agent.on('state', (s) => console.log('voice-agent state:', s)); // idle|connecting|live|ended|error
agent.on('error', (err) => console.error(err));

// Tear down the session and remove the painted UI when you're done.
// agent.destroy();
```

### CRM CTI bridge — `OrbitCTI`

An iframe-embeddable click-to-dial + call-state bridge a CRM admin drops into a Salesforce / HubSpot / Zendesk sidebar — Orbit's answer to the Genesys / Five9 / Talkdesk OpenCTI adapters. The host page fires events and calls `dial()`; all media transport lives inside the iframe, so the host page never touches `jssip` or `livekit-client`. Your backend mints the embed token server-side (or via a trusted same-origin proxy) and hands it to `init()`.

```ts theme={null}
import { OrbitCTI } from '@devotel/orbit-web-sdk';

const cti = OrbitCTI.init({
  embedUrl: 'https://orbit.devotel.io/cti-embed',
  // Token minted server-side by your backend — passed straight through.
  token: ctiTokenFromYourBackend,
  container: document.getElementById('orbit-cti-mount')!,
});

cti.on('ready', (e) => console.log('CTI ready, protocol', e.version));
cti.on('call:state', (e) => console.log(e.state, e.dialId)); // dialing|ringing|answered|ended|failed

document.querySelector('#dial')!.addEventListener('click', () => {
  cti.dial('+14155550100', { contactId: '0035g000abc' });
});

// cti.destroy(); // unmount the iframe and stop listening
```

### CDP analytics (Segment-compatible) — `OrbitCdpAnalytics`

A Segment-compatible analytics ingest client — `track`, `identify`, `alias`, `page`, `screen`, `group`, `batch` — that signs each request (HMAC, timestamp + nonce) **inside** the SDK boundary using the WebCrypto API, so it runs in any modern browser, Cloudflare Workers, Vercel Edge, Deno, and Bun. It mirrors the Node SDK's `CdpAnalyticsClient` on the same wire contract, so a request signed by either client verifies identically server-side.

> **This client holds an ingest secret, not a public key.** The ingest secret MUST stay server-side — do **not** ship it in your page bundle. Use `OrbitCdpAnalytics` only on a trusted edge / SSR surface (read the secret from your environment), or proxy the signing through your own backend. For pure browser-side eventing, prefer the public-key `OrbitPersonalization` surface above, which never needs the ingest secret.

```ts theme={null}
import { OrbitCdpAnalytics } from '@devotel/orbit-web-sdk';

// Runs on a trusted edge worker / SSR handler — never in the page bundle.
const analytics = new OrbitCdpAnalytics({
  apiUrl: 'https://api.orbit.devotel.io',
  ingestId: env.ORBIT_CDP_INGEST_ID,
  ingestSecret: env.ORBIT_CDP_INGEST_SECRET, // from your secret store — keep server-side
});

await analytics.identify({ userId: 'u_42', traits: { plan: 'business' } });
await analytics.track({
  userId: 'u_42',
  event: 'Order Completed',
  properties: { revenue: 99.99, currency: 'USD' },
});
```

For high-volume browser-adjacent surfaces, `OrbitCdpAnalyticsQueue` wraps the same client with a sticky `anonymousId`, batched flush, retry with exponential backoff, and offline survival via `localStorage` — an analytics.js-style drop-in on top of the base `OrbitCdpAnalytics`.

## Voice operations

The browser softphone is a thin client over the JsSIP WebRTC stack. Call control happens **on the live `CallSession` object** for the active call leg; for surface-area calls like recording, conferences, transfers to PSTN, transcripts, and dialer campaigns, your backend uses the [Node SDK](/sdks/node) against the same `call_id` the browser exposes via `session.id`.

All snippets below match the SDK source in [`packages/sdk-web/src/softphone/`](https://github.com/devotel/orbit/tree/main/packages/sdk-web/src/softphone).

### Make a call

```ts theme={null}
// `call()` accepts an optional `{ timeout }` (per-call dial timeout, ms) only.
// The caller-ID is not a per-call option — it is fixed at construction via the
// `fromNumber` passed to `Softphone.fromToken(jwt, { fromNumber })`.
const session = await softphone.call('+14155552671', { timeout: 30_000 });
session.on('ringing',  () => console.log('Ringing'));
session.on('answered', () => console.log('Answered — call.id =', session.id));
session.on('ended',    (reason) => console.log('Ended:', reason));
session.on('media-level', (level) => updateVUMeter(level)); // 0..1 RMS
```

### Get call details

Live state is on the `CallSession`. For historical details, query the server SDK with `session.id`:

```ts theme={null}
const callId = session.id;
console.log(session.direction); // 'outgoing' | 'incoming'
console.log(session.isOnHold(), session.isMuted());

// From your backend (Node SDK):
// const detail = await orbit.voice.calls.get(callId);
```

### Hang up a call

```ts theme={null}
session.hangup();                          // normal hangup
session.hangup('caller-disconnected');     // optional SIP cause
```

### Answer an incoming call

```ts theme={null}
softphone.on('incoming', async (incoming) => {
  // Show a UI prompt; if the user accepts:
  incoming.answer();
});
```

### Hold and mute

```ts theme={null}
session.hold(true);    // put on hold (sends re-INVITE with a=sendonly)
session.hold(false);   // resume

session.mute(true);    // mute local mic
session.mute(false);   // unmute
```

### Send DTMF

```ts theme={null}
session.sendDTMF('1');         // single digit
session.sendDTMF('123#');      // sequence — RFC 4733 telephone-event
// digits must match /^[0-9*#ABCD]+$/
```

### Transfer a call

The browser softphone supports **blind transfer (SIP REFER)** directly on the call session. **Warm (consult-first) transfer** is a server-orchestrated flow — your backend calls `voice.calls.warmTransfer(session.id, ...)` while keeping the local leg alive.

```ts theme={null}
// Cold / blind transfer (in-browser):
session.transfer('+14155552500');         // bare E.164 or full SIP URI

// Warm transfer (server-orchestrated):
await fetch('/your-backend/warm-transfer', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    callId: session.id,
    destination: '+14155552500',
    contextWhisper: true,
  }),
});
// Your backend handler then calls (Node SDK):
//   orbit.voice.calls.warmTransfer(callId, { destination, contextWhisper: true })
```

### Conference call

Conferencing is server-orchestrated — the browser stays in its own leg while the backend bridges additional participants:

```ts theme={null}
// From your backend (Node SDK):
// const conf = await orbit.voice.conferences.create({ name: 'support-room' });
// await orbit.voice.conferences.addParticipant(conf.data.id, {
//   callId: session.id, // the browser leg
// });
// await orbit.voice.conferences.addParticipant(conf.data.id, {
//   phoneNumber: '+14155552671',
//   coaching: false,
// });
```

### Record a call

Recording is enabled **at call setup** — your backend sets `record: true` when minting the softphone JWT, or when triggering the outbound call. After hangup, fetch the recording from your backend:

```ts theme={null}
// Browser: nothing to do once recording is enabled at the JWT layer.

// Backend (Node SDK), after the 'call.recording.ready' webhook fires:
// const recordings = await orbit.voice.recordings.list({ callId });
// const signed = await orbit.voice.recordings.getDownloadUrl(recordings.data[0].id);
// // Hand the signed URL to the browser to play in an <audio> element.
```

### Listen to a recording

Once your backend has the signed URL, drop it into a standard HTML5 audio element — no SDK API required:

```ts theme={null}
const { url } = await fetch(`/your-backend/recordings/${callId}/url`).then((r) => r.json());
const audio = document.querySelector<HTMLAudioElement>('#playback')!;
audio.src = url;
audio.play();
```

### Voice webhook handling

Voice webhooks are server-side — the browser SDK should never accept inbound HTTPS. Wire your backend (see the [Node SDK voice webhook guide](/sdks/node#voice-webhook-handling)) and forward state to the browser via your existing channel (WebSocket, SSE, or a `BroadcastChannel`):

```ts theme={null}
// Browser-side: subscribe to a backend-pushed event stream.
const es = new EventSource('/your-backend/voice-events?callId=' + session.id);
es.addEventListener('call.recording.ready', (e) => {
  const { recordingId } = JSON.parse(e.data);
  showDownloadButton(recordingId);
});
es.addEventListener('call.transcription.ready', (e) => {
  const { transcript } = JSON.parse(e.data);
  renderTranscript(transcript);
});
```

## Configuration

Each surface (`OrbitChat`, `Softphone`, `OrbitPersonalization`, `OrbitPush`) takes its own constructor options. The API base URL is configured with a **different option name on each surface** — there is no single shared `baseUrl`.

| Option       | Type                | Default                               | Description                                                                                                                                            |
| ------------ | ------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `publicKey`  | string              | —                                     | Your Devotel public key (`dv_live_pk_*`) — required for `OrbitChat`, `OrbitPersonalization`, and `OrbitPush`. NEVER ship a secret key in browser code. |
| `baseUrl`    | string              | `https://api.orbit.devotel.io/api/v1` | API base URL — `OrbitChat` only.                                                                                                                       |
| `apiUrl`     | string              | — (required)                          | API base URL — `OrbitPersonalization` only. Set to `https://api.orbit.devotel.io`.                                                                     |
| `apiBaseUrl` | string              | `https://api.orbit.devotel.io`        | API base URL — `OrbitPush` only.                                                                                                                       |
| `theme`      | `'light' \| 'dark'` | `'light'`                             | Widget theme — `OrbitChat` only.                                                                                                                       |
| `locale`     | string              | browser default                       | UI language — `OrbitChat` only.                                                                                                                        |

`Softphone` is constructed via the `Softphone.fromToken(jwt, opts)` factory (where `opts` carries the outbound `fromNumber` and any per-call overrides), not a public key — see the [Softphone module](#softphone-sip--webrtc-voice--softphone) above.

## Source

* npm: [@devotel/orbit-web-sdk](https://www.npmjs.com/package/@devotel/orbit-web-sdk)
* GitHub: [`packages/sdk-web`](https://github.com/devotel/orbit/tree/main/packages/sdk-web)

For server-side / private-key operations (sending messages on behalf of the user, accessing PII, etc.) use the [Node.js SDK](/sdks/node) — never the Web SDK with a secret key.

## Authentication

Web SDK surfaces (`OrbitChat`, `OrbitPersonalization`, `OrbitPush`) authenticate with **public keys** only (`dv_live_pk_*`). Public keys are scoped to surfaces that are safe to expose in browser code: chat widget submission, push subscription registration, personalisation events. They cannot send messages on behalf of arbitrary recipients, mutate billing, or access PII.

If you call the REST API directly from the browser, send the public key as `Authorization: Bearer dv_live_pk_...` whenever the `X-API-Key` custom header is blocked by CORS — the server accepts either form. See the [authentication guide](/authentication#bearer-token-dashboard-users) for details.

`Softphone` does NOT take a public key directly — your backend mints a short-lived JWT (≈ 60 minutes) via `POST /voice/softphone/register` using your `dv_live_sk_*` server key, and the browser receives only the JWT. The SDK then opens the SIP/WebRTC session against that JWT. This keeps your secret key server-side while still letting end-users place calls.

See the [authentication guide](/authentication) for the full scope matrix.
