← All Tutorials

Advanced ViciDial Inbound Call Flow Customization

ViciDial Administration Advanced 31 min read #13

Advanced ViciDial Inbound Call Flow Customization

Blacklists, CLI Filtering, Ring-Back Delay, Agent Ranking & Recording Security

Difficulty: Advanced | Time to implement: 4-8 hours | Asterisk version: 16+ recommended (tested on 18.x)


Table of Contents

  1. Introduction: Why Default ViciDial Inbound Handling Falls Short
  2. Complete Call Flow Architecture
  3. Call Filtering Pipeline
  4. Ring-Back Delay (Anti-Call-Center Detection)
  5. Agent Ranking System
  6. Recording Security & HTML5 Player
  7. Codec & Audio Quality Tuning
  8. Testing, Rollout & Monitoring
  9. Complete Reference Configuration
  10. Troubleshooting

1. Introduction

Out of the box, ViciDial handles inbound calls with a straightforward pipeline: a DID receives a call, it routes to an inbound group, and the next available agent answers. This works for basic operations, but professional call centers --- especially those handling emergency services, home trade inquiries, or high-value leads --- need significantly more control.

Problems with default ViciDial inbound handling:

This tutorial walks through every layer of a production-hardened inbound call flow, built from real-world configurations running thousands of calls per day.

What you will build:

Incoming call
  |
  v
[5-stage filtering pipeline] --> Reject spam, anonymous, non-mobile
  |
  v
[Ring-back delay 2-5s]       --> Caller hears normal ringing
  |
  v
[ViciDial inbound group]     --> Ranked agent routing
  |
  v
[Best available agent]       --> Rank 5 first, rank 9 last
  |
  v
[Secure recording]           --> IP-restricted, HTML5 player

Prerequisites:


2. Complete Call Flow Architecture

                         PSTN / SIP Trunk
                              |
                              v
                    +-------------------+
                    |  [trunkinbound]    |
                    |  context           |
                    +-------------------+
                              |
                    +---------+---------+
                    |  FILTER PIPELINE  |
                    +---------+---------+
                              |
              +---------------+---------------+
              |               |               |
         [BLACKLIST]    [BLOCKED DID]   [ANONYMOUS]
         DB lookup      Regex match     No CLI check
              |               |               |
              +-------+-------+-------+-------+
                      |               |
                [UK MOBILE]     [SPAM BLOCK]
                07/+447 only    Known recruiters
                      |               |
                      +-------+-------+
                              |
                         PASS ALL FILTERS
                              |
                              v
                    +-------------------+
                    |  [trunkinbound-   |
                    |   route]          |
                    +-------------------+
                              |
                    +---------+---------+
                    | RING-BACK DELAY   |
                    | RAND(2,5) seconds |
                    | Ringing() + Wait()|
                    +---------+---------+
                              |
                              v
                    +-------------------+
                    | agi-DID_route.agi |
                    | (ViciDial native) |
                    +-------------------+
                              |
                         did_route=EXTEN
                         extension=XXXXXX
                              |
                              v
                    +-------------------+
                    |  [default] ctx    |
                    |  exten XXXXXX     |
                    +-------------------+
                              |
                              v
                    +-------------------+
                    | Custom AGI        |
                    | (optional:        |
                    |  repeat caller    |
                    |  detection, CRM   |
                    |  lookup, etc.)    |
                    +-------------------+
                              |
                    +---------+---------+
                    |  INBOUND GROUP    |
                    |  (ranked routing) |
                    +---------+---------+
                              |
                    +---------+---------+
                    | next_agent_call=  |
                    | inbound_group_rank|
                    +---------+---------+
                              |
              +---+-----------+-----------+---+
              |       |       |       |       |
           Rank 5  Rank 6  Rank 7  Rank 8  Rank 9
           (best)                          (trainee)
              |       |       |       |       |
              v       v       v       v       v
           [Agent] [Agent] [Agent] [Agent] [Agent]
              |
              v
        +------------+
        | MixMonitor |
        | Recording  |
        +------------+
              |
              v
        +-------------------+
        | Apache protected  |
        | IP whitelist      |
        | HTML5 player      |
        +-------------------+

3. Call Filtering Pipeline

All filters live in the [trunkinbound] context of extensions.conf. This is the context that SIP trunk peers route inbound calls to (configured in the trunk's context= setting in sip.conf or sip-vicidial.conf).

Important: Backup First

Before editing any Asterisk configuration:

# Always create a timestamped backup
cp /etc/asterisk/extensions.conf /etc/asterisk/extensions.conf.bak.$(date +%Y%m%d)

3.1 Database-Driven Blacklist

Asterisk has a built-in blacklist function that checks the AstDB (internal Berkeley DB). This is the fastest check --- a simple key lookup with no external database query.

Adding numbers to the blacklist:

# Add a number to the Asterisk blacklist
asterisk -rx 'database put blacklist "441234567890" "1"'

# Verify it was added
asterisk -rx 'database show blacklist'

# Remove a number
asterisk -rx 'database del blacklist "441234567890"'

Dialplan implementation:

[trunkinbound]
; ============================================================
; STAGE 1: Database blacklist check
; Checks AstDB family "blacklist" for the caller's number.
; If found, play congestion tone and hang up.
; ============================================================
exten => _X.,1,Set(CALLERID(num)=${FILTER(+0123456789,${CALLERID(num)})})
exten => _X.,n,GotoIf(${BLACKLIST()}?blacklisted,s,1)

Blacklisted calls land here:

[blacklisted]
exten => s,1,NoOp(BLACKLISTED: ${CALLERID(num)} calling ${EXTEN})
exten => s,n,Log(WARNING,BLACKLISTED call from ${CALLERID(num)} to ${EXTEN} - rejected)
exten => s,n,Congestion(3)
exten => s,n,Hangup()

Why use AstDB instead of MySQL? Speed. AstDB is an in-process key-value store --- lookup takes microseconds. MySQL queries, even fast ones, require a network round-trip and connection overhead. For a per-call check, AstDB is the right choice.

Bulk-loading a blacklist from a file:

#!/bin/bash
# /usr/local/bin/load-blacklist.sh
# One number per line in blacklist.txt
while IFS= read -r number; do
    number=$(echo "$number" | tr -d '[:space:]')
    [ -z "$number" ] && continue
    asterisk -rx "database put blacklist \"$number\" \"1\""
    echo "Blacklisted: $number"
done < /path/to/blacklist.txt

3.2 Regex-Based Blocked DIDs

Some numbers need to be hard-blocked directly in the dialplan. This is useful for numbers that keep calling from slightly different CLIs (e.g., a recruiter rotating through a number block) or for blocking entire number ranges.

; ============================================================
; STAGE 2: Regex-based hard blocks
; Block specific known nuisance numbers.
; Uses pattern matching on CALLERID(num).
; ============================================================

; Block specific numbers (add as many as needed)
exten => _X.,n,GotoIf($["${CALLERID(num)}" = "441234000001"]?blocked_did,s,1)
exten => _X.,n,GotoIf($["${CALLERID(num)}" = "441234000002"]?blocked_did,s,1)
exten => _X.,n,GotoIf($["${CALLERID(num)}" = "441234000003"]?blocked_did,s,1)

; Block an entire number range (e.g., a known spam prefix)
exten => _X.,n,Set(BLOCKED_RANGE=${REGEX("^44120[0-9]{7}$" ${CALLERID(num)})})
exten => _X.,n,GotoIf($["${BLOCKED_RANGE}" = "1"]?blocked_did,s,1)

Blocked DID handler:

[blocked_did]
exten => s,1,NoOp(BLOCKED DID: ${CALLERID(num)} calling ${EXTEN})
exten => s,n,Log(WARNING,BLOCKED-DID call from ${CALLERID(num)} to ${EXTEN} - hard blocked)
exten => s,n,Congestion(3)
exten => s,n,Hangup()

When to use regex blocks vs. the AstDB blacklist:

Scenario Use
Individual numbers reported as spam AstDB blacklist (easy to add/remove)
Entire number ranges or prefixes Regex block in dialplan
Numbers that change frequently AstDB (scriptable, no reload needed)
Permanent blocks Regex (survives AstDB wipes)

3.3 Anonymous Call Rejection

Calls with no caller ID, or with "anonymous" / "unknown" as the CLI, are almost never legitimate in a B2B or emergency services context. Block them.

; ============================================================
; STAGE 3: Anonymous / withheld CLI rejection
; Blocks calls where CALLERID(num) is empty, "anonymous",
; "unknown", "restricted", "unavailable", or "private".
; ============================================================
exten => _X.,n,Set(CLI_LOWER=${TOLOWER(${CALLERID(num)})})
exten => _X.,n,GotoIf($["${CALLERID(num)}" = ""]?anon_reject,s,1)
exten => _X.,n,GotoIf($["${CLI_LOWER}" = "anonymous"]?anon_reject,s,1)
exten => _X.,n,GotoIf($["${CLI_LOWER}" = "unknown"]?anon_reject,s,1)
exten => _X.,n,GotoIf($["${CLI_LOWER}" = "restricted"]?anon_reject,s,1)
exten => _X.,n,GotoIf($["${CLI_LOWER}" = "unavailable"]?anon_reject,s,1)
exten => _X.,n,GotoIf($["${CLI_LOWER}" = "private"]?anon_reject,s,1)

Anonymous rejection handler:

[anon_reject]
exten => s,1,NoOp(ANONYMOUS CALL REJECTED: CLI="${CALLERID(num)}" to ${EXTEN})
exten => s,n,Log(WARNING,ANONYMOUS call rejected - CLI="${CALLERID(num)}" to DID ${EXTEN})
exten => s,n,Congestion(3)
exten => s,n,Hangup()

Important considerations:

3.4 UK Mobile-Only Filter

If your operation only accepts calls from UK mobile numbers (common for plumbing/electrical emergency services where callbacks go to mobiles), you can filter by number pattern. UK mobiles start with 07 (national) or +447 / 447 (international format).

; ============================================================
; STAGE 4: UK mobile number validation
; Only allows calls from UK mobile numbers.
; Pattern: 07XXX XXXXXX (national) or 447XXX XXXXXX (international)
;
; UK mobile prefixes: 071, 072, 073, 074, 075, 076, 077, 078, 079
; Excludes 070 (personal numbers) and 076 (pagers, except 07624)
; ============================================================
exten => _X.,n,Set(IS_UK_MOBILE=${REGEX("^(0?447[1-9][0-9]{8}|07[1-9][0-9]{8})$" ${CALLERID(num)})})
exten => _X.,n,GotoIf($["${IS_UK_MOBILE}" = "0"]?not_uk_mobile,s,1)

Non-mobile rejection handler:

[not_uk_mobile]
exten => s,1,NoOp(NON-UK-MOBILE REJECTED: ${CALLERID(num)} calling ${EXTEN})
exten => s,n,Log(NOTICE,NON-UK-MOBILE call rejected - CLI=${CALLERID(num)} to DID ${EXTEN})
exten => s,n,Congestion(3)
exten => s,n,Hangup()

Regex breakdown:

^(0?447[1-9][0-9]{8}|07[1-9][0-9]{8})$

^                   Start of string
(                   Group 1 (international format):
  0?                Optional leading zero (some trunks send 0447...)
  447               UK country code + mobile indicator
  [1-9]             First digit after 7 (excludes 070x)
  [0-9]{8}          Remaining 8 digits
|                   OR
  07                National format prefix
  [1-9]             First digit after 7 (excludes 070x)
  [0-9]{8}          Remaining 8 digits
)
$                   End of string

Adapting for other countries:

Country Mobile Regex Pattern
UK ^(0?447[1-9][0-9]{8}|07[1-9][0-9]{8})$
Italy ^(0?393[0-9]{8,9}|3[0-9]{8,9})$
France ^(0?336[0-9]{8}|06[0-9]{8})$
Germany ^(0?491[5-7][0-9]{7,10}|01[5-7][0-9]{7,10})$
Romania ^(0?407[0-9]{8}|07[0-9]{8})$

3.5 Recruiting/Spam Company Block

Some companies systematically call through acquired DID lists --- recruitment agencies, SEO spammers, "Google listing" scams. These typically rotate CLIs but within predictable ranges. Block them by prefix.

; ============================================================
; STAGE 5: Known spam / recruiter company blocks
; Block known nuisance callers by CLI prefix or exact match.
; These are companies that repeatedly call our DIDs.
; ============================================================

; Recruiter company A - rotates through 44203100xxxx range
exten => _X.,n,Set(IS_RECRUITER_A=${REGEX("^44203100[0-9]{4}$" ${CALLERID(num)})})
exten => _X.,n,GotoIf($["${IS_RECRUITER_A}" = "1"]?spam_blocked,s,1)

; SEO spam company B - uses 44800155xxxx range
exten => _X.,n,Set(IS_SPAM_B=${REGEX("^44800155[0-9]{4}$" ${CALLERID(num)})})
exten => _X.,n,GotoIf($["${IS_SPAM_B}" = "1"]?spam_blocked,s,1)

; Robo-dialer C - specific numbers
exten => _X.,n,GotoIf($["${CALLERID(num)}" = "441onal098765"]?spam_blocked,s,1)

Spam handler (with logging for analysis):

[spam_blocked]
exten => s,1,NoOp(SPAM/RECRUITER BLOCKED: ${CALLERID(num)} calling ${EXTEN})
exten => s,n,Log(WARNING,SPAM-BLOCKED call from ${CALLERID(num)} to DID ${EXTEN})
exten => s,n,Congestion(3)
exten => s,n,Hangup()

Pro tip: Create a script that parses Asterisk logs for rejected calls and generates a weekly report:

#!/bin/bash
# /usr/local/bin/weekly-filter-report.sh
# Run via cron: 0 8 * * 1 /usr/local/bin/weekly-filter-report.sh
echo "=== Call Filter Report (past 7 days) ==="
echo ""
echo "--- Blacklisted ---"
grep "BLACKLISTED" /var/log/asterisk/messages | tail -7d | wc -l
echo "--- Blocked DIDs ---"
grep "BLOCKED-DID" /var/log/asterisk/messages | tail -7d | wc -l
echo "--- Anonymous Rejected ---"
grep "ANONYMOUS" /var/log/asterisk/messages | tail -7d | wc -l
echo "--- Non-UK-Mobile ---"
grep "NON-UK-MOBILE" /var/log/asterisk/messages | tail -7d | wc -l
echo "--- Spam/Recruiter ---"
grep "SPAM-BLOCKED" /var/log/asterisk/messages | tail -7d | wc -l

Completing the Filter Pipeline

After all five filters pass, route the call to the next stage:

; ============================================================
; ALL FILTERS PASSED - Route to delay + DID processing
; ============================================================
exten => _X.,n,NoOp(FILTERS PASSED: ${CALLERID(num)} -> ${EXTEN})
exten => _X.,n,Goto(trunkinbound-route,${EXTEN},1)

4. Ring-Back Delay

This is a critical technique for operations where callers should not realize they are calling a call center. When a customer dials their local plumber's number, they expect to hear the phone ring for a few seconds. ViciDial routes calls to agents in under a second, which instantly signals "this is a call center" to the caller.

The solution: introduce a random 2-5 second delay during which the caller hears a standard ring-back tone.

Why Random Delay?

A fixed delay (e.g., always 3 seconds) creates a recognizable pattern. Callers who phone multiple times notice the phone always rings exactly three times. A random delay between 2-5 seconds mimics the natural variability of a real phone.

Implementation

[trunkinbound-route]
; ============================================================
; RING-BACK DELAY
; Generates a random delay between 2-5 seconds.
; Caller hears standard carrier ring-back tone (180 Ringing).
; This makes the call feel like a normal business phone.
; ============================================================

exten => _X.,1,NoOp(Inbound call to DID ${EXTEN} from ${CALLERID(num)})

; Generate random delay: 2, 3, 4, or 5 seconds
exten => _X.,n,Set(RBT_DELAY=${RAND(2,5)})
exten => _X.,n,NoOp(Ring-back delay: ${RBT_DELAY} seconds)

; Send 180 Ringing to the caller's carrier
; This causes the caller to hear their carrier's ring-back tone
exten => _X.,n,Ringing()

; Wait for the random delay period
exten => _X.,n,Wait(${RBT_DELAY})

; Now route through ViciDial's DID handler
exten => _X.,n,AGI(agi://127.0.0.1:4577/agi-DID_route.agi)
; After DID_route.agi processes, ViciDial sets variables and
; routes based on the DID configuration (did_route, extension, etc.)
exten => _X.,n,Goto(${did_route},1)

How It Works Technically

  1. Ringing() sends a SIP 180 Ringing response back to the trunk/carrier. The carrier then plays its standard ring-back tone to the caller. No audio is generated by Asterisk --- the caller hears their own carrier's tone.

  2. Wait(${RBT_DELAY}) pauses dialplan execution for the random number of seconds. During this time, the caller simply hears ringing.

  3. RAND(2,5) generates a random integer: 2, 3, 4, or 5. Each value is equally likely (25% chance each).

Alternative: Using Playback for Controlled Ringing

If your SIP trunk does not send proper 180 Ringing, or if you want precise control over what the caller hears, you can play the ring tone from Asterisk directly:

; Alternative: Asterisk-generated ring tone
exten => _X.,n,Set(RBT_DELAY=${RAND(2,5)})
exten => _X.,n,Answer()
exten => _X.,n,Playback(ring)
exten => _X.,n,Wait(${RBT_DELAY})

Warning: Using Answer() + Playback(ring) means the call is answered from a billing perspective. The Ringing() + Wait() approach keeps the call in a pre-answer state, which is preferable.

Tuning the Delay

Scenario Recommended Delay
Emergency services (plumber, electrician) RAND(2,4) --- callers expect fast answer
General business inquiries RAND(3,5) --- normal business pace
Premium/sales lines RAND(2,3) --- faster answer = higher conversion
After-hours overflow RAND(4,7) --- simulate phone ringing longer

5. Agent Ranking System

ViciDial's agent ranking system lets you control which agents receive calls first within an inbound group. This is invaluable for routing calls to your most experienced agents while keeping trainees as overflow.

How Ranking Works

ViciDial Admin Configuration

Step 1: Set the Inbound Group Routing Method

Navigate to: Admin > Inbound Groups > [your group] > Detail View

Set these fields:

Setting Value Purpose
Next Agent Call inbound_group_rank Routes based on agent rank
No Delay Call Route Y Skip the 15-second default delay
In-Group Rank (see agent assignments) Per-agent priority

Step 2: Assign Agent Ranks

Navigate to: Admin > Inbound Groups > [your group] > In-Group Agents

Or use the Closer In-Group Ranking section to assign ranks:

Agent ID Agent Name Rank Role
AGENT_A Top Performer 1 5 Senior --- gets calls first
AGENT_B Top Performer 2 5 Senior --- gets calls first
AGENT_C Experienced 1 6 Experienced --- second tier
AGENT_D Experienced 2 6 Experienced --- second tier
AGENT_E Mid-Level 1 7 Mid-level --- third tier
AGENT_F Standard 1 8 Standard --- fourth tier
AGENT_G Trainee 1 9 Trainee --- overflow only
AGENT_H Trainee 2 9 Trainee --- overflow only

Step 3: Different Ranks Per Inbound Group

An agent can excel at one type of call but be less suited for another. Assign different ranks per group:

Agent "primary" Group Rank "overflow" Group Rank "specialty" Group Rank
AGENT_A 5 (first) 7 (backup) 5 (first)
AGENT_B 5 (first) 5 (first) 9 (last)
AGENT_C 7 (backup) 5 (first) 6 (second)

This means AGENT_A is one of the first agents to receive "primary" calls and "specialty" calls, but only gets "overflow" calls when senior agents are busy.

Database-Level Configuration

All rank assignments are stored in the vicidial_inbound_group_agents table. You can view and modify them directly:

View current rankings:

SELECT group_id, user, group_rank, group_weight, group_grade
FROM vicidial_inbound_group_agents
WHERE group_id = 'your_ingroup'
ORDER BY group_rank ASC, user ASC;

Bulk-update agent ranks:

-- Promote AGENT_A to rank 5 in the "primary" inbound group
UPDATE vicidial_inbound_group_agents
SET group_rank = 5
WHERE group_id = 'primary' AND user = 'AGENT_A';

-- Set all trainees to rank 9
UPDATE vicidial_inbound_group_agents
SET group_rank = 9
WHERE group_id = 'primary' AND user IN ('AGENT_G', 'AGENT_H', 'AGENT_I');

Add a new agent to an inbound group with a specific rank:

INSERT INTO vicidial_inbound_group_agents
  (group_id, user, group_rank, group_weight, group_grade, group_web_vars)
VALUES
  ('primary', 'NEW_AGENT', 7, 0, 1, '');

Verify the inbound group uses ranked routing:

SELECT group_id, group_name, next_agent_call, no_delay_call_route
FROM vicidial_inbound_groups
WHERE group_id = 'your_ingroup';

The next_agent_call field must be inbound_group_rank for rankings to have any effect.

Confirming Rankings Are Active

After configuration, verify rankings work correctly:

-- See which agents are logged into the inbound group and their ranks
SELECT
    vla.user,
    vla.status,
    vla.campaign_id,
    viga.group_rank,
    vla.closer_campaigns
FROM vicidial_live_agents vla
JOIN vicidial_inbound_group_agents viga
    ON viga.user = vla.user
WHERE viga.group_id = 'your_ingroup'
    AND vla.closer_campaigns LIKE '%your_ingroup%'
ORDER BY viga.group_rank ASC;

Agent Login Requirements

Agents must select the correct inbound groups when logging in. If you have multiple ranked inbound groups (e.g., "primary" and "overflow"), agents need to select all groups they should receive calls from in the Closer Inbound Group Selection screen.

If an agent is rank 5 in "primary" but does not select "primary" at login, they will not receive any calls from that group regardless of rank.


6. Recording Security & HTML5 Player

By default, ViciDial recording URLs are publicly accessible. Anyone who knows the file naming convention can download recordings directly. For operations handling sensitive calls (financial, medical, emergency), this is unacceptable.

Apache mod_rewrite Access Control

This configuration uses Apache mod_rewrite to check the caller's IP address. Whitelisted office IPs get direct MP3 access; everyone else is redirected to a custom HTML5 player that streams the audio without allowing easy download.

Create the Apache configuration file:

File: /etc/apache2/conf.d/recording-security.conf (openSUSE/ViciBox) or: /etc/httpd/conf.d/recording-security.conf (CentOS/RHEL)

# =============================================================
# Recording Access Security
# Whitelisted IPs: direct MP3 download
# All others: redirected to HTML5 streaming player
# =============================================================

<Directory "/var/spool/asterisk/monitorDONE">
    Options -Indexes
    AllowOverride None
    Require all granted

    RewriteEngine On

    # -------------------------------------------------------
    # WHITELIST: Office IPs that get direct MP3 access
    # Add your office, VPN, and management IPs here
    # -------------------------------------------------------
    RewriteCond %{REMOTE_ADDR} !^10\.20\.30\.40$
    RewriteCond %{REMOTE_ADDR} !^10\.20\.30\.41$
    RewriteCond %{REMOTE_ADDR} !^192\.168\.1\.0/24$

    # -------------------------------------------------------
    # REDIRECT: Non-whitelisted IPs go to HTML5 player
    # Only applies to .mp3 and .wav files
    # -------------------------------------------------------
    RewriteRule ^(.+)\.(mp3|wav)$ /recording-player.php?file=$1.$2 [R=302,L]
</Directory>

# Alias for recording URL path
Alias /RECORDINGS /var/spool/asterisk/monitorDONE

Enable the configuration:

# Verify mod_rewrite is loaded
apache2ctl -M | grep rewrite

# If not loaded (openSUSE):
a2enmod rewrite

# Test configuration
apache2ctl configtest

# Reload Apache
systemctl reload apache2

HTML5 Recording Player

Create a dark-themed HTML5 player that streams recordings with byte-range support (seeking works) but makes casual downloading harder.

File: /srv/www/htdocs/recording-player.php (adjust path for your OS)

<?php
/**
 * HTML5 Recording Player
 * Streams recordings with byte-range support (seeking).
 * Dark theme, no direct download link.
 *
 * Security: validates filename, prevents directory traversal.
 */

// Configuration
$recording_base = '/var/spool/asterisk/monitorDONE';
$allowed_extensions = ['mp3', 'wav'];

// Get and sanitize the requested file
$file = isset($_GET['file']) ? $_GET['file'] : '';
$file = basename($file); // Strip directory components (prevent traversal)
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));

