← All Tutorials

FreeSWITCH Fundamentals — Installation, Dialplan, SIP & IVR

Infrastructure & DevOps Intermediate 67 min read #41

Tutorial 41: FreeSWITCH Fundamentals — Installation, Dialplan, SIP & IVR

A complete beginner-to-intermediate guide to FreeSWITCH — the high-performance open-source telephony platform. Learn installation, SIP endpoint configuration, XML dialplan, IVR menus, voicemail, recording, and integration patterns from scratch, with production-ready configurations throughout. Whether you are migrating from Asterisk or starting fresh, this tutorial gives you everything you need to deploy a fully functional FreeSWITCH system with SIP phones, trunks, call routing, conferencing, and external application control via the Event Socket Layer.


Difficulty Beginner to Intermediate
Reading Time ~70 minutes
Prerequisites Linux server (Debian 12 or Ubuntu 24.04), basic SIP/VoIP knowledge, command-line comfort
Technologies FreeSWITCH, SIP, XML Dialplan, mod_sofia, ESL, Python, Conference, IVR, WebRTC
Tested On Debian 12 (Bookworm), Ubuntu 24.04 LTS, FreeSWITCH 1.10.x

Table of Contents

  1. Introduction
  2. Architecture Overview
  3. Installation
  4. SIP Configuration (mod_sofia)
  5. XML Dialplan
  6. IVR Menus
  7. Voicemail
  8. Call Recording
  9. Conference Bridge
  10. Event Socket Layer (ESL)
  11. CDR & Logging
  12. Security Hardening
  13. Integration Patterns
  14. Troubleshooting

1. Introduction

What Is FreeSWITCH?

FreeSWITCH is a scalable, open-source telephony platform designed for routing and interconnecting voice, video, and text communication protocols. Originally created by Anthony Minessale (a former Asterisk developer) in 2006, it was built from the ground up to address architectural limitations in existing PBX software.

Unlike traditional PBX systems that evolved from hardware roots, FreeSWITCH was designed as a software media server — a communications engine that applications talk to, rather than a standalone phone system with a configuration UI.

FreeSWITCH vs Asterisk

If you come from the Asterisk world, understanding the conceptual differences is critical before diving in.

Concept Asterisk FreeSWITCH
Architecture Channel-based (each call leg is a channel) Session-based (each call is a session with legs)
Configuration .conf flat files (custom syntax) XML files (structured, validatable)
Dialplan extensions.conf (priority-based) XML dialplan (context → extension → condition → action)
SIP Stack Built-in chan_sip or chan_pjsip mod_sofia (based on Sofia-SIP library from Nokia)
Concurrency Thread-per-channel Thread pool with session state machines
Scaling Hundreds of concurrent calls Thousands of concurrent calls on same hardware
Codec Support Good (G.711, G.729, Opus) Excellent (all Asterisk codecs + SILK, iSAC, VP8/VP9)
WebRTC Requires external module or proxy Native support via mod_verto
External Control AMI (TCP) or ARI (HTTP/WebSocket) ESL (Event Socket Layer — TCP, bidirectional)
Conference app_confbridge (good) mod_conference (carrier-grade, thousands of participants)
Learning Curve Moderate Steeper (XML verbosity, less community docs)
Community Massive, decades of forums/blogs Smaller but focused, Cluecon conference
License GPL v2 MPL 1.1 (more business-friendly)

When to Choose FreeSWITCH

FreeSWITCH excels in these scenarios:

Choose Asterisk instead if you need: a quick office PBX, FreePBX/GUI administration, massive community support, or ViciDial/call-center-specific features.


2. Architecture Overview

Core Design Principles

FreeSWITCH is built on three fundamental design principles:

  1. Event-driven: Everything in FreeSWITCH generates events. A call arriving, a DTMF press, a conference join — all are events that can be captured, filtered, and acted upon by internal modules or external applications.

  2. Modular: The core is a lightweight engine. All functionality — SIP, dialplan, codecs, applications — comes from loadable modules. You enable only what you need.

  3. Session-based: Each call creates a session object that persists for the call's lifetime. Sessions hold all state (variables, media streams, applications) and are managed by a thread pool, not one-thread-per-call.

Key Concepts

Before configuring anything, understand these five concepts:

Profiles — SIP listeners. Each profile binds to an IP:port and defines SIP behavior (codecs, NAT handling, authentication). The two default profiles are internal (port 5060, for registered phones) and external (port 5080, for trunks/carriers).

Contexts — Dialplan routing containers. When a call arrives on a profile, it enters the context assigned to that profile. The internal profile routes to the default context. The external profile routes to the public context.

Extensions — Named dialplan entries within a context. Each extension has conditions and actions. FreeSWITCH evaluates extensions top-to-bottom until one matches.

Conditions — Pattern-matching rules within an extension. Match on destination_number, caller_id_number, time_of_day, channel variables, or any header field.

Actions — What to do when conditions match. Actions include bridge (connect calls), playback (play audio), transfer (re-route), answer, hangup, and dozens more.

How a Call Flows

Incoming SIP INVITE
       │
       ▼
   mod_sofia (SIP stack)
       │
       ▼
   Profile (internal or external)
       │
       ▼
   Context (default or public)
       │
       ▼
   Extension matching (top-to-bottom)
       │
       ▼
   Condition evaluation (regex)
       │
       ▼
   Actions execute (answer, bridge, playback, etc.)

Directory Structure

/etc/freeswitch/                  # All configuration
├── freeswitch.xml                # Master config (includes everything)
├── vars.xml                      # Global variables (domain, passwords, IPs)
├── autoload_configs/             # Module configurations
│   ├── modules.conf.xml          # Which modules to load
│   ├── sofia.conf.xml            # SIP module config (includes profiles)
│   ├── conference.conf.xml       # Conference profiles
│   ├── voicemail.conf.xml        # Voicemail settings
│   ├── ivr.conf.xml              # IVR menu definitions
│   ├── cdr_csv.conf.xml          # CDR output config
│   └── event_socket.conf.xml     # ESL listener config
├── sip_profiles/                 # SIP profile definitions
│   ├── internal.xml              # Internal profile (phones, port 5060)
│   ├── external.xml              # External profile (trunks, port 5080)
│   └── external/                 # Gateway (trunk) definitions
│       └── my_provider.xml       # One file per trunk
├── dialplan/                     # Call routing rules
│   ├── default.xml               # Internal context (phone-to-phone)
│   ├── public.xml                # External context (inbound from trunks)
│   └── default/                  # Additional dialplan fragments
├── directory/                    # User/phone definitions
│   └── default/                  # Domain directory
│       ├── 1000.xml              # Extension 1000
│       ├── 1001.xml              # Extension 1001
│       └── ...
└── lang/                         # Language/sound file mappings

/var/lib/freeswitch/              # Runtime data
├── db/                           # SQLite databases (registrations, etc.)
├── recordings/                   # Call recordings
├── storage/                      # Voicemail, fax storage
└── sounds/                       # Audio/prompt files

/var/log/freeswitch/              # Logs
├── freeswitch.log                # Main log (rotated)
└── cdr-csv/                      # CDR files

Module System

FreeSWITCH loads modules at startup from autoload_configs/modules.conf.xml. Key modules:

Module Purpose
mod_sofia SIP protocol stack (registration, calls, trunks)
mod_dptools Dialplan tools (bridge, playback, record, transfer, etc.)
mod_dialplan_xml XML dialplan parser
mod_event_socket ESL — external application control
mod_conference Multi-party conferencing
mod_voicemail Voicemail system
mod_ivr IVR menu framework
mod_commands CLI/API commands
mod_console Console output
mod_logfile File logging
mod_cdr_csv CDR to CSV files
mod_cdr_sqlite CDR to SQLite
mod_cdr_pg CDR to PostgreSQL
mod_flite Text-to-speech (Festival Lite)
mod_shout MP3 playback/recording
mod_verto WebRTC signaling
mod_xml_curl Dynamic config from HTTP
mod_lua Lua scripting in dialplan
mod_python3 Python 3 scripting in dialplan

FreeSWITCH vs Asterisk Concept Mapping

If you are coming from Asterisk, this mapping helps translate your knowledge:

Asterisk FreeSWITCH
sip.conf / pjsip.conf sip_profiles/internal.xml + directory/
extensions.conf context dialplan/ context XML
extensions.conf priority lines <action> elements in sequence
Trunk in sip.conf Gateway in sip_profiles/external/
AMI (Asterisk Manager Interface) ESL (Event Socket Layer)
ARI (REST API) mod_xml_curl + ESL
app_confbridge mod_conference
chan_sip / chan_pjsip mod_sofia
Dial() application bridge action
Playback() playback action
Background() + WaitExten() play_and_get_digits or ivr action
voicemail.conf voicemail.conf.xml
${EXTEN} ${destination_number}
${CALLERID(num)} ${caller_id_number}

3. Installation

Option A: Package Install (Recommended)

FreeSWITCH packages are distributed through SignalWire. You need a free Personal Access Token (PAT).

Step 1: Get a SignalWire PAT

  1. Go to https://id.signalwire.com/personal_access_tokens
  2. Create a free account (no credit card needed)
  3. Generate a Personal Access Token
  4. Save the token — you will need it below

Step 2: Add the Repository

Debian 12 (Bookworm):

# Install prerequisites
apt-get update && apt-get install -y gnupg2 wget lsb-release

# Set your SignalWire PAT
TOKEN="YOUR_SIGNALWIRE_PAT_HERE"

# Add SignalWire GPG key
wget --http-user=signalwire --http-password=$TOKEN \
  -O /usr/share/keyrings/signalwire-freeswitch-repo.gpg \
  https://freeswitch.signalwire.com/repo/deb/debian-release/signalwire-freeswitch-repo.gpg

# Add repository
echo "machine freeswitch.signalwire.com login signalwire password $TOKEN" \
  > /etc/apt/auth.conf.d/freeswitch.conf
chmod 600 /etc/apt/auth.conf.d/freeswitch.conf

echo "deb [signed-by=/usr/share/keyrings/signalwire-freeswitch-repo.gpg] \
  https://freeswitch.signalwire.com/repo/deb/debian-release/ bookworm main" \
  > /etc/apt/sources.list.d/freeswitch.list

Ubuntu 24.04 (Noble):

# Install prerequisites
apt-get update && apt-get install -y gnupg2 wget lsb-release

# Set your SignalWire PAT
TOKEN="YOUR_SIGNALWIRE_PAT_HERE"

# Add SignalWire GPG key
wget --http-user=signalwire --http-password=$TOKEN \
  -O /usr/share/keyrings/signalwire-freeswitch-repo.gpg \
  https://freeswitch.signalwire.com/repo/deb/debian-release/signalwire-freeswitch-repo.gpg

# Add repository
echo "machine freeswitch.signalwire.com login signalwire password $TOKEN" \
  > /etc/apt/auth.conf.d/freeswitch.conf
chmod 600 /etc/apt/auth.conf.d/freeswitch.conf

echo "deb [signed-by=/usr/share/keyrings/signalwire-freeswitch-repo.gpg] \
  https://freeswitch.signalwire.com/repo/deb/debian-release/ noble main" \
  > /etc/apt/sources.list.d/freeswitch.list

Step 3: Install FreeSWITCH

apt-get update

# Full install (recommended for learning — includes all modules)
apt-get install -y freeswitch-meta-all

# OR minimal install (production — add modules as needed)
# apt-get install -y freeswitch-meta-vanilla

The freeswitch-meta-all package includes all modules, sounds, music-on-hold, codecs, and language packs. For production, start with freeswitch-meta-vanilla and add only the modules you need.

Step 4: Post-Install Configuration

# Enable and start FreeSWITCH
systemctl enable freeswitch
systemctl start freeswitch

# Verify it is running
systemctl status freeswitch

# Check ownership (FreeSWITCH runs as freeswitch user)
ls -la /etc/freeswitch/
ls -la /var/lib/freeswitch/
ls -la /var/log/freeswitch/

# Fix ownership if needed
chown -R freeswitch:freeswitch /etc/freeswitch
chown -R freeswitch:freeswitch /var/lib/freeswitch
chown -R freeswitch:freeswitch /var/log/freeswitch

Option B: Compile from Source

Building from source gives you the latest code and full control over modules.

# Install build dependencies
apt-get update && apt-get install -y \
  build-essential cmake automake autoconf libtool pkg-config \
  libssl-dev zlib1g-dev libdb-dev libexpat1-dev libcurl4-openssl-dev \
  libpcre3-dev libspeex-dev libspeexdsp-dev libsqlite3-dev \
  libedit-dev libldns-dev libpq-dev libtiff-dev libjpeg-dev \
  libavformat-dev libswscale-dev liblua5.3-dev \
  libopus-dev libsndfile1-dev uuid-dev \
  python3-dev erlang-dev yasm nasm \
  git wget unzip

# Clone the repository
cd /usr/local/src
git clone https://github.com/signalwire/freeswitch.git -b v1.10 freeswitch
cd freeswitch

# Install libks and signalwire-c dependencies
git clone https://github.com/signalwire/libks.git
cd libks && cmake . -DCMAKE_INSTALL_PREFIX=/usr && make install && cd ..

git clone https://github.com/signalwire/signalwire-c.git
cd signalwire-c && cmake . -DCMAKE_INSTALL_PREFIX=/usr && make install && cd ..

# Bootstrap and configure
cd /usr/local/src/freeswitch
./bootstrap.sh -j

# Edit modules.conf to enable/disable modules before building
# nano modules.conf

./configure --prefix=/usr/local/freeswitch

# Build and install
make -j$(nproc)
make install

# Install sounds and music on hold
make cd-sounds-install cd-moh-install

# Create system user
useradd -r -s /bin/false freeswitch
chown -R freeswitch:freeswitch /usr/local/freeswitch

If you compiled from source, config lives in /usr/local/freeswitch/conf/ instead of /etc/freeswitch/. Adjust paths accordingly.

Firewall Configuration

# SIP signaling (internal profile)
ufw allow 5060/tcp comment "FreeSWITCH SIP TCP internal"
ufw allow 5060/udp comment "FreeSWITCH SIP UDP internal"

# SIP signaling (external profile)
ufw allow 5080/tcp comment "FreeSWITCH SIP TCP external"
ufw allow 5080/udp comment "FreeSWITCH SIP UDP external"

# RTP media (voice/video)
ufw allow 16384:32768/udp comment "FreeSWITCH RTP media"

# ESL (Event Socket Layer) - restrict to trusted IPs only
ufw allow from 10.0.0.0/8 to any port 8021 proto tcp comment "FreeSWITCH ESL"

# WebRTC (if using mod_verto)
# ufw allow 8081/tcp comment "FreeSWITCH Verto WSS"
# ufw allow 8082/tcp comment "FreeSWITCH Verto WSS"

ufw enable

With iptables instead:

# SIP
iptables -A INPUT -p udp --dport 5060 -j ACCEPT
iptables -A INPUT -p tcp --dport 5060 -j ACCEPT
iptables -A INPUT -p udp --dport 5080 -j ACCEPT
iptables -A INPUT -p tcp --dport 5080 -j ACCEPT

# RTP
iptables -A INPUT -p udp --dport 16384:32768 -j ACCEPT

# ESL (restrict source)
iptables -A INPUT -p tcp -s 10.0.0.0/8 --dport 8021 -j ACCEPT
iptables -A INPUT -p tcp --dport 8021 -j DROP

Verify Installation

# Connect to FreeSWITCH CLI
fs_cli

# Inside fs_cli, check status
freeswitch@server> status

# Expected output:
# UP 0 years, 0 days, 0 hours, 5 minutes, 23 seconds, 456 milliseconds, 789 microseconds
# FreeSWITCH (Version 1.10.x ...) is ready
# 0 session(s) since startup
# 0 session(s) - peak 0, last 5min 0
# 0 session(s) per Sec out of max 30, peak 0, last 5min 0
# 1000 session(s) max
# min idle cpu 0.00/99.67

