Tutorial Factory

Powered by Telnyx

Dev Docs Low Latency Club GitHub Log in

Outbound Call with Node.js and Express

Overview

Build a production-ready Express endpoint that initiates outbound phone calls using the Telnyx Node.js SDK and the Call Control API. This tutorial covers the command-event model that powers Telnyx voice calls: your server sends a dial command, Telnyx returns a call_control_id, and subsequent call events arrive as webhooks. You'll learn secure credential management, proper error handling, and how to extract serializable data from SDK response objects.

Prerequisites

  • Node.js 18 or higher.
  • A Telnyx account with an active API key from the Telnyx Portal.
  • A Telnyx phone number enabled for outbound voice.
  • A Call Control Application created in the Telnyx Portal (provides your Connection ID).
  • npm (Node.js package manager).

Step 1: Setup

Create a project directory and initialize it:

mkdir telnyx-outbound-call
cd telnyx-outbound-call
npm init -y

Install the required dependencies:

npm install telnyx express dotenv

Step 2: Configuration

Create a .env file in your project root to store credentials securely:

TELNYX_API_KEY=YOUR_API_KEY_HERE
TELNYX_PHONE_NUMBER=+15551234567
TELNYX_CONNECTION_ID=YOUR_CONNECTION_ID_HERE

Replace the placeholder values:

  • YOUR_API_KEY_HERE — your API key from the Telnyx Portal.
  • +15551234567 — your Telnyx phone number in E.164 format.
  • YOUR_CONNECTION_ID_HERE — the ID of your Call Control Application (found under "Call Control" in the portal). This is a static configuration value that links your phone number to your application.

Step 3: Implementation

Create app.js and initialize the Telnyx client. Define a helper function that dials an outbound call and returns a plain object (SDK response objects are not JSON-serializable):

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

const app = express();
app.use(express.json());

// Initialize client with the new SDK pattern
const client = new Telnyx({ apiKey: process.env.TELNYX_API_KEY });

/**
 * Initiate an outbound call via Telnyx Call Control.
 * Returns a plain object — SDK responses are NOT JSON-serializable.
 */
async function initiateCall(toNumber) {
  const fromNumber = process.env.TELNYX_PHONE_NUMBER;
  if (!fromNumber) {
    throw new Error("TELNYX_PHONE_NUMBER environment variable not set");
  }

  const connectionId = process.env.TELNYX_CONNECTION_ID;
  if (!connectionId) {
    throw new Error("TELNYX_CONNECTION_ID environment variable not set");
  }

  // Validate E.164 format to prevent API errors
  if (!toNumber.startsWith("+")) {
    throw new Error(
      "Phone number must be in E.164 format (e.g., +15551234567)"
    );
  }

  // Dial the call — connection_id is your Call Control App ID (static config),
  // NOT a per-call value. call_control_id is RETURNED, never passed in.
  const response = await client.calls.dial({
    from: fromNumber,
    to: toNumber,
    connection_id: connectionId,
  });

  // Extract serializable data — SDK objects are NOT JSON-serializable
  return {
    call_control_id: response.data.call_control_id,
    from: fromNumber,
    to: toNumber,
  };
}

Next, add a webhook handler so your application can react to call lifecycle events. Telnyx delivers events like call.initiated, call.answered, and call.hangup to your webhook URL:

/**
 * Handle incoming Telnyx webhook events for call lifecycle.
 * Configure your webhook URL in the Telnyx Portal Call Control App settings.
 */
app.post("/webhooks/call-events", (req, res) => {
  const event = req.body.data;

  if (!event) {
    return res.status(400).json({ error: "Invalid webhook payload" });
  }

  const eventType = event.event_type;
  const callControlId = event.payload?.call_control_id;

  switch (eventType) {
    case "call.initiated":
      console.log(`Call initiated: ${callControlId}`);
      break;
    case "call.answered":
      console.log(`Call answered: ${callControlId}`);
      break;
    case "call.hangup":
      // Always handle hangup to clean up resources
      console.log(`Call ended: ${callControlId}`);
      break;
    default:
      console.log(`Unhandled event: ${eventType}`);
  }

  // Acknowledge the webhook immediately to prevent retries
  return res.status(200).json({ status: "received" });
});

Step 4: Testing

Add the dial route with comprehensive error handling. Telnyx SDK exceptions are caught in the route handler and mapped to appropriate HTTP status codes:

