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
POST /hooks/events— internal carrier webhook. Don't expose or document it externally; it doesn't use agent authentication.POST /api/users/sync— used by your backend (not the agent's browser) to create/deactivate/update agents, protected by the samex-sync-secretas/api/agents/session. Not part of the agent flow.{ "action": "create", "email", "name", "team_name"?, "needs_webphone"?: true, "area_code"? }— the email must be pre-approved on our side before you call this. The secret proves it's you calling, not that this particular email is a real expected agent. Sending acreatefor an email your 2CXVoIP contact hasn't pre-approved returns403 { "error": "Email no pre-aprobado por el admin de esta organización" }— nothing is created, nothing is charged. Coordinate the list (email + name, optionally area code) with your contact first — thenamethey registered wins over whatever you send here. That same pre-approval is where this agent's 2CXVoIP password gets set (seePOST /api/agents/sessionin §1) — we share it with you along with confirming the email is pre-approved, no separate request needed. Withneeds_webphone: true(default) we try to buy and assign a real number automatically; passarea_codeif you care which one (falls back to whatever area code was saved on the pre-approval if you don't send one). Response includesline(the assigned number, if any) andpending_did(true if it's queued for manual assignment instead).{ "action": "deactivate", "email" }— soft-deletes the agent (status →inactive) and unassigns their line. The line itself is never released back to the carrier from this endpoint — it just becomes unassigned, so nothing gets deleted or stops billing by mistake. Releasing a number for real is a deliberate action on our side, not something a sync call can trigger.{ "action": "reactivate", "email" }— undoes a deactivate.{ "action": "update", "email", "name"?, "role"?, "team_name"? }— push a change that happened on your side (e.g. the agent moved teams) so it's reflected here without an admin touching the panel. Only name/role/team move this way — status and line assignment stay on create/deactivate/swap-line on purpose.
GET /api/agents— server-to-server roster, samex-sync-secretauth. Returns every agent in your organization with their current line, so your CRM can mirror the panel's agent list without an admin JWT:{ "agents": [ { "id", "email", "name", "role", "status", "last_seen", "team", "line" } ] }
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.