Softphone Integration Guide

Everything your application needs to embed the WebRTC softphone iframe and trigger outbound calls via the REST API.

WebRTC CenterPoint PBX JWT HS256 REST API

Architecture

The softphone is a hidden iframe that lives inside your CRM page. It registers to CenterPoint automatically on load and auto-answers incoming calls. Your backend server triggers calls via a REST endpoint — the iframe handles the rest.

How it fits into your application
flowchart TB subgraph your["Your Application"] UI["CRM Page\n(browser)"] BE["CRM Backend\n(server)"] end iframe["Softphone iframe"] CP["CenterPoint PBX"] PSTN["Customer\nPhone"] UI -- "① embed <iframe jwt=...>" --> iframe iframe -. "auto-registers" .-> CP BE -- "② POST /calls/originate\nX-Api-Key" --> SS["Softphone Service"] SS --> CP CP -- "③ rings agent" --> iframe CP -- "④ dials customer" --> PSTN iframe -- "⑤ postMessage events" --> UI
Registration — iframe load to ready
sequenceDiagram autonumber participant P as CRM Page participant I as Softphone iframe participant S as Softphone Service participant C as CenterPoint PBX P->>I: load src="...?jwt=<token>" I->>S: fetch SIP credentials S-->>I: uri · credentials · TURN servers I->>C: Register C-->>I: Ready I->>P: postMessage { type: "registered" }
Call origination — click-to-dial
sequenceDiagram autonumber participant B as CRM Backend participant S as Softphone Service participant C as CenterPoint PBX participant I as Softphone iframe participant P as CRM Page participant PSTN as Customer Phone B->>S: POST /calls/originate\nX-Api-Key · agentUserId · customerNumber S-->>B: { success: true } C->>I: Incoming call I->>C: Auto-answer C->>PSTN: Dial customer PSTN-->>C: Answer C->>C: Bridge — audio flowing I->>P: postMessage { type: "answered" }
Key design principle The iframe is a passive endpoint. It never dials, never shows UI to the agent. All call control comes from your backend through /calls/originate.

Embedding the iframe

Add a single hidden iframe to your CRM page. Generate a signed JWT on your server and inject it into the src. The iframe handles everything else.

HTML Client-side
<!-- Invisible softphone endpoint — renders nothing to the agent -->
<iframe
  src="https://agam1.zyx.bz/softphone.html?jwt={{ signed_jwt }}"
  allow="microphone; autoplay"
  style="display:none"
></iframe>
Required: allow="microphone; autoplay" Without this Permissions Policy attribute the browser blocks microphone access inside the iframe and audio will fail. The softphone logs policy: BLOCKED if this is missing.

JWT refresh

JWTs have a recommended TTL of 1 hour. The iframe decodes the exp claim locally and reloads itself 60 seconds before expiry — no action required on your side. To force a refresh, update the src with a new JWT.

🔑 JWT format

JWTs are signed with your tenant's jwt_secret using HS256. We provide the secret when your tenant is provisioned.

Payload claims

ClaimTypeDescription
tidstring (UUID)Your tenant ID — provided by us at onboarding
substringThe agent's user ID in your system — must match a provisioned extension
iatnumberIssued-at timestamp (Unix seconds) — set automatically by most JWT libraries
expnumberExpiry timestamp — recommend iat + 3600 (1 hour)

Signing examples

Node.js Server-side
const jwt = require('jsonwebtoken');

const token = jwt.sign(
  { tid: 'your-tenant-uuid', sub: agentUserId },
  process.env.SOFTPHONE_JWT_SECRET,
  { algorithm: 'HS256', expiresIn: '1h' }
);
Python Server-side
import jwt, os
from datetime import datetime, timedelta, timezone

token = jwt.encode(
  {
    "tid": "your-tenant-uuid",
    "sub": agent_user_id,
    "exp": datetime.now(timezone.utc) + timedelta(hours=1),
  },
  os.environ["SOFTPHONE_JWT_SECRET"],
  algorithm="HS256",
)
PHP Server-side
// composer require firebase/php-jwt
use Firebase\JWT\JWT;

