A minimal but complete service that
- authenticates against a 3CX PBX as a Service Principal (OAuth 2.0 client credentials),
- opens the call control WebSocket and follows call progress,
- initiates a call and reports every state transition until the call ends,
- tracks every call on every controlled DN — inbound and outbound, several at once — and summarizes each one when it finishes.
It uses two public endpoints of the PBX: /connect/token for authentication and
/callcontrol for everything else.
- Enterprise license. The token endpoint refuses call control credentials otherwise.
- A Service Principal created in the management console under
Admin → Integrations → API, with:
- Call Control enabled — without it the token request answers
access_denied; - the extensions you want to control listed as its DNs. A Service Principal only sees its own DN plus the DNs assigned to it; everything else answers 403.
- Call Control enabled — without it the token request answers
- The extension you call from must have at least one registered SIP device, or
makecallanswers 422.
The OAuth
client_idis the Service Principal's DN number, not its display name. That "number" does not have to be numeric — it may well be something likeapiexample— but it is the DN identifier, which is whatGET /callcontrollists asdn. Theclient_secretis the secret shown once at creation time.
cd call-control-example
npm install
cp .env.example .env # then fill in PBX_URL, CLIENT_ID, CLIENT_SECRET, CALL_FROM_DN, CALL_TO
npm start # or: npm run dev, which restarts on editsNote it is .env that is read, not .env.example — copy the file first.
Linting and type checking:
npm run check # typecheck + lint
npm run lint:fix # apply the auto-fixable rulesESLint runs in flat-config mode (eslint.config.js) with @eslint/js recommended plus
typescript-eslint's recommendedTypeChecked and stylisticTypeChecked, so the rules that
need type information — no-floating-promises, no-misused-promises, no-base-to-string
— are active.
node src/index.ts runs the sources as they are: Node ≥ 22.18 strips the type annotations
and executes the result, so there is no dist/ and nothing to keep in sync. tsc is still
installed, but only as a checker — tsconfig.json sets noEmit.
Stripping is not compiling, and that constrains the source in two ways the config enforces:
- imports name the real file on disk (
./client.ts, not./client.js), viaallowImportingTsExtensions; - every construct must erase to nothing.
erasableSyntaxOnlymakestscreject the ones that would not —enum,namespace, constructor parameter properties — so a violation surfaces at type-check time instead of as a runtimeERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. That is whyCallControlEventTypeinsrc/types.tsis aconstobject with a companion union type rather than anenum.
To run this on an older Node, add a transpiling runner (tsx src/index.ts) or reinstate a
tsc build; neither is needed on a current runtime.
Sample output:
2026-09-10T09:12:01.114Z [ws] connected to mypbx.example.com as 'apiexample'
2026-09-10T09:12:01.180Z [info] Service Principal controls 2 DN(s):
2026-09-10T09:12:01.181Z [info] 100 (Wextension) — sip:100@10.0.0.9:5062, sip:100@127.0.0.1:5060; 0 active leg(s)
2026-09-10T09:12:01.181Z [info] apiexample (Wroutepoint) — sip:apiexample@127.0.0.1:5483; 0 active leg(s)
2026-09-10T09:12:01.182Z [call] calling 101 from 100
2026-09-10T09:12:01.402Z [call] makecall -> Success (NotSpecified: )
2026-09-10T09:12:01.404Z [call] #7 outbound started on 100 — party MakeCall "MakeCall"
2026-09-10T09:12:01.404Z [call] #7 leg 100/24: - -> Ringing
2026-09-10T09:12:01.406Z [call] #7 leg 100/25: - -> Ringing
2026-09-10T09:12:04.881Z [call] #7 leg 100/24: Ringing -> Connected
2026-09-10T09:12:19.052Z [call] #7 leg 100/24 ended (last status Connected, 0 still up)
2026-09-10T09:12:19.052Z [call] #7 outbound answered, party 101 "Bob", dn 100, 18s total, 14s talk
An inbound call to a controlled extension produces the same events, plus a [popup] line
where a CRM integration would look the caller up:
2026-09-10T09:20:11.031Z [call] #9 inbound started on 100 — party +14155552671 "Bob Smith"
2026-09-10T09:20:11.031Z [popup] incoming call for 100 from +14155552671 "Bob Smith"
2026-09-10T09:20:11.032Z [call] #9 leg 100/31: - -> Ringing
2026-09-10T09:20:28.400Z [call] #9 leg 100/31 ended (last status Ringing, 0 still up)
2026-09-10T09:20:28.400Z [call] #9 inbound missed, party +14155552671 "Bob Smith", dn 100, 17s total, 0s talk
Leave CALL_TO empty to only listen: the service stays connected and reports every call
on the DNs it controls, in either direction.
CallMonitor exists because the raw event stream is further from "a call" than it looks:
- One call is several legs.
makecallrings every registered device of the extension, and each ringing device is its own participant with its own id. They share acallid, which is what the legs are grouped by, and the call is only over when the last of them goes away. - There is no direction field. Both an inbound call and the ringback phase of an
API-placed call arrive as
Ringing. The distinguishing mark is the party: while the PBX rings the caller's own devices it reports a placeholder route point namedMakeCall, and the real destination only appears once a device answers.inferDirectionuses that, falling back to the documented meaning ofDialingvsRinging, and reportsunknownrather than guessing for a leg first seen alreadyConnected. - An unanswered outbound call never names its destination. Every leg keeps the
placeholder, so the monitor remembers what was passed to
makecall(markOutbound(callId, destination)) and uses it in the summary. attacheddatais ignored bymakecall. Onlydestinationis read, so a caller's own correlation id cannot be attached when the call is placed — useattach_participant_dataonce the leg exists.- Reads race. Two events for one participant in quick succession mean two reads, and the slower one can land last carrying the older status, so reads are serialized per leg.
- State must be rebuilt after a reconnect. Events missed while the socket was down are
not replayed.
start()re-reads the calls in progress and is called again on everyconnected.
| File | Contents |
|---|---|
src/auth.ts |
Service Principal token provider — client credentials grant, caching, renewal |
src/client.ts |
CallControlClient: WebSocket events + request/response, REST fallback, call actions |
src/call-monitor.ts |
CallMonitor: turns entity notifications into per-call, per-leg state for all DNs |
src/types.ts |
Wire types of the request and response payloads |
src/config.ts |
.env loading |
src/index.ts |
The demo flow |
POST /connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=888&client_secret=…{ "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 3600, "scope": "…" }Errors come back as HTTP 400 with a JSON error field (invalid_client,
access_denied, …), per RFC 6749 §5.2 — not as 401.
Connect a WebSocket to wss://<pbx>/callcontrol/ws with
Authorization: Bearer <token>. Each push message is:
{ "sequence": 42, "event": { "event_type": 0, "entity": "/callcontrol/100/participants/5120" } }event_type arrives as a number, not as a name:
| Value | Meaning |
|---|---|
0 |
Upsert — entity added or changed |
1 |
Remove — entity gone; for a participant, the call leg ended |
2 |
DTMFstring — attached_data.dtmf_input holds the digits since the last event |
3 |
PromptPlaybackFinished — attached_data.prompt_id |
4 |
Response — reply to a request this client sent over the socket |
entity is one of /callcontrol, /callcontrol/{dn},
/callcontrol/{dn}/devices/{device_id}, /callcontrol/{dn}/participants/{id}.
Events carry no state. An Upsert only means read this entity again; that is what
CallTracker does, and how the Dialing → Connected transitions above are produced.
sequence is monotonic per app, so a gap means events were dropped and any cached view
should be rebuilt from GET /callcontrol.
Everything is reachable two ways, and this example uses both:
REST — GET/POST https://<pbx>/callcontrol/… with the Bearer token:
POST /callcontrol/100/makecall
Authorization: Bearer eyJ…
Content-Type: application/json
{ "destination": "101" }WebSocket request/response — the same paths over the open socket, correlated by
RequestID:
{ "RequestID": "888-1", "Path": "callcontrol/100/participants/5120/drop", "RequestData": {} }and the reply arrives as an event_type: 4 event whose attached_data is
{ "RequestID": "888-1", "Path": "…", "StatusCode": 200, "Response": { "finalstatus": "Success", … } }Note the PascalCase field names on the WebSocket envelope. The PBX matches them
case-sensitively, so they have to be spelled exactly as shown; the RequestData body
fields, by contrast, are lowercase (destination, reason, timeout, attacheddata).
POST /callcontrol/{dn}/participants/{id}/{action}:
| Action | Notes |
|---|---|
drop |
Hang up the leg |
answer |
Route points always; extensions only when direct_control is true |
divert |
Only while the leg is Ringing; needs destination |
routeto |
Adds a route; succeeds only if it is answered. timeout in seconds, default 180 |
transferto |
Blind transfer — replaces the leg's party with destination |
attach_participant_data / attach_party_data |
Keys must start with public_, otherwise the PBX rejects the request |
Any other action name comes back as 422.
Participant status: Undefined, Dialing (we initiated), Ringing (the other side
initiated), Connected, Hold, Held.
makecall and the participant actions return
{ finalstatus, reason, reasontext, result } where finalstatus is Success,
Rejected or Failed.
| Code | Meaning |
|---|---|
| 200 | Done. For makecall, the DN had exactly one device and result holds the new leg |
| 202 | makecall accepted for a DN with several devices — result may be null; wait for the participant Upsert |
| 403 | The DN is not assigned to this Service Principal |
| 404 | No such device or leg |
| 422 | No registered device, or the action is invalid in this call state |
| 424 | The PBX rejected the operation; the body carries reason/reasontext |
Real-time audio (GET/POST /callcontrol/{dn}/participants/{id}/stream and
cancel_stream_queue) is a raw octet-stream API used for media injection and recording,
and is out of scope for this example. playprompt currently answers 501.