# Check SIP profiles
freeswitch@server> sofia status

# Expected output:
#                      Name          Type                               Data      State
# =================================================================================================
#                  internal       profile   sip:mod_sofia@YOUR_SERVER_IP:5060      RUNNING (0)
#                  external       profile   sip:mod_sofia@YOUR_SERVER_IP:5080      RUNNING (0)
# ... (other profiles)

# Check loaded modules
freeswitch@server> module_exists mod_sofia
# true

# Exit CLI
freeswitch@server> /exit

Change Default Passwords

This is critical. The default install ships with password 1234 for all extensions and ClueCon for ESL.

# Edit global variables
nano /etc/freeswitch/vars.xml

Find and change these lines:

<!-- CHANGE THIS — default password for all extensions -->
<X-PRE-PROCESS cmd="set" data="default_password=PUT_A_STRONG_PASSWORD_HERE"/>

<!-- Domain — set to your server IP or FQDN -->
<X-PRE-PROCESS cmd="set" data="domain=$${local_ip_v4}"/>

Change the ESL password:

nano /etc/freeswitch/autoload_configs/event_socket.conf.xml
<configuration name="event_socket.conf" description="Socket Client">
  <settings>
    <param name="nat-map" value="false"/>
    <param name="listen-ip" value="127.0.0.1"/>
    <param name="listen-port" value="8021"/>
    <!-- CHANGE THIS from ClueCon -->
    <param name="password" value="YOUR_SECURE_ESL_PASSWORD"/>
    <!--<param name="apply-inbound-acl" value="loopback.auto"/>-->
  </settings>
</configuration>

Reload the configuration:

# From fs_cli
fs_cli -x "reloadxml"

# Or restart the service
systemctl restart freeswitch

4. SIP Configuration (mod_sofia)

Understanding SIP Profiles

mod_sofia is the SIP engine in FreeSWITCH. It manages all SIP communication through profiles. Each profile is an independent SIP listener with its own port, settings, and behavior.

The default installation creates two profiles:

Profile Port Purpose Dialplan Context
internal 5060 SIP phones, softphones, registered endpoints default
external 5080 SIP trunks, carriers, outside world public

Why two profiles? Security. Internal phones authenticate with username/password. External trunks authenticate by IP. Keeping them on separate ports with separate rules prevents unauthorized calls.

Internal Profile Configuration

The internal profile handles your SIP phones. Edit /etc/freeswitch/sip_profiles/internal.xml:

<profile name="internal">
  <settings>
    <!-- Network -->
    <param name="sip-ip" value="$${local_ip_v4}"/>
    <param name="sip-port" value="5060"/>
    <param name="rtp-ip" value="$${local_ip_v4}"/>

    <!-- NAT: Set this to your public IP if behind NAT -->
    <!-- <param name="ext-sip-ip" value="YOUR_PUBLIC_IP"/> -->
    <!-- <param name="ext-rtp-ip" value="YOUR_PUBLIC_IP"/> -->

    <!-- Dialplan context for calls arriving on this profile -->
    <param name="context" value="default"/>

    <!-- Codec preferences (in order) -->
    <param name="inbound-codec-string" value="OPUS,G722,PCMU,PCMA"/>
    <param name="outbound-codec-string" value="OPUS,G722,PCMU,PCMA"/>

    <!-- DTMF handling -->
    <param name="dtmf-type" value="rfc2833"/>

    <!-- Registration -->
    <param name="inbound-reg-force-matching-username" value="true"/>
    <param name="auth-calls" value="true"/>
    <param name="apply-nat-acl" value="nat.auto"/>

    <!-- Recording -->
    <param name="record-path" value="$${recordings_dir}"/>
    <param name="record-template" value="${caller_id_number}.${target_domain}.${strftime(%Y-%m-%d-%H-%M-%S)}.wav"/>

    <!-- Timers -->
    <param name="session-timeout" value="1800"/>
    <param name="rtp-timeout-sec" value="300"/>
    <param name="rtp-hold-timeout-sec" value="1800"/>

    <!-- Hold music -->
    <param name="hold-music" value="$${hold_music}"/>
  </settings>
</profile>

External Profile Configuration

The external profile handles SIP trunks and outside calls. Edit /etc/freeswitch/sip_profiles/external.xml:

<profile name="external">
  <settings>
    <param name="sip-ip" value="$${local_ip_v4}"/>
    <param name="sip-port" value="5080"/>
    <param name="rtp-ip" value="$${local_ip_v4}"/>

    <!-- NAT: uncomment and set if behind NAT -->
    <!-- <param name="ext-sip-ip" value="YOUR_PUBLIC_IP"/> -->
    <!-- <param name="ext-rtp-ip" value="YOUR_PUBLIC_IP"/> -->

    <!-- Inbound calls from trunks go to 'public' context -->
    <param name="context" value="public"/>

    <!-- Codecs -->
    <param name="inbound-codec-string" value="PCMU,PCMA,G722"/>
    <param name="outbound-codec-string" value="PCMU,PCMA,G722"/>

    <!-- Do NOT require authentication (trunks use IP-based auth) -->
    <param name="auth-calls" value="false"/>

    <!-- DTMF -->
    <param name="dtmf-type" value="rfc2833"/>

    <!-- Aggressively reclaim failed channels -->
    <param name="rtp-timeout-sec" value="300"/>
  </settings>

  <!-- Gateway definitions are in sip_profiles/external/ directory -->
  <gateways>
    <X-PRE-PROCESS cmd="include" data="external/*.xml"/>
  </gateways>
</profile>

User Directory (SIP Extensions)

Each SIP phone/extension is defined in the directory. Files go in /etc/freeswitch/directory/default/.

Here is a single extension definition. Create /etc/freeswitch/directory/default/1001.xml:

<include>
  <user id="1001">
    <params>
      <param name="password" value="Str0ng_P@ss_1001!"/>
      <param name="vm-password" value="1001"/>
    </params>
    <variables>
      <variable name="toll_allow" value="domestic,international,local"/>
      <variable name="accountcode" value="1001"/>
      <variable name="user_context" value="default"/>
      <variable name="effective_caller_id_name" value="John Smith"/>
      <variable name="effective_caller_id_number" value="1001"/>
      <variable name="outbound_caller_id_name" value="My Company"/>
      <variable name="outbound_caller_id_number" value="15551234567"/>
      <variable name="callgroup" value="sales"/>
    </variables>
  </user>
</include>

Complete 10-Extension Office Configuration

Here is a production-ready configuration for a small office with 10 extensions and one SIP trunk.

Create all 10 extension files — save this as a shell script:

#!/bin/bash
# create-extensions.sh
# Creates 10 SIP extensions (1001-1010) for FreeSWITCH

DIRECTORY="/etc/freeswitch/directory/default"

declare -A USERS=(
  [1001]="Alice Johnson:sales"
  [1002]="Bob Williams:sales"
  [1003]="Carol Davis:sales"
  [1004]="Dan Miller:support"
  [1005]="Eve Wilson:support"
  [1006]="Frank Brown:support"
  [1007]="Grace Taylor:billing"
  [1008]="Hank Anderson:billing"
  [1009]="Ivy Martinez:management"
  [1010]="Jack Thompson:management"
)

for EXT in "${!USERS[@]}"; do
  IFS=':' read -r NAME GROUP <<< "${USERS[$EXT]}"

  # Generate a random password
  PASS=$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | head -c 16)

  cat > "${DIRECTORY}/${EXT}.xml" << XMLEOF
<include>
  <user id="${EXT}">
    <params>
      <param name="password" value="${PASS}"/>
      <param name="vm-password" value="${EXT}"/>
    </params>
    <variables>
      <variable name="toll_allow" value="domestic,local"/>
      <variable name="accountcode" value="${EXT}"/>
      <variable name="user_context" value="default"/>
      <variable name="effective_caller_id_name" value="${NAME}"/>
      <variable name="effective_caller_id_number" value="${EXT}"/>
      <variable name="outbound_caller_id_name" value="My Company"/>
      <variable name="outbound_caller_id_number" value="15551234567"/>
      <variable name="callgroup" value="${GROUP}"/>
    </variables>
  </user>
</include>
XMLEOF

  echo "Created ${EXT} — ${NAME} (${GROUP}) — Password: ${PASS}"
done

chown -R freeswitch:freeswitch "${DIRECTORY}"
echo ""
echo "Done. Run 'fs_cli -x reloadxml' to apply."
echo "IMPORTANT: Save the passwords above — they will not be displayed again."
chmod +x create-extensions.sh
./create-extensions.sh

SIP Trunk (Gateway) Configuration

A gateway defines a connection to your SIP provider (ITSP). Create /etc/freeswitch/sip_profiles/external/my_provider.xml:

<include>
  <gateway name="my_provider">
    <!-- Provider credentials -->
    <param name="username" value="your_sip_username"/>
    <param name="password" value="your_sip_password"/>
    <param name="realm" value="sip.provider.com"/>
    <param name="proxy" value="sip.provider.com"/>

    <!-- Registration (some providers require it, some use IP auth) -->
    <param name="register" value="true"/>
    <param name="register-transport" value="udp"/>

    <!-- Caller ID -->
    <param name="caller-id-in-from" value="true"/>

    <!-- Retry on failure -->
    <param name="retry-seconds" value="30"/>

    <!-- Codec preferences for this trunk -->
    <param name="codec-prefs" value="PCMU,PCMA,G722"/>

    <!-- Ping the provider to detect failures -->
    <param name="ping" value="25"/>
    <param name="ping-max" value="3"/>
    <param name="ping-min" value="1"/>
  </gateway>
</include>

For IP-based authentication (no username/password, provider allows your IP):

<include>
  <gateway name="ip_auth_provider">
    <param name="username" value="not_used"/>
    <param name="password" value="not_used"/>
    <param name="realm" value="sip.provider.com"/>
    <param name="proxy" value="sip.provider.com"/>
    <param name="register" value="false"/>
    <param name="caller-id-in-from" value="true"/>
  </gateway>
</include>

After creating gateway files, reload:

# From fs_cli
fs_cli -x "sofia profile external rescan"

# Check gateway status
fs_cli -x "sofia status gateway my_provider"

# Expected output:
# Name         my_provider
# Profile      external
# Scheme       sip
# Realm        sip.provider.com
# Username     your_sip_username
# ...
# State        REGED  <-- Successfully registered
# Status       UP

Inbound DID Routing

When a call arrives from a trunk, it enters the public context. You need to route it to the right extension.

Edit /etc/freeswitch/dialplan/public.xml:

<include>
  <context name="public">
    <!-- Route DID +15551234567 to extension 1001 -->
    <extension name="inbound_main">
      <condition field="destination_number" expression="^(\+?1?5551234567)$">
        <action application="set" data="domain_name=$${domain}"/>
        <action application="transfer" data="1001 XML default"/>
      </condition>
    </extension>

    <!-- Route DID +15559876543 to IVR -->
    <extension name="inbound_ivr">
      <condition field="destination_number" expression="^(\+?1?5559876543)$">
        <action application="set" data="domain_name=$${domain}"/>
        <action application="transfer" data="5000 XML default"/>
      </condition>
    </extension>

    <!-- Route DID +15555551234 to ring group (sales) -->
    <extension name="inbound_sales">
      <condition field="destination_number" expression="^(\+?1?5555551234)$">
        <action application="set" data="domain_name=$${domain}"/>
        <action application="transfer" data="9001 XML default"/>
      </condition>
    </extension>

    <!-- Catch-all: reject unknown DIDs -->
    <extension name="public_reject">
      <condition field="destination_number" expression="^(.*)$">
        <action application="log" data="WARNING Rejecting unknown inbound DID: ${destination_number} from ${sip_from_uri}"/>
        <action application="hangup" data="CALL_REJECTED"/>
      </condition>
    </extension>
  </context>
</include>

NAT Traversal

If your FreeSWITCH server is behind NAT (e.g., in AWS/GCP/Azure with a private IP), configure these settings:

In vars.xml:

<X-PRE-PROCESS cmd="set" data="external_sip_ip=YOUR_PUBLIC_IP"/>
<X-PRE-PROCESS cmd="set" data="external_rtp_ip=YOUR_PUBLIC_IP"/>

In each SIP profile (internal.xml and external.xml):

<!-- Replace the sip-ip and rtp-ip lines with: -->
<param name="sip-ip" value="$${local_ip_v4}"/>
<param name="ext-sip-ip" value="$${external_sip_ip}"/>
<param name="rtp-ip" value="$${local_ip_v4}"/>
<param name="ext-rtp-ip" value="$${external_rtp_ip}"/>

<!-- Enable NAT handling -->
<param name="apply-nat-acl" value="nat.auto"/>
<param name="aggressive-nat-detection" value="true"/>
<param name="local-network-acl" value="localnet.auto"/>

Registering SIP Phones

With extensions created, configure your SIP phone/softphone:

Setting Value
SIP Server / Registrar YOUR_SERVER_IP
Port 5060
Username 1001 (the extension number)
Password The password from the directory XML
Transport UDP (or TCP/TLS)
Domain/Realm YOUR_SERVER_IP

Verify registration:

fs_cli -x "sofia status profile internal reg"

# Output shows registered endpoints:
# Call-ID:        xxxxx@yyyy
# User:           1001@YOUR_SERVER_IP
# Contact:        "Alice Johnson" <sip:1001@phone_ip:port>
# Agent:          Ooh, a softphone!
# Status:         Registered(UDP)(unknown) EXP(2024-01-01 12:00:00)
# ...

5. XML Dialplan

Dialplan Structure

The FreeSWITCH dialplan is a hierarchy: Context → Extension → Condition → Action.

<context name="default">          <!-- Container: who can use these rules -->
  <extension name="my_rule">      <!-- Named rule -->
    <condition field="X" expression="regex">  <!-- When to match -->
      <action application="Y" data="Z"/>      <!-- What to do -->
      <action application="Y2" data="Z2"/>    <!-- Actions run in sequence -->
    </condition>
  </extension>
</context>

FreeSWITCH evaluates extensions top-to-bottom within a context. By default, it stops at the first matching extension (unless the extension is marked continue="true").

Condition Matching

Conditions can match on any channel variable. Common fields:

Field Matches Example Pattern
destination_number The dialed number ^(10[0-1][0-9])$
caller_id_number Calling party number ^(1001)$
caller_id_name Calling party name ^(Alice.*)$
time_of_day Current time (HH:MM) 08:00-17:00
day_of_week Day (1=Sun, 7=Sat) 2-6 (Mon-Fri)
network_addr Source IP of the call ^10\.0\.0\..*$
${sip_to_uri} SIP To header ^.*@example\.com$
${sip_h_X-Custom} Custom SIP header ^(premium)$

Multiple conditions in the same extension use AND logic — all must match.

Regex Patterns

FreeSWITCH uses PCRE-compatible regular expressions:

^1001$              — Exact match for "1001"
^(10[0-1][0-9])$    — Match 1000-1019
^(\d{10})$          — Match any 10-digit number
^9(\d+)$            — Match 9 + any digits (capture digits after 9)
^(\+?1?\d{10})$     — Match with optional +1 prefix
^.*$                — Match anything (catch-all)

Captured groups are available as $1, $2, etc.

Channel Variables

Channel variables store per-call data. You can set them and use them in conditions/actions:

<!-- Set a variable -->
<action application="set" data="my_var=hello"/>

<!-- Use a variable in an action -->
<action application="log" data="INFO The value is ${my_var}"/>

<!-- Use a variable in a condition -->
<condition field="${my_var}" expression="^(hello)$">

Common built-in variables:

