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.
Two things that will bite you
Timestamp units are not consistent. oncdr timestamps (start, connect, end, and the
same fields on legs) are in seconds; onagent timestamps are in milliseconds. Normalize
before doing arithmetic:
// Accepts either unit and returns milliseconds:
function toMillis (t) {
if (!t) return 0;
return t > 1e12 ? t : t * 1000;
}
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 unambiguous, because the legs carry it explicitly: trunk and extension
legs use 'I' for inbound and 'O' for outbound.
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';
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') n = '+' + n.substr(2);
else if (n.substr(0, 1) !== '+' && n.length === 10) n = '+1' + n; // adapt to your dial plan
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 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;
// Only inbound trunk calls. Leg direction is 'I' for inbound, 'O' for outbound.
if (!cdr.trunklegs || !cdr.trunklegs.length || 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.
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 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.trunklegs[0].directiontells you inbound ('I') or outbound ('O') without guessing.- 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.- Timestamps here are seconds. Multiply by 1000 before
new Date().
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 |
Tenant HTML
<!-- Northwind Cloud -->
<input type="password" style="display:none;" readonly>
<div class="row">
<div class="col-sm-12"><div id="northwind-status"></div></div>
</div>
<div class="row">
<div class="form-group">
<label for="northwind_org_url" class="col-sm-6 control-label">Organization URL</label>
<div class="col-sm-6">
<input type="url" class="form-control" id="northwind_org_url"
placeholder="https://yourorg.northwind.example">
<small class="form-text text-muted">Required before requesting a token</small>
</div>
</div>
</div>
<div class="row">
<div class="form-group">
<div class="col-sm-4">
<button id="northwind-consent" class="btn btn-warning fancy">
<i class="fa fa-shield"></i> 1. Grant Admin Consent
</button>
</div>
<div class="col-sm-4">
<button id="northwind-token" class="btn btn-primary fancy">
<i class="fa fa-key"></i> 2. Get OAuth Token
</button>
</div>
<div class="col-sm-4">
<button id="northwind-remove" class="btn btn-danger fancy">
<i class="fa fa-trash"></i> Remove Tokens
</button>
</div>
</div>
</div>
The hidden password input at the top is a small trick that stops browsers from autofilling credentials into the real fields below.
Tenant JavaScript
Bootstrap and jQuery are available in the tenant interface, so $(...) works here — unlike in
the user portal.
'use strict';
const CLIENT_ID = '00000000-0000-0000-0000-000000000000';
const REDIRECT_URI = 'https://portal.example.com/en/northwindredir';
const LOGIN_BASE = 'https://login.northwind.example/common';
// Tokens handed back as query parameters after the redirect:
window.northwindTokens = null;
function collectTokens () {
const p = new URLSearchParams(window.location.search);
if (p.get('northwind_success') !== '1' || !p.get('northwind_access_token')) return;
const expires = parseInt(p.get('northwind_expires_in')) || 3600;
window.northwindTokens = {
access_token: p.get('northwind_access_token'),
refresh_token: p.get('northwind_refresh_token') || '',
org_url: p.get('northwind_org_url') || '',
token_expires: Date.now() + expires * 1000
};
// Clean the tokens out of the address bar so they are not bookmarked or shared:
window.history.replaceState({}, document.title, window.location.href.split('?')[0]);
alert('Authorization successful. Please save your settings.');
}
collectTokens();
export const load = (form, data) => {
form.querySelector('#northwind_org_url').value = (data || {}).northwind_org_url || '';
if (!window.northwindTokens) collectTokens();
updateStatus();
}
export const save = (form) => {
const orgUrl = form.querySelector('#northwind_org_url').value.trim();
const pub = { northwind_org_url: orgUrl };
const t = window.northwindTokens;
if (!t) return [pub];
// Public settings plus the refresh token in the secret slot:
pub.northwind_org_url = t.org_url || orgUrl;
pub.northwind_token_expires = t.token_expires;
return [pub, t.refresh_token || t.access_token];
}
function validOrgUrl (url) {
try {
const u = new URL(url);
return u.protocol === 'https:' && u.hostname.endsWith('.northwind.example');
} catch (e) {
return false;
}
}
function updateStatus () {
const div = document.getElementById('northwind-status');
const t = window.northwindTokens;
div.innerHTML = t && t.access_token
? `<div class="alert alert-success"><i class="fa fa-check-circle"></i>
<strong>Connected</strong><br>Organization: ${t.org_url || 'configured'}</div>`
: '';
}
function authUrl (kind, orgUrl) {
const dn = sessionStorage.getItem('domain');
if (!dn) {
alert('Domain not found. Please reload the page.');
return null;
}
const back = window.location.href.split('?')[0];
const state = [ encodeURIComponent(dn), back, kind, encodeURIComponent(orgUrl) ].join('::');
if (kind === 'admin_consent') {
return `${LOGIN_BASE}/adminconsent` +
`?client_id=${encodeURIComponent(CLIENT_ID)}` +
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
`&state=${encodeURIComponent(state)}`;
}
const scope = orgUrl.replace(/\/+$/, '') + '/user_impersonation offline_access';
return `${LOGIN_BASE}/oauth2/v2.0/authorize` +
`?client_id=${encodeURIComponent(CLIENT_ID)}` +
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
`&response_type=code` +
`&scope=${encodeURIComponent(scope)}` +
`&state=${encodeURIComponent(state)}`;
}
$(function () {
$('#northwind-consent').on('click', function () {
const url = authUrl('admin_consent', $('#northwind_org_url').val().trim());
if (url) window.location.href = url;
});
$('#northwind-token').on('click', function () {
const orgUrl = $('#northwind_org_url').val().trim();
if (!validOrgUrl(orgUrl)) {
alert('Please enter a valid organization URL first.');
$('#northwind_org_url').focus();
return;
}
const url = authUrl('oauth', orgUrl);
if (url) window.location.href = url;
});
$('#northwind-remove').on('click', function () {
const domain = encodeURIComponent(sessionStorage.getItem('domain'));
fetch('/rest/domain/' + domain + '/integrations?identifier=northwind', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ disable: 'true' })
}).then(() => {
window.northwindTokens = null;
updateStatus();
});
});
updateStatus();
});
Note the two REST paths. The tenant interface posts to /rest/domain/<tenant>/integrations,
the user portal to /rest/user/<ext>@<tenant>/integrations. Both arrive at onfrontend; the
difference is whether data.user is set.
This pattern is convenient but it puts an access token in a URL, where it can end up in browser
history, proxy logs and referrer headers. Mitigate it: use a short-lived code or refresh token
rather than a long-lived access token, call history.replaceState immediately as above, and put
the token in the secret slot when saving rather than in the public settings object.
Backend JavaScript
'use strict';
var VENDOR = 'Northwind';
function onfrontend (data, callback) {
var domain = data.domain;
var body = JSON.parse(data.body || '{}');
if (body.disable === 'true') {
system.updateOAuthSetting(VENDOR, data.user || 0, false, true);
saveIntr(domain, '', 'northwind_enabled', '', false);
callback(200, 'Ok', 'application/json', '{"result":true}');
return;
}
callback(200, 'Ok', 'application/json', '{"result":true}');
}
// Read the tenant token whenever a request needs to be made:
function northwindRequest (domain, method, path, body, callback) {
var org = loadIntr(domain, '', 'northwind_org_url', false);
var token = loadIntr(domain, '', '', true);
if (!org || !token) {
console.trace('CRM', 4, domain, 'Northwind: not configured');
return;
}
system.http({
method: method,
url: org.replace(/\/+$/, '') + path,
header: [
{ name: 'Authorization', value: 'Bearer ' + token, secret: true },
{ name: 'Content-Type', value: 'application/json' }
],
body: body,
callback: callback
});
}
Pattern 5: Click to call from an external application
onhttp lets an outside application reach into the PBX. Requests arrive on a URL prefixed with
your identifier, and you answer through the callback. This example rings the agent's own extension
first, then dials the customer once the agent picks up.
POST https://pbx.example.com/northwind/dial
Content-Type: application/json
X-Integration-Secret: shared-secret-000000
{ "domain": "tenant.example.com", "extension": "41", "to": "+15105550142" }
'use strict';
function onhttp (data, callback) {
if (data.method !== 'POST' || data.url !== 'dial') {
callback(404, 'Not Found', 'application/json', '{"error":"unknown endpoint"}');
return;
}
// Authenticate. Never leave this endpoint open - it can place calls.
var secret = '';
for (var i = 0; i < data.headers.length; i++) {
if (data.headers[i].name.toLowerCase() === 'x-integration-secret') {
secret = data.headers[i].value;
}
}
var expected = loadIntr(data.domain || '', '', '', true);
if (!expected || secret !== expected) {
callback(403, 'Forbidden', 'application/json', '{"error":"bad secret"}');
return;
}
var req = {};
try {
req = JSON.parse(data.body || '{}');
} catch (e) {
callback(400, 'Bad Request', 'application/json', '{"error":"invalid json"}');
return;
}
if (!req.extension || !req.to) {
callback(400, 'Bad Request', 'application/json', '{"error":"extension and to are required"}');
return;
}
var id = startCall({
domain: req.domain || data.domain,
user: req.extension,
to: req.to,
timeout: 30,
connect: true // connect the two parties automatically
});
console.trace('SCRIPT', 9, data.domain,
'Click to call: ' + req.extension + ' -> ' + req.to + ' (call ' + id + ')');
callback(200, 'Ok', 'application/json', JSON.stringify({ callid: id }));
}
startCall returns an identifier you can pass to endCall(id) to cancel the attempt.
Reporting failures back
If the external system wants to know why a click-to-dial attempt failed, subscribe to the system's call error events:
system.on('call-error-rejected', function (data) {
report(data, 'rejected', 'The agent did not accept the call');
});
system.on('call-error-notavailable', function (data) {
report(data, 'notavailable', 'The agent is not available');
});
system.on('call-error-invalid', function (data) {
report(data, 'invalid', 'The dialed number is invalid');
});
function report (data, code, message) {
// data.call has domain, from and to; data.user is the extension that dialed.
var from = system.parseSipUri(system.parseSipContact(data.call.from).uri).user;
var to = system.parseSipUri(system.parseSipContact(data.call.to).uri).user;
console.trace('SCRIPT', 7, data.call.domain,
'Click to call failed: ' + code + ' ' + from + ' -> ' + to + ' (' + message + ')');
// ...forward to the external system here
}
These fire for every call on the system, so check that the call belongs to your integration before acting — for example by comparing the tenant against your own settings.
Pattern 6: Directory sync and other non-call integrations
Nothing in the framework restricts integrations to calls. A useful category has no call hooks at all: it adds buttons to the tenant interface, and the backend does work when they are pressed. Directory sync is the usual example — read users from an identity provider, then create matching extensions.
The mechanics: the tenant page posts an action, onfrontend dispatches on it, and the backend
uses the PBX's own REST API to make changes.
'use strict';
function onfrontend (data, callback) {
var domain = data.domain;
var body = JSON.parse(data.body || '{}');
switch (body.action) {
case 'preview':
// Fetch the directory and return it, so the administrator can review and choose
// before anything is created. Do not create accounts on the first click.
fetchDirectory(domain, function (users) {
callback(200, 'Ok', 'application/json', JSON.stringify({ users: users }));
});
return;
case 'create':
// body.users is what the administrator confirmed in the dialog.
createExtensions(domain, body.users || [], function (results) {
callback(200, 'Ok', 'application/json', JSON.stringify({ results: results }));
});
return;
case 'settings':
saveIntr(domain, '', 'sync_enabled', body.enabled ? 'true' : '', false);
callback(200, 'Ok', 'application/json', '{"result":true}');
return;
}
callback(400, 'Bad Request', 'application/json', '{"error":"unknown action"}');
}
function createExtensions (domain, users, done) {
var results = [];
for (var i = 0; i < users.length; i++) {
var u = users[i];
// Do not overwrite an account that already exists:
if (Account.get(domain, u.extension, '*')) {
results.push({ email: u.email, ok: false, error: 'extension ' + u.extension + ' in use' });
continue;
}
// The exact payload depends on your PBX version - see the REST API documentation.
var r = system.restApi({
method: 'POST',
path: '/rest/domain/' + domain + '/accounts',
body: JSON.stringify({
name: u.extension,
'display-name': u.display_name,
email_address: u.email
})
});
results.push({ email: u.email, ok: !!r, extension: u.extension });
}
done(results);
}
Three points that matter more here than in a CRM integration:
Preview before writing. Creating extensions is destructive if the mapping is wrong. Return the proposed list, let the administrator adjust extension numbers and deselect rows, then create only what came back confirmed.
Detect conflicts explicitly. Check for an existing extension number, for an e-mail already attached to a different account, and for duplicates within the batch itself. Report each conflict next to the row that caused it rather than failing the whole run.
Make automatic sync opt-in. Store a flag and check it before doing anything unattended, and report what a scheduled run did in the trace log — nobody will be watching when it happens.
Reference: what the backend gives you
Settings
| Function | Purpose |
|---|---|
loadIntr(domain, user, name, secret) | Read a setting. "" as user for tenant settings. secret: true with an empty name reads the one secret slot. |
saveIntr(domain, user, name, value, secret) | Write a setting from the backend. |
system.setting(name) | Read a system-level setting, e.g. a client ID shared by all tenants. |
HTTP and OAuth
| Function | Purpose |
|---|---|
system.http({ method, url, header, body, callback }) | Outbound HTTP. callback(code, response, headers). Mark credential headers secret: true. |
system.getOAuthToken(arg) | Promise resolving to a valid access token; also performs the initial code exchange with obtainToken: true. Vendor extras such as api_domain come back in the result. |
system.updateOAuthSetting(vendor, user, enabled, remove) | Mark a user as connected, or drop the stored token. |
system.oauthList | The current OAuth registrations. |
system.restApi({ method, path, body }) | Call the PBX's own REST API. |
toBase64String(text) | Base64, e.g. for HTTP basic authentication. |
Calls
| Function | Purpose |
|---|---|
system.setCallInfo(callid, msg) | Attach information to a live call — this is the screen pop. |
startCall(args) | Place a call; returns an ID. |
endCall(id, code, expl, forced) | Tear a call down. |
system.on(event, fn) | Subscribe to system events such as call-error-rejected, call-error-notavailable, call-error-invalid. |
Looking things up
| Function | Purpose |
|---|---|
tables['domains'].search() | List tenants, e.g. at start-up. |
Account.get(domain, account, field) | Account lookup. Useful fields: alias-name, display-name, email_address. Returns nothing when the account does not exist, which is the cheapest internal-number test: Account.get(domain, number, '*'). |
Domain.get(domain, field) | Tenant lookup, e.g. name, or '*' to resolve a reference. |
system.httpRepresentation(domain) | The externally reachable address for the tenant — use it to build links. |
Avoid table access that goes through the user index — for example resolving a tenant from a numeric user ID with the users table. That access path changes in version 71, and code written against it will need reworking.
You never need it. Every hook hands you the tenant: event.domain.address in onagent and
ongroup, cdr.domain in oncdr, data.domain in onfrontend and onhttp. Pass it down
through your own functions, as sendCmd does in
Pattern 2, and use Account.get and Domain.get for
the lookups themselves — those stay as they are.
Parsing and formatting
| Function | Purpose |
|---|---|
system.parseSipContact(header) | Split a SIP contact into name and uri. |
system.parseSipUri(uri) | Split a URI; .user is the number. |
system.domainGlobalNumber(domain, number) | Expand an extension to its global number. |
system.format(fmt, ...) | printf-style formatting. |
console.trace(area, level, domain, text) | Log. Higher level means more verbose. |
Practical advice
Log at deliberate levels. Level 9 for the detail you want while building, level 7 for failures worth noticing, level 4 for misconfiguration the administrator must fix. Raise the log level on the system's Logging page while testing, then leave the verbose lines in — they cost nothing at a low level and save hours later.
Filter internal traffic first. Extension-to-extension calls, star codes and pickup codes all
fire onagent. Testing Account.get(domain, number, '*') before anything else stops the target
system being hammered with lookups that can never match.
Return quickly. These hooks run inside call processing. Do the work in the system.http
callback and return; never wait for a response inline.
Expect duplicate events. A single call produces several onagent events per state as legs
change. If a write is not idempotent, key it on event.callid.
Prefer oncdr for logging. It fires once, the direction is explicit in the legs, and
transfers, recordings and queue names are all resolvable. Reconstructing the same picture from
onagent events is considerably more work and easier to get wrong.
Numbers rarely match on the first attempt. The PBX passes on whatever the trunk sent — ten
digits, sometimes a leading 1, sometimes full E.164. The target system holds whatever somebody
typed. Normalize both sides, and if the API allows it, search on the last seven to nine digits
instead of the whole string.