Skip to content

Repository files navigation

3CX Call Control API — TypeScript service example

A minimal but complete service that

  1. authenticates against a 3CX PBX as a Service Principal (OAuth 2.0 client credentials),
  2. opens the call control WebSocket and follows call progress,
  3. initiates a call and reports every state transition until the call ends,
  4. 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.

Prerequisites on the PBX

  • 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.
  • The extension you call from must have at least one registered SIP device, or makecall answers 422.

The OAuth client_id is the Service Principal's DN number, not its display name. That "number" does not have to be numeric — it may well be something like apiexample — but it is the DN identifier, which is what GET /callcontrol lists as dn. The client_secret is the secret shown once at creation time.

Setup

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 edits

Note 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 rules

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

No build step

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), via allowImportingTsExtensions;
  • every construct must erase to nothing. erasableSyntaxOnly makes tsc reject the ones that would not — enum, namespace, constructor parameter properties — so a violation surfaces at type-check time instead of as a runtime ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. That is why CallControlEventType in src/types.ts is a const object with a companion union type rather than an enum.

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.

What the monitor has to deal with

CallMonitor exists because the raw event stream is further from "a call" than it looks:

  • One call is several legs. makecall rings every registered device of the extension, and each ringing device is its own participant with its own id. They share a callid, 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 named MakeCall, and the real destination only appears once a device answers. inferDirection uses that, falling back to the documented meaning of Dialing vs Ringing, and reports unknown rather than guessing for a leg first seen already Connected.
  • 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.
  • attacheddata is ignored by makecall. Only destination is read, so a caller's own correlation id cannot be attached when the call is placed — use attach_participant_data once 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 every connected.

Layout

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

How the API works

1. Token

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.

2. Events

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.

3. Acting on calls

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

Actions on a participant

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.

Statuses and status codes

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

Not covered here

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.

About

Example of a service integration controlling multiple users

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages