Skip to main content

Manage Multiple Calls

The Telnyx WebRTC JS SDK supports multiple simultaneous calls within a single TelnyxRTC client session. This guide covers concurrent call management, per-call media elements, call waiting, hold/transfer patterns, and the warnings the SDK emits when multiple calls are active.
The SDK does not recommend having multiple active calls simultaneously and cannot guarantee stable behavior in all scenarios. The supported pattern is accepting and holding an inbound call while finishing an active one — the SDK handles this well. Running two fully active calls at the same time (both with bidirectional audio) is not recommended and may produce unpredictable media or signaling behavior.

Overview

A single TelnyxRTC instance connected to rtc.telnyx.com can have multiple active calls at the same time. Each call has its own:
  • Call ID (call.id) — unique per call leg
  • Direction (call.direction) — inbound or outbound
  • State (call.state) — ringing, trying, active, held, hangup, destroyed
  • PeerConnection — independent RTCPeerConnection per call
  • Media elementremoteElement / localElement (see Per-call media elements)
const client = new TelnyxRTC({ login_token: jwt });
client.connect();

// client.calls is an array of all call objects
client.on('telnyx.notification', (notification) => {
  if (notification.type === 'callUpdate') {
    console.log(`Active calls: ${client.calls.length}`);
    client.calls.forEach(call => {
      console.log(`  ${call.id}: ${call.direction} ${call.state}`);
    });
  }
});

Per-call media elements

Starting with SDK v2.27.4, you can assign a distinct remoteElement (and localElement) per call. This is essential for concurrent calls — without it, all calls share the same audio element and the SDK emits a SHARED_REMOTE_ELEMENT_OVERWRITE warning when a second call overwrites the first call’s stream.

Why per-call elements matter

ScenarioShared elementPer-call element
Two active callsSecond call overwrites first call’s audio; SHARED_REMOTE_ELEMENT_OVERWRITE warningEach call plays through its own <audio> element independently
Hang up one callElement is detached; remaining call may lose audioOnly the hung-up call’s element is detached; other call’s audio continues
Call waiting UICan’t play ringback for second call while first is activeRingback plays on second call’s element; first call continues on its element

Outbound calls

Pass remoteElement at call creation:
// First call
const callA = client.newCall({
  destinationNumber: '+12345678900',
  remoteElement: document.getElementById('remoteMediaA'),
});

// Second call (while first is still active)
const callB = client.newCall({
  destinationNumber: '+19876543210',
  remoteElement: document.getElementById('remoteMediaB'),
});

Inbound calls

Pass remoteElement when answering:
client.on('telnyx.notification', (notification) => {
  if (notification.type === 'callUpdate') {
    const call = notification.call;

    if (call.state === 'ringing' && call.direction === 'inbound') {
      // Answer with a specific remote element
      call.answer({
        remoteElement: document.getElementById('remoteMediaB'),
      });
    }
  }
});

Backward compatibility

Omitting the per-call params keeps the session-level client.remoteElement as the fallback default. This is backward compatible with existing single-call integrations — no changes are needed for apps that handle only one call at a time.
// Session-level default — used when no per-call element is specified
client.remoteElement = 'remoteMedia';

// This call uses the session-level default
const call = client.newCall({ destinationNumber: '+12345678900' });

Call waiting

When a second inbound call arrives while a call is already active, the SDK emits a MULTIPLE_ACTIVE_CALLS_DETECTED warning (33010). This is diagnostic only — the SDK does not block the second call.
import { SwEvent, TELNYX_WARNING_CODES } from '@telnyx/webrtc';

client.on(SwEvent.Warning, ({ warning, callId, sessionId }) => {
  if (warning.code === TELNYX_WARNING_CODES.MULTIPLE_ACTIVE_CALLS_DETECTED) {
    // Show call-waiting UI
    showCallWaitingPrompt(callId);
  }
});

Accept the second call

// Put the first call on hold
const firstCall = client.calls[0];
firstCall.hold();

// Answer the second call with its own remote element
const secondCall = client.calls[1];
secondCall.answer({
  remoteElement: document.getElementById('remoteMediaB'),
});

