Integrations/CRM bridge protocol
Integrations

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.

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.

Setup, origin checks, and the handshake are covered in Embedding Voiger. This page is the message reference.

Envelope#

json
// 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:

Replies carry no addressing. You sent module, path, and action; you

correlate by id. This is the JSON-RPC response shape.

`data` and `error` are mutually exclusive. data always holds the success

payload, so you never inspect it to find out whether the command worked.

Only commands carry `v`. Replies inherit the version from the command they

answer; events inherit it from the subscribe that opened the stream.

Because a subscription pins its version, a stream opened at v1 keeps delivering v1 shapes for its whole life, even after the app starts accepting v2 commands. The subscription — not the message — is the unit of version negotiation.

Addressing#

An address is a module plus an optional path, mirroring the socket topics the app itself uses. path may be omitted when empty.

Address
Covers
live_calls
the dialer: placing calls and live call state
conversations
conversation list and change events
conversations + ["<id>", "activities"]
activities within one conversation
recordings
signed URLs for call recordings
integrations
the bridge itself — handshake and unattributable errors
live_calls is in-progress call state. Finished call records are a separate resource and are not exposed on the bridge yet.

Vocabulary#

Kind
Values
action
dial, read, count, resolve, subscribe, unsubscribe
event
connecting, ringing, answered, ended, created, updated, deleted, ready, lossy, error
Verbs repeat across addresses. read 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#

json
{ "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#

json
{ "v": 1, "type": "command", "id": "crm-8815",
  "module": "conversations", "action": "read",
  "data": { "query": { "_limit": 20, "_skip": 0, "_search": "", "_sort": "updated_at__dsc" } } }
Field
Rule
Default
_limit
integer, 1–30
20
_skip
integer, 0 or more
0
_search
string
""
_sort
field name, optionally __asc or __dsc
omitted

Any other key is rejected. Note the descending suffix is __dsc.

The reply carries the page and nothing else:

json
{ "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.

json
{ "v": 1, "type": "command", "id": "crm-8816",
  "module": "conversations", "path": ["6f1c2a90-4d3b-11ee-be56-0242ac120002", "activities"],
  "action": "read",
  "data": { "query": { "_limit": 20, "_skip": 0 } } }
json
{ "type": "reply", "id": "crm-8816", "status": "success",
  "data": { "activities": [ /* … */ ] } }

conversations / count#

json
{ "v": 1, "type": "command", "id": "crm-8817",
  "module": "conversations", "action": "count",
  "data": { "query": { "_search": "acme" } } }
json
{ "type": "reply", "id": "crm-8817", "status": "success",
  "data": { "count": 128 } }

Filters only — _limit, _skip, and _sort are rejected.

recordings / resolve#

json
{ "v": 1, "type": "command", "id": "crm-8818",
  "module": "recordings", "action": "resolve",
  "data": { "call_id": "e88b4a71-0f45-11ef-9262-0242ac120002", "expires_in": 900 } }
json
{ "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.

json
{ "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.

Subscriptions are session-scoped and die when the frame reloads. Re-subscribe on every 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:

json
{ "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#

json
{ "type": "event", "ts": "2026-08-21T14:31:52Z",
  "module": "conversations", "event": "created",
  "data": { "conversations": [ /* … */ ] } }
json
{ "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.

json
{ "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.

json
{ "type": "reply", "id": "crm-8815", "status": "error",
  "error": { "code": "backend_error", "message": "Conversation read failed." } }
Field
Meaning
code
machine-readable class, listed below
message
human-readable English, safe to log, not to show a customer
details
optional, free-form; field-level specifics when a payload was rejected
code
Cause
invalid_request
payload or required fields invalid at a known address
unsupported_version
v missing, or not in ready.versions
unsupported_module
module not exposed by this deployment
unsupported_path
module known, path segment is not
unsupported_action
address known, action not offered there
unauthorized
session rejected
forbidden
session valid, access denied
not_ready
subsystem not up — dialer unregistered, workspace still loading
socket_unavailable
the app cannot reach the backend right now
backend_error
the request ran and the backend failed

Validation order#

The first check that fails decides the outcome; nothing after it runs.

#
Check
On failure
1
well-formed object with a valid id
ignored — no reply
2
v present and supported
unsupported_version
3
module exposed
unsupported_module
4
path valid for that module
unsupported_path
5
action supported at that address
unsupported_action
6
payload valid
invalid_request
7
execution
not_ready, socket_unavailable, backend_error, unauthorized, forbidden

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

A record the app has not cached is sent as 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#

json
{
  "id": "<uuid>",
  "created_at": "<ISO 8601>",
  "updated_at": "<ISO 8601>",
  "latest_activity": { },
  "participants": [ ]
}

participant#

One of three shapes, by type:

json
{ "id": "<uuid>", "type": "admin",    "admin":   { } }
{ "id": "<uuid>", "type": "agent",    "agent":   { } }
{ "id": "<uuid>", "type": "customer", "contact": { } }

activity#

json
{
  "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#

json
{
  "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>"
}