Integrations/Embedding Voiger
Integrations

Embedding Voiger

Put the Voiger dialer inside your CRM as an iframe, complete the handshake, and start receiving call and conversation events.

Voiger embeds as an iframe and talks to the surrounding page over window.postMessage. Your CRM never handles Voiger credentials — the embedded app carries the agent's own session, and the bridge exposes only what you ask for.

This describes the v1 bridge, which is not yet implemented. Track VOI-1057. The shape currently on plugin-dev is the older contract and does not match this page.

There are three things to get right: the frame itself, the origin checks on both sides, and the handshake. Everything after that is the protocol.

The dialer is a frame you position wherever it suits your layout — a corner, a side panel, a drawer. Your application keeps the rest of the page.

Why embed rather than build direct#

The bridge is not the only way in. The Admin and Agent APIs are documented, and Voiger speaks standard SIP over WebRTC, so a softphone of your own is possible. The difference is how much telephony you have to know, and how much of it you have to build.

Embedding is a frame, an origin check, and a handful of JSON messages. Everything on the left of this table is work you do not do.

Concern
Building direct
Embedded
Skills needed
SIP, SDP, WebRTC, ICE, codecs, browser media APIs
JavaScript and postMessage
Media and signalling
registration over WebSocket, ICE and STUN, codec negotiation, device selection, recovery when a device disappears mid-call
handled
One registration per agent
your CRM open in three tabs must not register three times or contend for the microphone
leader elected across tabs
Call control
hold, blind transfer, attended transfer, consultation, DTMF, wrap-up — each a SIP flow with its own edge cases
four commands
Call correlation
correlation headers and masked-number resolution decide routing and reporting
handled
Live data
conversations, activities, agent and queue state, with cross-tab caching and resync
subscribe and receive
Credentials
your page holds a Voiger session, and any bug in your code can leak it
the agent signs in inside the frame; your page holds nothing
Dialer UI
you design and build it
shipped, and it stays current

A working embedded integration is a day's work for a web developer with no telephony background. A direct softphone is a project, and one where the hard part is not the first call but everything after it — a device unplugged mid-conversation, a transfer that half-completes, an agent who reloads the tab while on a call.

The errors also differ in kind. A wrong correlation header does not raise an exception; it produces a call with the wrong caller ID, or one that never appears in reporting. Those are the failures you find in a customer complaint rather than in a stack trace.

When building direct is the right call#

No audio involved. Reporting, bulk contact sync, provisioning — reach for

the REST APIs. The bridge exists for the live call surface, not for data at rest.

Not a browser. Native mobile or desktop clients cannot embed the frame.
A dialer UI you fully control. If the shipped dialer cannot be made to fit,

a direct build is the honest answer.

Call flows the bridge does not expose. Check

the protocol first — the surface grows.

These are not exclusive. Embedding for the call surface and using the REST APIs for reporting and bulk work is the common shape, and the two share the same session.

1 · Embed the frame#

The dialer needs microphone access to place calls, so the iframe must carry an allow attribute. Without it the agent can join a call but will have no audio path, and the failure is silent.

html
<iframe
  id="voiger-iframe"
  src="https://app.voiger.io"
  allow="microphone"
  style="width: 420px; height: 620px; border: 0;"
></iframe>
allow="microphone" is required. Permissions-Policy blocks getUserMedia inside a cross-origin frame otherwise, and the dialer reports a device error rather than a permissions one.

The agent signs in inside the frame using their normal Voiger session. Your page does not pass tokens, and there is no separate integration credential.

2 · Validate both ends of every message#

The bridge does not restrict who may embed it. Treat that as your responsibility on the host side: check both the origin and the source of every message you receive, and always name a target origin when you send.

js
const VOIGER_ORIGIN = 'https://app.voiger.io';
const iframe = document.getElementById('voiger-iframe');

window.addEventListener('message', (messageEvent) => {
  if (messageEvent.origin !== VOIGER_ORIGIN) return;
  if (messageEvent.source !== iframe.contentWindow) return;

  handle(messageEvent.data);
});

function send(command) {
  iframe.contentWindow.postMessage(command, VOIGER_ORIGIN);
}
Never use '*' as the target origin, and never skip the source check. Origin alone is not enough — any frame on a matching origin would pass it.

3 · Wait for the handshake#

The bridge announces itself once it can accept commands. Nothing sent before ready is guaranteed to be seen, so queue until it arrives rather than dialing and hoping.

json
{
  "type": "event",
  "ts": "2026-08-21T09:14:02Z",
  "module": "integrations",
  "event": "ready",
  "data": {
    "versions": [1],
    "addresses": [
      { "module": "live_calls",
        "actions": ["dial", "subscribe", "unsubscribe"],
        "events":  ["connecting", "ringing", "answered", "ended"] },
      { "module": "conversations",
        "actions": ["read", "count", "subscribe", "unsubscribe"],
        "events":  ["created", "updated", "deleted"] },
      { "module": "conversations", "path": ["*", "activities"],
        "actions": ["read", "subscribe", "unsubscribe"],
        "events":  ["created", "updated", "deleted"] },
      { "module": "recordings", "actions": ["resolve"], "events": [] }
    ]
  }
}

addresses tells you what this deployment actually supports. Feature-detect against it instead of hardcoding — an action missing from the list is not available, and sending it earns an unsupported_action error.

ready fires again after the frame reloads or navigates. Subscriptions do not survive that, so treat every ready as a signal to re-subscribe, not just the first.

4 · Subscribe to what you need#

No events reach you until you ask. Subscriptions are per address and independent, so send them together and correlate the replies by id.

js
const pending = new Map();
let seq = 0;

function command(module, action, data = {}, path) {
  const id = `crm-${action}-${++seq}`;
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      pending.delete(id);
      reject(new Error(`timeout: ${module}/${action}`));
    }, 10000);
    pending.set(id, { resolve, reject, timer });
    send({ v: 1, type: 'command', id, module, action, ...(path && { path }), data });
  });
}

function handle(msg) {
  if (msg.type === 'reply') {
    const p = pending.get(msg.id);
    if (!p) return;
    clearTimeout(p.timer);
    pending.delete(msg.id);
    msg.status === 'success' ? p.resolve(msg.data) : p.reject(msg.error);
    return;
  }
  if (msg.type === 'event') onEvent(msg);
}

async function onReady() {
  await Promise.all([
    command('live_calls', 'subscribe'),
    command('conversations', 'subscribe'),
  ]);
}
Implement your own timeout, as above. The bridge answers every command that carries an id, but only a timeout detects a frame that has crashed outright.

5 · Handle events#

Events are addressed by module plus an optional path; replies are not, and correlate by id alone. Match on the pair, never on the verb — read, created, updated, and deleted are each used by more than one module.

js
function onEvent(msg) {
  const address = [msg.module, ...(msg.path ?? [])].join('/');

  if (address === 'live_calls' && msg.event === 'answered') {
    showCallScreen(msg.data.call_id, msg.data.contact);
  }

  if (address === 'conversations' && msg.event === 'created') {
    msg.data.conversations.forEach(addToInbox);
  }

  if (msg.event === 'lossy') {
    refetch(address);
  }
}

Event payloads for record streams are always arrays, even when a single record changed, so the bridge can batch without breaking you.

6 · Place a call#

js
await command('live_calls', 'dial', { phone: '+14155552671' });

The reply acknowledges that the dialer accepted the number, not that the call connected. Progress arrives as connecting, ringing, answered, and ended events on the live_calls address.

Next#

Full message reference — every command, reply, event, and error, with the record shapes they carry: CRM bridge protocol.