Reject the second call

const secondCall = client.calls[1];
secondCall.hangup();

Hold and switch between calls

Use hold() and unhold() to switch between concurrent calls:
async function switchToCall(targetCall) {
  // Hold all other calls
  client.calls.forEach(call => {
    if (call !== targetCall && call.state === 'active') {
      call.hold();
    }
  });

  // Unhold the target call
  await targetCall.unhold();
}
hold() sends a re-INVITE to the remote party to pause media. The call stays in the held state and can be resumed with unhold().

Warnings for multiple calls

CodeNameWhenAction
33010MULTIPLE_ACTIVE_CALLS_DETECTEDA new call started while another is activeVerify this is intentional (call waiting/transfer); otherwise hang up the previous call
33011SHARED_REMOTE_ELEMENT_OVERWRITETwo calls sharing the same remoteElementAssign a distinct remoteElement per call (see above)
33007DUPLICATE_INBOUND_ANSWERSame callID answered twiceSince v2.27.4, this is scoped per callID — a different callID proceeds normally; same callID emits a warning without hangup
See Error Handling for the full warning reference.

Transfer

Blind transfer and attended transfer work with multiple calls. To perform an attended transfer:
// Call A is active
const callA = client.calls[0];

// Make call B to the transfer target
const callB = client.newCall({
  destinationNumber: '+19876543210',
  remoteElement: document.getElementById('remoteMediaB'),
});

// Once call B is active, transfer call A to call B
callB.on('telnyx.notification', (notification) => {
  if (notification.call.state === 'active') {
    callA.transfer(callB);
  }
});

Server-dialed consult leg (auto-answered second inbound call)

Some attended-transfer and consult flows are driven from the server: while the agent is on their customer call, Call Control dials the agent’s own credential as a second leg and the client auto-answers it. Because both legs are inbound calls on the same single registration, this depends on the SDK allowing a second answer() for a distinct call ID.
In v2.27.0–v2.27.3 this second answer() was silently ignored: the duplicate-answer guard keyed on any other active inbound call, so the second leg stayed ringing with only a DUPLICATE_INBOUND_ANSWER (33007) warning and no error or state change (webrtc#726). v2.27.4 fixes this — the guard is scoped per call ID, so answering a genuinely distinct second inbound call proceeds normally. The previous call.options.attach = true workaround is no longer needed.
Auto-answer the server-dialed leg when it rings (identify it however your backend marks it — e.g. a custom SIP header):
client.on('telnyx.notification', (notification) => {
  if (notification.type !== 'callUpdate') return;
  const call = notification.call;

  // Distinct second inbound leg dialed by your server (e.g. a consult leg
  // tagged with a custom header). In v2.27.4 this answer() is honored even
  // while the first call is still active/held.
  if (call.state === 'ringing' && isConsultLeg(call)) {
    client.calls
      .filter((c) => c !== call && c.state === 'active')
      .forEach((c) => c.hold());

    call.answer({ remoteElement: document.getElementById('remoteMediaB') });
  }
});
A DUPLICATE_INBOUND_ANSWER (33007) warning now fires only when the same call ID is answered twice (for example a duplicate WebSocket registration of the same leg); it is warning-only and never tears down established media.

Best practices for concurrent calls

  1. One active call at a time — the SDK does not recommend having two fully active calls simultaneously. Use hold to manage the active call and accept/hold an inbound call while finishing the active one.
  2. Assign distinct remoteElement per call — use separate <audio> or <video> elements for each call to avoid SHARED_REMOTE_ELEMENT_OVERWRITE warnings and ensure independent playout lifecycles.
  3. Track calls by ID — use call.id as the key in your application state. The call ID may change after reconnection (see recoveredCallId).
  4. Clean up on destroyed — remove call references from your state when call.state === 'destroyed' to prevent memory leaks.
  5. Use hold() before answering — when accepting a second call, hold the first call first to avoid overlapping audio.
  6. One TelnyxRTC instance — keep a single client instance per tab/session. Multiple instances with the same credential cause DUPLICATE_INBOUND_ANSWER warnings.

See Also