2CXVoIPDevelopers
2CXVoIP · Developer documentation

2CXVoIP Communications API — CRM integration guide

This API gives you: agent login, a session token for the embedded softphone, a realtime channel for calls/SMS/presence, and SMS sending. Your CRM owns the user's session (who's logged in, which tab, per-contact conversation history) — this API only provides the communications infrastructure (phone line, balance, and call events).

Base URL: https://webphone.2cxvoip.com

Every error response uses this shape: { "error": "message" }

1. Authentication

POST /auth/login

// body
{ "email": "agent@yourcompany.com", "password": "..." }
// 200
{
  "token": "<jwt, valid 7 days>",
  "user": {
    "id": "uuid", "email": "...", "name": "...", "role": "agent|supervisor|admin",
    "status": "active", "team": "Sales", "line": "+19165551234"
  }
}

Store token and send it as Authorization: Bearer <token> on every subsequent request (including the WebSocket auth message, see §3).

Rate limit: 5 attempts per IP every 15 minutes.

POST /api/agents/session — server-to-server, requires the agent's 2CXVoIP password

Your backend calls this per agent (never from their browser) to get the JWT + softphone token in one call. Requires two separate credentials: your organization's secret (x-sync-secret, same as /api/users/sync) and that specific agent's 2CXVoIP password — holding the secret alone no longer lets you request a session for any email in your organization, it has to be that exact agent.

The agent doesn't choose or type that password — we share it with you out of band (at the same time we pre-approve their email, see §5), and your backend stores/forwards it on every call, same as you already do with the secret. Lost it or need to rotate it? Ask us — it can be regenerated any time without touching the agent's email or anything else.

Header: x-sync-secret: <your organization's secret>
Body:   { "email": "agent@yourcompany.com", "password": "<the password we shared for this agent>" }
// 200
{
  "token": "<jwt, valid 7 days — same as /auth/login>",
  "user": { "id", "email", "name", "role", "status", "team", "line" },
  "webphone": { "session_token": "...", "line": "+1916...", "expires_in": 900 }
  // webphone is null if the agent doesn't have a line/credential assigned yet
}