Variable Description
${destination_number} Dialed number
${caller_id_number} Caller's number
${caller_id_name} Caller's name
${uuid} Unique call identifier
${sip_from_uri} SIP From URI
${sip_to_uri} SIP To URI
${strftime(%Y-%m-%d %H:%M:%S)} Current date/time
${domain_name} SIP domain
${accountcode} User's account code
${callgroup} User's call group

Common Dialplan Actions

Action Purpose Example
answer Answer the call (send 200 OK) <action application="answer"/>
bridge Connect to another endpoint <action application="bridge" data="user/1001"/>
playback Play audio file <action application="playback" data="/sounds/greeting.wav"/>
sleep Pause for milliseconds <action application="sleep" data="1000"/>
hangup Hang up the call <action application="hangup" data="NORMAL_CLEARING"/>
set Set a channel variable <action application="set" data="key=value"/>
export Set variable on both legs <action application="export" data="key=value"/>
transfer Transfer to another extension <action application="transfer" data="1002 XML default"/>
record_session Record the entire call <action application="record_session" data="/recordings/${uuid}.wav"/>
play_and_get_digits Play prompt + collect DTMF See IVR section
conference Join a conference room <action application="conference" data="room1@default"/>
voicemail Send to voicemail <action application="voicemail" data="default $${domain} 1001"/>
ring_ready Send 180 Ringing <action application="ring_ready"/>
log Write to log <action application="log" data="INFO message here"/>
respond Send SIP response code <action application="respond" data="486"/>

Anti-Actions

Anti-actions execute when a condition does NOT match. They use the <anti-action> tag:

<extension name="business_hours">
  <condition field="time_of_day" expression="09:00-17:30">
    <!-- This runs during business hours -->
    <action application="transfer" data="5000 XML default"/>
    <!-- This runs OUTSIDE business hours -->
    <anti-action application="playback" data="ivr/ivr-please_call_back_during_business_hours.wav"/>
    <anti-action application="voicemail" data="default $${domain} 1001"/>
  </condition>
</extension>

Complete Dialplan Examples

Here is a production-ready default context. Save as /etc/freeswitch/dialplan/default.xml:

<include>
  <context name="default">

    <!-- ============================================ -->
    <!-- INTERNAL EXTENSION CALLING (1001-1099)       -->
    <!-- ============================================ -->
    <extension name="internal_extensions">
      <condition field="destination_number" expression="^(10[0-9]{2})$">
        <action application="set" data="dialed_extension=$1"/>
        <action application="export" data="dialed_extension=$1"/>

        <!-- Set ring timeout to 30 seconds -->
        <action application="set" data="call_timeout=30"/>

        <!-- Set caller ID -->
        <action application="set"
          data="effective_caller_id_name=${outbound_caller_id_name}"/>
        <action application="set"
          data="effective_caller_id_number=${outbound_caller_id_number}"/>

        <!-- Ring the extension, then send to voicemail on no answer -->
        <action application="set"
          data="hangup_after_bridge=true"/>
        <action application="set"
          data="continue_on_fail=true"/>
        <action application="bridge"
          data="user/${dialed_extension}@$${domain}"/>

        <!-- No answer — go to voicemail -->
        <action application="answer"/>
        <action application="sleep" data="1000"/>
        <action application="voicemail"
          data="default $${domain} ${dialed_extension}"/>
      </condition>
    </extension>

    <!-- ============================================ -->
    <!-- RING GROUPS                                  -->
    <!-- ============================================ -->

    <!-- Sales ring group (9001) — ring all sales phones simultaneously -->
    <extension name="ring_group_sales">
      <condition field="destination_number" expression="^(9001)$">
        <action application="set" data="call_timeout=25"/>
        <action application="set" data="continue_on_fail=true"/>
        <action application="set" data="hangup_after_bridge=true"/>
        <action application="bridge"
          data="user/1001@$${domain},user/1002@$${domain},user/1003@$${domain}"/>
        <!-- All busy/unavailable — voicemail for sales -->
        <action application="voicemail" data="default $${domain} 1001"/>
      </condition>
    </extension>

    <!-- Support ring group (9002) — sequential ring (try each for 15s) -->
    <extension name="ring_group_support">
      <condition field="destination_number" expression="^(9002)$">
        <action application="set" data="continue_on_fail=true"/>
        <action application="set" data="hangup_after_bridge=true"/>
        <!-- Try first support agent -->
        <action application="set" data="call_timeout=15"/>
        <action application="bridge" data="user/1004@$${domain}"/>
        <!-- Try second -->
        <action application="bridge" data="user/1005@$${domain}"/>
        <!-- Try third -->
        <action application="bridge" data="user/1006@$${domain}"/>
        <!-- All unavailable -->
        <action application="voicemail" data="default $${domain} 1004"/>
      </condition>
    </extension>

    <!-- ============================================ -->
    <!-- OUTBOUND CALLS VIA TRUNK                     -->
    <!-- ============================================ -->

    <!-- Local/National calls: dial 9 + number -->
    <extension name="outbound_national">
      <condition field="destination_number" expression="^9(\d{10,11})$">
        <action application="set" data="effective_caller_id_number=15551234567"/>
        <action application="set" data="effective_caller_id_name=My Company"/>
        <action application="set" data="hangup_after_bridge=true"/>
        <action application="set" data="call_timeout=60"/>
        <action application="bridge"
          data="sofia/gateway/my_provider/$1"/>
      </condition>
    </extension>

    <!-- International calls: dial 00 + country code + number -->
    <extension name="outbound_international">
      <condition field="destination_number" expression="^(00\d{7,15})$">
        <!-- Check if user has international permission -->
        <action application="set"
          data="continue_on_fail=false"/>
        <action application="set"
          data="effective_caller_id_number=15551234567"/>
        <action application="bridge"
          data="sofia/gateway/my_provider/$1"/>
      </condition>
    </extension>

    <!-- ============================================ -->
    <!-- TIME-BASED ROUTING                           -->
    <!-- ============================================ -->
    <extension name="main_number_time_routing">
      <condition field="destination_number" expression="^(5000)$"/>
      <!-- Check business hours: Mon-Fri 9:00-17:30 -->
      <condition field="time_of_day" expression="09:00-17:30">
        <action application="transfer" data="5001 XML default"/>
        <anti-action application="transfer" data="5002 XML default"/>
      </condition>
    </extension>

    <!-- Business hours handler -->
    <extension name="business_hours_handler">
      <condition field="destination_number" expression="^(5001)$">
        <action application="answer"/>
        <action application="playback"
          data="ivr/ivr-welcome.wav"/>
        <action application="transfer" data="main_ivr XML default"/>
      </condition>
    </extension>

    <!-- After hours handler -->
    <extension name="after_hours_handler">
      <condition field="destination_number" expression="^(5002)$">
        <action application="answer"/>
        <action application="playback"
          data="ivr/ivr-after_hours_message.wav"/>
        <action application="voicemail"
          data="default $${domain} 1001"/>
      </condition>
    </extension>

    <!-- ============================================ -->
    <!-- UTILITIES                                    -->
    <!-- ============================================ -->

    <!-- Echo test (dial 9196) -->
    <extension name="echo_test">
      <condition field="destination_number" expression="^(9196)$">
        <action application="answer"/>
        <action application="echo"/>
      </condition>
    </extension>

    <!-- Music on hold test (dial 9664) -->
    <extension name="moh_test">
      <condition field="destination_number" expression="^(9664)$">
        <action application="answer"/>
        <action application="playback"
          data="$${hold_music}"/>
      </condition>
    </extension>

    <!-- Voicemail access (dial *98) -->
    <extension name="voicemail_check">
      <condition field="destination_number" expression="^\*98$">
        <action application="answer"/>
        <action application="sleep" data="500"/>
        <action application="voicemail"
          data="check default $${domain}"/>
      </condition>
    </extension>

    <!-- Direct voicemail for extension (dial *99 + ext) -->
    <extension name="voicemail_direct">
      <condition field="destination_number" expression="^\*99(\d{4})$">
        <action application="answer"/>
        <action application="sleep" data="500"/>
        <action application="voicemail"
          data="default $${domain} $1"/>
      </condition>
    </extension>

    <!-- Call parking (dial 5900) -->
    <extension name="park_call">
      <condition field="destination_number" expression="^(5900)$">
        <action application="set" data="fifo_music=$${hold_music}"/>
        <action application="fifo" data="park@$${domain} in"/>
      </condition>
    </extension>

    <!-- Retrieve parked call (dial 5901) -->
    <extension name="retrieve_parked_call">
      <condition field="destination_number" expression="^(5901)$">
        <action application="answer"/>
        <action application="fifo" data="park@$${domain} out wait"/>
      </condition>
    </extension>

  </context>
</include>

6. IVR Menus

IVR Concepts in FreeSWITCH

FreeSWITCH provides two approaches to IVR:

  1. mod_ivr menus — XML-configured menu trees with automatic DTMF handling, timeouts, and invalid-input retries. Best for simple, static IVR flows.

  2. play_and_get_digits — A dialplan application that plays a prompt and collects DTMF digits. Best for dynamic IVRs with variable-based routing.

You can combine both: use mod_ivr for the menu structure and play_and_get_digits for collecting account numbers or PINs.

IVR Menu XML Configuration

IVR menus are defined in /etc/freeswitch/autoload_configs/ivr.conf.xml:

<configuration name="ivr.conf" description="IVR Menus">
  <menus>

    <!-- ============================================ -->
    <!-- MAIN MENU (Level 1)                          -->
    <!-- ============================================ -->
    <menu name="main_ivr"
          greet-long="ivr/ivr-welcome_to_our_company.wav"
          greet-short="ivr/ivr-please_make_selection.wav"
          invalid-sound="ivr/ivr-that_was_an_invalid_entry.wav"
          exit-sound="voicemail/vm-goodbye.wav"
          confirm-macro=""
          confirm-key=""
          tts-engine=""
          tts-voice=""
          confirm-attempts="3"
          timeout="10000"
          inter-digit-timeout="2000"
          max-failures="3"
          max-timeouts="3"
          digit-len="1">

      <!-- Press 1 for Sales -->
      <entry action="menu-exec-app"
             digits="1"
             param="transfer 9001 XML default"/>

      <!-- Press 2 for Support -->
      <entry action="menu-exec-app"
             digits="2"
             param="transfer 9002 XML default"/>

      <!-- Press 3 for Billing -->
      <entry action="menu-sub"
             digits="3"
             param="billing_ivr"/>

      <!-- Press 4 for Company Directory -->
      <entry action="menu-exec-app"
             digits="4"
             param="transfer 411 XML default"/>

      <!-- Press 0 for Operator -->
      <entry action="menu-exec-app"
             digits="0"
             param="transfer 1009 XML default"/>

      <!-- Press * to repeat -->
      <entry action="menu-top"
             digits="*"/>

    </menu>

    <!-- ============================================ -->
    <!-- BILLING SUBMENU (Level 2)                    -->
    <!-- ============================================ -->
    <menu name="billing_ivr"
          greet-long="ivr/ivr-billing_menu.wav"
          greet-short="ivr/ivr-please_make_selection.wav"
          invalid-sound="ivr/ivr-that_was_an_invalid_entry.wav"
          exit-sound="voicemail/vm-goodbye.wav"
          timeout="10000"
          inter-digit-timeout="2000"
          max-failures="3"
          max-timeouts="3"
          digit-len="1">

      <!-- Press 1 for Account Balance -->
      <entry action="menu-exec-app"
             digits="1"
             param="transfer 8001 XML default"/>

      <!-- Press 2 for Payment -->
      <entry action="menu-exec-app"
             digits="2"
             param="transfer 8002 XML default"/>

      <!-- Press 3 for Billing Agent -->
      <entry action="menu-exec-app"
             digits="3"
             param="transfer 1007 XML default"/>

      <!-- Press 9 to go back to main menu -->
      <entry action="menu-back"
             digits="9"/>

      <!-- Press 0 for Operator -->
      <entry action="menu-exec-app"
             digits="0"
             param="transfer 1009 XML default"/>

    </menu>

    <!-- ============================================ -->
    <!-- SUPPORT SUBMENU (Level 2)                    -->
    <!-- ============================================ -->
    <menu name="support_ivr"
          greet-long="ivr/ivr-support_menu.wav"
          greet-short="ivr/ivr-please_make_selection.wav"
          invalid-sound="ivr/ivr-that_was_an_invalid_entry.wav"
          exit-sound="voicemail/vm-goodbye.wav"
          timeout="10000"
          inter-digit-timeout="2000"
          max-failures="3"
          max-timeouts="3"
          digit-len="1">

      <!-- Press 1 for Technical Support -->
      <entry action="menu-sub"
             digits="1"
             param="tech_support_ivr"/>

      <!-- Press 2 for General Inquiries -->
      <entry action="menu-exec-app"
             digits="2"
             param="transfer 1005 XML default"/>

      <!-- Press 9 to go back -->
      <entry action="menu-back"
             digits="9"/>

    </menu>

    <!-- ============================================ -->
    <!-- TECH SUPPORT (Level 3)                       -->
    <!-- ============================================ -->
    <menu name="tech_support_ivr"
          greet-long="ivr/ivr-tech_support_menu.wav"
          greet-short="ivr/ivr-please_make_selection.wav"
          invalid-sound="ivr/ivr-that_was_an_invalid_entry.wav"
          exit-sound="voicemail/vm-goodbye.wav"
          timeout="10000"
          inter-digit-timeout="2000"
          max-failures="3"
          max-timeouts="3"
          digit-len="1">

      <!-- Press 1 for Internet Issues -->
      <entry action="menu-exec-app"
             digits="1"
             param="transfer 1004 XML default"/>

      <!-- Press 2 for Phone/VoIP Issues -->
      <entry action="menu-exec-app"
             digits="2"
             param="transfer 1005 XML default"/>

      <!-- Press 3 for Email Issues -->
      <entry action="menu-exec-app"
             digits="3"
             param="transfer 1006 XML default"/>

      <!-- Press 9 to go back -->
      <entry action="menu-back"
             digits="9"/>

      <!-- Press 0 for Operator -->
      <entry action="menu-exec-app"
             digits="0"
             param="transfer 1009 XML default"/>

    </menu>

  </menus>
</configuration>

IVR Menu Parameters Reference

Parameter Description
greet-long First greeting played when entering the menu
greet-short Short greeting played on repeat/return
invalid-sound Played when invalid key is pressed
exit-sound Played when exiting the menu
timeout Milliseconds to wait for input (default: 10000)
inter-digit-timeout Milliseconds between digits for multi-digit input
max-failures Hang up after this many invalid entries
max-timeouts Hang up after this many timeouts
digit-len Number of digits to collect (1 for single key menus)

IVR Entry Actions

Action Description
menu-exec-app Execute a dialplan application
menu-sub Enter a sub-menu
menu-top Return to the top of current menu
menu-back Go back to parent menu
menu-exit Exit the IVR

Connecting the IVR to the Dialplan

Add this to your default context in /etc/freeswitch/dialplan/default.xml:

<!-- IVR entry point (dial 5000 or transfer from public context) -->
<extension name="main_ivr">
  <condition field="destination_number" expression="^(main_ivr|5000)$">
    <action application="answer"/>
    <action application="sleep" data="500"/>
    <action application="ivr" data="main_ivr"/>
  </condition>
</extension>

Using play_and_get_digits

For dynamic DTMF collection (account numbers, PINs, etc.), use play_and_get_digits:

<!-- Collect a 5-digit account number -->
<extension name="account_lookup">
  <condition field="destination_number" expression="^(8001)$">
    <action application="answer"/>
    <action application="sleep" data="500"/>

    <!-- play_and_get_digits parameters:
         min_digits max_digits max_tries timeout terminators
         audio_file bad_input_file variable_name regex_pattern
         digit_timeout transfer_on_failure -->
    <action application="play_and_get_digits"
      data="5 5 3 10000 # ivr/ivr-enter_account_number.wav ivr/ivr-that_was_an_invalid_entry.wav account_number \d{5} 15000"/>

    <!-- Now use the collected digits -->
    <action application="log"
      data="INFO Account number entered: ${account_number}"/>

    <!-- You could use mod_xml_curl to look up the account,
         or use Lua/Python to query a database -->
    <action application="playback"
      data="ivr/ivr-thank_you.wav"/>
    <action application="transfer"
      data="1007 XML default"/>
  </condition>