app.post("/calls/dial", async (req, res) => {
  const { to } = req.body;

  if (!to) {
    return res.status(400).json({ error: "Missing required field: 'to'" });
  }

  try {
    const result = await initiateCall(to);
    return res.status(200).json(result);
  } catch (err) {
    // Map Telnyx SDK exceptions to HTTP status codes
    if (err instanceof Telnyx.AuthenticationError) {
      return res.status(401).json({ error: "Invalid API key" });
    }
    if (err instanceof Telnyx.RateLimitError) {
      return res
        .status(429)
        .json({ error: "Rate limit exceeded. Please slow down." });
    }
    if (err instanceof Telnyx.APIStatusError) {
      return res
        .status(err.status_code)
        .json({ error: err.message, status_code: err.status_code });
    }
    if (err instanceof Telnyx.APIConnectionError) {
      return res
        .status(503)
        .json({ error: "Network error connecting to Telnyx" });
    }
    // Handle validation errors from the helper function
    if (err instanceof Error) {
      return res.status(400).json({ error: err.message });
    }
    return res.status(500).json({ error: "Internal server error" });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Start the server:

node app.js

Test the endpoint using curl:

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

Expected response:

{
  "call_control_id": "v3:abc123def456-7890-abcd-ef01-234567890abc",
  "from": "+15551234567",
  "to": "+15559876543"
}

The call_control_id is the per-call runtime identifier you'll use for all subsequent actions (answer, hangup, transfer, recording). Save it for later use. Telnyx will also begin sending webhook events to your /webhooks/call-events endpoint as the call progresses.

Complete Code

#!/usr/bin/env node
/**
 * Production-ready Express endpoint for initiating outbound calls via Telnyx.
 * Uses the Call Control command-event model: dial a call, receive webhook events.
 */

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

const app = express();
app.use(express.json());

// Initialize client with the new SDK pattern
const client = new Telnyx({ apiKey: process.env.TELNYX_API_KEY });

/**
 * Initiate an outbound call via Telnyx Call Control.
 * Returns a plain object — SDK responses are NOT JSON-serializable.
 */
async function initiateCall(toNumber) {
  const fromNumber = process.env.TELNYX_PHONE_NUMBER;
  if (!fromNumber) {
    throw new Error("TELNYX_PHONE_NUMBER environment variable not set");
  }

  const connectionId = process.env.TELNYX_CONNECTION_ID;
  if (!connectionId) {
    throw new Error("TELNYX_CONNECTION_ID environment variable not set");
  }

  // Validate E.164 format to prevent API errors
  if (!toNumber.startsWith("+")) {
    throw new Error(
      "Phone number must be in E.164 format (e.g., +15551234567)"
    );
  }

  // Dial the call — connection_id is your Call Control App ID (static config),
  // NOT a per-call value. call_control_id is RETURNED, never passed in.
  const response = await client.calls.dial({
    from: fromNumber,
    to: toNumber,
    connection_id: connectionId,
  });

  // Extract serializable data — SDK objects are NOT JSON-serializable
  return {
    call_control_id: response.data.call_control_id,
    from: fromNumber,
    to: toNumber,
  };
}

/**
 * POST /calls/dial — Initiate an outbound call.
 * Body: { "to": "+15559876543" }
 */
app.post("/calls/dial", async (req, res) => {
  const { to } = req.body;

  if (!to) {
    return res.status(400).json({ error: "Missing required field: 'to'" });
  }

  try {
    const result = await initiateCall(to);
    return res.status(200).json(result);
  } catch (err) {
    // Map Telnyx SDK exceptions to HTTP status codes
    if (err instanceof Telnyx.AuthenticationError) {
      return res.status(401).json({ error: "Invalid API key" });
    }
    if (err instanceof Telnyx.RateLimitError) {
      return res
        .status(429)
        .json({ error: "Rate limit exceeded. Please slow down." });
    }
    if (err instanceof Telnyx.APIStatusError) {
      return res
        .status(err.status_code)
        .json({ error: err.message, status_code: err.status_code });
    }
    if (err instanceof Telnyx.APIConnectionError) {
      return res
        .status(503)
        .json({ error: "Network error connecting to Telnyx" });
    }
    // Handle validation errors from the helper function
    if (err instanceof Error) {
      return res.status(400).json({ error: err.message });
    }
    return res.status(500).json({ error: "Internal server error" });
  }
});

/**
 * POST /webhooks/call-events — Receive Telnyx call lifecycle webhooks.
 * Configure this URL in your Call Control Application settings in the Telnyx Portal.
 */
app.post("/webhooks/call-events", (req, res) => {
  const event = req.body.data;

  if (!event) {
    return res.status(400).json({ error: "Invalid webhook payload" });
  }

  const eventType = event.event_type;
  const callControlId = event.payload?.call_control_id;

  switch (eventType) {
    case "call.initiated":
      console.log(`Call initiated: ${callControlId}`);
      break;
    case "call.answered":
      console.log(`Call answered: ${callControlId}`);
      break;
    case "call.hangup":
      // Always handle hangup to clean up resources
      console.log(`Call ended: ${callControlId}`);
      break;
    default:
      console.log(`Unhandled event: ${eventType}`);
  }

  // Acknowledge the webhook immediately to prevent retries
  return res.status(200).json({ status: "received" });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Troubleshooting

Issue Problem Solution
Authentication Error (401) The endpoint returns {"error": "Invalid API key"} with HTTP 401. Verify your TELNYX_API_KEY in the .env file matches the key shown in the Telnyx Portal. Ensure there are no trailing spaces or quotes around the value. If the key was recently regenerated, update your .env file and restart the server with node app.js.
Invalid Connection ID Telnyx returns a 422 or 404 error referencing the connection ID. Confirm TELNYX_CONNECTION_ID is set to your Call Control Application ID (not a phone number or call_control_id). Find it in the Telnyx Portal under Voice > Call Control > Applications. The connection ID is a static config value that links your number to your app — it does not change per call.
Phone Number Format Error You receive a 400 error stating "Phone number must be in E.164 format" or a Telnyx API error about an invalid destination. Ensure all phone numbers use E.164 format: start with +, followed by country code and number with no spaces or dashes. Example: +15551234567 (US) or +447700900123 (UK). Update your curl command accordingly.
Webhooks Not Arriving The /webhooks/call-events endpoint never receives events after dialing. Your webhook URL must be publicly accessible. During development, use a tunneling tool like ngrok (ngrok http 3000) and set the resulting HTTPS URL in your Call Control Application's webhook settings in the Telnyx Portal. Ensure the path matches /webhooks/call-events.

Next Steps