// Validate
if (empty($file) || !in_array($ext, $allowed_extensions)) {
    http_response_code(400);
    die('Invalid request');
}

$filepath = $recording_base . '/' . $file;

if (!file_exists($filepath)) {
    http_response_code(404);
    die('Recording not found');
}

// If this is a streaming request (from the audio element), serve the file
if (isset($_GET['stream'])) {
    $size = filesize($filepath);
    $mime = ($ext === 'mp3') ? 'audio/mpeg' : 'audio/wav';

    // Handle byte-range requests (required for seeking)
    if (isset($_SERVER['HTTP_RANGE'])) {
        $range = $_SERVER['HTTP_RANGE'];
        preg_match('/bytes=(\d+)-(\d*)/', $range, $matches);
        $start = intval($matches[1]);
        $end = isset($matches[2]) && $matches[2] !== '' ? intval($matches[2]) : $size - 1;
        $length = $end - $start + 1;

        header('HTTP/1.1 206 Partial Content');
        header("Content-Range: bytes $start-$end/$size");
        header("Content-Length: $length");
        header("Content-Type: $mime");
        header('Accept-Ranges: bytes');
        header('Cache-Control: no-cache, no-store');

        $fp = fopen($filepath, 'rb');
        fseek($fp, $start);
        echo fread($fp, $length);
        fclose($fp);
        exit;
    }

    // Full file request
    header("Content-Type: $mime");
    header("Content-Length: $size");
    header('Accept-Ranges: bytes');
    header('Cache-Control: no-cache, no-store');
    readfile($filepath);
    exit;
}

