@3cx/call-control-sdk

中文文档
0.1.10发布于 2026/08/04MITTypeScriptES Module近 30 天下载 1992

SDK for interacting with the 3CX Call Control API

原始使用文档
文档目录16 节
  1. Installation
  2. Quick Start
  3. WebSocket Reconnection
  4. Extension Participant Monitoring
  5. Why no audio on extension participants?
  6. Event lifecycle note
  7. Outbound Calls
  8. Attached Data
  9. MCP Integration
  10. API Reference
  11. Participant
  12. CallControlClient
  13. Events - RoutePoint (Own DN)
  14. Events - Monitored Extensions
  15. Events - Connection
  16. License

@3cx/call-control-sdk

The 3CX Call Control SDK for TypeScript provides a higher-level interface to the 3CX Call Control API.

Installation

npm install @3cx/call-control-sdk

Quick Start

import { CallControlClient } from "@3cx/call-control-sdk";

const client = new CallControlClient({
  pbxBase: "https://your-pbx.example.com",
  appId: "your-app-id",
  appSecret: "your-app-secret",
});

await client.connect();

client.on("participantConnected", async (participant) => {
  console.log(`Participant ${participant.id} connected`);

  // Get audio stream from the caller (PCM 8kHz 16-bit mono)
  const audioStream = await participant.getAudioStream();

  // Create audio writer to the caller
  const audioWriter = participant.getAudioWriter();

  // Pipe audio through your AI provider (STT -> Agent -> TTS)
  // ...

  // Write PCM 8kHz 16-bit mono audio back
  audioWriter.write(pcmBuffer);

  // Call actions directly on the participant
  await participant.transfer("1001");
});

client.on("participantDisconnected", (participantId) => {
  // SDK automatically cleans up audio streams/writers
  console.log(`Participant ${participantId} disconnected`);
});

WebSocket Reconnection

On unexpected disconnects the SDK reconnects automatically with exponential backoff. By default it never gives up (maxReconnects: Infinity).

OptionDefaultDescription
websocket.maxReconnectsInfinityAttempts before giving up. Set a finite number to stop retrying.
websocket.reconnectDelayMs5000Base delay before the first retry (doubles each attempt).
websocket.reconnectBackoffMaxMs120000Upper cap per retry (2 minutes).

Delays grow roughly 5s → 10s → 20s → … up to the cap. Token fetch failures during reconnect use the same backoff. To limit retries:

websocket: { maxReconnects: 5 }

Extension Participant Monitoring

When extensions are attached to the app's service principal (controlled DNs), the SDK emits separate events for their call activity. This lets you monitor calls on those extensions without conflating them with calls on the app's own programmable DN.

// Calls on the app's own DN - full control (audio + call actions)
client.on("participantConnected", async (participant) => {
  console.log(`Own DN call from ${participant.info.party_caller_id}`);
  const audio = await participant.getAudioStream(); // works
  // ...
});

// Calls on monitored extensions - call actions only, no audio
client.on("extensionParticipantConnected", async (participant) => {
  console.log(
    `Extension ${participant.dn} call from ${participant.info.party_caller_id}`,
  );
  // participant.getAudioStream() -> throws Error
  await participant.transfer("1001"); // works
});

client.on("extensionParticipantDisconnected", (participantId) => {
  console.log(`Extension call ${participantId} ended`);
});

Why no audio on extension participants?

Audio streams and DTMF are only available for the app's own programmable DN (RoutePoint). Extension calls are physically handled by the extension's own device (IP phone, softphone, etc.) - the media path goes directly between the PBX and that device. The programmable extension never sits in the media path for those calls, so there is no audio to intercept or inject. Call control actions (transfer, drop, divert, routeTo) work at the signaling level; answer() on extensions requires supportsDirectControl (uaCSTA).

Event lifecycle note

