Voice

How to Build a Warm Transfer System with Node.js and the Telnyx Voice API

Build a production-ready warm transfer service in Node.js using the Telnyx Voice API. Full code, webhook handling, and production patterns included.

By Telnyx Expert Team

A warm transfer is the difference between a customer feeling handed off and a customer feeling taken care of. When a support agent says "let me connect you with our billing specialist who can help," the customer expects to actually talk to that person, not to be dropped into a queue, not to repeat their story, and not to wonder whether the context survived the handoff.

Building that experience programmatically is harder than it looks. You need to keep the original agent on the line while a third party answers, bridge the audio, and clean up state when the call ends. Most CPaaS APIs expose the primitives, but stitching them together into a reliable flow is where engineering teams lose days.

This walkthrough shows how to build a production-ready warm transfer service in Node.js using the Telnyx Voice API. The full code is open source and runs in under five minutes from clone to first call.

What a Warm Transfer Actually Is

A cold transfer is the simplest case: agent A is on a call with a customer, agent A dials agent B, agent B answers, agent A hangs up. The customer and agent B are now connected, but agent B has zero context.

A warm transfer adds a private conversation between agent A and agent B before the handoff. Agent A typically says something like "I have a customer on the line who needs help with a disputed charge on invoice 4471, can you take it?" Agent B accepts or escalates, and only then does agent A drop off.

The technical requirements:

  1. Three-way audio bridging - the platform must support adding a third party to an active two-party call without dropping anyone.
  2. Programmable call control - your code needs to initiate, answer, transfer, speak to, and hang up calls via API.
  3. Webhook-driven state - every state change (initiated, answered, hangup) arrives as an HTTP webhook, and your service must reconcile state from those events.
  4. E.164 validation - phone numbers must be in international format (+15551234567) or the API rejects them.

Telnyx's Call Control API exposes all of this through a single SDK. The example below wires it together with Express.

The Architecture

┌──────────────┐         ┌──────────────────┐         ┌──────────────┐
│   Customer   │ ◄────── │  Telnyx Voice    │ ◄────── │   Agent A    │
│   (caller)   │         │  Private Network │         │  (your app)  │
└──────────────┘         └──────────────────┘         └──────────────┘
                                  │
                                  │  (warm transfer)
                                  ▼
                         ┌──────────────────┐
                         │     Agent B      │
                         │  (third party)   │
                         └──────────────────┘

Your Node.js service sits behind the Telnyx Voice API. It receives webhooks for every call event, maintains a small in-memory state map keyed by call_control_id, and exposes HTTP endpoints that trigger call control actions.

Prerequisites

  • Node.js 18 or higher (Node.js 20 LTS recommended)
  • A Telnyx account, sign up at portal.telnyx.com for free trial credit
  • A Telnyx phone number with voice capability
  • A Call Control Application configured in the Telnyx Portal with a webhook URL
  • ngrok or another tunnel for local webhook testing

Step 1: Clone and Configure

git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/warm-transfer-nodejs
npm install
cp .env.example .env

Edit .env with your credentials:

TELNYX_API_KEY=KEY_your_api_key_here
TELNYX_PHONE_NUMBER=+15551234567
TELNYX_CONNECTION_ID=your_connection_id_here
WEBHOOK_URL=https://your-ngrok-url.ngrok.io
PORT=3000

Step 2: Initialize the Telnyx Client

The Telnyx Node.js SDK follows the standard constructor pattern:

const telnyx = require("telnyx");
require("dotenv").config();

const client = new telnyx.Telnyx({
  apiKey: process.env.TELNYX_API_KEY,
});

One client, one API key, one bill. Telnyx is an AI Communications Infrastructure platform: voice, messaging, SIP, AI assistants, and IoT all live behind the same authentication surface.

Step 3: Define the Call Control Functions

Each call control action maps to a single SDK call:

async function initiateCall(toNumber) {
  const response = await client.calls.dial({
    from: process.env.TELNYX_PHONE_NUMBER,
    to: toNumber,
    connection_id: process.env.TELNYX_CONNECTION_ID,
  });
  return {
    call_control_id: response.data.call_control_id,
    state: "initiated",
  };
}

async function answerCall(callControlId) {
  await client.calls.actions.answer(callControlId);
}

async function transferCall(callControlId, transferTo) {
  await client.calls.actions.transfer(callControlId, { to: transferTo });
}

async function speakToCall(callControlId, text) {
  await client.calls.actions.speak(callControlId, {
    payload: text,
    language: "en-US",
    voice: "female",
  });
}

async function hangupCall(callControlId) {
  await client.calls.actions.hangup(callControlId);
}

The transfer action is the warm transfer primitive. When you call it on an active call, Telnyx's media server bridges the third party into the existing call without dropping the original participants. The agent stays on the line until they hang up their leg.

Step 4: Handle Webhooks for State Tracking

Every call event arrives as a POST to your webhook URL. The handler reconciles state:

const callState = {};