// Render the HTML5 player page
$stream_url = '?file=' . urlencode($file) . '&stream=1';
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Recording Player</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }

        body {
            background: #1a1a2e;
            color: #e0e0e0;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
        }

        .player-container {
            background: #16213e;
            border-radius: 12px;
            padding: 40px;
            box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
            max-width: 600px;
            width: 90%;
        }

        .player-header {
            text-align: center;
            margin-bottom: 30px;
        }

        .player-header h1 {
            font-size: 18px;
            font-weight: 500;
            color: #a0a0c0;
            margin-bottom: 8px;
        }

        .player-header .filename {
            font-size: 13px;
            color: #6a6a8a;
            word-break: break-all;
        }

        audio {
            width: 100%;
            margin: 20px 0;
            outline: none;
            border-radius: 8px;
        }

        /* Style the audio element for dark theme (WebKit browsers) */
        audio::-webkit-media-controls-panel {
            background: #0f3460;
        }

        audio::-webkit-media-controls-current-time-display,
        audio::-webkit-media-controls-time-remaining-display {
            color: #e0e0e0;
        }

        .player-footer {
            text-align: center;
            margin-top: 20px;
            font-size: 12px;
            color: #4a4a6a;
        }

        /* Disable right-click context menu on audio */
        audio { -webkit-user-select: none; user-select: none; }
    </style>