A RoutePoint (the app's own DN) answers incoming calls automatically - participants almost always arrive with status Connected, so participantConnected fires immediately.

Extensions behave differently: a call first rings on the device (Ringing), and only becomes Connected after the user picks up. The extensionParticipantConnected event fires only when the extension actually answers the call. To detect incoming (ringing) calls on extensions, listen to extensionParticipantUpdated and check participant.info.status:

client.on("extensionParticipantUpdated", (participant) => {
  if (participant.info.status === "Ringing") {
    console.log(`Extension ${participant.dn} is ringing`);
    if (participant.supportsDirectControl) {
      void participant.answer(); // uaCSTA leg only
    }
  }
});

Multi-device extensions get one participant leg per device. Call answer() on the leg with supportsDirectControl === true, not on mobile/softphone legs.

CapabilityOwn DN (RoutePoint)Extension DNs
Call eventsparticipantConnected / Updated / DisconnectedextensionParticipantConnected / Updated / Disconnected
Audio streamsYesNo
DTMFYesNo
Transfer / DropYesYes
DivertYesYes — Ringing inbound only
RouteToYesYes — Ringing or Connected
AnswerYesOnly with supportsDirectControl (uaCSTA)
Attach dataYesYes

divert() redirects an unanswered call — use on a Ringing inbound leg (fails on Connected). routeTo() adds alternative routes while the participant stays in the call — valid on Ringing or Connected. Use transfer() for blind transfer on established calls. Neither applies to an outbound Dialing leg.

Outbound Calls

makeCall returns the created participant ID directly from the API response, so you don't have to wait for a WebSocket event to correlate the call:

const participantId = await client.makeCall("101");
// or from a specific source DN:
const participantId = await client.makeCall("101", "cctest2");

if (participantId !== undefined) {
  await client.attachPartyData(participantId, { public_ticket: "1234" });
}

The PBX may return 202 Accepted (call accepted, id not yet available). In that case makeCall resolves to undefined - track the call via WebSocket events instead.

Attached Data

Attach metadata to a call with attachParticipantData / attachPartyData. Every key must be prefixed with public_ - the PBX rejects other keys with HTTP 422.

Both methods take the same participant ID and write two different fields on that participant's record (the same call leg). They do not target the collocutor's DN or a second participant object:

SDK API / accessorPBX field on this participantMeaning
attachParticipantData / participantDataparticipant_attached_dataData on this participant's own leg
attachPartyData / partyDatacaller_attached_dataCollocutor data in the party slot of this leg (same participant ID)

Both values are read from the controlled participant handle — the collocutor is not exposed as a separate object. Attach party data on a monitored extension when you need metadata to survive transfer or similar operations.

// via the client (same participantId for both)
await client.attachParticipantData(participantId, { public_ticket: "1234" });
await client.attachPartyData(participantId, { public_peer: "181" });

// or via a participant handle
await participant.attachParticipantData({ public_ticket: "1234" });
await participant.attachPartyData({ public_peer: "181" });

console.log(participant.participantData.public_ticket); // '1234'
console.log(participant.partyData.public_peer); // '181'

await participant.attachPartyData({ public_ticket: "5678" });
await participant.refresh(); // party data is not pushed over WebSocket
console.log(participant.partyData.public_ticket); // '5678'

To inspect another DN's legs, use client.getState().callcontrol.get(dn)?.participants (or the live handle from getExtensionParticipantHandle). There is no getParticipantByDn helper.

MCP Integration

Use the SDK's token store with 3CX MCP server:

const authProvider = client.createMcpAuthProvider();
const mcpUrl = client.getMcpUrl();

const mcpServer = new MCPServerStreamableHttp({
  url: mcpUrl,
  authProvider,
});

API Reference

Participant

MethodDescription
idNumeric participant ID
dnDN number this participant belongs to
isExtensionParticipanttrue if this is a monitored extension participant
infoRaw 3CX CallParticipant metadata
destroyedWhether participant has been cleaned up
supportsDirectControlPBX direct_control (uaCSTA); required for answer() on extensions
participantDataThis leg's participant_attached_data via attachParticipantData() ({} if none)
partyDataCollocutor's caller_attached_data on this leg via attachPartyData() ({} if none)
getAudioStream()Get readable PCM audio stream (own DN only)
getAudioWriter()Get writable audio channel (own DN only)
makeCall(to)Place a new independent outbound call from this participant's DN
transfer(destination, reason?)Transfer to destination
drop()Drop from call
answer()Answer call (RoutePoint or supportsDirectControl only)
routeTo(destination, timeout?, reason?)Add alternative routes (Ringing or Connected)
divert(destination, reason?)Redirect unanswered call (Ringing inbound only)
transferToVoiceMail(dn, reason?)Transfer to voicemail
routeToVoiceMail(dn, timeout?, reason?)Route to voicemail
divertToVoiceMail(dn, reason?)Divert to voicemail
attachParticipantData(data)Write participant_attached_data on this leg
attachPartyData(data)Write caller_attached_data on this leg (not the remote DN)
refresh()Fetch this leg from the REST API and update cached state
cancelStreamQueue()Cancel PBX-queued outgoing audio (ends active POST; prefer getAudioWriter().clear() for barge-in)

CallControlClient

MethodDescription
connect()Connect to 3CX PBX (auth + WebSocket)
disconnect()Disconnect and clean up resources
getParticipantHandle(id)Look up active own-DN Participant handle by ID
getExtensionParticipantHandle(id)Look up active extension Participant handle by ID (use this for monitored DNs)
getAudioStream(participantId)Get readable PCM audio stream
createAudioWriter(participantId)Create writable audio channel with keep-alive
transfer(participantId, destination)Transfer participant to extension
drop(participantId)Drop participant from call
answer(participantId)Answer call (RoutePoint or direct_control / uaCSTA only)
routeTo(participantId, destination)Add alternative routes (Ringing or Connected). No timeout — use participant.routeTo() for timeout
divert(participantId, destination)Redirect unanswered call (Ringing inbound only)
makeCall(to, from?)Place outbound call. Returns the created participant ID (HTTP 200) or undefined (HTTP 202). from defaults to appId
attachParticipantData(participantId, data)Write participant_attached_data on that participant's leg
attachPartyData(participantId, data)Write caller_attached_data on that participant's leg (same ID; not the remote DN)
cancelStreamQueue(participantId)Cancel PBX-queued outgoing audio (ends active POST; prefer createAudioWriter().clear() for barge-in)
controlParticipant(participantId, method, body?)Generic call control action
getParticipant(id)Raw CallParticipant from state for the app's own DN only. Extension legs: use getExtensionParticipantHandle(id) or getState().callcontrol.get(dn)?.participants
getState()Get full call control state (all visible DNs and their participants)
getFullInfo()Fetch full state from REST API (does not update cache)
refreshParticipant(participantId)Fetch one participant from REST API and update cached state + handles
createMcpAuthProvider()Create MCP auth provider
getMcpUrl()Get MCP endpoint URL

Events - RoutePoint (Own DN)

Fired for participants on the app's own programmable DN. Full control: audio streams, DTMF, and call actions.

EventPayloadDescription
participantConnectedParticipantParticipant connected to call
participantDisconnectednumberParticipant removed from call
participantUpdatedParticipantParticipant state changed
dtmfstring, numberDTMF digits received and Participant who pressed

Events - Monitored Extensions

Fired for participants on extensions attached to the service principal. Call control only - no audio or DTMF (the media path is handled by the extension's own device, not by the programmable extension).

EventPayloadDescription
extensionParticipantConnectedParticipantExtension participant connected
extensionParticipantDisconnectednumberExtension participant removed
extensionParticipantUpdatedParticipantExtension participant state changed

Events - Connection

EventPayloadDescription
connected-WebSocket connected
disconnected-WebSocket disconnected
errorErrorError occurred

License

MIT

版本与发布记录

@3cx/call-control-sdk 共发布 4 个版本,首次发布于 2026/07/29;过去 12 个月发布了 4 个版本。

版本发布日期距今
0.1.10latest2026/08/043 个月前
0.1.92026/07/303 个月前
0.1.82026/07/293 个月前
0.1.72026/07/293 个月前

关于 @3cx/call-control-sdk 的常见问题

@3cx/call-control-sdk 是什么?

SDK for interacting with the 3CX Call Control API

如何安装 @3cx/call-control-sdk?

在项目目录运行 npm install @3cx/call-control-sdk,或使用 pnpm add / yarn add / bun add。

@3cx/call-control-sdk 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@3cx/call-control-sdk 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@3cx/call-control-sdk 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@3cx/call-control-sdk 可以商用吗?

@3cx/call-control-sdk 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@3cx/call-control-sdk 的下载量有多少?

根据 npm 官方下载统计,@3cx/call-control-sdk 在 2026/10/05 – 2026/11/03 这 30 天内被下载 1,992 次,日均约 66 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@3cx/call-control-sdk 还在维护吗?

最新版本 0.1.10 发布于 2026/08/04(3 个月前);至今共发布 4 个版本,其中过去 12 个月发布了 4 个;首次发布于 2026/07/29。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。