$token = JWT::encode([
  'tid' => 'your-tenant-uuid',
  'sub' => $agentUserId,
  'exp' => time() + 3600,
], $_ENV['SOFTPHONE_JWT_SECRET'], 'HS256');
Never sign JWTs in the browser The jwt_secret must stay on your server. Always sign server-side and inject the token into the iframe src.

Triggering a call

When an agent clicks a contact in your CRM, your backend server calls POST /calls/originate. The service resolves the agent's extension and instructs CenterPoint to dial — the iframe auto-answers without any agent interaction.

Request Server-side
POST /calls/originate
X-Api-Key: <your api_key>
Content-Type: application/json

{
  "agentUserId":    "crm-user-id-123",   // same value used as JWT sub
  "customerNumber": "0501234567"
}
Response — immediate, call is async
HTTP 200
{ "success": true, "uniqueid": "1745000000.42" }
Fire-and-forget The endpoint returns immediately. CenterPoint dials both sides asynchronously. Listen for postMessage events from the iframe to track call state.

Error responses

StatusReason
400Missing agentUserId or customerNumber
401Missing or invalid X-Api-Key
404Agent not provisioned or deactivated
502CenterPoint unreachable — contact support

Code example

Node.js / fetch Server-side
async function clickToDial(agentUserId, customerNumber) {
  const res = await fetch('https://agam1.zyx.bz/calls/originate', {
    method:  'POST',
    headers: {
      'X-Api-Key':    process.env.SOFTPHONE_API_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ agentUserId, customerNumber }),
  });
  if (!res.ok) throw new Error(await res.text());
  return res.json(); // { success: true, uniqueid: "..." }
}

postMessage events

The iframe posts all call state events to window.parent. Listen for them in your CRM page to update the UI.

JavaScript — listen for events Client-side
window.addEventListener('message', (e) => {
  // Optional: restrict to the softphone origin
  // if (e.origin !== 'https://agam1.zyx.bz') return;

  const { type, ...data } = e.data;

  switch (type) {
    case 'registered':
      console.log('Agent ready'); break;
    case 'answered':
      startCallTimer(); break;
    case 'hangup':
      endCallTimer(); break;
    case 'error':
      console.error('SIP error', data.reason, data.phase); break;
  }
});

Event reference

registeredAgent is ready to receive calls. Safe to enable click-to-dial in your UI. { uri }
unregisteredRegistration expired or dropped. Disable click-to-dial until registered fires again.
incomingIncoming call received — auto-answer in progress. { from, displayName }
answeredCall connected. Both sides bridged — audio flowing. { from, displayName }
hangupCall ended (either side). { from, displayName }
rejectedIncoming call rejected — agent already in a call. { reason: "already_in_call" }
mic_errorMicrophone access denied. Agent must grant browser permission. { reason }
errorFatal error. { reason, phase } — phase: config · startup · accept · init

Sending commands to the iframe

Hangup command Client-side
const iframe = document.querySelector('iframe');
iframe.contentWindow.postMessage({ type: 'hangup' }, '*');

Security notes

ConcernHow it's handled
JWT secret exposure Per-tenant secrets — your secret cannot be used by other tenants. Never put it in client-side code.
API key scope Your api_key can only trigger calls for your own agents. It has no access to any other tenant's data or admin operations.
Transport security All endpoints are served over HTTPS. Media is encrypted end-to-end (DTLS-SRTP — browser WebRTC standard).
Rate limiting /calls/originate Apply rate limiting in your backend before calling this endpoint to prevent accidental call flooding.
Store secrets as environment variables Keep SOFTPHONE_JWT_SECRET and SOFTPHONE_API_KEY in your environment or secrets manager — never in source code.

Live Demo

Test your credentials end-to-end without writing any backend code. Server-side steps show what your backend would do; client-side steps show what the browser does.

For testing only. Your jwt_secret and api_key are processed in this browser tab — never use this demo in production or share your screen with these fields populated.
1 Sign JWT Server-side
2 Load softphone Client-side
idle
about:blank
3 Click-to-call (unlocks when registered)
Server-side POST /calls/originate
Client-side postMessage to iframe

iframe.contentWindow.postMessage({ type: 'hangup' }, '*')

Call originated