app.post("/webhooks/call-events", (req, res) => {
  const event = req.body.data;
  const callControlId = event.call_control_id;

  if (!callState[callControlId]) {
    callState[callControlId] = {};
  }

  switch (event.event_type) {
    case "call.initiated":
      callState[callControlId].state = "initiated";
      callState[callControlId].from = event.from;
      callState[callControlId].to = event.to;
      break;
    case "call.answered":
      callState[callControlId].state = "answered";
      break;
    case "call.hangup":
      delete callState[callControlId];
      break;
  }

  res.status(200).json({ status: "ok" });
});

The in-memory callState object is fine for single-instance development. For production, swap it for Redis or PostgreSQL keyed by call_control_id so state survives restarts and scales horizontally.

Step 5: Expose HTTP Endpoints

Each call control action gets an HTTP endpoint so your frontend, CRM, or agent dashboard can trigger it:

app.post("/calls/initiate", async (req, res) => {
  const { to_number } = req.body;
  if (!to_number?.startsWith("+")) {
    return res.status(400).json({
      error: "Phone number must be in E.164 format (e.g., +15551234567)",
    });
  }
  try {
    const result = await initiateCall(to_number);
    res.status(200).json(result);
  } catch (error) {
    if (error instanceof telnyx.AuthenticationError) {
      return res.status(401).json({ error: "Invalid API key" });
    }
    if (error instanceof telnyx.RateLimitError) {
      return res.status(429).json({ error: "Rate limit exceeded" });
    }
    res.status(500).json({ error: error.message });
  }
});

app.post("/calls/:call_control_id/transfer", async (req, res) => {
  const { call_control_id } = req.params;
  const { transfer_to } = req.body;
  if (!transfer_to?.startsWith("+")) {
    return res.status(400).json({
      error: "Phone number must be in E.164 format",
    });
  }
  await transferCall(call_control_id, transfer_to);
  res.status(200).json({ status: "transferred", call_control_id, transfer_to });
});

The error handling matters. The Telnyx SDK throws typed errors (AuthenticationError, RateLimitError, APIStatusError, APIConnectionError) that map cleanly to HTTP status codes. Your agent dashboard can render meaningful messages instead of generic 500s.

Step 6: Run It

node server.js

In another terminal, expose your local server:

ngrok http 3000

Set the ngrok URL as your Call Control Application's webhook URL in the Telnyx Portal. Now trigger a call:

curl -X POST http://localhost:3000/calls/initiate \
  -H "Content-Type: application/json" \
  -d '{"to_number": "+15551234567"}'

The response includes a call_control_id. Use it to transfer:

curl -X POST http://localhost:3000/calls/{call_control_id}/transfer \
  -H "Content-Type: application/json" \
  -d '{"transfer_to": "+15559876543"}'

The third party answers, the original agent stays on the line, and the handoff happens with full context.

Why This Pattern Works for Production

The example above is intentionally minimal, but the pattern scales. Three things make it production-ready:

1. State is keyed by call_control_id, not by phone number. Phone numbers are not unique identifiers. A customer might call from two lines, or a transfer might loop back to the original number. The call_control_id is a UUID that Telnyx generates per call leg and is the only safe key for state.

2. Webhooks are the source of truth, not the API response. When you call client.calls.dial, you get back a call_control_id, but the call hasn't connected yet. The call.answered webhook is what tells you the customer picked up. Your UI should listen for webhooks (via WebSocket or SSE) rather than polling the API.

3. E.164 validation happens at the edge. Rejecting malformed numbers at your HTTP boundary saves a round trip to Telnyx and gives the caller a faster error. The + prefix check is a coarse filter; for stricter validation, use the Telnyx Number Lookup API.

Extending the Example

The base example covers the happy path. Real call centers need more:

  • Recording - Add client.calls.actions.record_start(call_control_id) before the transfer to capture the full conversation for QA.
  • Whisper coaching - Use the call.whisper event to brief the receiving agent before the customer hears them.
  • AI-assisted handoffs - Pipe the call audio through Telnyx AI Inference to auto-generate a summary for the receiving agent.
  • Queue routing - Replace the static transfer_to with a lookup against your routing rules engine.

Each of these is a small addition on top of the same call control primitives.

The Bigger Picture: Why Carrier-Layer Control Matters

Warm transfers are a trust signal. When a customer hears "I'm connecting you with Maria in billing, who has all the context," and Maria actually has the context, that's the carrier layer doing its job. The audio bridged cleanly, the state reconciled, the webhook fired, and the agent dashboard updated, all because Telnyx owns the media path end to end.

That's the difference between an API you integrate and infrastructure you build on. Telnyx's private global network carries the call from your customer's phone to your agent's softphone to the third party's desk, with sub-second latency and no public internet hops. When something goes wrong, you call one support number and one team owns the fix.

Get the Code

The full example is in the telnyx-code-examples repository under warm-transfer-nodejs. It includes:

  • server.js - the complete Express application
  • README.md - quickstart and API reference
  • GUIDE.md - step-by-step walkthrough
  • API.md - typed endpoint reference
  • .env.example - environment variable template

Clone it, add your API key, and you have a working warm transfer service in under five minutes.

Resources

Share on Social