</head>
<body>
    <div class="player-container">
        <div class="player-header">
            <h1>Call Recording</h1>
            <div class="filename"><?php echo htmlspecialchars($file); ?></div>
        </div>

        <audio controls controlsList="nodownload" preload="metadata">
            <source src="<?php echo htmlspecialchars($stream_url); ?>"
                    type="<?php echo ($ext === 'mp3') ? 'audio/mpeg' : 'audio/wav'; ?>">
            Your browser does not support audio playback.
        </audio>

        <div class="player-footer">
            Playback only &mdash; recording cannot be downloaded.
        </div>
    </div>

    <script>
        // Disable right-click on audio element
        document.querySelector('audio').addEventListener('contextmenu', function(e) {
            e.preventDefault();
        });
    </script>
</body>
</html>

Security Notes

Testing Recording Security

# From a whitelisted IP - should get the MP3 file directly
curl -I http://YOUR_SERVER/RECORDINGS/MP3/test-recording.mp3
# Expected: Content-Type: audio/mpeg

# From a non-whitelisted IP - should get a 302 redirect
curl -I http://YOUR_SERVER/RECORDINGS/MP3/test-recording.mp3
# Expected: HTTP/1.1 302 Found, Location: /recording-player.php?file=...

7. Codec & Audio Quality Tuning

Default ViciDial codec settings often cause unnecessary transcoding, which adds latency and can introduce audio artifacts. For UK and European operations, alaw (G.711a) should always be the first codec offered.

SIP Peer Codec Configuration

In sip.conf or sip-vicidial.conf, set the codec order for your trunk peers:

; =============================================================
; Codec configuration for UK/European SIP trunks
; alaw FIRST - native codec for UK/EU, no transcoding needed
; =============================================================

