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
- Introduction
- Architecture Overview
- Installation
- SIP Configuration (mod_sofia)
- XML Dialplan
- IVR Menus
- Voicemail
- Call Recording
- Conference Bridge
- Event Socket Layer (ESL)
- CDR & Logging
- Security Hardening
- Integration Patterns
- 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:
- Carrier-grade switching: High call volume (1,000+ concurrent), SIP-to-SIP routing, least-cost routing
- WebRTC gateway: Browser-based calling without external proxies
- IVR platform: Complex multi-level IVR systems with database lookups and REST API integration
- Conference server: Large-scale conferencing (hundreds of rooms, thousands of participants)
- Media server: Behind Kamailio/OpenSIPS as a B2BUA or media processing engine
- Telecom applications: When your app controls calls programmatically via ESL
- Multi-tenant PBX: Built-in domain-based multi-tenancy
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:
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.
Modular: The core is a lightweight engine. All functionality — SIP, dialplan, codecs, applications — comes from loadable modules. You enable only what you need.
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
- Go to https://id.signalwire.com/personal_access_tokens
- Create a free account (no credit card needed)
- Generate a Personal Access Token
- 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:
mod_ivrmenus — XML-configured menu trees with automatic DTMF handling, timeouts, and invalid-input retries. Best for simple, static IVR flows.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:
- 5 — Configuration menu
- 1 — Record a new greeting
- 2 — Choose between recorded greetings
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:
- mod_callcenter — Built-in ACD (Automatic Call Distribution) for contact centers
- mod_fifo — Call queues and parking
- mod_lcr — Least Cost Routing for multi-carrier environments
- mod_nibblebill — Real-time prepaid billing
- mod_skinny — Cisco SCCP phone support
- FusionPBX — Full web GUI for FreeSWITCH administration