400 if email or password is missing, 401 if the password doesn't match, 404 if the email doesn't exist in your organization (never resolves another client's agents), 403 if the agent isn't active yet.

Contract change (2026-09-04): this endpoint used to only check the organization secret, no password — meaning anyone holding the secret could request a session for any guessed email, without really authenticating the agent. If you already integrated against the old version, you'll need to add the password field to every call.

GET /auth/me

Header: Authorization: Bearer <token>. Returns the same shape as user above — use it to validate the token when your app loads.

2. Embedded softphone

GET /api/webphone/token

Header: Authorization: Bearer <token>.

// 200
{ "session_token": "...", "line": "+19165551234", "expires_in": 900 }

session_token is valid for ~15 minutes — request a new one before it expires (e.g. every 10 min while the agent is active). If the agent doesn't have a line assigned yet, the API responds 503 { "error": "No phone line assigned yet..." }.

Softphone widget

Don't install any third-party SDK. Load the 2CXVoIP widget straight from the CDN:

<script src="https://cdn.2cxvoip.com/softphone.js"></script>

This exposes window.TwoCXSoftphone:

TwoCXSoftphone.connect(session_token, line)  // both from GET /api/webphone/token above
TwoCXSoftphone.on('ready', () => {...})
TwoCXSoftphone.on('incoming', ({ from }) => {...})   // show your "incoming call" popup
TwoCXSoftphone.on('callStateChange', ({ state }) => {...})
TwoCXSoftphone.on('error', (err) => {...})

TwoCXSoftphone.dial('+19165551234')          // outbound call
TwoCXSoftphone.answer()
TwoCXSoftphone.reject()
TwoCXSoftphone.hangup()
TwoCXSoftphone.disconnect()

Mute / hold

TwoCXSoftphone.mute()          // or unmute() / toggleMute()
TwoCXSoftphone.isMuted()       // boolean, read anytime

TwoCXSoftphone.hold()          // returns a Promise — or unhold() / toggleHold()
TwoCXSoftphone.isOnHold()      // boolean — also reflected as callStateChange({ state: 'held' })

TwoCXSoftphone.on('muteStateChange', ({ muted }) => {...})

hold()/unhold() are async — the carrier confirms the hold before callStateChange fires with state: 'held'. Reflect that in your UI rather than assuming the request succeeded immediately.

DTMF (in-call keypad)

TwoCXSoftphone.dtmf('1')       // one key per call: 0-9, *, #

Build the keypad UI yourself (buttons for 0-9, *, #) and call dtmf() on each press. The tone goes peer-to-peer over the already-connected WebRTC call — it never touches our backend, so there's no event or response to wait for. Only works while a call is active; calling it with no active call is a no-op.

Sound library

Automatic, no action needed on your side. The widget wires a ringtone for incoming calls and a ringback tone for outbound calls into the carrier SDK. Both are synthesized tones (no licensed audio), and both play in the agent's own browser — they are not sent to the caller. Hold (hold()) is different: it's a real signaling action sent to the carrier, which plays its own hold music to whoever is bridged to that leg — not something this widget generates.

Call quality diagnostics

The widget surfaces the carrier SDK's own real-time network/media monitor for the agent's leg only (it has no visibility into the caller's side of the call):

TwoCXSoftphone.on('qualityWarning', (event) => {
  const w = event.warning
  console.log(w.name, w.message)   // e.g. HIGH_JITTER, HIGH_PACKET_LOSS, LOW_MOS, ICE_CONNECTIVITY_LOST
})

Use this to show a "your connection is unstable" banner to the agent, or to log quality events against the call on your own side for later correlation. It's diagnostic only — the SDK attempts automatic recovery (ICE restart, reconnect) on its own; you don't need to react to keep the call alive.

Honest note: the widget removes any carrier dependency from your own code and package.json — that's the exposure that matters in practice. It does not guarantee the browser's network traffic is 100% unrecognizable to someone deliberately inspecting the Network tab; that's outside what a client-side wrapper can control.

If you also want these warnings saved on our side (so a supervisor can see them later in the panel, not just in your own console.log), forward them via TwoCXClient — TwoCXSoftphone has no token or REST client of its own, so this one line of wiring is on your end:

TwoCXSoftphone.on('qualityWarning', (event) =>
  client.reportQualityEvent(TwoCXSoftphone.getActiveCallControlId(), event.warning.name, event.warning.message))

Warm call transfer (internal or external, beta)

Uses window.TwoCXClient (see below), not the softphone widget directly — it's a REST action against our own API, not a carrier SDK call. Only works on an inbound call (a customer calling in); an agent-placed outbound call has no separate caller leg to hold, so there's nothing to consult-transfer there yet.

Pass exactly one of targetUserId (an agent on this platform) or targetNumber (any PSTN number, E.164 — e.g. someone not on this platform at all) — same mechanics either way.

const callId = TwoCXSoftphone.getActiveCallControlId()
const { consultCallControlId, targetAgent, targetNumber } =
  await client.transferConsult(callId, { targetUserId })   // or { targetNumber: '+15551234567' }
// caller is now held (they hear the carrier's hold music); you're privately
// connected to the target to confirm before connecting them

client.on('transfer_status', ({ status }) => {
  // 'consult_answered' — they picked up, show your "complete/cancel" UI
  // 'consult_failed' — they rejected/hung up; the caller is already restored,
  //                     nothing to do on your side
})

await client.transferComplete(callId)   // connects caller <-> targetAgent, drops you off
// or:
await client.transferCancel(callId)     // aborts, restores the caller to you

Not yet live-tested against a real two-agent call — treat as beta until confirmed.

Real hold music (not just comfort noise)

The provider's own hold, triggered client-side via TwoCXSoftphone.hold(), only ever produces "comfort noise" — a faint hiss to stop the far end's audio timing out, not music. For real hold music on an inbound call, use TwoCXClient instead:

const callId = TwoCXSoftphone.getActiveCallControlId()
await client.holdCall(callId)    // or unholdCall(callId)

Same inbound-only limitation as transfer above — falls back to TwoCXSoftphone.hold() for outbound calls.

3. Realtime channel — WS /ws/presence

Connect one socket per logged-in agent. The first message must always be auth:

→ { "type": "auth", "token": "<jwt from /auth/login>" }
← { "type": "auth_ok", "userId": "uuid" }
   // or { "type": "auth_error" } followed by the socket closing

Messages you can send

{ "type": "status", "status": "available" | "busy" | "in_call" | "offline" }
{ "type": "ping" }   // heartbeat, replies with { "type": "pong" }

Events the server pushes

presence_update — full snapshot whenever any agent's status changes (useful for a wallboard):

{ "type": "presence_update", "agents": [
  { "id": "uuid", "name": "...", "email": "...", "role": "agent",
    "team": "Sales", "line": "+1916...", "status": "available" }
] }

call_incoming — a call is ringing on the agent's line. This is just the signal to show your "incoming call" popup with caller info — the softphone (browser widget, authenticated with session_token) is what actually answers/rings:

{ "type": "call_incoming", "from": "+1...", "to": "+1916...", "callControlId": "..." }

sms_inbound — an inbound SMS already saved, to refresh the contact's conversation thread:

{ "type": "sms_inbound", "conversationId": "uuid", "from": "+1...", "to": "+1916...",
  "text": "...", "contactId": "uuid" }

Every event is delivered only to the socket of the agent who owns the line — there's no broadcast between agents.

GET /api/presence

Initial snapshot (same shape as presence_update.agents) for when your app loads and doesn't have the WebSocket connected yet.

4. SMS

POST /api/sms/send

// body — option A: existing contact
{ "contact_id": "uuid", "body": "message text" }

// body — option B: new number (creates the contact if it doesn't exist)
{ "to": "+1916...", "name": "optional", "body": "message text" }

Sends from the authenticated agent's assigned line. Pass contact_id to reply within an existing thread, or to to start a new one — the contact is created automatically if that number doesn't exist yet. 404 if contact_id doesn't exist, 503 if the agent has no line.

// 201
{ "message": {...}, "conversation_id": "uuid", "contact_id": "uuid" }

Save the returned contact_id for future replies in the same thread.

GET /api/contacts/:id/thread

Full history (SMS + calls) for a contact:

{
  "contact": { "id": "...", "phone_e164": "...", "name": "...", "email": "..." },
  "messages": [{ "id", "direction", "body", "sent_at", "agent_name" }],
  "calls": [{ "id", "direction", "status", "duration_seconds", "started_at", "ended_at", "agent_name" }]
}

Useful if your CRM doesn't have its own conversation store yet and wants to lean on this endpoint — not required, just a convenience.

5. What is NOT part of this API

6. CORS

Your frontend's domain needs to be on the server's allowlist before you can call this API from the browser. Email your domain(s) and your client number to support@2cxvoip.com to get them added.