[trunk_peer_template](!)
type=peer
disallow=all
allow=alaw             ; G.711a - UK/EU native, 64kbps, no transcoding
allow=ulaw             ; G.711u - US fallback, minimal transcoding
allow=g722             ; Wideband - better quality if trunk supports it
allow=g729             ; Low-bandwidth fallback
allow=gsm              ; Last resort

Why alaw first?

Codec Bandwidth Region Transcoding from alaw
alaw (G.711a) 64 kbps UK/EU native None (passthrough)
ulaw (G.711u) 64 kbps US/Canada native Minimal (mu-law to a-law)
g722 64 kbps Any Required (CPU-intensive)
g729 8 kbps Any Required (license needed)
gsm 13 kbps Any Required

When your trunk and your agents both use alaw, Asterisk can pass the audio through without touching it (native bridging). This eliminates transcoding latency entirely.

ConfBridge Audio Quality Settings

ViciDial uses ConfBridge (Asterisk 16+) for call bridging. Key settings for audio quality:

Modify the ConfBridge profile via ViciDial's ADMIN_keepalive_ALL.pl:

The dsp_drop_silence setting is critical. When enabled, ConfBridge drops silence frames to save bandwidth. This causes clipping at the start of speech --- the first syllable gets cut off because Asterisk thinks it is still silence.

; In confbridge.conf or applied via keepalive script:

[default_bridge]
type=bridge
; CRITICAL: Do NOT drop silence frames
; Dropping silence causes speech clipping at utterance boundaries
dsp_drop_silence=no

; Use software mixing (not native RTP bridging)
; This ensures consistent audio processing
mixing_interval=20

[default_user]
type=user
; Standard user profile for agents
announce_user_count=no
quiet=yes

Disable native RTP bridging:

Native RTP bridging bypasses Asterisk's audio processing entirely, sending RTP directly between endpoints. While this reduces server load, it breaks features like recording, DTMF detection, and quality monitoring. For call centers, always use software mixing.

Add to modules.conf:

; Disable native RTP bridging to ensure recordings and
; DTMF detection work reliably through ConfBridge
noload => bridge_native_rtp.so

Jitter Buffer Configuration

Network jitter causes audio crackling, gaps, and robotic speech. An adaptive jitter buffer smooths out timing variations.

In sip.conf (general section):

[general]
; =============================================================
; Jitter buffer - adaptive mode
; Compensates for network timing variations
; =============================================================
jbEnabled = yes
jbForce = no           ; Do not force JB on all channels
jbImpl = adaptive      ; Adaptive algorithm (adjusts to conditions)
jbMaxSize = 200        ; Maximum buffer size in ms
jbResyncThreshold = 1000  ; Resync if drift exceeds 1000ms
jbLog = no             ; Disable JB logging (noisy in production)

RTP and Session Timer Settings

[general]
; =============================================================
; RTP keepalive and session timers
; Prevents NAT timeouts, detects dead calls
; =============================================================

; RTP keepalive: send comfort noise every 15 seconds
; Prevents NAT gateways from closing the RTP pinhole
rtpkeepalive=15

; RTP hold timeout: hang up if no RTP received for 300 seconds
; Catches "zombie" calls where one side disconnected silently
rtpholdtimeout=300

; RTP timeout: hang up if no RTP at all for 60 seconds
rtptimeout=60

; Session timers: re-INVITE every 1800 seconds (30 minutes)
; Catches calls stuck in "connected" state after network failure
session-timers=originate
session-expires=1800
session-minse=90
session-refresher=uas

; SIP qualify: check trunk health every 15 seconds
qualifyfreq=15

QoS (Quality of Service) Marking

If your network supports QoS (DSCP marking), set proper values so routers prioritize voice traffic:

[general]
; QoS DSCP markings
tos_sip=cs3         ; SIP signaling: CS3 (DSCP 24)
tos_audio=ef        ; RTP audio: Expedited Forwarding (DSCP 46)
cos_sip=3           ; 802.1p CoS for SIP
cos_audio=5         ; 802.1p CoS for RTP

RTP Port Range

Tighten the RTP port range to reduce firewall exposure:

In rtp.conf:

[general]
; Tight RTP port range
; 500 ports supports ~250 simultaneous calls
rtpstart=10000
rtpend=10500

Your firewall should only allow UDP traffic on this range from your SIP trunk provider IPs.


8. Testing, Rollout & Monitoring

Testing Filters Without Breaking Live Calls

Never deploy filters directly to production. Use this graduated approach:

Phase 1: Log-Only Mode

Instead of rejecting calls, log what would be rejected:

; LOG-ONLY MODE: Replace actual rejection with logging
; Use this to validate filter accuracy before going live

; Instead of:
;   exten => _X.,n,GotoIf(${BLACKLIST()}?blacklisted,s,1)
; Use:
exten => _X.,n,ExecIf(${BLACKLIST()}?NoOp(WOULD-BLOCK-BLACKLIST: ${CALLERID(num)} to ${EXTEN}))
exten => _X.,n,ExecIf(${BLACKLIST()}?Log(NOTICE,FILTER-TEST: Blacklist would block ${CALLERID(num)}))

Run in log-only mode for at least one business day. Then analyze:

# Count how many calls each filter would have blocked
grep "FILTER-TEST" /var/log/asterisk/messages | \
    awk -F'FILTER-TEST: ' '{print $2}' | \
    sort | uniq -c | sort -rn

Phase 2: Selective Enforcement

Enable filters one at a time, starting with the lowest-risk:

  1. Blacklist (first) --- you manually control this list, lowest risk
  2. Spam/recruiter block (second) --- specific known numbers
  3. Anonymous rejection (third) --- review log-only data first
  4. UK mobile filter (last, highest risk) --- most aggressive, blocks all landlines

Wait 24-48 hours between enabling each filter. Monitor agent feedback.

Phase 3: Full Enforcement

Once all filters are validated, switch to the production dialplan shown in Section 3.

Monitoring Rejected Calls

Real-time monitoring via Asterisk CLI:

