Integration Examples (Build Your Own Integration)
Scope
This page contains complete, working integration examples that you can copy and adapt. It builds on the Integrations Framework page, which describes each hook in detail; here the focus is on the handful of patterns that almost every real integration turns out to be a variation of.
All names, URLs, tokens, client IDs and field IDs on this page are fictional. Replace them with the values from the system you are integrating with.
| Pattern | Example | Credentials live at | Hooks |
|---|---|---|---|
| API key, screen pop while ringing | AcmeCRM | Tenant | onagent |
| Per-user OAuth, live call state | Zenith Desk | User | onfrontend, onagent |
| Ticket or activity written after the call | Beacon Support | Tenant | oncdr |
| Tenant OAuth with admin consent | Northwind Cloud | Tenant | onfrontend |
| External application starts calls | Click to call | Tenant | onhttp |
| Directory sync, no calls involved | Directory sync | Tenant | onfrontend |
An integration is not a file bundle you upload. You create it on the system-level Integrations page, give it a display name and an identifier, then paste each part into the matching text field (tenant HTML / CSS / JavaScript, user HTML / CSS / JavaScript, backend JavaScript). Leave the fields you do not need empty.
Before you start
Three decisions shape the code more than anything else.
1. The identifier. Lower-case alphanumeric, unique on the system, e.g. acmecrm. It appears
in the REST URL the front end posts to (?identifier=acmecrm) and it namespaces every stored
setting. Changing it later orphans the data, so choose it once.
2. Where the credentials live. Tenant settings are entered once by the tenant administrator and shared by the whole tenant — right for a service API key. User settings are entered by each extension in the user portal — right when every agent has their own login in the target system, which is nearly always the case with OAuth.
3. Which events you need.
Inbound call arrives
│
├─ onagent state = "ringing" → look up the caller, show a screen pop
├─ onagent state = "connected" → the agent answered
├─ onagent state = "terminated" → duration is known
│
└─ oncdr → complete record: every leg, trunk,
recording, CMC, comment, rating
Use onagent for anything the agent must see during the call, and oncdr for anything you
write after it. oncdr fires once and is the only hook with the full picture, which makes it
the better choice for logging; onagent fires repeatedly and is the only one early enough for a
screen pop.
Three things that will bite you
Timestamp units are not consistent — and not even consistent within oncdr. oncdr
timestamps (start, connect, end, and the same fields on trunk and extension legs) are in
seconds; onagent timestamps are in milliseconds. But durationtalk on a state leg is in
milliseconds even though the timestamps beside it are in seconds. Getting this wrong by a
factor of 1000 is silent — the record still writes, the number is just absurd.
// Verified on 70.6, same call:
// trunk leg connect = 1789362030.818482 end = 1789362034.306992 -> 3.4885 s
// state leg durationtalk = 3489 -> 3489 ms
// Accepts either unit and returns milliseconds:
function toMillis (t) {
if (!t) return 0;
return t > 1e12 ? t : t * 1000;
}
// durationtalk is already milliseconds - convert unconditionally:
var talkSeconds = Math.round((sleg.durationtalk || 0) / 1000);
Do not write a heuristic such as if (durationtalk > 1000) durationtalk /= 1000. It looks
defensive and is wrong: an 800 ms call passes the test untouched and gets logged as 800 seconds.
Verify call direction on your own system before you rely on it. Several integrations have
been caught out by event.inbound here. Log one inbound and one outbound test call at trace level
9 and read what your version actually reports:
console.trace('CRM', 9, event.domain.address, 'inbound=' + event.inbound +
' caller=' + event.caller.number + ' agent=' + event.agent.number);
In oncdr the direction is explicit, but only on the trunk and extension legs — they carry
'I' for inbound and 'O' for outbound. State legs do not carry a usable direction. On
70.6, an outbound call placed by an agent who is logged into a queue produces a state leg of type
acd with no direction of its own; the 'O' is on the trunk leg. Code that tests
states[i].direction === 'O' silently never fires and logs every such call as inbound, with the
agent's own extension as the customer.
// Check the state leg first, then fall back to the trunk leg.
function legIsOutbound (sleg, tleg) {
if (sleg.type === 'extcall') return true;
if (sleg.type !== 'acd' && sleg.type !== 'hunt') return false;
if (sleg.direction === 'O') return true; // some builds
if (tleg && tleg.direction === 'O') return true; // 70.6
return false;
}
A SIP response code does not tell you whether the call was answered. This is the one that produces the most wrong data, because the resulting record looks entirely plausible. Two real 70.6 CDRs:
| Call | Trunk code | Trunk connect | Talk time | Actually |
|---|---|---|---|---|
| Outbound 504 → 0433337285 | 487 | non-zero | 3489 ms | answered |
| Inbound 0433337285 → 316 | 486 | non-zero | 14085 ms | answered |
487 is Request Terminated and 486 is Busy Here, yet both calls connected and carried audio. The codes belong to a forked or cancelled branch of the call, not to the branch that answered.
Decide answered from talk time, and use the SIP code only to classify why an unanswered call failed:
function legAnswered (sleg) {
return !!(sleg && sleg.durationtalk > 0); // or, on a trunk/extension leg: connect > 0
}
function legOutcome (sleg, tleg) {
if (legAnswered(sleg)) return 'Connected';
var code = (tleg && tleg.code) || (sleg && sleg.code) || 0;
if (code === 486) return 'Busy';
if (code === 404) return 'Wrong Number';
return 'No Answer'; // 487, 603, 480, or no code at all
}
The three leg arrays in a CDR
A CDR carries up to three parallel arrays. They describe the same call from different angles and they are not the same length or in the same order. Which one you want depends on the question.
| Array | One entry per | Use it to answer |
|---|---|---|
trunklegs | external leg over a trunk | Was this call external at all? Which way did it go? |
extensionlegs | extension the call touched | Which extension actually handled it, after transfers? |
states | PBX object the call passed through | Did it hit an auto-attendant, a queue, a mailbox? |
trunklegs[] — direction ('I'/'O'), code (final SIP status, see the warning above),
start / connect / end (seconds), extension, remoteparty, localparty, trunk-name.
extensionlegs[] — direction, extension, redirect (a SIP contact whose display name is
the queue or ring group), start / connect / end (seconds).
states[] — type (extcall, acd, hunt, attendant, mailbox), durationtalk
(milliseconds), code, start (seconds), extension, account.
Three rules follow from this.
An empty trunklegs means the call never left the PBX. This is the cheapest and most reliable
internal-call test in oncdr — the equivalent of the Account.get(domain, number, '*') test you
use in onagent. Guard on it first, before any lookup:
function oncdr (cdr) {
// Extension-to-extension calls have no trunk leg. Without this guard the "customer"
// ends up being the calling extension, and most CRMs will happily create a contact
// named after it.
if (!cdr.trunklegs || !cdr.trunklegs.length) return;
...
}
Never pair the arrays by index. An attendant-to-queue call has two state legs riding one trunk
leg, so states[1] has no trunklegs[1]. Two failure modes follow, and integrations have shipped
with both:
// WRONG - drops every state past the shortest array. An attendant -> queue call
// loses the queue leg entirely, which is usually the one that answered.
for (var j = 0; j < Math.min(tlegs.length, slegs.length); j++) { ... }
// WRONG in the other direction - throws when tlegs is shorter.
for (var j = 0; j < slegs.length; j++) { var tleg = tlegs[j]; ...tleg.code... }
// Correct - iterate the states, pair where possible, fall back to the last trunk leg.
function trunkLegFor (tlegs, j) {
if (!tlegs || !tlegs.length) return null;
return j < tlegs.length ? tlegs[j] : tlegs[tlegs.length - 1];
}
for (var j = 0; j < slegs.length; j++) {
var tleg = trunkLegFor(tlegs, j);
...
}
One call is one CRM record, not one record per leg. A call that hits the auto-attendant and then a queue produces two state legs, both with talk time. Logging both gives the customer two activities for one conversation. Collect candidates, then pick one:
// Priority: last answered leg with a resolvable owner -> last leg with an owner
// -> last answered leg -> last leg.
function selectCallLeg (calls) {
if (calls.length <= 1) return calls;
var answeredWithOwner = null, withOwner = null, answeredAny = null;
for (var i = 0; i < calls.length; i++) {
var k = calls[i];
if (k.answered && k.ownerEmail) answeredWithOwner = k;
if (k.ownerEmail) withOwner = k;
if (k.answered) answeredAny = k;
}
return [ answeredWithOwner || withOwner || answeredAny || calls[calls.length - 1] ];
}
Pattern 1: AcmeCRM — tenant API key and screen pop
The tenant administrator enters a URL and an API key once. Every extension in the tenant then gets a contact card when a known number calls.
Fictional API:
| Purpose | Request |
|---|---|
| Find a contact by phone | GET /api/v2/contacts?phone=%2B15105550142 |
| Authentication | Authorization: Bearer ak_test_00000000000000000000 |
Tenant HTML
The tenant interface uses Bootstrap 3. Address fields by
name rather than id — several integrations share one page and id values collide.
<!-- AcmeCRM -->
<div class="row">
<div class="form-group has-feedback">
<label class="col-sm-6 control-label">AcmeCRM API URL</label>
<div class="col-sm-6">
<input class="form-control" type="text" name="acme_url"
placeholder="https://api.acmecrm.example">
<span class="glyphicon form-control-feedback" aria-hidden="true"></span>
</div>
</div>
</div>
<div class="row">
<div class="form-group has-feedback">
<label class="col-sm-6 control-label">AcmeCRM API Key</label>
<div class="col-sm-6">
<input class="form-control" type="password" name="acme_api_key"
placeholder="ak_test_00000000000000000000">
<span class="glyphicon form-control-feedback" aria-hidden="true"></span>
</div>
</div>
</div>
Tenant JavaScript
'use strict';
// Fill the form from the stored settings:
export const load = (form, data) => {
const d = data || {};
form.querySelector('[name="acme_url"]').value = d.acme_url || '';
// Secrets are never sent back to the browser - clear the field:
form.querySelector('[name="acme_api_key"]').value = '';
}
// First element: public settings. Second element: the secret.
export const save = (form) => {
const result = [{
acme_url: form.querySelector('[name="acme_url"]').value.replace(/\/+$/, '')
}];
const key = form.querySelector('[name="acme_api_key"]').value;
key && result.push(key); // only when the administrator typed something
return result;
}
The second array element is a plain string, and there is one secret slot per level — one for the tenant, one for each user. The backend reads it with an empty name:
var apiKey = loadIntr(domain, '', '', true); // tenant secret
var userKey = loadIntr(domain, user, '', true); // that user's secret
If you need more than one secret, pack them into a single string and split it in the backend, for
example email + ':' + token. Do not put passwords in the public object to work around the limit
— public settings are readable from the tenant interface.
Guarding the push with key && is what makes "leave blank to keep the current key" work. Without
it, every save with an empty field wipes the stored credential.
Backend JavaScript
The backend runs on the PBX, not in a browser: ES5 only, no fetch, no async/await, no DOM.
HTTP goes through system.http, which is callback-based.
//
// AcmeCRM integration - example code
//
'use strict';
var PATH_CONTACTS = '/api/v2/contacts';
// Set this to the country code of your dial plan, or leave it empty to disable
// national-to-E.164 expansion entirely. See the note below.
var DEFAULT_COUNTRY_CODE = '';
function AcmeCrm () {
// Read what the tenant administrator entered:
this.init = function (domain) {
this.domain = domain;
this.url = (loadIntr(domain, '', 'acme_url', false) || '').replace(/\/+$/, '');
this.key = loadIntr(domain, '', '', true);
if (!this.url || !this.key) {
console.trace('CRM', 4, domain, 'AcmeCRM: URL or API key missing, integration is idle');
return false;
}
return true;
};
// One place for every request, so the credential header is never forgotten:
this.send = function (method, path, body, callback) {
system.http({
method: method,
url: this.url + path,
header: [
{ name: 'Authorization', value: 'Bearer ' + this.key, secret: true },
{ name: 'Content-Type', value: 'application/json' }
],
body: body,
callback: callback
});
};
// CRMs usually store E.164 - make the PBX number match.
this.e164 = function (number) {
var n = String(number || '').replace(/[^0-9+]/g, '');
if (n.substr(0, 2) === '00') return '+' + n.substr(2);
if (n.substr(0, 1) === '+') return n;
// Only expand when a country code has been configured. Blindly prefixing one
// corrupts every number that is not from that country: an Australian
// 0433337285 becomes +10433337285 and matches nothing.
if (DEFAULT_COUNTRY_CODE && n.substr(0, 1) === '0') {
return '+' + DEFAULT_COUNTRY_CODE + n.substr(1);
}
return n;
};
this.screenPop = function (domain, callid, number) {
var cmd = PATH_CONTACTS + '?phone=' + encodeURIComponent(this.e164(number));
this.send('GET', cmd, '', function (code, response, headers) {
if (code !== 200 || !response) {
console.trace('CRM', 7, domain, 'AcmeCRM: contact lookup failed, code ' + code);
return;
}
var d = JSON.parse(response || '{}');
if (!d.contacts || !d.contacts.length) {
console.trace('CRM', 9, domain, 'AcmeCRM: no contact for ' + number);
return;
}
var c = d.contacts[0];
system.setCallInfo(callid, {
action: 'crmcontact',
type: 'AcmeCRM',
name: c.first_name + ' ' + c.last_name,
url: 'https://app.acmecrm.example/contacts/' + c.id
});
console.trace('CRM', 9, domain, 'AcmeCRM: screen pop for ' + c.first_name);
});
};
}
// One instance per tenant, cached in memory:
var objs = {};
function instance (domain) {
if (!(domain in objs)) {
var o = new AcmeCrm();
objs[domain] = o.init(domain) ? o : null;
}
return objs[domain];
}
function onagent (event) {
if (event.state !== 'ringing') return;
var crm = instance(event.domain.address);
if (!crm) return;
// The external party: caller on inbound, callee on outbound.
// Confirm which way round event.inbound reports on your version - see "Before you start".
var number = event.inbound ? event.caller.number : event.callee.number;
if (!number) return;
// Internal calls and star codes are never CRM contacts:
if (Account.get(event.domain.id, number, '*')) return;
if (number.substr(0, 1) === '*') return;
crm.screenPop(event.domain.address, event.callid, number);
}
// Warm the cache for tenants that are already configured:
var domains = tables['domains'].search();
for (var i = 0; i < domains.length; i++) instance(domains[i]);
The e164 helper above is deliberately conservative: it converts an international prefix, passes
through anything already in E.164, and only expands a national number when you have set
DEFAULT_COUNTRY_CODE. Earlier versions of this example hard-coded +1 for any ten-digit number,
which quietly corrupts every non-NANP deployment.
If the target API allows it, searching on the last seven to nine digits is more robust than trying to reconstruct E.164 on both sides.
The screen pop message
system.setCallInfo(callid, msg) attaches information to a live call. With
action: 'crmcontact' the user portal and the apps render a clickable contact card:
{
action: 'crmcontact', // required
type: 'AcmeCRM', // shown as the source
name: 'Dana Whitfield', // shown as the contact name
url: 'https://app.acmecrm.example/contacts/c_10427' // opened when clicked
}
Send it on ringing, not on connected, so the agent knows who is calling before answering.
Pattern 2: Zenith Desk — per-user OAuth
When each agent has their own account in the target system, credentials belong at the user level
and the agent authorizes the connection themselves. The PBX stores and refreshes the tokens
through system.getOAuthToken.
Fictional service:
| Setting | Value |
|---|---|
| Authorization URL | https://auth.zenithdesk.example/oauth2/authorize |
| Token URL | https://auth.zenithdesk.example/oauth2/token |
| API base | https://api.zenithdesk.example |
| Client ID | zd-client-000000 |
| Client secret | zd-secret-0000000000000000 |
1. Agent clicks "Connect" in the user portal
2. User JS → POST /rest/user/<ext>@<tenant>/integrations?identifier=zenith { "info": true }
3. Backend → onfrontend() replies with auth_url + client_id
4. User JS opens the vendor's consent screen in a pop-up
5. Vendor redirects back with ?code=...
6. Relay → POST ...?identifier=zenith { "code": "...", "url": "<redirect_uri>" }
7. Backend → system.getOAuthToken({ obtainToken: true, ... }) exchanges the code
8. Backend → system.updateOAuthSetting() marks the user as connected
9. User JS polls the settings and turns the status icon green
User HTML
The user portal does not include Bootstrap or jQuery. It uses the pbx- web components.
<!-- Zenith Desk -->
<div class="grid">
<pbx-label>Status</pbx-label>
<pbx-icon name="crm-status" icon="fa-xmark"></pbx-icon>
<pbx-label>Connection</pbx-label>
<div>
<pbx-button name="crm-auth-code" appearance="contained"
icon="fa-regular fa-link">Connect</pbx-button>
<pbx-button name="crm-disable" appearance="outlined"
icon="fa-regular fa-link-slash">Disconnect</pbx-button>
</div>
</div>
User CSS
div.grid {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 0.5em;
row-gap: 0.5em;
justify-items: stretch;
align-items: center;
}
User JavaScript
This runs in a modern browser, so modules, fetch and async/await are all available. There
is nothing for save to do — the backend owns the tokens — but the export must still exist.
import { api } from '/modules/api.js?v=[[version]]'
import { domcontrol } from '/tools/domcontrol.js?v=[[version]]'
import { localSettings } from '/tools/localsettings.js?v=[[version]]'
const IDENTIFIER = 'zenith';
export const load = (form, data) => {
domcontrol.click(form.querySelector('[name="crm-auth-code"]'), () => crmAuth(form));
domcontrol.click(form.querySelector('[name="crm-disable"]'), () => crmDisable(form));
crmStatus(form);
}
// Nothing to save: the backend stores the tokens.
export const save = (form) => {
return {};
}
// Post to our own backend - onfrontend() receives this:
const post = (payload) => {
const url = '/rest/user/' + encodeURIComponent(localSettings.getUnDn()) +
'/integrations?identifier=' + IDENTIFIER;
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(payload)
});
}
// Green check when the PBX holds a token for this user:
const crmStatus = async (form) => {
const s = await api.getAllSettings(true);
const status = form.querySelector('[name="crm-status"]');
const connected = s.oauthlist && s.oauthlist[IDENTIFIER];
status.setAttribute('icon', connected ? 'fa-check' : 'fa-xmark');
status.style.color = connected ? 'green' : 'red';
}
const crmAuth = async (form) => {
const response = await post({ info: 'true' });
const info = await response.json();
if (!info || !info.auth_url || !info.client_id) {
console.log('Zenith Desk: no auth_url or client_id configured');
return;
}
// The redirect URI registered with the vendor is fixed, so the pop-up lands on a relay page
// that forwards the code to this PBX. The PBX address travels in the state parameter.
const relay = 'https://portal.vodia.com/en/oauthredir';
const back = `${window.location.protocol}//${window.location.host}/crm_token.htm`;
const state = [ localSettings.getDn(), localSettings.getUsername(), IDENTIFIER, back ].join(':');
const requrl = `${info.auth_url}/oauth2/authorize` +
`?response_type=code` +
`&access_type=offline` +
`&client_id=${encodeURIComponent(info.client_id)}` +
`&scope=${encodeURIComponent('contacts.read activities.write')}` +
`&redirect_uri=${encodeURIComponent(relay)}` +
`&state=${encodeURIComponent(state)}`;
const win = window.open(requrl, 'zenithauth', 'width=800, height=600');
const refresh = () => {
if (win && win.closed) setTimeout(() => crmStatus(form), 3000);
else setTimeout(refresh, 3000);
};
refresh();
}
const crmDisable = async (form) => {
await post({ disable: 'true' });
setTimeout(() => crmStatus(form), 3000);
}
Close the fetch( call around the whole URL. A stray parenthesis such as
fetch("/rest/user/" + localSettings.getUnDn()) + "/integrations..." closes the call early and
posts to the wrong path — and it is valid JavaScript, so nothing complains. Also run the extension
identity through encodeURIComponent, because 41@tenant.example contains characters that must
be escaped.
Ask for offline access (access_type=offline, or the vendor's equivalent scope) during
authorization. Without a refresh token the integration works for an hour and then quietly stops.
Backend JavaScript
//
// Zenith Desk integration - example code
//
'use strict';
var VENDOR = 'ZenithDesk';
var AUTH_URL = 'https://auth.zenithdesk.example';
var TOKEN_URL = 'https://auth.zenithdesk.example/oauth2/token';
// System-level settings are a good place for credentials shared by every tenant:
var CLIENT_ID = system.setting('zenith_client_id') || 'zd-client-000000';
var CLIENT_SECRET = system.setting('zenith_client_secret') || 'zd-secret-0000000000000000';
function ZenithDesk () {
// Every request carries the individual user's token. The tenant is passed in rather than
// looked up from the user ID - see the note on table access in the reference section.
this.sendCmd = function (method, command, domain, user, body, callback) {
system.getOAuthToken({ vendor: VENDOR, user: user }).then(function (result) {
if (!result || !('access_token' in result)) {
console.trace('CRM', 4, domain, 'Zenith Desk: no token for ' +
Account.get(domain, user, 'alias-name') + ', the user must connect first');
return;
}
// Some vendors return extra fields alongside the token - for example a per-account API
// host. Read them from the result rather than hard-coding one base URL.
var base = result.api_domain || 'https://api.zenithdesk.example';
system.http({
method: method,
url: base + command,
header: [
{ name: 'Authorization', value: 'Bearer ' + result.access_token, secret: true },
{ name: 'Content-Type', value: 'application/json' }
],
body: body,
callback: callback
});
});
};
this.screenPop = function (domain, user, callid, number) {
var cmd = '/v1/contacts?filter[phone]=' + encodeURIComponent(number);
this.sendCmd('GET', cmd, domain, user, '', function (code, response) {
if (code !== 200 || !response) return;
var d = JSON.parse(response || '{}');
if (!d.data || !d.data.length) {
console.trace('CRM', 9, domain, 'Zenith Desk: no contact for ' + number);
return;
}
var c = d.data[0];
system.setCallInfo(callid, {
action: 'crmcontact',
type: 'Zenith Desk',
name: c.name,
url: 'https://app.zenithdesk.example/contacts/' + c.id
});
});
};
// Some systems want to follow the call live, not just receive a summary afterwards.
// 'state' is one of ringing / answered / ended.
this.callState = function (domain, user, callid, from, to, state, startMs, duration) {
var body = JSON.stringify({
id: callid,
state: state,
from: from,
to: to,
started_at: new Date(startMs).toISOString(),
duration_seconds: duration || 0
});
this.sendCmd('POST', '/v1/calls/notify', domain, user, body, function (code, response) {
if (code !== 200 && code !== 201) {
console.trace('CRM', 7, domain, 'Zenith Desk: call notify failed, code ' + code);
}
});
};
this.disable = function (user, domain) {
console.trace('CRM', 9, domain, 'Zenith Desk: removing token for ' +
Account.get(domain, user, 'alias-name'));
system.updateOAuthSetting(VENDOR, user, false, true);
};
}
var objs = {};
//
// Called for POST requests the user portal sends to
// /rest/user/<ext>@<tenant>/integrations?identifier=zenith
//
function onfrontend (data, callback) {
var domain = data.domain;
var user = data.user;
var zd = objs[domain] || new ZenithDesk();
var body = JSON.parse(data.body || '{}');
// Step 3: tell the front end where to send the browser.
if (body.info === 'true') {
callback(200, 'Ok', 'application/json',
JSON.stringify({ auth_url: AUTH_URL, client_id: CLIENT_ID }));
return;
}
// The agent disconnected the integration.
if (body.disable === 'true') {
zd.disable(user, domain);
saveIntr(domain, '', 'zenith_enabled', '', false);
callback(200, 'Ok', 'application/json', '{"result":true}');
return;
}
// Step 7: exchange the code for tokens. The PBX stores and refreshes them from here on.
var arg = {
vendor: VENDOR,
user: user,
domain: domain,
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
redirectUri: body.url,
server: TOKEN_URL,
code: body.code,
obtainToken: true,
inBody: true, // false sends the credentials as an Authorization header instead
authorization: 'Basic ' + toBase64String(CLIENT_ID + ':' + CLIENT_SECRET)
};
system.getOAuthToken(arg).then(function (result) {
if (result) {
system.updateOAuthSetting(VENDOR, user, true);
objs[domain] = zd;
saveIntr(domain, '', 'zenith_enabled', 'true', false);
console.trace('CRM', 9, domain, 'Zenith Desk: token obtained');
} else {
console.trace('CRM', 4, domain, 'Zenith Desk: token exchange failed');
}
});
callback(200, 'Ok', 'application/json', '{"result":true}');
}
var STATES = { ringing: 'ringing', connected: 'answered', terminated: 'ended' };
function onagent (event) {
var state = STATES[event.state];
if (!state) return;
var domain = event.domain.address;
var zd = objs[domain];
if (!zd) return;
var number = event.inbound ? event.caller.number : event.callee.number;
if (!number) return;
if (Account.get(event.domain.id, number, '*')) return; // internal
if (number.substr(0, 1) === '*') return; // star code
if (state === 'ringing') {
zd.screenPop(domain, event.agent.id, event.callid, number);
}
var from = event.inbound ? number : event.agent.number;
var to = event.inbound ? event.agent.number : number;
var startMs = toMillis(event.timestamps[event.state]);
var duration = 0;
if (state === 'ended') {
var c = toMillis(event.timestamps['connected']);
var e = toMillis(event.timestamps['terminated']);
duration = (c && e && e > c) ? Math.floor((e - c) / 1000) : 0;
}
zd.callState(domain, event.agent.id, event.callid, from, to, state, startMs, duration);
}
function toMillis (t) {
if (!t) return 0;
return t > 1e12 ? t : t * 1000;
}
var domains = tables['domains'].search();
for (var i = 0; i < domains.length; i++) {
if (loadIntr(domains[i], '', 'zenith_enabled', false) === 'true') {
objs[domains[i]] = new ZenithDesk();
}
}
Pattern 3: Beacon Support — tickets from the CDR
A helpdesk integration. When an inbound call to a queue ends, find the customer, attach the call to their most recent ticket, and open a new one if there is none. This is the pattern to copy whenever you write after the call rather than during it.
The interesting work is not the HTTP — it is extracting sensible values out of the CDR.
Fictional API, using basic authentication with an agent e-mail and an API token:
| Purpose | Request |
|---|---|
| Find a user by phone | GET /api/v2/search?query=type:user phone:15105550142 |
| Find their tickets | GET /api/v2/search?query=type:ticket requester:42 order_by:created_at sort:desc |
| Append to a ticket | PUT /api/v2/tickets/1001.json |
| Create a ticket | POST /api/v2/tickets.json |
Tenant JavaScript
Two secrets are needed here, and there is only one secret slot — so pack them into one string:
'use strict';
export const load = (form, data) => {
const d = data || {};
form.querySelector('[name="beacon_url"]').value = d.beacon_url || '';
form.querySelector('[name="beacon_email"]').value = d.beacon_email || '';
form.querySelector('[name="beacon_token"]').value = '';
}
export const save = (form) => {
const email = form.querySelector('[name="beacon_email"]').value;
const result = [{
beacon_url: form.querySelector('[name="beacon_url"]').value.replace(/\/+$/, ''),
beacon_email: email
}];
const token = form.querySelector('[name="beacon_token"]').value;
// The API expects "<email>/token:<token>" as basic credentials - store it ready to use:
token && result.push(email + '/token:' + token);
return result;
}
Backend JavaScript
//
// Beacon Support integration - example code
//
'use strict';
var BASE = '/api/v2/';
// Fictional custom field IDs from the helpdesk - yours will differ:
var FIELD_DURATION = 100000000001;
var FIELD_CALLID = 100000000002;
function Beacon () {}
Beacon.prototype.sendCmd = function (method, settings, command, body, callback) {
var args = {
method: method,
url: settings.url + BASE + command,
header: [
{ name: 'Authorization', value: 'Basic ' + toBase64String(settings.cred), secret: true },
{ name: 'Content-Type', value: 'application/json' }
],
callback: callback
};
if (body) args.body = JSON.stringify(body);
system.http(args);
};
Beacon.prototype.hhmmss = function (seconds) {
var d = new Date(null);
d.setSeconds(seconds);
return d.toISOString().substr(11, 8);
};
Beacon.prototype.logCall = function (domain, settings, call) {
var self = this;
var body = { ticket: { comment: { public: false } } };
body.ticket.comment.body = 'Call from ' + call.from + ' to ' + call.to +
' at ' + new Date(call.startMs).toISOString() +
' for ' + this.hhmmss(call.duration);
if (call.recordingLink) {
body.ticket.comment.body += '\nRecording: ' + call.recordingLink;
}
body.ticket.custom_fields = [
{ id: FIELD_DURATION, value: call.duration },
{ id: FIELD_CALLID, value: call.callid }
];
// Strip a leading + - many helpdesks store numbers without it:
var caller = call.from.substr(0, 1) === '+' ? call.from.substr(1) : call.from;
self.sendCmd('GET', settings, 'search?query=type:user%20phone:' + encodeURIComponent(caller),
'', function (code, response) {
var res = JSON.parse(response || '{}');
if (!res.results || !res.results.length || !res.results[0].id) {
// No such customer: create the ticket and the contact together.
body.ticket.subject = 'Call from ' + caller;
body.ticket.requester = { name: caller, phone: caller };
if (call.groupId) body.ticket.group_id = call.groupId;
self.sendCmd('POST', settings, 'tickets.json', body, function (c2, r2) {
console.trace('CRM', 9, domain, 'Beacon: new ticket and contact, code ' + c2);
});
return;
}
var customerId = res.results[0].id;
var customerEmail = res.results[0].email;
var q = 'search?query=type:ticket%20requester:' + customerId +
'%20order_by:created_at%20sort:desc';
self.sendCmd('GET', settings, q, '', function (c3, r3) {
var res3 = JSON.parse(r3 || '{}');
if (res3.results && res3.results.length && res3.results[0].id) {
// Append to the most recent ticket:
self.sendCmd('PUT', settings, 'tickets/' + res3.results[0].id + '.json', body,
function (c4) {
console.trace('CRM', 9, domain, 'Beacon: appended to ticket, code ' + c4);
});
}
else if (customerEmail) {
body.ticket.subject = 'Call from ' + caller;
body.ticket.requester = { email: customerEmail };
if (call.groupId) body.ticket.group_id = call.groupId;
self.sendCmd('POST', settings, 'tickets.json', body, function (c5) {
console.trace('CRM', 9, domain, 'Beacon: new ticket, code ' + c5);
});
}
});
});
};
var beacon = new Beacon();
function oncdr (cdr) {
// In oncdr, cdr.domain is the tenant address. Resolve it the way the settings were stored:
var domain = Domain.get(cdr.domain, '*');
var settings = {
url: loadIntr(domain, '', 'beacon_url', false),
email: loadIntr(domain, '', 'beacon_email', false),
cred: loadIntr(domain, '', '', true)
};
if (!settings.url || !settings.cred) return;
// No trunk leg at all means an internal extension-to-extension call. Filter it out
// before anything else - see "The three leg arrays in a CDR".
if (!cdr.trunklegs || !cdr.trunklegs.length) {
console.trace('CRM', 9, domain, 'Beacon: internal call, not logging');
return;
}
// This integration only wants inbound calls. Leg direction is 'I' for inbound.
if (cdr.trunklegs[0].direction !== 'I') return;
// The From header is a full SIP contact - parse it down to the number:
var contact = system.parseSipContact(cdr.from);
var from = system.parseSipUri(contact.uri).user;
// Walk the extension legs and keep the LAST one that actually connected. After a transfer
// there are several, and the last one is the extension that really handled the call.
// Note this tests connect/end timestamps, NOT a SIP response code - a cancelled fork
// reports 486 or 487 on calls that were in fact answered.
var answered = null;
var group = '';
if (cdr.extensionlegs) {
for (var i = 0; i < cdr.extensionlegs.length; i++) {
var e = cdr.extensionlegs[i];
if (e.direction === 'O' && e.connect > 0 && e.end > e.connect) {
answered = e;
if (e.redirect) group = system.parseSipContact(e.redirect).name || '';
}
}
}
if (!answered) {
console.trace('CRM', 9, domain, 'Beacon: call from ' + from + ' was never answered');
return;
}
// CDR leg timestamps are in seconds:
var call = {
callid: cdr.callid,
from: from,
to: answered.extension,
startMs: answered.start * 1000,
duration: Math.floor(answered.end - answered.connect),
groupId: 0,
recordingLink: ''
};
// Build a playback link for the first recording, if there is one:
if (cdr.recordings && cdr.recordings.length) {
var rec = cdr.recordings[0];
if (rec.url) {
call.recordingLink = rec.url;
} else if (rec.id) {
var rep = system.httpRepresentation(domain);
var host = (rep && rep.address) ? rep.address : Domain.get(domain, 'name');
call.recordingLink = 'https://' + host + '/rest/user/' + call.to + '@' +
Domain.get(domain, 'name') + '/recs?id=' + rec.id;
}
}
// Route the ticket to the helpdesk group matching the PBX queue name, when there is one:
if (group) {
var cmd = 'search?query=type:group%20name:' + encodeURIComponent(group);
beacon.sendCmd('GET', settings, cmd, '', function (code, response) {
var res = JSON.parse(response || '{}');
if (res.results && res.results.length && res.results[0].id) {
call.groupId = res.results[0].id;
}
beacon.logCall(domain, settings, call);
});
} else {
beacon.logCall(domain, settings, call);
}
}
cdr.fromandcdr.toare full SIP headers. Usesystem.parseSipContactthensystem.parseSipUrito get a bare number.- An empty
cdr.trunklegsmeans an internal call — return early. trunklegs[0].directiontells you inbound ('I') or outbound ('O'). State legs do not.- Decide answered from talk time or
connect > 0, never from a SIP response code. - Iterate
extensionlegsand keep the last leg withconnect > 0 && end > connect— that is the extension that handled the call after any transfers. No such leg means the call was never answered. extensionlegs[i].redirectis a SIP contact whose display name is the queue or ring group the call came through.- Leg timestamps (
start,connect,end) are seconds;states[i].durationtalkis milliseconds. Multiply the former by 1000 beforenew Date(); divide the latter. states[]andtrunklegs[]are not the same length. Do not pair them by index.
Pattern 4: Northwind Cloud — tenant OAuth with admin consent
Some platforms authorize once per organization rather than per user, often with a separate
administrator consent step. The tenant administrator does this from the tenant interface, and
because the whole page navigates away and back, the tokens arrive as query parameters and are
saved with the ordinary save call.
Fictional values:
| Setting | Value |
|---|---|
| Client ID | 00000000-0000-0000-0000-000000000000 |
| Redirect URI (registered with the vendor) | https://portal.example.com/en/northwindredir |
| Consent endpoint | https://login.northwind.example/common/adminconsent |
| Authorize endpoint | https://login.northwind.example/common/oauth2/v2.0/authorize |