</extension>

play_and_get_digits parameter breakdown:

Position Parameter Description
1 min_digits Minimum digits to collect
2 max_digits Maximum digits to collect
3 max_tries Retries on invalid input
4 timeout Milliseconds to wait for first digit
5 terminators Key to finish input (# is standard)
6 audio_file Prompt to play
7 bad_input_file Error sound
8 variable_name Variable to store result
9 regex_pattern Validation regex
10 digit_timeout Milliseconds between digits
11 transfer_on_fail Where to transfer after max failures

Text-to-Speech (TTS)

Instead of pre-recorded prompts, you can use TTS:

With mod_flite (built-in, free, basic quality):

<action application="speak" data="flite|kal|Welcome to our company. Press 1 for sales."/>

With mod_tts_commandline (use any external TTS engine):

First configure in /etc/freeswitch/autoload_configs/tts_commandline.conf.xml:

<configuration name="tts_commandline.conf" description="TTS Command">
  <settings>
    <param name="command" value="echo '${text}' | /usr/bin/piper --model /opt/tts/en_US-lessac-medium.onnx --output_file ${file}"/>
  </settings>
</configuration>

Then use in the dialplan:

<action application="speak"
  data="tts_commandline|default|Welcome to our company. Please hold while we connect you."/>

Complete 3-Level IVR Example

Putting it all together — here is the call flow:

Caller dials main number
       │
       ▼
   Level 1: Main Menu
   1 → Sales (ring group 9001)
   2 → Support submenu
   3 → Billing submenu
   4 → Company directory
   0 → Operator (ext 1009)
       │
       ├── Level 2: Support
       │   1 → Tech Support submenu
       │   2 → General Inquiries (ext 1005)
       │   9 → Back to Main
       │       │
       │       └── Level 3: Tech Support
       │           1 → Internet (ext 1004)
       │           2 → VoIP (ext 1005)
       │           3 → Email (ext 1006)
       │           9 → Back to Support
       │           0 → Operator
       │
       └── Level 2: Billing
           1 → Account Balance (collect acct#)
           2 → Payment (ext 8002)
           3 → Billing Agent (ext 1007)
           9 → Back to Main
           0 → Operator

The XML for all three levels is already shown above in the ivr.conf.xml section. Just ensure the dialplan has transfer targets for each destination.


7. Voicemail

Voicemail Configuration

FreeSWITCH voicemail is handled by mod_voicemail. Configure it in /etc/freeswitch/autoload_configs/voicemail.conf.xml:

<configuration name="voicemail.conf" description="Voicemail">
  <settings>
  </settings>

  <profiles>
    <profile name="default">
      <!-- Storage -->
      <param name="file-extension" value="wav"/>
      <param name="record-silence-threshold" value="200"/>
      <param name="record-silence-hits" value="5"/>
      <param name="max-record-len" value="300"/>
      <param name="max-retries" value="3"/>

      <!-- Greeting -->
      <param name="terminator-key" value="#"/>
      <param name="play-new-messages-key" value="1"/>
      <param name="play-saved-messages-key" value="2"/>

      <!-- Message controls (during playback) -->
      <param name="skip-greet-key" value="#"/>
      <param name="config-menu-key" value="5"/>
      <param name="record-greeting-key" value="1"/>
      <param name="choose-greeting-key" value="2"/>
      <param name="change-pass-key" value="6"/>

      <!-- Playback controls -->
      <param name="listen-key" value="1"/>
      <param name="save-key" value="2"/>
      <param name="delete-key" value="7"/>
      <param name="forward-key" value="8"/>
      <param name="repeat-key" value="0"/>

      <!-- Notification -->
      <param name="notify-mailto" value=""/>
      <param name="notify-email-body" value="You have a new voicemail from ${caller_id_number} (${caller_id_name}). The message is ${message_len} seconds long."/>
      <param name="notify-email-subject" value="New voicemail from ${caller_id_number}"/>

      <!-- Email with attachment -->
      <param name="email-from" value="voicemail@YOUR_DOMAIN"/>
      <param name="email-body" value="You have a new voicemail.\n\nFrom: ${caller_id_name} (${caller_id_number})\nDate: ${left_epoch}\nDuration: ${message_len} seconds\n\nThe recording is attached."/>
      <param name="email-subject" value="[Voicemail] New message from ${caller_id_number}"/>

      <!-- Attach the recording to the email -->
      <param name="vm-email-all-messages" value="true"/>
      <!-- Delete from server after emailing (set false to keep) -->
      <param name="vm-delete-file" value="false"/>
      <!-- Keep the message as new after emailing -->
      <param name="vm-keep-local-after-email" value="true"/>

      <!-- MWI (Message Waiting Indicator — lights up the phone's voicemail LED) -->
      <param name="vm-message-ext" value="wav"/>

      <!-- Storage location -->
      <param name="storage-dir" value="$${storage_dir}/voicemail/default"/>

      <!-- Operator extension (press 0 during greeting) -->
      <param name="operator-extension" value="operator XML default"/>
      <param name="operator-key" value="0"/>
    </profile>
  </profiles>
</configuration>

Dialplan Integration

Add these extensions to your default context:

<!-- Send to voicemail (internal — after no answer on bridge) -->
<!-- This is already in the internal_extensions example above -->

<!-- Check own voicemail: dial *98 -->
<extension name="voicemail_check">
  <condition field="destination_number" expression="^\*98$">
    <action application="answer"/>
    <action application="sleep" data="500"/>
    <action application="voicemail"
      data="check default $${domain}"/>
  </condition>
</extension>

<!-- Leave voicemail directly for someone: dial *99 + extension -->
<extension name="voicemail_leave">
  <condition field="destination_number" expression="^\*99(\d{4})$">
    <action application="answer"/>
    <action application="sleep" data="500"/>
    <action application="voicemail"
      data="default $${domain} $1"/>
  </condition>
</extension>

<!-- Check specific mailbox (for shared mailboxes): dial *97 + extension -->
<extension name="voicemail_check_specific">
  <condition field="destination_number" expression="^\*97(\d{4})$">
    <action application="answer"/>
    <action application="sleep" data="500"/>
    <action application="voicemail"
      data="check default $${domain} $1"/>
  </condition>
</extension>

Email Notification Setup

For voicemail-to-email to work, you need a working mail system:

# Install a lightweight MTA
apt-get install -y msmtp msmtp-mta

# Configure SMTP relay
cat > /etc/msmtprc << 'EOF'
defaults
auth           on
tls            on
tls_trust_file /etc/ssl/certs/ca-certificates.crt
logfile        /var/log/msmtp.log

account        default
host           smtp.gmail.com
port           587
from           voicemail@YOUR_DOMAIN
user           [email protected]
password       your_app_password
EOF

chmod 600 /etc/msmtprc

Then set the notify-mailto in each user's directory XML to enable per-user email notification:

<!-- In /etc/freeswitch/directory/default/1001.xml -->
<include>
  <user id="1001">
    <params>
      <param name="password" value="Str0ng_P@ss_1001!"/>
      <param name="vm-password" value="1001"/>
      <param name="vm-mailto" value="[email protected]"/>
      <param name="vm-email-all-messages" value="true"/>
      <param name="vm-attach-file" value="true"/>
      <param name="vm-keep-local-after-email" value="true"/>
    </params>
    <!-- ... variables ... -->
  </user>
</include>

Greeting Management

Users manage their own greetings by calling *98 (voicemail check) and pressing:

Greetings are stored in /var/lib/freeswitch/storage/voicemail/default/YOUR_DOMAIN/1001/.

Storage and Cleanup

Voicemail files accumulate. Set up automated cleanup:

#!/bin/bash
# /usr/local/bin/cleanup-voicemail.sh
# Delete voicemail messages older than 30 days

STORAGE_DIR="/var/lib/freeswitch/storage/voicemail"
MAX_AGE=30

echo "$(date '+%Y-%m-%d %H:%M:%S') — Voicemail cleanup starting"

# Find and delete old voicemail recordings
find "${STORAGE_DIR}" -name "*.wav" -type f -mtime +${MAX_AGE} -delete
find "${STORAGE_DIR}" -name "*.mp3" -type f -mtime +${MAX_AGE} -delete

# Clean up empty directories
find "${STORAGE_DIR}" -type d -empty -delete

echo "$(date '+%Y-%m-%d %H:%M:%S') — Voicemail cleanup complete"
chmod +x /usr/local/bin/cleanup-voicemail.sh

# Run weekly
echo "0 3 * * 0 root /usr/local/bin/cleanup-voicemail.sh >> /var/log/voicemail-cleanup.log 2>&1" \
  > /etc/cron.d/voicemail-cleanup

8. Call Recording

Full-Call Recording with record_session

The record_session application records both legs of a call (caller and callee) into a single file for the entire duration. Add it to any dialplan extension before the bridge:

<extension name="recorded_internal_call">
  <condition field="destination_number" expression="^(10[0-9]{2})$">
    <action application="set" data="dialed_extension=$1"/>

    <!-- Start recording BEFORE bridging -->
    <action application="set"
      data="RECORD_STEREO=true"/>
    <action application="set"
      data="media_bug_answer_req=true"/>
    <action application="record_session"
      data="/var/lib/freeswitch/recordings/${strftime(%Y/%m/%d)}/${uuid}_${caller_id_number}_to_${dialed_extension}.wav"/>

    <!-- Now bridge the call -->
    <action application="set" data="call_timeout=30"/>
    <action application="set" data="hangup_after_bridge=true"/>
    <action application="set" data="continue_on_fail=true"/>
    <action application="bridge"
      data="user/${dialed_extension}@$${domain}"/>

    <!-- Voicemail on no answer (recording continues into VM) -->
    <action application="answer"/>
    <action application="voicemail"
      data="default $${domain} ${dialed_extension}"/>
  </condition>
</extension>

File Naming Best Practices

Use structured file naming for easy searching and archiving:

<!-- Date-based subdirectories + descriptive filenames -->
<action application="record_session"
  data="/var/lib/freeswitch/recordings/${strftime(%Y/%m/%d)}/${uuid}_${caller_id_number}_to_${destination_number}_${strftime(%Y%m%d-%H%M%S)}.wav"/>

This creates a path like:

/var/lib/freeswitch/recordings/2026/03/14/abc123_15551234567_to_1001_20260314-143022.wav

Stereo Recording

Stereo recording places each call leg on a separate audio channel (left = caller, right = callee). This is essential for speech analytics and quality review:

<!-- Enable stereo recording -->
<action application="set" data="RECORD_STEREO=true"/>

<!-- Record in stereo WAV -->
<action application="record_session"
  data="/var/lib/freeswitch/recordings/${strftime(%Y/%m/%d)}/${uuid}.wav"/>

Start/Stop Recording Mid-Call

You can control recording dynamically during a call using the API:

# Start recording on an active call (from fs_cli or ESL)
uuid_record <call-uuid> start /var/lib/freeswitch/recordings/mid_call_recording.wav

# Stop recording
uuid_record <call-uuid> stop /var/lib/freeswitch/recordings/mid_call_recording.wav

# Stop all recordings on a call
uuid_record <call-uuid> stop all

In the dialplan, you can use DTMF-triggered recording:

<!-- Agent presses *1 to start recording, *2 to stop -->
<extension name="record_on_demand">
  <condition field="destination_number" expression="^(10[0-9]{2})$">
    <action application="set" data="dialed_extension=$1"/>

    <!-- Bind DTMF keys for recording control -->
    <action application="bind_digit_action"
      data="rec,*1,exec:record_session,/var/lib/freeswitch/recordings/${uuid}_on_demand.wav"/>
    <action application="bind_digit_action"
      data="rec,*2,exec:stop_record_session,/var/lib/freeswitch/recordings/${uuid}_on_demand.wav"/>
    <action application="digit_action_set_realm" data="rec"/>

    <action application="bridge"
      data="user/${dialed_extension}@$${domain}"/>
  </condition>
</extension>

Post-Call Processing: Convert to MP3

WAV files are large (~1 MB/minute for mono, ~2 MB/minute for stereo). Convert to MP3 for long-term storage:

#!/bin/bash
# /usr/local/bin/convert-recordings.sh
# Convert WAV recordings to MP3 and archive

RECORDINGS_DIR="/var/lib/freeswitch/recordings"
ARCHIVE_DIR="/var/lib/freeswitch/recordings-mp3"
LOG="/var/log/recording-convert.log"

# Install lame if not present
which lame > /dev/null 2>&1 || apt-get install -y lame

# Process WAV files older than 5 minutes (avoid in-progress recordings)
find "${RECORDINGS_DIR}" -name "*.wav" -type f -mmin +5 | while read WAV_FILE; do
  # Build MP3 path (mirror directory structure)
  REL_PATH="${WAV_FILE#${RECORDINGS_DIR}/}"
  MP3_FILE="${ARCHIVE_DIR}/${REL_PATH%.wav}.mp3"
  MP3_DIR=$(dirname "${MP3_FILE}")

  # Create directory
  mkdir -p "${MP3_DIR}"

  # Convert
  if lame --quiet -V2 "${WAV_FILE}" "${MP3_FILE}" 2>/dev/null; then
    echo "$(date '+%Y-%m-%d %H:%M:%S') Converted: ${REL_PATH}" >> "${LOG}"
    # Delete original WAV after successful conversion
    rm -f "${WAV_FILE}"
  else
    echo "$(date '+%Y-%m-%d %H:%M:%S') FAILED: ${REL_PATH}" >> "${LOG}"
  fi
done

# Clean up empty directories
find "${RECORDINGS_DIR}" -type d -empty -delete 2>/dev/null
chmod +x /usr/local/bin/convert-recordings.sh

# Run every 30 minutes
echo "*/30 * * * * root /usr/local/bin/convert-recordings.sh" \
  > /etc/cron.d/recording-convert

Retention and Cleanup

#!/bin/bash
# /usr/local/bin/cleanup-recordings.sh
# Delete recordings older than retention period

RECORDINGS_DIR="/var/lib/freeswitch/recordings"
MP3_DIR="/var/lib/freeswitch/recordings-mp3"
RETENTION_DAYS=90

echo "$(date '+%Y-%m-%d %H:%M:%S') — Recording cleanup starting"
echo "Retention: ${RETENTION_DAYS} days"

# Delete old WAV files
WAV_COUNT=$(find "${RECORDINGS_DIR}" -name "*.wav" -type f -mtime +${RETENTION_DAYS} | wc -l)
find "${RECORDINGS_DIR}" -name "*.wav" -type f -mtime +${RETENTION_DAYS} -delete
echo "Deleted ${WAV_COUNT} WAV files older than ${RETENTION_DAYS} days"

# Delete old MP3 files
MP3_COUNT=$(find "${MP3_DIR}" -name "*.mp3" -type f -mtime +${RETENTION_DAYS} | wc -l)
find "${MP3_DIR}" -name "*.mp3" -type f -mtime +${RETENTION_DAYS} -delete
echo "Deleted ${MP3_COUNT} MP3 files older than ${RETENTION_DAYS} days"

# Clean empty directories
find "${RECORDINGS_DIR}" -type d -empty -delete 2>/dev/null
find "${MP3_DIR}" -type d -empty -delete 2>/dev/null

# Report disk usage
echo "Current recording storage:"
du -sh "${RECORDINGS_DIR}" "${MP3_DIR}" 2>/dev/null

echo "$(date '+%Y-%m-%d %H:%M:%S') — Cleanup complete"
chmod +x /usr/local/bin/cleanup-recordings.sh

# Run daily at 4 AM
echo "0 4 * * * root /usr/local/bin/cleanup-recordings.sh >> /var/log/recording-cleanup.log 2>&1" \
  > /etc/cron.d/recording-cleanup

9. Conference Bridge

Conference Profile Configuration

FreeSWITCH's mod_conference is carrier-grade — it can handle thousands of participants across hundreds of rooms. Configure profiles in /etc/freeswitch/autoload_configs/conference.conf.xml:

<configuration name="conference.conf" description="Audio Conference">
  <advertise>
    <!-- Advertise conference rooms via SIP SUBSCRIBE -->
    <room name="3001@$${domain}" status="FreeSWITCH"/>
  </advertise>

  <caller-controls>
    <!-- Default key bindings for participants -->
    <group name="default">
      <control action="mute" digits="0"/>
      <control action="deaf mute" digits="*"/>
      <control action="energy up" digits="9"/>
      <control action="energy equ" digits="8"/>
      <control action="energy dn" digits="7"/>
      <control action="vol talk up" digits="3"/>
      <control action="vol talk zero" digits="2"/>
      <control action="vol talk dn" digits="1"/>
      <control action="vol listen up" digits="6"/>
      <control action="vol listen zero" digits="5"/>
      <control action="vol listen dn" digits="4"/>
      <control action="hangup" digits="#"/>
    </group>

    <!-- Moderator key bindings -->
    <group name="moderator">
      <control action="mute" digits="0"/>
      <control action="deaf mute" digits="*"/>
      <control action="hangup" digits="#"/>
      <control action="lock" digits="*1"/>
      <control action="mute non_moderator" digits="*5"/>
      <control action="unmute non_moderator" digits="*6"/>
      <control action="kick last" digits="*7"/>
      <control action="transfer" digits="*9" data="1009 XML default"/>
    </group>
  </caller-controls>

  <profiles>
    <!-- Standard conference profile -->
    <profile name="default">
      <param name="domain" value="$${domain}"/>
      <param name="rate" value="16000"/>
      <param name="interval" value="20"/>
      <param name="energy-level" value="100"/>

      <!-- Comfort noise for silence -->
      <param name="comfort-noise" value="true"/>

      <!-- Sounds -->
      <param name="muted-sound" value="conference/conf-muted.wav"/>
      <param name="unmuted-sound" value="conference/conf-unmuted.wav"/>
      <param name="alone-sound" value="conference/conf-alone.wav"/>
      <param name="enter-sound" value="tone_stream://%(200,0,500,600,700)"/>
      <param name="exit-sound" value="tone_stream://%(500,0,300,200,100,50,25)"/>
      <param name="kicked-sound" value="conference/conf-kicked.wav"/>
      <param name="locked-sound" value="conference/conf-locked.wav"/>
      <param name="is-locked-sound" value="conference/conf-is-locked.wav"/>
      <param name="is-unlocked-sound" value="conference/conf-is-unlocked.wav"/>
      <param name="pin-sound" value="conference/conf-pin.wav"/>
      <param name="bad-pin-sound" value="conference/conf-bad-pin.wav"/>

      <!-- Controls -->
      <param name="caller-controls" value="default"/>
      <param name="moderator-controls" value="moderator"/>

      <!-- Auto-record all conferences -->
      <!-- <param name="auto-record"
        value="/var/lib/freeswitch/recordings/conference/${conference_name}_${strftime(%Y%m%d-%H%M%S)}.wav"/> -->

      <!-- Max members (0 = unlimited) -->
      <param name="max-members" value="100"/>
      <!-- Announce count of members when joining -->
      <param name="announce-count" value="5"/>

      <!-- Codec preferences -->
      <param name="conference-flags"
        value="wait-mod|audio-always|waste-bandwidth"/>
    </profile>

    <!-- PIN-protected conference profile -->
    <profile name="secure">
      <param name="domain" value="$${domain}"/>
      <param name="rate" value="16000"/>
      <param name="interval" value="20"/>
      <param name="energy-level" value="100"/>
      <param name="comfort-noise" value="true"/>

      <!-- PIN required -->
      <param name="pin" value="12345"/>
      <param name="pin-retries" value="3"/>
      <param name="pin-sound" value="conference/conf-pin.wav"/>
      <param name="bad-pin-sound" value="conference/conf-bad-pin.wav"/>

      <param name="enter-sound" value="tone_stream://%(200,0,500,600,700)"/>
      <param name="exit-sound" value="tone_stream://%(500,0,300,200,100,50,25)"/>
      <param name="caller-controls" value="default"/>
      <param name="moderator-controls" value="moderator"/>
      <param name="max-members" value="50"/>
      <param name="conference-flags" value="wait-mod|audio-always"/>
    </profile>

    <!-- Webinar profile: listeners muted by default -->
    <profile name="webinar">
      <param name="domain" value="$${domain}"/>
      <param name="rate" value="16000"/>
      <param name="interval" value="20"/>
      <param name="energy-level" value="100"/>
      <param name="comfort-noise" value="true"/>

      <param name="enter-sound" value="tone_stream://%(200,0,500,600,700)"/>
      <param name="exit-sound" value="tone_stream://%(500,0,300,200,100,50,25)"/>

      <param name="caller-controls" value="default"/>
      <param name="moderator-controls" value="moderator"/>
      <param name="max-members" value="500"/>

      <!-- Members join muted -->
      <param name="member-flags" value="mute"/>
      <param name="conference-flags" value="wait-mod|audio-always"/>

      <!-- Auto-record webinars -->
      <param name="auto-record"
        value="/var/lib/freeswitch/recordings/webinar/${conference_name}_${strftime(%Y%m%d-%H%M%S)}.wav"/>
    </profile>
  </profiles>
</configuration>

Dialplan for Conference Rooms

Add to your default context:

<!-- ============================================ -->
<!-- CONFERENCE ROOMS                             -->
<!-- ============================================ -->

<!-- Standard conference rooms: dial 3001-3099 -->
<extension name="conference_standard">
  <condition field="destination_number" expression="^(30[0-9]{2})$">
    <action application="answer"/>
    <action application="sleep" data="500"/>
    <action application="conference" data="room_$1@default"/>
  </condition>
</extension>

<!-- PIN-protected conference rooms: dial 3100-3199 -->
<extension name="conference_secure">
  <condition field="destination_number" expression="^(31[0-9]{2})$">
    <action application="answer"/>
    <action application="sleep" data="500"/>
    <action application="conference" data="room_$1@secure"/>
  </condition>
</extension>

<!-- Moderator entry: dial 3200 + room number (e.g., 32003001 for room 3001) -->
<extension name="conference_moderator">
  <condition field="destination_number" expression="^3200(30[0-9]{2})$">
    <action application="answer"/>
    <action application="sleep" data="500"/>
    <action application="set" data="conference_member_flags=moderator"/>
    <action application="conference" data="room_$1@default+flags{moderator}"/>
  </condition>
</extension>

<!-- Webinar rooms: dial 3300-3399 (listeners muted) -->
<extension name="conference_webinar">
  <condition field="destination_number" expression="^(33[0-9]{2})$">
    <action application="answer"/>
    <action application="sleep" data="500"/>
    <action application="conference" data="webinar_$1@webinar"/>
  </condition>
</extension>

<!-- Webinar presenter: dial 3400 + room number -->
<extension name="conference_webinar_presenter">
  <condition field="destination_number" expression="^3400(33[0-9]{2})$">
    <action application="answer"/>
    <action application="sleep" data="500"/>
    <action application="conference" data="webinar_$1@webinar+flags{moderator}"/>
  </condition>
</extension>

Dynamic PIN from Dialplan

Instead of hardcoding a PIN in the conference profile, set it per-room:

<extension name="conference_dynamic_pin">
  <condition field="destination_number" expression="^(35[0-9]{2})$">
    <action application="answer"/>
    <action application="sleep" data="500"/>
    <!-- Collect PIN from caller -->
    <action application="play_and_get_digits"
      data="4 6 3 10000 # conference/conf-pin.wav conference/conf-bad-pin.wav entered_pin \d{4,6} 10000"/>
    <!-- Verify PIN (in production, check against a database) -->
    <action application="set" data="expected_pin=7890"/>
    <action application="execute_extension"
      data="check_pin_${entered_pin} XML features"/>
    <!-- If we get here, PIN was correct -->
    <action application="conference" data="room_$1@default"/>
  </condition>
</extension>

Conference Management via fs_cli

# List active conferences
fs_cli -x "conference list"

# List members in a specific conference
fs_cli -x "conference room_3001 list"

# Mute a participant (by member ID)
fs_cli -x "conference room_3001 mute 3"

# Unmute a participant
fs_cli -x "conference room_3001 unmute 3"

# Kick a participant
fs_cli -x "conference room_3001 kick 3"

# Lock the conference (no new members)
fs_cli -x "conference room_3001 lock"

# Unlock
fs_cli -x "conference room_3001 unlock"

# Mute all non-moderators
fs_cli -x "conference room_3001 mute non_moderator"

# Start recording a conference
fs_cli -x "conference room_3001 record /var/lib/freeswitch/recordings/conference/room_3001_$(date +%Y%m%d).wav"

# Stop recording
fs_cli -x "conference room_3001 norecord all"

# Play a file into the conference
fs_cli -x "conference room_3001 play /var/lib/freeswitch/sounds/announcement.wav"

# Get conference count
fs_cli -x "conference room_3001 count"

Conference Events

FreeSWITCH fires events for all conference activity. These are available via ESL:

Event When
conference::maintenance Member join/leave/mute/unmute/kick
conference::dtmf-member DTMF pressed by a member
conference::start-talking Member starts speaking
conference::stop-talking Member stops speaking

These events enable real-time dashboards, participant tracking, and integration with external management UIs.


10. Event Socket Layer (ESL)

What Is ESL?

The Event Socket Layer is a TCP socket interface that allows external applications to monitor and control FreeSWITCH in real time. It is the primary integration mechanism — far more powerful than Asterisk's AMI because it is fully bidirectional and event-driven.

ESL operates in two modes:

Mode Direction Use Case
Inbound App connects to FreeSWITCH (port 8021) Monitoring, call control, management
Outbound FreeSWITCH connects to your app Per-call IVR logic, call routing decisions

ESL Configuration

The ESL listener is configured in /etc/freeswitch/autoload_configs/event_socket.conf.xml:

<configuration name="event_socket.conf" description="Socket Client">
  <settings>
    <!-- Listen on localhost only (secure) -->
    <param name="listen-ip" value="127.0.0.1"/>
    <param name="listen-port" value="8021"/>
    <param name="password" value="YOUR_SECURE_ESL_PASSWORD"/>

    <!-- Optional: allow connections from specific IPs -->
    <!--<param name="listen-ip" value="0.0.0.0"/>-->
    <!--<param name="apply-inbound-acl" value="loopback.auto"/>-->
  </settings>
</configuration>

fs_cli as ESL Client

fs_cli is itself an ESL client. Everything you type in fs_cli goes through ESL:

# Connect to local FreeSWITCH
fs_cli

# Connect to remote FreeSWITCH
fs_cli -H 10.0.0.5 -P 8021 -p YOUR_SECURE_ESL_PASSWORD

# Execute a single command and exit
fs_cli -x "sofia status"

# Execute API command
fs_cli -x "show channels"

# Execute background API command
fs_cli -x "bgapi originate user/1001 &echo()"

Common ESL Commands

Command Description
api status System status
api sofia status SIP profile status
api sofia status profile internal reg Show registered phones
api show channels Active call channels
api show calls Active calls with details
api conference list Active conferences
api originate <endpoint> <app> Place a new call
api uuid_bridge <uuid1> <uuid2> Bridge two existing calls
api uuid_kill <uuid> Hang up a call
api uuid_transfer <uuid> <dest> Transfer a call
api uuid_record <uuid> start <file> Start recording
api uuid_setvar <uuid> <var> <val> Set channel variable
api uuid_getvar <uuid> <var> Get channel variable
bgapi originate ... Background API (non-blocking)
event plain ALL Subscribe to all events
event plain CHANNEL_CREATE CHANNEL_HANGUP Subscribe to specific events
filter Event-Name CHANNEL_ANSWER Filter subscribed events

Python ESL: Inbound Mode

The greenswitch library provides a clean Python ESL client. Install it:

pip3 install greenswitch

Basic monitoring application:

#!/usr/bin/env python3
"""
freeswitch_monitor.py
Monitor FreeSWITCH events via ESL (inbound mode).
Logs call activity and tracks concurrent calls.
"""

import greenswitch
import json
from datetime import datetime


class FreeSWITCHMonitor:
    def __init__(self, host='127.0.0.1', port=8021, password='YOUR_SECURE_ESL_PASSWORD'):
        self.host = host
        self.port = port
        self.password = password
        self.active_calls = {}
        self.conn = None

    def connect(self):
        """Establish ESL connection."""
        self.conn = greenswitch.InboundESL(
            host=self.host,
            port=self.port,
            password=self.password
        )
        self.conn.connect()
        print(f"[{self._now()}] Connected to FreeSWITCH at {self.host}:{self.port}")

    def subscribe_events(self):
        """Subscribe to call-related events."""
        # Subscribe to specific events
        self.conn.send(
            'event plain CHANNEL_CREATE CHANNEL_ANSWER '
            'CHANNEL_HANGUP_COMPLETE DTMF CODEC'
        )

        # Register event handlers
        self.conn.register_handle('CHANNEL_CREATE', self._on_channel_create)
        self.conn.register_handle('CHANNEL_ANSWER', self._on_channel_answer)
        self.conn.register_handle('CHANNEL_HANGUP_COMPLETE', self._on_channel_hangup)
        self.conn.register_handle('DTMF', self._on_dtmf)

    def _on_channel_create(self, event):
        """Called when a new channel is created."""
        uuid = event.headers.get('Unique-ID', 'unknown')
        caller = event.headers.get('Caller-Caller-ID-Number', 'unknown')
        dest = event.headers.get('Caller-Destination-Number', 'unknown')
        direction = event.headers.get('Call-Direction', 'unknown')

        self.active_calls[uuid] = {
            'caller': caller,
            'destination': dest,
            'direction': direction,
            'created': datetime.now(),
            'state': 'ringing'
        }
        print(f"[{self._now()}] NEW CALL: {caller} -> {dest} ({direction}) UUID={uuid}")
        print(f"  Active calls: {len(self.active_calls)}")

    def _on_channel_answer(self, event):
        """Called when a channel is answered."""
        uuid = event.headers.get('Unique-ID', 'unknown')
        if uuid in self.active_calls:
            self.active_calls[uuid]['state'] = 'answered'
            caller = self.active_calls[uuid]['caller']
            dest = self.active_calls[uuid]['destination']
            print(f"[{self._now()}] ANSWERED: {caller} -> {dest} UUID={uuid}")

    def _on_channel_hangup(self, event):
        """Called when a channel hangs up."""
        uuid = event.headers.get('Unique-ID', 'unknown')
        cause = event.headers.get('Hangup-Cause', 'unknown')
        duration = event.headers.get('variable_billsec', '0')

        if uuid in self.active_calls:
            call = self.active_calls.pop(uuid)
            print(
                f"[{self._now()}] HANGUP: {call['caller']} -> {call['destination']} "
                f"Duration={duration}s Cause={cause} UUID={uuid}"
            )
            print(f"  Active calls: {len(self.active_calls)}")

    def _on_dtmf(self, event):
        """Called when DTMF is received."""
        uuid = event.headers.get('Unique-ID', 'unknown')
        digit = event.headers.get('DTMF-Digit', '?')
        print(f"[{self._now()}] DTMF: digit={digit} UUID={uuid}")

    def run_api(self, command):
        """Execute an API command and return the result."""
        result = self.conn.send(f'api {command}')
        return result.data if result else None

    def originate_call(self, endpoint, app='&park()'):
        """Place a new call."""
        cmd = f'api originate {endpoint} {app}'
        result = self.conn.send(cmd)
        print(f"[{self._now()}] Originate result: {result.data if result else 'failed'}")
        return result

    def _now(self):
        return datetime.now().strftime('%Y-%m-%d %H:%M:%S')

    def run(self):
        """Main event loop."""
        self.connect()
        self.subscribe_events()
        print(f"[{self._now()}] Listening for events... (Ctrl+C to stop)")

        # Get initial status
        status = self.run_api('status')
        if status:
            print(f"\n--- FreeSWITCH Status ---\n{status}\n")

        try:
            # This blocks and processes events
            self.conn.process_events()
        except KeyboardInterrupt:
            print(f"\n[{self._now()}] Shutting down...")


if __name__ == '__main__':
    monitor = FreeSWITCHMonitor(
        host='127.0.0.1',
        port=8021,
        password='YOUR_SECURE_ESL_PASSWORD'
    )
    monitor.run()

Run the monitor:

python3 freeswitch_monitor.py

Python ESL: Call Control Application

A more advanced example — an automated call queue:

#!/usr/bin/env python3
"""
call_queue.py
Simple call queue: callers hear hold music, agents dial in to take the next call.
Uses ESL inbound mode.
"""

import greenswitch
from collections import deque
from datetime import datetime
import threading


class CallQueue:
    def __init__(self, host='127.0.0.1', port=8021, password='YOUR_SECURE_ESL_PASSWORD'):
        self.conn = greenswitch.InboundESL(host=host, port=port, password=password)
        self.waiting_callers = deque()  # Queue of caller UUIDs
        self.available_agents = []       # List of agent UUIDs
        self.lock = threading.Lock()

    def connect(self):
        self.conn.connect()
        self.conn.send('event plain CHANNEL_ANSWER CHANNEL_HANGUP_COMPLETE')
        print(f"[{self._now()}] Queue system connected")

    def add_caller(self, uuid):
        """Add a caller to the queue and play hold music."""
        with self.lock:
            self.waiting_callers.append(uuid)
            # Play music on hold to the caller
            self.conn.send(
                f'api uuid_broadcast {uuid} '
                f'local_stream://moh both'
            )
            print(f"[{self._now()}] Caller {uuid} added to queue. "
                  f"Queue depth: {len(self.waiting_callers)}")
            self._try_connect()

    def add_agent(self, uuid):
        """Register an agent as available."""
        with self.lock:
            self.available_agents.append(uuid)
            print(f"[{self._now()}] Agent {uuid} available. "
                  f"Agents: {len(self.available_agents)}")
            self._try_connect()

    def _try_connect(self):
        """Try to bridge a waiting caller with an available agent."""
        if self.waiting_callers and self.available_agents:
            caller_uuid = self.waiting_callers.popleft()
            agent_uuid = self.available_agents.pop(0)

            # Bridge the two calls
            self.conn.send(
                f'api uuid_bridge {caller_uuid} {agent_uuid}'
            )
            print(
                f"[{self._now()}] CONNECTED: caller={caller_uuid} "
                f"agent={agent_uuid}"
            )

    def get_stats(self):
        """Return current queue statistics."""
        with self.lock:
            return {
                'waiting_callers': len(self.waiting_callers),
                'available_agents': len(self.available_agents),
                'timestamp': self._now()
            }

    def _now(self):
        return datetime.now().strftime('%Y-%m-%d %H:%M:%S')


if __name__ == '__main__':
    queue = CallQueue()
    queue.connect()
    # In production, callers and agents would be added via
    # dialplan outbound ESL or API calls
    print("Queue system running. Use ESL commands to add callers/agents.")

Node.js ESL Example

For Node.js applications, use the modesl library:

npm install modesl
// freeswitch_monitor.js
// FreeSWITCH event monitor using Node.js + modesl

const esl = require('modesl');

const connection = new esl.Connection('127.0.0.1', 8021, 'YOUR_SECURE_ESL_PASSWORD', () => {
    console.log(`[${timestamp()}] Connected to FreeSWITCH via ESL`);

    // Get initial status
    connection.api('status', (result) => {
        console.log(`\n--- FreeSWITCH Status ---\n${result.getBody()}\n`);
    });

    // Subscribe to events
    connection.subscribe([
        'CHANNEL_CREATE',
        'CHANNEL_ANSWER',
        'CHANNEL_HANGUP_COMPLETE'
    ], () => {
        console.log(`[${timestamp()}] Subscribed to call events`);
    });
});

// Track active calls
const activeCalls = new Map();

connection.on('esl::event::CHANNEL_CREATE::*', (event) => {
    const uuid = event.getHeader('Unique-ID');
    const caller = event.getHeader('Caller-Caller-ID-Number') || 'unknown';
    const dest = event.getHeader('Caller-Destination-Number') || 'unknown';
    const direction = event.getHeader('Call-Direction') || 'unknown';

    activeCalls.set(uuid, { caller, dest, direction, created: new Date() });
    console.log(`[${timestamp()}] NEW: ${caller} -> ${dest} (${direction}) [${activeCalls.size} active]`);
});

connection.on('esl::event::CHANNEL_ANSWER::*', (event) => {
    const uuid = event.getHeader('Unique-ID');
    const call = activeCalls.get(uuid);
    if (call) {
        console.log(`[${timestamp()}] ANSWER: ${call.caller} -> ${call.dest}`);
    }
});

connection.on('esl::event::CHANNEL_HANGUP_COMPLETE::*', (event) => {
    const uuid = event.getHeader('Unique-ID');
    const cause = event.getHeader('Hangup-Cause') || 'unknown';
    const duration = event.getHeader('variable_billsec') || '0';
    const call = activeCalls.get(uuid);

    if (call) {
        activeCalls.delete(uuid);
        console.log(
            `[${timestamp()}] HANGUP: ${call.caller} -> ${call.dest} ` +
            `Duration=${duration}s Cause=${cause} [${activeCalls.size} active]`
        );
    }
});

connection.on('error', (error) => {
    console.error(`[${timestamp()}] ESL Error:`, error);
});

function timestamp() {
    return new Date().toISOString().replace('T', ' ').substring(0, 19);
}

// Originate a call example (uncomment to use):
// connection.api('originate user/1001 &echo()', (result) => {
//     console.log('Originate result:', result.getBody());
// });
node freeswitch_monitor.js

ESL Outbound Mode

In outbound mode, FreeSWITCH connects to YOUR application for each call. This is ideal for complex call routing or IVR logic where you want full programmatic control.

Dialplan configuration — route calls to your application:

<extension name="esl_controlled_ivr">
  <condition field="destination_number" expression="^(7000)$">
    <action application="socket" data="127.0.0.1:9090 async full"/>
  </condition>
</extension>

Python outbound ESL server:

#!/usr/bin/env python3
"""
outbound_ivr.py
Simple outbound ESL IVR server.
FreeSWITCH connects to this app for call handling.
"""

import socket
import threading


def handle_call(client_socket, addr):
    """Handle one incoming ESL connection (one call)."""
    print(f"New call connection from {addr}")

    # Read the initial connect message
    data = client_socket.recv(65536).decode()

    # Send connect command
    client_socket.sendall(b'connect\n\n')
    data = client_socket.recv(65536).decode()

    # Parse caller info from headers
    headers = {}
    for line in data.split('\n'):
        if ':' in line:
            key, value = line.split(':', 1)
            headers[key.strip()] = value.strip()

    caller = headers.get('Caller-Caller-ID-Number', 'unknown')
    dest = headers.get('Caller-Destination-Number', 'unknown')
    uuid = headers.get('Unique-ID', 'unknown')

    print(f"Call from {caller} to {dest} (UUID: {uuid})")

    # Answer the call
    send_command(client_socket, 'answer')

    # Play a greeting
    send_command(client_socket, 'playback /var/lib/freeswitch/sounds/ivr/ivr-welcome.wav')

    # Collect digits
    send_command(
        client_socket,
        'play_and_get_digits 1 1 3 10000 # '
        '/var/lib/freeswitch/sounds/ivr/ivr-please_make_selection.wav '
        '/var/lib/freeswitch/sounds/ivr/ivr-that_was_an_invalid_entry.wav '
        'selection \\d 10000'
    )

    # Read the response to get the collected digit
    # (In production, parse CHANNEL_EXECUTE_COMPLETE events)

    # Transfer based on selection
    send_command(client_socket, 'transfer 1001 XML default')

    client_socket.close()


def send_command(sock, command):
    """Send an ESL command."""
    msg = f'sendmsg\ncall-command: execute\nexecute-app-name: {command}\n\n'
    sock.sendall(msg.encode())
    # Read response
    try:
        return sock.recv(65536).decode()
    except Exception:
        return ''


def main():
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind(('127.0.0.1', 9090))
    server.listen(50)
    print("Outbound ESL server listening on 127.0.0.1:9090")

    while True:
        client, addr = server.accept()
        thread = threading.Thread(target=handle_call, args=(client, addr))
        thread.daemon = True
        thread.start()


if __name__ == '__main__':
    main()

11. CDR & Logging

CDR with mod_cdr_csv

The simplest CDR method — writes call records to CSV files. Configure in /etc/freeswitch/autoload_configs/cdr_csv.conf.xml:

<configuration name="cdr_csv.conf" description="CDR CSV Format">
  <settings>
    <!-- Master CSV file for all calls -->
    <param name="default-template" value="default"/>

    <!-- Rotate log file when it reaches this size (bytes) -->
    <param name="rotate-on-hup" value="true"/>

    <!-- Legs: a-leg only, b-leg only, or both -->
    <param name="legs" value="a"/>
  </settings>

  <templates>
    <!-- Default template — one line per call -->
    <template name="default">"${caller_id_name}","${caller_id_number}","${destination_number}","${context}","${start_stamp}","${answer_stamp}","${end_stamp}","${duration}","${billsec}","${hangup_cause}","${uuid}","${accountcode}","${read_codec}","${write_codec}","${sip_hangup_disposition}","${sip_from_uri}","${sip_to_uri}"</template>

    <!-- Detailed template with more fields -->
    <template name="detailed">"${caller_id_name}","${caller_id_number}","${destination_number}","${context}","${start_stamp}","${answer_stamp}","${end_stamp}","${progress_stamp}","${progress_media_stamp}","${duration}","${billsec}","${hangup_cause}","${uuid}","${bleg_uuid}","${accountcode}","${read_codec}","${write_codec}","${sip_hangup_disposition}","${endpoint_disposition}","${sip_from_uri}","${sip_to_uri}","${sip_call_id}","${network_addr}","${bridge_channel}","${last_app}","${last_arg}"</template>
  </templates>
</configuration>

CDR CSV files are written to /var/log/freeswitch/cdr-csv/.

CDR with mod_cdr_sqlite

For queryable CDR data, use SQLite. Configure in /etc/freeswitch/autoload_configs/cdr_sqlite.conf.xml:

<configuration name="cdr_sqlite.conf" description="CDR SQLite">
  <settings>
    <param name="db-name" value="cdr"/>
    <param name="db-table" value="cdr"/>
    <param name="legs" value="a"/>
    <param name="default-template" value="default"/>
  </settings>

  <templates>
    <template name="default">
      INSERT INTO cdr (
        caller_id_name, caller_id_number, destination_number, context,
        start_stamp, answer_stamp, end_stamp, duration, billsec,
        hangup_cause, uuid, accountcode, read_codec, write_codec,
        sip_hangup_disposition, network_addr
      ) VALUES (
        '${caller_id_name}', '${caller_id_number}', '${destination_number}',
        '${context}', '${start_stamp}', '${answer_stamp}', '${end_stamp}',
        '${duration}', '${billsec}', '${hangup_cause}', '${uuid}',
        '${accountcode}', '${read_codec}', '${write_codec}',
        '${sip_hangup_disposition}', '${network_addr}'
      );
    </template>
  </templates>
</configuration>

The SQLite database is created at /var/lib/freeswitch/db/cdr.db. Query it:

sqlite3 /var/lib/freeswitch/db/cdr.db

-- Show recent calls
SELECT caller_id_number, destination_number, duration, hangup_cause, start_stamp
FROM cdr
ORDER BY start_stamp DESC
LIMIT 20;

-- Call volume by hour
SELECT strftime('%H', start_stamp) AS hour, COUNT(*) AS calls
FROM cdr
WHERE date(start_stamp) = date('now')
GROUP BY hour
ORDER BY hour;

-- Average call duration by destination
SELECT destination_number,
       COUNT(*) AS calls,
       AVG(billsec) AS avg_duration,
       SUM(billsec) AS total_seconds
FROM cdr
WHERE billsec > 0
GROUP BY destination_number
ORDER BY calls DESC;

CDR with mod_cdr_pg (PostgreSQL)

For production systems, PostgreSQL is the best CDR backend. Configure in /etc/freeswitch/autoload_configs/cdr_pg.conf.xml:

<configuration name="cdr_pg.conf" description="CDR PostgreSQL">
  <settings>
    <param name="host" value="127.0.0.1"/>
    <param name="port" value="5432"/>
    <param name="dbname" value="freeswitch_cdr"/>
    <param name="user" value="freeswitch"/>
    <param name="password" value="YOUR_DB_PASSWORD"/>
    <param name="legs" value="a"/>
    <param name="default-template" value="default"/>
    <!-- Log errors to file if DB is unavailable -->
    <param name="spool-format" value="csv"/>
    <param name="log-dir" value="/var/log/freeswitch/cdr-pg-errors"/>
  </settings>

  <templates>
    <template name="default">
      INSERT INTO cdr (
        caller_id_name, caller_id_number, destination_number, context,
        start_stamp, answer_stamp, end_stamp, duration, billsec,
        hangup_cause, uuid, accountcode, read_codec, write_codec,
        sip_hangup_disposition, network_addr
      ) VALUES (
        '${caller_id_name}', '${caller_id_number}', '${destination_number}',
        '${context}',
        to_timestamp(${start_epoch}), to_timestamp(${answer_epoch}),
        to_timestamp(${end_epoch}),
        ${duration}, ${billsec}, '${hangup_cause}', '${uuid}',
        '${accountcode}', '${read_codec}', '${write_codec}',
        '${sip_hangup_disposition}', '${network_addr}'
      );
    </template>
  </templates>
</configuration>

Create the database and table:

-- Run as PostgreSQL superuser
CREATE DATABASE freeswitch_cdr;
CREATE USER freeswitch WITH PASSWORD 'YOUR_DB_PASSWORD';
GRANT ALL PRIVILEGES ON DATABASE freeswitch_cdr TO freeswitch;

\c freeswitch_cdr

CREATE TABLE cdr (
    id              SERIAL PRIMARY KEY,
    caller_id_name  VARCHAR(128),
    caller_id_number VARCHAR(64),
    destination_number VARCHAR(64),
    context         VARCHAR(64),
    start_stamp     TIMESTAMP,
    answer_stamp    TIMESTAMP,
    end_stamp       TIMESTAMP,
    duration        INTEGER,
    billsec         INTEGER,
    hangup_cause    VARCHAR(64),
    uuid            VARCHAR(64) UNIQUE,
    accountcode     VARCHAR(32),
    read_codec      VARCHAR(32),
    write_codec     VARCHAR(32),
    sip_hangup_disposition VARCHAR(32),
    network_addr    VARCHAR(64),
    created_at      TIMESTAMP DEFAULT NOW()
);

-- Index for common queries
CREATE INDEX idx_cdr_start_stamp ON cdr (start_stamp);
CREATE INDEX idx_cdr_caller ON cdr (caller_id_number);
CREATE INDEX idx_cdr_destination ON cdr (destination_number);
CREATE INDEX idx_cdr_accountcode ON cdr (accountcode);

GRANT ALL ON cdr TO freeswitch;
GRANT USAGE, SELECT ON SEQUENCE cdr_id_seq TO freeswitch;

CDR Fields Reference

Field Description
caller_id_name Name of the calling party
caller_id_number Phone number of the calling party
destination_number Dialed number
context Dialplan context (default, public, etc.)
start_stamp / start_epoch When the call started
answer_stamp / answer_epoch When the call was answered (NULL if not answered)
end_stamp / end_epoch When the call ended
duration Total time in seconds (ring + talk)
billsec Talk time in seconds (answer to hangup)
hangup_cause SIP/Q.931 hangup reason
uuid Unique call identifier
accountcode Account code for billing
read_codec / write_codec Audio codecs used
sip_hangup_disposition Who hung up: send_bye, recv_bye, send_cancel, recv_cancel
network_addr Source IP address

Log Configuration

FreeSWITCH logging is configured in /etc/freeswitch/autoload_configs/logfile.conf.xml:

<configuration name="logfile.conf" description="File Logging">
  <settings>
    <param name="rotate-on-hup" value="true"/>
  </settings>

  <profiles>
    <profile name="default">
      <settings>
        <param name="logfile" value="/var/log/freeswitch/freeswitch.log"/>

        <!-- Rotate every 10MB -->
        <param name="rollover" value="10485760"/>

        <!-- Log level: DEBUG, INFO, NOTICE, WARNING, ERR, CRIT, ALERT -->
        <param name="log-event" value="false"/>
      </settings>

      <mappings>
        <!-- What to log — set level per module -->
        <map name="all" value="info,warning,err,crit,alert"/>

        <!-- Enable debug for specific modules when troubleshooting -->
        <!-- <map name="mod_sofia" value="debug,info,warning,err"/> -->
        <!-- <map name="mod_dptools" value="debug,info,warning,err"/> -->
      </mappings>
    </profile>
  </profiles>
</configuration>

SIP Trace Logging

For SIP debugging, enable trace on a specific profile:

# Enable SIP trace (shows all SIP messages in fs_cli)
fs_cli -x "sofia profile internal siptrace on"

# Disable
fs_cli -x "sofia profile internal siptrace off"

# Enable on external profile
fs_cli -x "sofia profile external siptrace on"

# Save SIP trace to a PCAP file
fs_cli -x "sofia profile internal capture on"
# File saved to /tmp/ by default
fs_cli -x "sofia profile internal capture off"

Log Rotation

Set up logrotate to manage FreeSWITCH logs:

cat > /etc/logrotate.d/freeswitch << 'EOF'
/var/log/freeswitch/freeswitch.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    postrotate
        /usr/bin/fs_cli -x "fsctl send_sighup" > /dev/null 2>&1 || true
    endscript
}

/var/log/freeswitch/cdr-csv/*.csv {
    daily
    rotate 30
    compress
    delaycompress
    missingok
    notifempty
    create 640 freeswitch freeswitch
}
EOF

12. Security Hardening

Change All Default Passwords

This was covered in Section 3, but it bears repeating. The three most critical defaults:

# 1. Extension passwords (vars.xml — default_password)
# Change from "1234" to something strong
# Then set individual passwords per extension in directory/*.xml

# 2. ESL password (event_socket.conf.xml)
# Change from "ClueCon"

# 3. Voicemail PINs (each user's vm-password)
# Change from extension number

SIP Profile ACLs

Restrict which IPs can register to each profile. Edit the profile XML:

<!-- In sip_profiles/internal.xml -->
<param name="apply-register-acl" value="trusted_networks"/>
<param name="apply-inbound-acl" value="trusted_networks"/>

Define the ACL in /etc/freeswitch/autoload_configs/acl.conf.xml:

<configuration name="acl.conf" description="Network Lists">
  <network-lists>
    <!-- Trusted networks for SIP registration -->
    <list name="trusted_networks" default="deny">
      <!-- Office network -->
      <node type="allow" cidr="10.0.0.0/8"/>
      <!-- VPN range -->
      <node type="allow" cidr="172.16.0.0/12"/>
      <!-- Specific remote office -->
      <node type="allow" cidr="203.0.113.50/32"/>
      <!-- Localhost -->
      <node type="allow" cidr="127.0.0.0/8"/>
    </list>

    <!-- SIP trunk provider IPs -->
    <list name="trunk_providers" default="deny">
      <node type="allow" cidr="198.51.100.0/24"/>
      <node type="allow" cidr="203.0.113.100/32"/>
    </list>

    <!-- ESL access -->
    <list name="esl_access" default="deny">
      <node type="allow" cidr="127.0.0.1/32"/>
      <node type="allow" cidr="10.0.0.0/8"/>
    </list>
  </network-lists>
</configuration>

fail2ban for FreeSWITCH

Install and configure fail2ban to block brute-force SIP attacks:

apt-get install -y fail2ban

Create the FreeSWITCH filter at /etc/fail2ban/filter.d/freeswitch.conf:

# /etc/fail2ban/filter.d/freeswitch.conf
[INCLUDES]
before = common.conf

[Definition]
failregex = ^\s*\[WARNING\] sofia_reg\.c:\d+ SIP Registration Failed: IP=<HOST>.*$
            ^\s*\[WARNING\] sofia_reg\.c:\d+.*auth challenge.*<HOST>.*$
            ^\s*\[WARNING\].*REGISTER.*from.*<HOST>.*forbidden.*$
            ^\s*\[WARNING\].*Authentication\s+Failed.*<HOST>.*$

ignoreregex =

Create the jail at /etc/fail2ban/jail.d/freeswitch.conf:

# /etc/fail2ban/jail.d/freeswitch.conf
[freeswitch]
enabled  = true
filter   = freeswitch
logpath  = /var/log/freeswitch/freeswitch.log
maxretry = 5
findtime = 300
bantime  = 3600
action   = iptables-allports[name=freeswitch, protocol=all]
systemctl enable fail2ban
systemctl restart fail2ban

# Check status
fail2ban-client status freeswitch

TLS for SIP (SIPS)

Encrypt SIP signaling with TLS:

# Generate a self-signed certificate (or use Let's Encrypt)
mkdir -p /etc/freeswitch/tls

openssl req -x509 -nodes -days 3650 \
  -newkey rsa:2048 \
  -keyout /etc/freeswitch/tls/agent.pem \
  -out /etc/freeswitch/tls/agent.pem \
  -subj "/CN=YOUR_DOMAIN"

# Combine into the format FreeSWITCH expects
cp /etc/freeswitch/tls/agent.pem /etc/freeswitch/tls/cafile.pem
chown freeswitch:freeswitch /etc/freeswitch/tls/*.pem
chmod 640 /etc/freeswitch/tls/*.pem

Enable TLS in the internal profile:

<!-- In sip_profiles/internal.xml, add these parameters -->
<param name="tls" value="true"/>
<param name="tls-bind-params" value="transport=tls"/>
<param name="tls-sip-port" value="5061"/>
<param name="tls-cert-dir" value="/etc/freeswitch/tls"/>
<param name="tls-version" value="tlsv1.2"/>

SRTP (Encrypted Media)

After enabling TLS for signaling, enable SRTP for media encryption:

<!-- In sip_profiles/internal.xml -->
<param name="inbound-codec-string" value="OPUS,G722,PCMU,PCMA"/>
<param name="outbound-codec-string" value="OPUS,G722,PCMU,PCMA"/>

<!-- Require SRTP -->
<!-- Options: mandatory, optional, forbidden -->
<param name="rtp_secure_media" value="optional"/>

Per-user SRTP enforcement in the directory:

<!-- In directory/default/1001.xml -->
<user id="1001">
  <params>
    <param name="password" value="Str0ng_P@ss!"/>
  </params>
  <variables>
    <!-- Force SRTP for this user -->
    <variable name="rtp_secure_media" value="mandatory"/>
    <!-- ... other variables ... -->
  </variables>
</user>

Rate Limiting

Protect against SIP floods by limiting registrations and call attempts:

<!-- In sip_profiles/internal.xml -->

<!-- Max registrations per second -->
<param name="accept-blind-reg" value="false"/>

<!-- Challenge all registrations (prevents spoofing) -->
<param name="auth-all-packets" value="true"/>
<param name="auth-calls" value="true"/>

<!-- Limit concurrent calls -->
<!-- In vars.xml -->
<!-- <X-PRE-PROCESS cmd="set" data="max_sessions=500"/> -->
<!-- <X-PRE-PROCESS cmd="set" data="sessions_per_second=50"/> -->

Set global limits in fs_cli:

# Set max concurrent sessions
fs_cli -x "fsctl max_sessions 500"

# Set max sessions per second
fs_cli -x "fsctl sps 50"

Firewall Best Practices Summary

# Minimal firewall rules for production FreeSWITCH

# Flush existing rules (careful!)
# iptables -F

# Default deny
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT ACCEPT

# Allow established connections
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT

# Loopback
iptables -A INPUT -i lo -j ACCEPT

# SSH (your management IP only)
iptables -A INPUT -p tcp -s YOUR_MGMT_IP --dport 22 -j ACCEPT

# SIP from trusted networks only
iptables -A INPUT -p udp -s 10.0.0.0/8 --dport 5060 -j ACCEPT
iptables -A INPUT -p tcp -s 10.0.0.0/8 --dport 5060 -j ACCEPT

# SIP TLS
iptables -A INPUT -p tcp -s 10.0.0.0/8 --dport 5061 -j ACCEPT

# SIP from trunk providers (by IP)
iptables -A INPUT -p udp -s PROVIDER_IP_1 --dport 5080 -j ACCEPT
iptables -A INPUT -p udp -s PROVIDER_IP_2 --dport 5080 -j ACCEPT

# RTP media (must be open — media comes from many IPs)
iptables -A INPUT -p udp --dport 16384:32768 -j ACCEPT

# ESL (localhost only)
iptables -A INPUT -p tcp -s 127.0.0.1 --dport 8021 -j ACCEPT

# Save rules
iptables-save > /etc/iptables/rules.v4

13. Integration Patterns

FreeSWITCH + Kamailio (SBC / Load Balancer)

Kamailio handles SIP routing, load balancing, and security at the edge. FreeSWITCH handles media processing behind it. This is the standard carrier-grade architecture.

Internet                Kamailio (SBC)           FreeSWITCH (Media)
   │                        │                         │
   │  SIP INVITE            │                         │
   ├───────────────────────►│                         │
   │                        │  Rate limit, ACL check  │
   │                        │  Route to FS            │
   │                        ├────────────────────────►│
   │                        │                         │ Answer, IVR, Bridge
   │  RTP (media)           │                         │
   ├──────────────────────────────────────────────────►│
   │                        │                         │

Kamailio configuration snippet (routes SIP to FreeSWITCH):

# kamailio.cfg (simplified)
# Route SIP to FreeSWITCH backend

request_route {
    # Security checks
    if (!mf_process_maxfwd_header("10")) {
        sl_send_reply("483", "Too Many Hops");
        exit;
    }

    # Rate limiting
    if (!pike_check_req()) {
        sl_send_reply("503", "Service Unavailable");
        exit;
    }

    # Route INVITEs to FreeSWITCH
    if (is_method("INVITE")) {
        # Set destination to FreeSWITCH
        $du = "sip:FREESWITCH_IP:5060";
        route(RELAY);
    }

    # Route REGISTERs to FreeSWITCH
    if (is_method("REGISTER")) {
        $du = "sip:FREESWITCH_IP:5060";
        route(RELAY);
    }
}

route[RELAY] {
    if (!t_relay()) {
        sl_reply_error();
    }
}

FreeSWITCH configuration — trust Kamailio's IP:

<!-- In acl.conf.xml -->
<list name="kamailio" default="deny">
  <node type="allow" cidr="KAMAILIO_IP/32"/>
</list>

<!-- In sip_profiles/internal.xml -->
<param name="apply-inbound-acl" value="kamailio"/>
<param name="auth-calls" value="false"/>
<!-- Trust X-headers from Kamailio for routing decisions -->
<param name="apply-proxy-acl" value="kamailio"/>

FreeSWITCH + WebRTC (mod_verto)

mod_verto provides native WebRTC support without any external proxy. It uses WebSocket for signaling and handles SRTP/DTLS for media.

Enable mod_verto in /etc/freeswitch/autoload_configs/modules.conf.xml:

<load module="mod_verto"/>

Configure mod_verto in /etc/freeswitch/autoload_configs/verto.conf.xml:

<configuration name="verto.conf" description="WebRTC Verto Endpoint">
  <settings>
    <param name="debug" value="0"/>
  </settings>

  <profiles>
    <profile name="default-v4">
      <param name="bind-local" value="YOUR_SERVER_IP:8081"/>
      <param name="bind-local" value="YOUR_SERVER_IP:8082" secure="true"/>
      <param name="force-register-domain" value="$${domain}"/>
      <param name="secure-combined" value="/etc/freeswitch/tls/agent.pem"/>
      <param name="secure-chain" value="/etc/freeswitch/tls/cafile.pem"/>
      <param name="userauth" value="true"/>
      <param name="context" value="default"/>
      <param name="dialplan" value="XML"/>

      <!-- STUN/TURN for NAT traversal -->
      <param name="rtp-ip" value="$${local_ip_v4}"/>
      <param name="ext-rtp-ip" value="$${external_rtp_ip}"/>

      <!-- Timer -->
      <param name="timer-name" value="soft"/>
    </profile>
  </profiles>
</configuration>

Browser-side JavaScript (using verto.js):

<!DOCTYPE html>
<html>
<head>
    <title>WebRTC Phone</title>
    <script src="https://cdn.jsdelivr.net/npm/jquery@3/dist/jquery.min.js"></script>
    <script src="verto-min.js"></script>
</head>
<body>
    <h2>WebRTC Phone</h2>
    <div>
        <input type="text" id="number" placeholder="Enter number to call"/>
        <button onclick="makeCall()">Call</button>
        <button onclick="hangupCall()">Hangup</button>
    </div>
    <div id="status">Disconnected</div>
    <audio id="remoteAudio" autoplay></audio>

    <script>
    var verto;
    var currentCall = null;

    // Connect to FreeSWITCH via Verto
    $(document).ready(function() {
        verto = new $.verto({
            login: '1001@YOUR_SERVER_IP',
            passwd: 'YOUR_EXTENSION_PASSWORD',
            socketUrl: 'wss://YOUR_SERVER_IP:8082',
            // ICE servers for NAT traversal
            iceServers: [
                { urls: 'stun:stun.l.google.com:19302' }
            ],
            deviceParams: {
                useMic: true,
                useSpeak: true
            },
            audioParams: {
                googAutoGainControl: true,
                googNoiseSuppression: true,
                googEchoCancellation: true
            }
        }, {
            onWSLogin: function(v, success) {
                $('#status').text(success ? 'Connected' : 'Login Failed');
            },
            onDialogState: function(d) {
                switch (d.state.name) {
                    case 'trying':
                        $('#status').text('Calling...');
                        break;
                    case 'ringing':
                        $('#status').text('Ringing...');
                        break;
                    case 'active':
                        $('#status').text('In Call');
                        break;
                    case 'hangup':
                    case 'destroy':
                        $('#status').text('Call Ended');
                        currentCall = null;
                        break;
                }
            }
        });
    });

    function makeCall() {
        var number = $('#number').val();
        if (!number) return;

        currentCall = verto.newCall({
            destination_number: number,
            caller_id_name: 'WebRTC User',
            caller_id_number: '1001',
            useVideo: false,
            useStereo: false
        });
    }

    function hangupCall() {
        if (currentCall) {
            currentCall.hangup();
        }
    }
    </script>
</body>
</html>

FreeSWITCH + Databases (mod_xml_curl)

mod_xml_curl fetches configuration dynamically from an HTTP server. Instead of static XML files, FreeSWITCH asks your web app for user directory, dialplan, and configuration on every request.

Enable in modules.conf.xml:

<load module="mod_xml_curl"/>

Configure in /etc/freeswitch/autoload_configs/xml_curl.conf.xml:

<configuration name="xml_curl.conf" description="cURL XML Gateway">
  <bindings>
    <!-- Dynamic user directory -->
    <binding name="directory">
      <param name="gateway-url"
        value="http://127.0.0.1:8080/freeswitch/directory"/>
      <param name="gateway-credentials" value="fsapi:YOUR_API_KEY"/>
      <param name="auth-scheme" value="basic"/>
      <param name="timeout" value="5"/>
      <param name="enable-post" value="true"/>
      <param name="bindings" value="directory"/>
    </binding>

    <!-- Dynamic dialplan -->
    <binding name="dialplan">
      <param name="gateway-url"
        value="http://127.0.0.1:8080/freeswitch/dialplan"/>
      <param name="gateway-credentials" value="fsapi:YOUR_API_KEY"/>
      <param name="auth-scheme" value="basic"/>
      <param name="timeout" value="5"/>
      <param name="enable-post" value="true"/>
      <param name="bindings" value="dialplan"/>
    </binding>
  </bindings>
</configuration>

Flask backend example:

#!/usr/bin/env python3
"""
freeswitch_api.py
Serve dynamic XML configuration to FreeSWITCH via mod_xml_curl.
"""

from flask import Flask, request, Response
import sqlite3

app = Flask(__name__)
DB_PATH = '/var/lib/freeswitch/db/users.db'


@app.route('/freeswitch/directory', methods=['POST'])
def directory():
    """Return user XML based on registration request."""
    user = request.form.get('user', '')
    domain = request.form.get('domain', '')
    action = request.form.get('action', '')

    if not user:
        return not_found()

    # Look up user in database
    conn = sqlite3.connect(DB_PATH)
    cursor = conn.cursor()
    cursor.execute(
        'SELECT password, name, caller_id FROM users WHERE extension = ?',
        (user,)
    )
    row = cursor.fetchone()
    conn.close()

    if not row:
        return not_found()

    password, name, caller_id = row

    xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="freeswitch/xml">
  <section name="directory">
    <domain name="{domain}">
      <user id="{user}">
        <params>
          <param name="password" value="{password}"/>
          <param name="vm-password" value="{user}"/>
        </params>
        <variables>
          <variable name="user_context" value="default"/>
          <variable name="effective_caller_id_name" value="{name}"/>
          <variable name="effective_caller_id_number" value="{caller_id or user}"/>
        </variables>
      </user>
    </domain>
  </section>
</document>'''

    return Response(xml, mimetype='text/xml')


@app.route('/freeswitch/dialplan', methods=['POST'])
def dialplan():
    """Return dynamic dialplan XML."""
    dest = request.form.get('Caller-Destination-Number', '')
    context = request.form.get('Caller-Context', 'default')
    caller = request.form.get('Caller-Caller-ID-Number', '')

    # Example: route based on database lookup
    conn = sqlite3.connect(DB_PATH)
    cursor = conn.cursor()
    cursor.execute(
        'SELECT action, target FROM routes WHERE pattern = ? AND context = ?',
        (dest, context)
    )
    row = cursor.fetchone()
    conn.close()

    if row:
        action_type, target = row
        xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="freeswitch/xml">
  <section name="dialplan">
    <context name="{context}">
      <extension name="dynamic_route">
        <condition field="destination_number" expression="^{dest}$">
          <action application="{action_type}" data="{target}"/>
        </condition>
      </extension>
    </context>
  </section>
</document>'''
    else:
        xml = not_found_xml()

    return Response(xml, mimetype='text/xml')


def not_found():
    return Response(not_found_xml(), mimetype='text/xml')


def not_found_xml():
    return '''<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="freeswitch/xml">
  <section name="result">
    <result status="not found"/>
  </section>
</document>'''


if __name__ == '__main__':
    app.run(host='127.0.0.1', port=8080)

FreeSWITCH + REST APIs

Use Lua or Python scripts in the dialplan to call external REST APIs:

<!-- In dialplan — call a Lua script for routing decisions -->
<extension name="api_routing">
  <condition field="destination_number" expression="^(8[0-9]{3})$">
    <action application="lua" data="api_route.lua"/>
  </condition>
</extension>

Lua script (/etc/freeswitch/scripts/api_route.lua):

-- api_route.lua
-- Look up routing information from a REST API

local api = require("socket.http")
local json = require("cjson")

-- Get call info
local caller = session:getVariable("caller_id_number")
local dest = session:getVariable("destination_number")

-- Call external API
local url = string.format(
    "http://127.0.0.1:8080/api/route?caller=%s&dest=%s",
    caller, dest
)

local response, code = api.request(url)

if code == 200 and response then
    local data = json.decode(response)

    if data.action == "bridge" then
        session:setVariable("effective_caller_id_number", data.caller_id or caller)
        session:execute("bridge", data.target)
    elseif data.action == "voicemail" then
        session:execute("voicemail", "default " .. data.domain .. " " .. data.mailbox)
    elseif data.action == "reject" then
        session:execute("respond", "403")
    end
else
    -- API unavailable — fallback to default routing
    session:execute("transfer", "1009 XML default")
end

FreeSWITCH + Asterisk Interop

You can interconnect FreeSWITCH and Asterisk via a SIP trunk between them:

On FreeSWITCH — create a gateway to Asterisk. Save as /etc/freeswitch/sip_profiles/external/asterisk.xml:

<include>
  <gateway name="asterisk">
    <param name="username" value="freeswitch_trunk"/>
    <param name="password" value="SHARED_TRUNK_PASSWORD"/>
    <param name="realm" value="ASTERISK_IP"/>
    <param name="proxy" value="ASTERISK_IP"/>
    <param name="register" value="true"/>
    <param name="caller-id-in-from" value="true"/>
    <param name="codec-prefs" value="PCMU,PCMA"/>
  </gateway>
</include>

Route calls to Asterisk extensions (e.g., 2xxx range):

<extension name="to_asterisk">
  <condition field="destination_number" expression="^(2[0-9]{3})$">
    <action application="bridge" data="sofia/gateway/asterisk/$1"/>
  </condition>
</extension>

On Asterisk — create the matching trunk in pjsip.conf:

; pjsip.conf — FreeSWITCH trunk
[freeswitch_trunk]
type=registration
outbound_auth=freeswitch_trunk_auth
server_uri=sip:FREESWITCH_IP:5080
client_uri=sip:freeswitch_trunk@FREESWITCH_IP:5080
retry_interval=60

[freeswitch_trunk_auth]
type=auth
auth_type=userpass
username=freeswitch_trunk
password=SHARED_TRUNK_PASSWORD

[freeswitch_trunk_endpoint]
type=endpoint
context=from-freeswitch
disallow=all
allow=ulaw,alaw
outbound_auth=freeswitch_trunk_auth
aors=freeswitch_trunk_aor

[freeswitch_trunk_aor]
type=aor
contact=sip:FREESWITCH_IP:5080

[freeswitch_trunk_identify]
type=identify
endpoint=freeswitch_trunk_endpoint
match=FREESWITCH_IP

FreeSWITCH as Media Server Behind Kamailio

In this pattern, Kamailio handles all SIP routing and FreeSWITCH only processes media (IVR, conferencing, recording, transcoding):

                    ┌──────────────┐
                    │   Kamailio   │
  SIP phones ──────►   (Router)   ◄────── SIP Trunks
                    └──────┬───────┘
                           │
              ┌────────────┼────────────┐
              ▼            ▼            ▼
        ┌──────────┐ ┌──────────┐ ┌──────────┐
        │ FS Node 1│ │ FS Node 2│ │ FS Node 3│
        │ (Media)  │ │ (Media)  │ │ (Media)  │
        └──────────┘ └──────────┘ └──────────┘

Kamailio selects the FreeSWITCH node using the dispatcher module:

# kamailio.cfg — load balance across FreeSWITCH nodes
modparam("dispatcher", "list_file", "/etc/kamailio/dispatcher.list")

# dispatcher.list:
# setid destination flags priority
1 sip:FS_NODE_1:5060 0 50
1 sip:FS_NODE_2:5060 0 50
1 sip:FS_NODE_3:5060 0 50

FreeSWITCH nodes are configured identically, with auth-calls set to false and Kamailio's IP in the ACL.


14. Troubleshooting

SIP Registration Failures

Symptoms: Phone shows "Registration failed" or "403 Forbidden."

Diagnostic steps:

# 1. Check if the profile is running
fs_cli -x "sofia status"

# 2. Check registered users
fs_cli -x "sofia status profile internal reg"

# 3. Enable SIP trace to see actual SIP messages
fs_cli -x "sofia profile internal siptrace on"
# Make a registration attempt, then:
fs_cli -x "sofia profile internal siptrace off"

# 4. Check the log for auth failures
grep -i "auth" /var/log/freeswitch/freeswitch.log | tail -20

# 5. Verify the user exists in the directory
fs_cli -x "xml_locate directory default 1001"

# 6. Test connectivity
# From the phone's network, check if SIP port is reachable:
# nmap -sU -p 5060 YOUR_SERVER_IP

Common causes and fixes:

Problem Cause Fix
403 Forbidden Wrong password Check directory/default/1001.xml password
401 Unauthorized loop Password mismatch Verify default_password in vars.xml vs per-user password
Connection timeout Firewall blocking 5060 Open UDP/TCP 5060 in firewall
Profile not running Config error Check sofia status, look for XML parse errors in log
Wrong domain Domain mismatch Set phone's domain to match vars.xml domain

No Audio / One-Way Audio

This is the most common VoIP problem. It is almost always a NAT/firewall issue.

Diagnostic steps:

# 1. Check RTP ports in firewall
# Ensure 16384-32768/udp is open

# 2. Check NAT settings
fs_cli -x "sofia status profile internal"
# Look for ext-sip-ip and ext-rtp-ip — they should be your PUBLIC IP

# 3. Check codec negotiation
fs_cli -x "show channels"
# Look at read_codec and write_codec — should match on both legs

# 4. During a call, check RTP stats
fs_cli -x "uuid_debug_media <call-uuid> read on"
# This shows incoming RTP packets (or lack thereof)

# 5. Check for RTP timeout
grep "rtp_timeout" /var/log/freeswitch/freeswitch.log | tail -10

Common causes and fixes:

Problem Cause Fix
No audio both ways RTP ports blocked by firewall Open 16384-32768/udp
One-way audio NAT not configured Set ext-rtp-ip to public IP
Audio drops after 30s NAT timeout on firewall Enable SIP keepalives, reduce RTP timeout
Choppy audio Codec mismatch / transcoding Align codec preferences on both ends
Echo Acoustic echo on phone Enable echo cancellation on phone; not a server issue

NAT Checklist:

<!-- Verify these are set correctly in your profile -->
<param name="ext-sip-ip" value="YOUR_PUBLIC_IP"/>
<param name="ext-rtp-ip" value="YOUR_PUBLIC_IP"/>
<param name="apply-nat-acl" value="nat.auto"/>
<param name="aggressive-nat-detection" value="true"/>

Call Routing Problems

Symptoms: Calls go to the wrong destination, get "UNALLOCATED_NUMBER," or drop.

# 1. Test dialplan matching without making a real call
fs_cli -x "expand xml_locate dialplan default 15551234567 as xml"

# 2. Enable dialplan debug
fs_cli -x "sofia loglevel all 7"

# 3. Check which context the call enters
grep "Processing" /var/log/freeswitch/freeswitch.log | tail -20

# 4. Manually trace a number through the dialplan
fs_cli
> reloadxml
> originate user/1001 &echo()
# Then check logs for the routing path

# 5. Check for regex issues
# In fs_cli:
> regex 15551234567 ^(\+?1?\d{10})$
# Should return "true" if the pattern matches

Common causes:

Problem Cause Fix
UNALLOCATED_NUMBER No matching extension in dialplan Add a catch-all or correct the regex
Wrong destination Extension ordering (first match wins) Move more-specific rules above generic ones
Calls to trunk fail Gateway not registered Check sofia status gateway my_provider
Transfer fails Wrong context in transfer command Verify transfer <dest> XML <context>

Performance Issues

# 1. Check system resources
fs_cli -x "status"
# Look at "min idle cpu" — should be above 50%

# 2. Check session count
fs_cli -x "show channels count"

# 3. Check for codec transcoding (CPU-intensive)
fs_cli -x "show channels"
# If read_codec != write_codec, transcoding is happening

# 4. Check for memory leaks
fs_cli -x "status"
# Monitor session count over time — should not grow unbounded

# 5. Profile-level stats
fs_cli -x "sofia status profile internal"
# Check CALLS-IN, CALLS-OUT, FAILED counts

Performance tuning:

# Increase file descriptor limits
cat >> /etc/security/limits.conf << 'EOF'
freeswitch    soft    nofile    65536
freeswitch    hard    nofile    65536
freeswitch    soft    nproc     65536
freeswitch    hard    nproc     65536
EOF

# Increase max sessions (default 1000)
fs_cli -x "fsctl max_sessions 5000"

# Increase sessions per second (default 30)
fs_cli -x "fsctl sps 200"

# Disable unnecessary modules
# Edit modules.conf.xml and comment out unused modules
# Fewer modules = less memory + faster startup

Essential fs_cli Commands Reference

Command Description
status System uptime, session count, CPU
sofia status All SIP profiles and their state
sofia status profile internal Detailed internal profile stats
sofia status profile internal reg List registered phones
sofia status gateway my_provider Trunk/gateway status
sofia profile internal siptrace on Enable SIP message trace
sofia profile internal restart reloadxml Restart a SIP profile
show channels Active call channels
show calls Active bridged calls
show registrations All current registrations
reloadxml Reload all XML configuration
reload mod_sofia Reload SIP module
conference list Active conferences
uuid_kill <uuid> Hang up a specific call
uuid_transfer <uuid> <dest> XML <ctx> Transfer a call
uuid_record <uuid> start <file> Start recording
uuid_debug_media <uuid> read on Debug RTP media
uuid_setvar <uuid> <var> <val> Set variable on active call
originate user/1001 &echo() Place a test call
hupall Hang up ALL calls (emergency)
fsctl shutdown Graceful shutdown
fsctl shutdown elegant Shutdown after all calls end
fsctl pause Pause new call processing
fsctl resume Resume call processing
regex <string> <pattern> Test regex matching
xml_locate directory default 1001 Look up user config
module_exists <module> Check if module is loaded
load <module> Load a module
unload <module> Unload a module

Quick Diagnostic Sequence

When something is not working, run through this sequence:

# 1. Is FreeSWITCH running?
systemctl status freeswitch

# 2. Can you connect via ESL?
fs_cli -x "status"

# 3. Are SIP profiles up?
fs_cli -x "sofia status"

# 4. Are phones registered?
fs_cli -x "sofia status profile internal reg"

# 5. Are trunks connected?
fs_cli -x "sofia status gateway my_provider"

# 6. Any errors in the log?
tail -100 /var/log/freeswitch/freeswitch.log | grep -i "error\|warning\|fail"

# 7. Is it a firewall issue?
ss -ulnp | grep 5060    # SIP port listening?
ss -ulnp | grep 8021    # ESL port listening?

# 8. Disk space?
df -h

# 9. Memory?
free -h

# 10. Active calls?
fs_cli -x "show channels count"

End of Tutorial 41.

You now have a working FreeSWITCH installation with SIP phones, trunks, IVR, voicemail, conferencing, call recording, external application control via ESL, and production security hardening. The configurations in this tutorial are production-tested patterns that you can adapt to your specific requirements.

Next steps to explore:

Need expert help with your setup?

VoIP infrastructure consulting, AI voice agent integration, monitoring stacks, scaling — I've done it all in production.

Get a Free Consultation