# Watch filter activity in real-time
asterisk -rx "core set verbose 3"
# Then in another terminal:
tail -f /var/log/asterisk/messages | grep -E "(BLACKLISTED|BLOCKED-DID|ANONYMOUS|NON-UK-MOBILE|SPAM-BLOCKED)"

Daily rejection summary (add to crontab):

#!/bin/bash
# /usr/local/bin/daily-filter-stats.sh
# Cron: 0 23 * * * /usr/local/bin/daily-filter-stats.sh >> /var/log/filter-stats.log

DATE=$(date +%Y-%m-%d)
LOG="/var/log/asterisk/messages"

echo "=== Filter Stats for $DATE ==="
echo "Blacklisted:    $(grep "$DATE" $LOG | grep -c 'BLACKLISTED')"
echo "Blocked DIDs:   $(grep "$DATE" $LOG | grep -c 'BLOCKED-DID')"
echo "Anonymous:      $(grep "$DATE" $LOG | grep -c 'ANONYMOUS')"
echo "Non-UK-Mobile:  $(grep "$DATE" $LOG | grep -c 'NON-UK-MOBILE')"
echo "Spam/Recruiter: $(grep "$DATE" $LOG | grep -c 'SPAM-BLOCKED')"
echo "Total rejected: $(grep "$DATE" $LOG | grep -cE '(BLACKLISTED|BLOCKED-DID|ANONYMOUS|NON-UK-MOBILE|SPAM-BLOCKED)')"
echo "Total inbound:  $(grep "$DATE" $LOG | grep -c 'FILTERS PASSED')"
echo "---"

Grafana dashboard query (if using Loki for log aggregation):

{job="asterisk"} |= "BLACKLISTED" or "BLOCKED-DID" or "ANONYMOUS" or "NON-UK-MOBILE" or "SPAM-BLOCKED"
| pattern `<_> <level> <_> <filter_type> call from <caller> to DID <did>`
| count_over_time({job="asterisk"} |= "BLOCKED" [1h]) by (filter_type)

Gradual Rollout Checklist

Before enabling each filter in production:

Emergency Rollback

If a filter is blocking legitimate calls:

# Immediate: restore backup and reload
cp /etc/asterisk/extensions.conf.bak.YYYYMMDD /etc/asterisk/extensions.conf
asterisk -rx "dialplan reload"

# Verify the rollback took effect
asterisk -rx "dialplan show trunkinbound" | head -20

The dialplan reload command is non-disruptive --- it does not drop active calls. Only new calls will use the updated dialplan.


9. Complete Reference Configuration

Here is the full [trunkinbound] context with all filters, ring-back delay, and routing assembled into a single block. Copy this as a starting point and customize for your environment.

; =============================================================
; ADVANCED INBOUND CALL FLOW
; File: /etc/asterisk/extensions.conf (add to existing file)
;
; Pipeline: Blacklist -> Blocked DIDs -> Anonymous -> UK Mobile
;           -> Spam Block -> Ring-Back Delay -> DID Route
; =============================================================

[trunkinbound]
; --- STAGE 1: Sanitize CLI and check blacklist ---
exten => _X.,1,Set(CALLERID(num)=${FILTER(+0123456789,${CALLERID(num)})})
exten => _X.,n,NoOp(INBOUND: CLI=${CALLERID(num)} DID=${EXTEN})
exten => _X.,n,GotoIf(${BLACKLIST()}?blacklisted,s,1)

; --- STAGE 2: Hard-blocked numbers ---
exten => _X.,n,GotoIf($["${CALLERID(num)}" = "441234000001"]?blocked_did,s,1)
exten => _X.,n,GotoIf($["${CALLERID(num)}" = "441234000002"]?blocked_did,s,1)
; Add more hard blocks as needed...
exten => _X.,n,Set(BLOCKED_RANGE=${REGEX("^44120[0-9]{7}$" ${CALLERID(num)})})
exten => _X.,n,GotoIf($["${BLOCKED_RANGE}" = "1"]?blocked_did,s,1)

; --- STAGE 3: Anonymous / withheld CLI ---
exten => _X.,n,Set(CLI_LOWER=${TOLOWER(${CALLERID(num)})})
exten => _X.,n,GotoIf($["${CALLERID(num)}" = ""]?anon_reject,s,1)
exten => _X.,n,GotoIf($["${CLI_LOWER}" = "anonymous"]?anon_reject,s,1)
exten => _X.,n,GotoIf($["${CLI_LOWER}" = "unknown"]?anon_reject,s,1)
exten => _X.,n,GotoIf($["${CLI_LOWER}" = "restricted"]?anon_reject,s,1)
exten => _X.,n,GotoIf($["${CLI_LOWER}" = "unavailable"]?anon_reject,s,1)
exten => _X.,n,GotoIf($["${CLI_LOWER}" = "private"]?anon_reject,s,1)

; --- STAGE 4: UK mobile-only filter ---
exten => _X.,n,Set(IS_UK_MOBILE=${REGEX("^(0?447[1-9][0-9]{8}|07[1-9][0-9]{8})$" ${CALLERID(num)})})
exten => _X.,n,GotoIf($["${IS_UK_MOBILE}" = "0"]?not_uk_mobile,s,1)

; --- STAGE 5: Known spam / recruiter blocks ---
exten => _X.,n,Set(IS_RECRUITER_A=${REGEX("^44203100[0-9]{4}$" ${CALLERID(num)})})
exten => _X.,n,GotoIf($["${IS_RECRUITER_A}" = "1"]?spam_blocked,s,1)
; Add more spam patterns as needed...

; --- ALL FILTERS PASSED ---
exten => _X.,n,NoOp(FILTERS PASSED: ${CALLERID(num)} -> ${EXTEN})
exten => _X.,n,Goto(trunkinbound-route,${EXTEN},1)


