CRM bridge protocol
Every message the embedded dialer exchanges with its host page — commands, replies, events, and errors.
The bridge carries three kinds of message. type says which, and it is always present.
plugin-dev is the older contract and does not match this page.Setup, origin checks, and the handshake are covered in Embedding Voiger. This page is the message reference.
Envelope#
// command (host → app) // reply (app → host) // event (app → host) { "v": 1, { "type": "reply", { "type": "event", "type": "command", "id": "<string>", "ts": "<ISO 8601>", "id": "<string>", "status": "success|error", "module": "<string>", "module": "<string>", "data": { }, "path?": [ ], "path?": [ ], "error": { } } "event": "<string>", "action": "<string>", "data": { } } "data": { } }
Dispatch on type, then on id for replies and on (module, path, event) for events.
Three rules follow from that shape, and each removes a class of bug:
module, path, and action; youcorrelate by id. This is the JSON-RPC response shape.
data always holds the successpayload, so you never inspect it to find out whether the command worked.
answer; events inherit it from the subscribe that opened the stream.
Addressing#
An address is a module plus an optional path, mirroring the socket topics the app itself uses. path may be omitted when empty.
live_callsconversationsconversations + ["<id>", "activities"]recordingsintegrationslive_calls is in-progress call state. Finished call records are a separate resource and are not exposed on the bridge yet.Vocabulary#
actiondial, read, count, resolve, subscribe, unsubscribeeventconnecting, ringing, answered, ended, created, updated, deleted, ready, lossy, errorread belongs to two, and created / updated / deleted to two more. Always match on the address and the verb together.Commands#
Every command carries a non-empty id. A message without one is ignored — not answered, not errored — which is what keeps browser extensions and dev tooling from drawing error replies.
live_calls / dial#
{ "v": 1, "type": "command", "id": "crm-8814", "module": "live_calls", "action": "dial", "data": { "phone": "+14155552671" } }
phone must be a possible E.164 number. The reply carries data: null and means the dialer accepted the number — not that the call connected.
conversations / read#
{ "v": 1, "type": "command", "id": "crm-8815", "module": "conversations", "action": "read", "data": { "query": { "_limit": 20, "_skip": 0, "_search": "", "_sort": "updated_at__dsc" } } }
_limit20_skip0_search""_sort__asc or __dscAny other key is rejected. Note the descending suffix is __dsc.
The reply carries the page and nothing else:
{ "type": "reply", "id": "crm-8815", "status": "success", "data": { "conversations": [ /* … */ ] } }
You are on the last page when the array is shorter than the _limit you sent. For a total, use count.
conversations / read — activities#
Activities live under a conversation, so they are addressed by path rather than by a filter.
{ "v": 1, "type": "command", "id": "crm-8816", "module": "conversations", "path": ["6f1c2a90-4d3b-11ee-be56-0242ac120002", "activities"], "action": "read", "data": { "query": { "_limit": 20, "_skip": 0 } } }
{ "type": "reply", "id": "crm-8816", "status": "success", "data": { "activities": [ /* … */ ] } }
conversations / count#
{ "v": 1, "type": "command", "id": "crm-8817", "module": "conversations", "action": "count", "data": { "query": { "_search": "acme" } } }
{ "type": "reply", "id": "crm-8817", "status": "success", "data": { "count": 128 } }
Filters only — _limit, _skip, and _sort are rejected.
recordings / resolve#
{ "v": 1, "type": "command", "id": "crm-8818", "module": "recordings", "action": "resolve", "data": { "call_id": "e88b4a71-0f45-11ef-9262-0242ac120002", "expires_in": 900 } }
{ "type": "reply", "id": "crm-8818", "status": "success", "data": { "url": "https://…", "expires_at": "2026-08-21T09:29:02Z" } }
expires_in is a duration in seconds, 1 to 86400. expires_at in the reply is an absolute instant. They are not the same field in two forms.subscribe and unsubscribe#
Available at every address. Nothing is pushed until you subscribe.
{ "v": 1, "type": "command", "id": "crm-9100", "module": "conversations", "path": ["6f1c2a90-4d3b-11ee-be56-0242ac120002", "activities"], "action": "subscribe", "data": {} }
Both replies carry data: null. unsubscribe is idempotent. subscribe returns no records — call read if you want a snapshot.
ready. Activity subscriptions are also capped, since each one may open a channel; over the cap, subscribe fails with invalid_request.Events#
Events are unsolicited, so unlike replies they carry their full address.
live_calls#
connecting, ringing, answered, ended — one payload shape:
{ "type": "event", "ts": "2026-08-21T09:14:02Z", "module": "live_calls", "event": "answered", "data": { "call_id": "<string> or null", "contact": null, "caller": { "number": "<string>", "name": "<string> or null" }, "callee": { "number": "<string>", "name": "<string> or null" } } }
contact is the full contact record when Voiger can resolve the number, otherwise null. caller is populated for inbound calls and callee for outbound. Internal and eavesdrop calls are never exposed.
Each event fires at most once per call, so a call re-entering a state does not re-emit.
conversations and activities#
{ "type": "event", "ts": "2026-08-21T14:31:52Z", "module": "conversations", "event": "created", "data": { "conversations": [ /* … */ ] } }
{ "type": "event", "ts": "2026-08-21T14:31:52Z", "module": "conversations", "path": ["6f1c2a90-…", "activities"], "event": "updated", "data": { "activities": [ /* … */ ] } }
Payloads are arrays even for a single record.
lossy#
The bridge bounds its outbound queue. If you cannot drain fast enough it stops sending on that address and tells you so.
{ "type": "event", "ts": "2026-08-21T14:32:10Z", "module": "conversations", "path": ["6f1c2a90-…", "activities"], "event": "lossy", "data": { "dropped": 412 } }
Re-read that address rather than assuming your cache is intact.
Errors#
A command that fails produces a reply, not an event.
{ "type": "reply", "id": "crm-8815", "status": "error", "error": { "code": "backend_error", "message": "Conversation read failed." } }
codemessagedetailsinvalid_requestunsupported_versionv missing, or not in ready.versionsunsupported_moduleunsupported_pathunsupported_actionunauthorizedforbiddennot_readysocket_unavailablebackend_errorValidation order#
The first check that fails decides the outcome; nothing after it runs.
idv present and supportedunsupported_versionmodule exposedunsupported_modulepath valid for that moduleunsupported_pathaction supported at that addressunsupported_actioninvalid_requestnot_ready, socket_unavailable, backend_error, unauthorized, forbiddenSteps 1 to 6 are decided without touching the network. Step 7 is runtime.
id is checked before v deliberately, so a command with an unsupported version still gets an error reply rather than silence. Only genuinely unattributable traffic is ignored.Record shapes#
Reference ids are expanded into whole records before they cross the boundary.
null, which is indistinguishable from a record that does not exist. This applies to contact, assignee, queue, and every participant. Do not treat null as proof of absence.conversation#
{ "id": "<uuid>", "created_at": "<ISO 8601>", "updated_at": "<ISO 8601>", "latest_activity": { }, "participants": [ ] }
participant#
One of three shapes, by type:
{ "id": "<uuid>", "type": "admin", "admin": { } } { "id": "<uuid>", "type": "agent", "agent": { } } { "id": "<uuid>", "type": "customer", "contact": { } }
activity#
{ "id": "<uuid>", "type": "call", "created_at": "<ISO 8601>", "data": { } }
data expands the call: direction, started_at, ended_at, talk_time, duration, status, result, plus resolved caller, callee, contact, assignee, queue, tags, and notes.
tags drops entries the app has not cached rather than sending null for them, so it can be shorter than the tag list on the underlying record. Every other expanded field nulls instead.contact#
{ "id": "<uuid>", "first_name": "<string> or null", "last_name": "<string> or null", "phones": [ { "phone": "<string>", "is_primary": true } ], "emails": [ { "email": "<string>", "is_primary": true } ], "gender": "male | female | prefer_not_to_mention | null", "access": "private | public | shared | null", "country": "<string> or null", "image": null, "outbound_calls_blocked": false, "inbound_calls_blocked": false, "created_at": "<ISO 8601>", "updated_at": "<ISO 8601>" }