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.
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.
postMessageA 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#
the REST APIs. The bridge exists for the live call surface, not for data at rest.
a direct build is the honest answer.
the protocol first — the surface grows.
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.
<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.
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); }
'*' 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.
{ "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.
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'), ]); }
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.
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#
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.