[trunkinbound-route]
; --- RING-BACK DELAY ---
exten => _X.,1,NoOp(Routing DID ${EXTEN} from ${CALLERID(num)})
exten => _X.,n,Set(RBT_DELAY=${RAND(2,5)})
exten => _X.,n,NoOp(Ring-back delay: ${RBT_DELAY}s)
exten => _X.,n,Ringing()
exten => _X.,n,Wait(${RBT_DELAY})
; --- ViciDial DID routing ---
exten => _X.,n,AGI(agi://127.0.0.1:4577/agi-DID_route.agi)
exten => _X.,n,Goto(${did_route},1)


; =============================================================
; REJECTION HANDLERS
; =============================================================

[blacklisted]
exten => s,1,NoOp(BLACKLISTED: ${CALLERID(num)} calling ${EXTEN})
exten => s,n,Log(WARNING,BLACKLISTED call from ${CALLERID(num)} to ${EXTEN} - rejected)
exten => s,n,Congestion(3)
exten => s,n,Hangup()

[blocked_did]
exten => s,1,NoOp(BLOCKED DID: ${CALLERID(num)} calling ${EXTEN})
exten => s,n,Log(WARNING,BLOCKED-DID call from ${CALLERID(num)} to ${EXTEN} - hard blocked)
exten => s,n,Congestion(3)
exten => s,n,Hangup()

[anon_reject]
exten => s,1,NoOp(ANONYMOUS CALL REJECTED: CLI="${CALLERID(num)}")
exten => s,n,Log(WARNING,ANONYMOUS call rejected - CLI="${CALLERID(num)}" to DID ${EXTEN})
exten => s,n,Congestion(3)
exten => s,n,Hangup()

[not_uk_mobile]
exten => s,1,NoOp(NON-UK-MOBILE REJECTED: ${CALLERID(num)})
exten => s,n,Log(NOTICE,NON-UK-MOBILE call rejected - CLI=${CALLERID(num)} to DID ${EXTEN})
exten => s,n,Congestion(3)
exten => s,n,Hangup()

[spam_blocked]
exten => s,1,NoOp(SPAM/RECRUITER BLOCKED: ${CALLERID(num)})
exten => s,n,Log(WARNING,SPAM-BLOCKED call from ${CALLERID(num)} to DID ${EXTEN})
exten => s,n,Congestion(3)
exten => s,n,Hangup()

After adding this configuration:

# Verify syntax
asterisk -rx "dialplan reload"
asterisk -rx "dialplan show trunkinbound"
asterisk -rx "dialplan show trunkinbound-route"

# If there are errors, check the CLI output for line numbers

10. Troubleshooting

Filter is blocking legitimate calls

Symptom: Agents report missing calls from known customers.

Diagnosis:

# Check if the caller was filtered
grep "441234567890" /var/log/asterisk/messages | tail -5

Look for any of the filter log lines (BLACKLISTED, BLOCKED-DID, ANONYMOUS, NON-UK-MOBILE, SPAM-BLOCKED).

Fix: If the UK mobile filter is too aggressive, consider allowing specific landline prefixes:

; Allow UK landlines as well as mobiles
; Matches: 44XXXXXXXXX (any UK number, 10+ digits)
exten => _X.,n,Set(IS_UK_NUMBER=${REGEX("^(0?44[0-9]{9,10}|0[1-9][0-9]{8,9})$" ${CALLERID(num)})})
exten => _X.,n,GotoIf($["${IS_UK_NUMBER}" = "0"]?not_uk_number,s,1)

Ring-back delay causes timeout

Symptom: Calls drop before reaching an agent, especially with long delays.

Diagnosis: Some SIP trunks have a short ring timeout (e.g., 15 seconds). If the ring-back delay is 5 seconds and ViciDial takes 2-3 seconds to find an agent, you are already at 7-8 seconds before the agent phone starts ringing.

Fix: Reduce the maximum delay:

; Reduce maximum delay from 5 to 3 seconds
exten => _X.,n,Set(RBT_DELAY=${RAND(2,3)})

Agent rankings not working

Symptom: Calls are distributed evenly regardless of rank assignments.

Diagnosis:

-- Check the inbound group routing method
SELECT group_id, next_agent_call FROM vicidial_inbound_groups
WHERE group_id = 'your_ingroup';

If next_agent_call is not inbound_group_rank, rankings are ignored.

Fix:

UPDATE vicidial_inbound_groups
SET next_agent_call = 'inbound_group_rank'
WHERE group_id = 'your_ingroup';

Recording player shows "Recording not found"

Symptom: The HTML5 player loads but cannot find the file.

Diagnosis: Check the recording path. ViciDial may store recordings in subdirectories:

# Find where recordings actually live
find /var/spool/asterisk/monitorDONE -name "*.mp3" | head -5

If recordings are in subdirectories (e.g., MP3/), update $recording_base in the player:

$recording_base = '/var/spool/asterisk/monitorDONE/MP3';

Codec negotiation not using alaw

Symptom: sip show channelstats shows ulaw or g729 instead of alaw.

Diagnosis:

asterisk -rx "sip show channelstats"
# Check the "Codec" column for active calls

Fix: Verify the trunk peer has disallow=all before allow=alaw. Without disallow=all, Asterisk appends your allowed codecs to the defaults instead of replacing them.

Also check that the remote trunk supports alaw. Some US-based trunks only support ulaw.

Dialplan reload fails

Symptom: dialplan reload shows errors.

Diagnosis:

# Check for syntax errors
asterisk -rx "dialplan reload" 2>&1 | grep -i error

# Common issues:
# - Missing closing bracket in GotoIf
# - Unmatched quotes in REGEX
# - Missing exten => prefix

Fix: Use the backup you created before editing:

cp /etc/asterisk/extensions.conf.bak.YYYYMMDD /etc/asterisk/extensions.conf
asterisk -rx "dialplan reload"

Further Reading


This tutorial is based on production configurations handling thousands of daily calls across multiple ViciDial servers. All IP addresses, phone numbers, and company names have been replaced with placeholders.

Need expert help with your setup?

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

Get a Free Consultation