Emergency server help: get in touch

PJSIP Health Check: Monitor Asterisk Trunks, Registrations and Phones

A read-only Bash script that checks Asterisk and FreePBX PJSIP trunks, outbound registrations, contact round-trip times, phones and active calls, with Nagios-style exit codes, JSON output and mail or webhook alerts when something changes.

Version
1.0.1
Last updated
October 18, 2026
Language
Bash
Tested on
1.0.1 and 1.0.0 on Debian 12 with FreePBX 17.0.33 and Asterisk 22.11 (lab test, 16 Oct 2026): trunk up, trunk taken down, JSON output, unreachable phones
Testing level
Lab-tested
License
MIT
Pricing
Free

A SIP trunk that stops registering does not raise an error anyone sees: inbound calls simply stop arriving and outbound calls fail with “all circuits busy” until a customer complains. This PJSIP health check asks Asterisk for the state of every trunk registration, contact and endpoint, flags what is down or slow, and prints one clear status line that works from cron, Nagios, Icinga, Zabbix or a webhook.

It is read-only: it only runs read-only show commands through asterisk -rx, such as asterisk -rx "pjsip show registrations". No reload, no configuration change and no database write, so it is safe to run every minute on a busy PBX.

Version 1.0.1 (17 October 2026): An independent review found that 1.0.0 reported OK, with zero objects and exit code 0, when Asterisk answered its first query but the detailed PJSIP queries failed. 1.0.1 checks every query and reports UNKNOWN (exit 3) instead. Our test for this release also found that endpoints without a caller ID, which is typical for trunks, were left out of the endpoint count; that is fixed too. New: --require-trunk, and failed mail or webhook alerts are retried on the next run. If you use 1.0.0 for monitoring, update.

What the PJSIP health check looks at

  • Outbound registrations (pjsip show registrations): any trunk that is Rejected, Unregistered or stuck retrying is CRITICAL.
  • Contacts and qualify: unreachable trunk contacts are CRITICAL; a round-trip time above 200 ms is a warning and above 800 ms is critical (both adjustable).
  • Trunks vs phones: an endpoint with a registration, an identify match or a non-numeric name is treated as a trunk. Unreachable phones are listed but do not raise the status (offices close at night) unless you add --phones-warn.
  • Active calls: with --max-calls, a sudden jump in concurrent calls raises a warning, a common early sign of toll fraud.
  • Uptime and last reload are shown so you can tell a restart from a provider problem.

Install and first run

curl -fsSLO https://scr.srvscripts.com/pjsip-health-check/pjsip-health-check.sh
bash pjsip-health-check.sh
bash pjsip-health-check.sh --json
bash pjsip-health-check.sh --quiet          # silent when everything is OK

Run it as root, or as a user that can run asterisk -rx (on FreePBX that is root or asterisk). The exit code follows the Nagios convention: 0 OK, 1 WARNING, 2 CRITICAL, 3 UNKNOWN (Asterisk not running, or a query failed or answered in an unexpected format, so the script cannot vouch for the PBX).

Tested on a real server

ServerWhat we checkedResult
FreePBX 17.0.33, Asterisk 22.11, Debian 12Normal state: one registered trunk, three extensions (two phones offline)OK, exit 0; offline phones listed as info only
Same serverOur test carrier stopped, so the trunk contact went UnavailableCRITICAL “trunk labcarrier contact … is Unavail”, exit 2
Same server--json outputValid JSON with status, counts, problems and a ready-made alert text
Same server--max-calls 1 with two calls in progressWARNING on the call count, exit 1
Same server, 1.0.1--require-trunk labcarrier,backup1CRITICAL “required trunk backup1 is not configured”, exit 2
Regression test (fake Asterisk CLI)Uptime answers, but every PJSIP list query fails1.0.1: UNKNOWN, exit 3. 1.0.0: OK, exit 0 (the bug)
Regression testA webhook that cannot be reachedNo state saved, so the alert is sent again on the next run

We ran these tests on our FreePBX 17 lab server (Debian 12, Asterisk 22.11, 4 vCPU) on 16 and 17 October 2026. The “carrier” was a SIPp process acting as a SIP provider on the same server, so the trunk could be taken down on purpose. The screenshots show version 1.0.0, which counted 3 endpoints instead of 4 (the trunk was missing from that count); the statuses are the same in 1.0.1. The regression tests run on every release in our public CI. The screenshots are the real terminal output; only IP addresses are masked.

PJSIP health check: terminal output of ./pjsip-health-check.sh on Debian 12 with FreePBX 17 and Asterisk 22.11
./pjsip-health-check.sh — exit code 0, 0.2 s. Debian 12, FreePBX 17.0, Asterisk 22.11, 16 Oct 2026. IP addresses masked.
PJSIP health check: terminal output of ./pjsip-health-check.sh on Debian 12 with FreePBX 17 and Asterisk 22.11
./pjsip-health-check.sh — exit code 2, 0.2 s. Debian 12, FreePBX 17.0, Asterisk 22.11, 16 Oct 2026. IP addresses masked.
PJSIP health check: terminal output of ./pjsip-health-check.sh --json | python3 -m json.tool | head -40 on Debian 12 with FreePBX 17 and Asterisk 22.11
./pjsip-health-check.sh --json | python3 -m json.tool | head -40 — exit code 0, 0.2 s. Debian 12, FreePBX 17.0, Asterisk 22.11, 16 Oct 2026. IP addresses masked.

Options

OptionMeaning
--rtt-warn MSWarn when a reachable contact answers slower than MS milliseconds (default 200)
--rtt-crit MSCritical above MS milliseconds (default 800)
--max-calls NWarn when more than N calls are active (default 0 = no limit)
--phones-warnTreat unreachable phones as WARNING instead of listing them
--ignore REGEXSkip endpoints whose name matches, for example '^(9[0-9]{3}|test)'
--require-trunk LISTComma-separated trunk names that must exist; a missing one is CRITICAL, so a trunk deleted by mistake does not just lower the counts
--jsonPrint the report as JSON
-q, --quietPrint nothing when everything is OK
--mail ADDRE-mail the report when the status is not OK, or with --notify-on-change whenever the problems change, including the recovery (needs a working mail command)
--webhook URLPOST the JSON report, on the same rule as --mail; it includes a text field that Slack, Discord and Teams display
--notify-on-changeOnly send mail or webhook when the set of problems changes, so a long outage sends one alert, not one per minute
--state-dir DIRWhere the change-detection state is kept (default /var/lib/pjsip-health-check)
--timeout SECSeconds to wait for each Asterisk command (default 10)

Run it from cron

Every five minutes, with one e-mail when a problem starts and one when it changes or clears:

# /etc/cron.d/pjsip-health-check
*/5 * * * * root /usr/local/bin/pjsip-health-check.sh --quiet --notify-on-change --mail ops@example.com

For Nagios or Icinga, call it through NRPE or SSH and use the exit code as the service state. For Zabbix, use --json and read .status.

What to do when it reports a problem

  • Trunk registration Rejected: the provider refused the credentials or the source IP. Check the trunk password and that your public IP is allowed in the provider portal.
  • Contact Unavailable: OPTIONS pings get no answer. Usually a firewall, NAT or provider outage; see PJSIP endpoint unreachable.
  • High round-trip time: a congested uplink or a distant provider PoP. Use the MOS calculator to see what that latency does to call quality.
  • Unexpected jump in active calls: check the toll fraud watch output and core show channels straight away.

Notes

  • Read-only, so there is nothing to back up or revert. It writes only its own state file when --notify-on-change is used.
  • chan_sip is not checked (it was removed in Asterisk 21). For older systems, see chan_sip to PJSIP migration.
  • Outbound registrations are only one way to connect a trunk. IP-authenticated trunks have no registration, so the script relies on their qualify (OPTIONS) status instead; make sure qualify_frequency is set on those AORs.

Related: FreePBX PJSIP trunk setup, Asterisk CLI cheat sheet, troubleshoot SIP with sngrep.

Official reference for the PJSIP commands and settings used here: the Asterisk documentation.

The script

pjsip-health-check.shDownload
#!/usr/bin/env bash
# PJSIP Health Check: Trunks, Registrations, Phones and Call Load for Asterisk and FreePBX (v1.0.1) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/pjsip-health-check/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
#
# Read-only health check for Asterisk 18+ with chan_pjsip (FreePBX 16/17, plain Asterisk, VitalPBX, Issabel 5).
# It asks Asterisk (asterisk -rx) for: uptime, outbound registrations, contact reachability and round-trip time
# (qualify), endpoint states and the number of active calls, and prints one line per problem plus a summary.
# Nothing on the PBX is changed: no reload, no config edits, no database writes. It is safe to run every minute.
#
#   bash pjsip-health-check.sh                       # human report
#   bash pjsip-health-check.sh --quiet               # only problems (for cron); silent when all is OK
#   bash pjsip-health-check.sh --json                # machine-readable report
#   */5 * * * * root /usr/local/bin/pjsip-health-check.sh --quiet --notify-on-change --mail ops@example.com
#
# What counts as a trunk: an endpoint with an outbound registration, an identify (match=) section, or a name that is
# not all digits. Everything else is treated as a phone. Unreachable trunks are CRITICAL; unreachable phones are
# only listed (offices close at night) unless you add --phones-warn.
# Exit codes (Nagios/Icinga compatible): 0 OK, 1 WARNING, 2 CRITICAL, 3 UNKNOWN (Asterisk not running, a CLI query
# failed or answered in an unexpected format, bad option).
# Version 1.0.1 (2026-10-17): a failed or malformed CLI query is UNKNOWN (1.0.0 could report OK with zero objects);
#   endpoints without a caller ID (typical for trunks) are now counted; new --require-trunk; notification state is saved per channel only after delivery. Found in an independent review.
# Version 1.0.0 (2026-10-17): first release. Tested on FreePBX 17.0.33 / Asterisk 22.11 (Debian 12) with a
#   registered trunk, a trunk taken down, one reachable and two unreachable phones, and calls in progress.
set -uo pipefail
export LC_ALL=C

RTT_WARN=200; RTT_CRIT=800; MAX_CALLS=0; PHONES_WARN=0; IGNORE=""; JSON=0; QUIET=0; MAIL_TO=""; WEBHOOK=""
NOTIFY_CHANGE=0; STATE_DIR=/var/lib/pjsip-health-check; AST=${ASTERISK_BIN:-asterisk}; TIMEOUT=10; REQUIRE=""

usage() {
  cat <<'EOF'
Usage: pjsip-health-check.sh [options]
  --rtt-warn MS        warn when a reachable contact's round-trip time is above MS (default 200)
  --rtt-crit MS        critical above MS (default 800)
  --max-calls N        warn when more than N calls are active (default 0 = no limit). A sudden jump is a
                       common sign of toll fraud.
  --phones-warn        treat unreachable phones as WARNING (default: listed only)
  --ignore REGEX       skip endpoints whose name matches REGEX (e.g. '^(9[0-9]{3}|test)')
  --json               print the report as JSON
  -q, --quiet          print nothing when everything is OK; problems only
  --mail ADDR          e-mail the report (needs a working 'mail' command) when the status is not OK
  --webhook URL        POST the JSON report to URL (Slack/Discord/Teams-compatible "text" field included)
  --notify-on-change   only send mail/webhook when the set of problems changes (state in /var/lib/pjsip-health-check)
  --state-dir DIR      where the change-detection state is kept
  --require-trunk LIST comma-separated trunk names that must exist; a missing one is CRITICAL (catches a trunk
                       that disappeared from the configuration, which would otherwise just lower the counts)
  --timeout SEC        seconds to wait for each Asterisk CLI command (default 10)
  -h, --help           this help
Environment: ASTERISK_BIN path to the asterisk binary (default: asterisk in PATH)
EOF
}
die() { echo "UNKNOWN: $*" >&2; exit 3; }
num() { [[ "$2" =~ ^[0-9]+$ ]] || die "$1 needs a whole number (got '$2')"; }

while [ $# -gt 0 ]; do
  case "$1" in
    --rtt-warn) num "$1" "${2:-}"; RTT_WARN=$2; shift 2 ;;
    --rtt-crit) num "$1" "${2:-}"; RTT_CRIT=$2; shift 2 ;;
    --max-calls) num "$1" "${2:-}"; MAX_CALLS=$2; shift 2 ;;
    --phones-warn) PHONES_WARN=1; shift ;;
    --ignore) IGNORE="${2:-}"; [ -n "$IGNORE" ] || die "--ignore needs a pattern"; shift 2 ;;
    --json) JSON=1; shift ;;
    -q|--quiet) QUIET=1; shift ;;
    --mail) MAIL_TO="${2:-}"; [[ "$MAIL_TO" == *@* ]] || die "--mail needs an e-mail address"; shift 2 ;;
    --webhook) WEBHOOK="${2:-}"; [[ "$WEBHOOK" =~ ^https?:// ]] || die "--webhook needs an http(s) URL"; shift 2 ;;
    --notify-on-change) NOTIFY_CHANGE=1; shift ;;
    --state-dir) STATE_DIR="${2:-}"; [ -n "$STATE_DIR" ] || die "--state-dir needs a folder"; shift 2 ;;
    --require-trunk) REQUIRE="${2:-}"; [ -n "$REQUIRE" ] || die "--require-trunk needs a name list"; shift 2 ;;
    --timeout) num "$1" "${2:-}"; TIMEOUT=$2; shift 2 ;;
    -h|--help) usage; exit 0 ;;
    *) usage >&2; die "unknown option: $1" ;;
  esac
done
[ "$RTT_CRIT" -ge "$RTT_WARN" ] || die "--rtt-crit must be >= --rtt-warn"
if [ -n "$IGNORE" ]; then echo "" | grep -Eq -- "$IGNORE" 2>/dev/null; [ $? -le 1 ] || die "--ignore is not a valid regular expression"; fi
command -v "$AST" >/dev/null 2>&1 || die "asterisk binary not found (set ASTERISK_BIN)"

ax() { timeout "$TIMEOUT" "$AST" -rx "$1" 2>/dev/null; }
up=$(ax "core show uptime seconds")
[[ "$up" == *"System uptime:"* ]] || die "cannot talk to Asterisk (is it running, and are you root or in the asterisk group?)"
uptime_s=$(awk -F': ' '/System uptime/{print $2+0}' <<<"$up")
reload_s=$(awk -F': ' '/Last reload/{print $2+0}' <<<"$up")
ver=$(ax "core show version" | awk '{print $2; exit}')
ax "module show like res_pjsip.so" | grep -q 'res_pjsip.so' || die "chan_pjsip/res_pjsip is not loaded on this Asterisk"

STATUS=0; PROBLEMS=(); INFO=(); regs=""; idents=""; ctc=""; eps=""; chancount=""
add() { # level text. UNKNOWN (3) wins over CRITICAL: the check itself could not see the PBX.
  local lv=$1; shift
  case "$lv" in UNKNOWN) STATUS=3 ;; CRIT) [ "$STATUS" -lt 2 ] && STATUS=2 ;; WARN) [ "$STATUS" -lt 1 ] && STATUS=1 ;; esac
  PROBLEMS+=("$lv $*")
}
# q WHAT CMD REGEX: run a CLI command and accept its output only if the command succeeded and the output has
# the expected shape ("No objects found." is a real, valid empty answer). Anything else is UNKNOWN, never a
# healthy zero count.
q() { # VAR WHAT CMD REGEX (sets VAR; runs in this shell so UNKNOWN reaches STATUS)
  local var=$1 what=$2 cmd=$3 re=$4 out rc
  printf -v "$var" '%s' ""
  out=$(timeout "$TIMEOUT" "$AST" -rx "$cmd" 2>&1); rc=$?
  if [ "$rc" -ne 0 ] || ! grep -Eq -- "$re" <<<"$out" || grep -Eqi 'No such command|Unable to connect|permission denied' <<<"$out"; then
    add UNKNOWN "could not read $what ('$cmd' exit $rc$([ "$rc" = 124 ] && echo ', timed out'): $(head -1 <<<"$out" | cut -c1-80))"
    return 1
  fi
  printf -v "$var" '%s' "$out"
}
skip() { [ -n "$IGNORE" ] && grep -Eq -- "$IGNORE" <<<"$1"; }

# Trunk names: endpoints with a registration or identify section, or non-numeric names
q regs registrations "pjsip show registrations" 'No objects found|<Registration|/sip'
q idents identifies "pjsip show identifies" 'No objects found|Identify:'
declare -A TRUNK=()
while read -r n; do [ -n "$n" ] && TRUNK[$n]=1; done < <(awk '/^ *[^ <=]+\/sip:/{split($1,a,"/"); print a[1]}' <<<"$regs")
while read -r n; do [ -n "$n" ] && TRUNK[$n]=1; done < <(awk '$1=="Identify:" && $2 !~ /^</ {split($2,a,"/"); print a[2]}' <<<"$idents")

# 1. Outbound registrations
reg_total=0; reg_ok=0
while read -r line; do
  name=${line%%/*}; name=${name// /}
  skip "$name" && continue
  reg_total=$((reg_total + 1))
  st=$(awk '{for(i=3;i<=NF;i++) if($i ~ /^(Registered|Unregistered|Rejected|Stopped|Retrying)$/){print $i; exit}}' <<<"$line")
  [ -n "$st" ] || st=$(awk '{print $3}' <<<"$line")
  if [ "$st" = "Registered" ]; then reg_ok=$((reg_ok + 1)); else add CRIT "registration $name is $st"; fi
done < <(grep -E '^ *[^ <=]+/sip' <<<"$regs")

# 2. Contacts (qualify reachability and round-trip time)
q ctc contacts "pjsip show contacts" 'No objects found|Contact:'
declare -A AORSTATE=()
c_total=0; c_avail=0
while read -r aor uri status rtt; do
  skip "$aor" && continue
  c_total=$((c_total + 1))
  kind=phone; [ -n "${TRUNK[$aor]:-}" ] && kind=trunk
  case "$status" in
    Avail|Reachable)
      c_avail=$((c_avail + 1)); AORSTATE[$aor]=up
      ms=${rtt%.*}; [[ "$ms" =~ ^[0-9]+$ ]] || ms=0
      if [ "$ms" -ge "$RTT_CRIT" ]; then add CRIT "$kind $aor round-trip ${rtt} ms (>= $RTT_CRIT)";
      elif [ "$ms" -ge "$RTT_WARN" ]; then add WARN "$kind $aor round-trip ${rtt} ms (>= $RTT_WARN)"; fi ;;
    NonQual)
      [ "${AORSTATE[$aor]:-}" = up ] || AORSTATE[$aor]=unqualified
      INFO+=("$kind $aor has qualify off (NonQual): its reachability is not being checked") ;;
    Unknown)
      [ "${AORSTATE[$aor]:-}" = up ] || AORSTATE[$aor]=unqualified
      INFO+=("$kind $aor not qualified yet (status Unknown, normal for a minute after it registers)") ;;
    *)
      [ "${AORSTATE[$aor]:-}" = up ] || AORSTATE[$aor]=down
      if [ "$kind" = trunk ]; then add CRIT "trunk $aor contact $uri is $status";
      elif [ "$PHONES_WARN" = 1 ]; then add WARN "phone $aor contact $uri is $status";
      else INFO+=("phone $aor contact $uri is $status"); fi ;;
  esac
done < <(awk '$1=="Contact:" && $2 ~ /\// && $2 !~ /^</ {split($2,a,"/"); u=substr($2,length(a[1])+2); print a[1], u, $(NF-1), $NF}' <<<"$ctc")

# 3. Endpoints: states, and phones/trunks with no contact at all
q eps endpoints "pjsip show endpoints" 'No objects found|Endpoint:'
declare -A SEEN=()
e_total=0; e_unavail=0; e_inuse=0; nocontact=()
while read -r ename estate; do
  SEEN[$ename]=1
  skip "$ename" && continue
  e_total=$((e_total + 1))
  case "$estate" in
    Unavailable) e_unavail=$((e_unavail + 1)) ;;
    "In use"|Busy|Ringing|"On Hold") e_inuse=$((e_inuse + 1)) ;;
  esac
  if [ "$estate" = Unavailable ] && [ -z "${AORSTATE[$ename]:-}" ]; then
    if [ -n "${TRUNK[$ename]:-}" ]; then add CRIT "trunk $ename is Unavailable (no reachable contact)"; else nocontact+=("$ename"); fi
  fi
done < <(awk '$1=="Endpoint:" && $2 !~ /^<Endpoint/ && NF>=6 {split($2,a,"/"); st=$3; for(i=4;i<=NF-3;i++) st=st" "$i; print a[1], st}' <<<"$eps")
[ "${#nocontact[@]}" -gt 0 ] && {
  msg="${#nocontact[@]} phone(s) not registered: $(printf '%s ' "${nocontact[@]:0:15}")"; [ "${#nocontact[@]}" -gt 15 ] && msg+="..."
  if [ "$PHONES_WARN" = 1 ]; then add WARN "$msg"; else INFO+=("$msg"); fi
}

# Required trunks (only judged when the endpoint list itself could be read)
if [ -n "$REQUIRE" ] && [ -n "$eps" ]; then
  IFS=',' read -ra req <<<"$REQUIRE"
  for t in "${req[@]}"; do t=${t// /}; [ -n "$t" ] && [ -z "${SEEN[$t]:-}" ] && add CRIT "required trunk $t is not configured (no such endpoint)"; done
fi

# 4. Calls in progress
q chancount "active calls" "core show channels count" 'active call'
calls=$(awk '/active call/{print $1+0}' <<<"$chancount")
calls=${calls:-0}
if [ "$MAX_CALLS" -gt 0 ] && [ "$calls" -gt "$MAX_CALLS" ]; then add WARN "$calls active calls (limit $MAX_CALLS): check for toll fraud or a call loop"; fi
[ "$uptime_s" -lt 300 ] && add WARN "Asterisk restarted $uptime_s seconds ago"

label=(OK WARNING CRITICAL UNKNOWN)
host=$(hostname -f 2>/dev/null || hostname)
summary="${label[$STATUS]}: registrations $reg_ok/$reg_total up, contacts $c_avail/$c_total reachable, endpoints $e_total ($e_unavail unavailable, $e_inuse busy), $calls active call(s)"

jstr() { local s=${1//\\/\\\\}; s=${s//\"/\\\"}; s=${s//$'\t'/ }; printf '"%s"' "${s//$'\n'/ }"; }
json() {
  printf '{"host":%s,"asterisk":%s,"status":%s,"uptime_seconds":%s,"last_reload_seconds":%s,' "$(jstr "$host")" "$(jstr "$ver")" "$(jstr "${label[$STATUS]}")" "${uptime_s:-0}" "${reload_s:-0}"
  printf '"registrations":{"total":%s,"registered":%s},"contacts":{"total":%s,"reachable":%s},' "$reg_total" "$reg_ok" "$c_total" "$c_avail"
  printf '"endpoints":{"total":%s,"unavailable":%s,"busy":%s},"active_calls":%s,"problems":[' "$e_total" "$e_unavail" "$e_inuse" "$calls"
  local i; for i in "${!PROBLEMS[@]}"; do [ "$i" -gt 0 ] && printf ','; jstr "${PROBLEMS[$i]}"; done
  printf '],"info":['; for i in "${!INFO[@]}"; do [ "$i" -gt 0 ] && printf ','; jstr "${INFO[$i]}"; done
  printf '],"text":%s}\n' "$(jstr "$host $summary")"
}
report() {
  echo "PJSIP health on $host (Asterisk $ver, up $((uptime_s / 86400))d $(((uptime_s % 86400) / 3600))h)"
  local p; for p in "${PROBLEMS[@]}"; do echo "  $p"; done
  if [ "$QUIET" = 0 ]; then for p in "${INFO[@]}"; do echo "  info $p"; done; fi
  echo "$summary"
}

if [ "$JSON" = 1 ]; then json; elif [ "$QUIET" = 0 ] || [ "$STATUS" -gt 0 ]; then report; fi

# Notifications. With --notify-on-change the state is saved per channel and only after that channel delivered,
# so a failed mail or webhook is retried on the next run.
deliver() { # channel
  case "$1" in
    mail) command -v mail >/dev/null 2>&1 || { echo "pjsip-health-check: 'mail' command not found, cannot e-mail $MAIL_TO" >&2; return 1; }
          report | mail -s "[PJSIP] ${label[$STATUS]} on $host" "$MAIL_TO" ;;
    webhook) json | curl -fsS -m 15 -H 'Content-Type: application/json' --data-binary @- "$WEBHOOK" >/dev/null ;;
  esac
}
if [ -n "$MAIL_TO$WEBHOOK" ]; then
  sig=$(printf '%s\n' "${label[$STATUS]}" "${PROBLEMS[@]}" | sed -E 's/round-trip [0-9.]+ ms/round-trip/' | sha256sum | cut -c1-16)
  [ "$NOTIFY_CHANGE" = 1 ] && { mkdir -p "$STATE_DIR" 2>/dev/null && chmod 700 "$STATE_DIR" 2>/dev/null; }
  for ch in mail webhook; do
    [ "$ch" = mail ] && [ -z "$MAIL_TO" ] && continue
    [ "$ch" = webhook ] && [ -z "$WEBHOOK" ] && continue
    send=1
    if [ "$NOTIFY_CHANGE" = 1 ]; then
      old=$(cat "$STATE_DIR/last.$ch" 2>/dev/null || true)
      [ "$sig" = "$old" ] && send=0
      [ -z "$old" ] && [ "$STATUS" = 0 ] && { send=0; echo "$sig" > "$STATE_DIR/last.$ch" 2>/dev/null; }  # first run and healthy
    elif [ "$STATUS" = 0 ]; then send=0; fi
    [ "$send" = 1 ] || continue
    if deliver "$ch"; then
      [ "$NOTIFY_CHANGE" = 1 ] && { echo "$sig" > "$STATE_DIR/last.$ch" 2>/dev/null || echo "pjsip-health-check: cannot write $STATE_DIR/last.$ch" >&2; }
    else echo "pjsip-health-check: $ch delivery failed; it will be retried on the next run" >&2; fi
  done
fi
exit "$STATUS"
Version 1.0.1 · SHA-256 b388b3c9fbd4871542fded1b647c8b717af5b3bbf3c0dd08f5d72c1e19d38d33
Download and verify on Linux or macOS
curl -fsSL -o pjsip-health-check.sh https://scr.srvscripts.com/pjsip-health-check/pjsip-health-check.sh && curl -fsSL https://scr.srvscripts.com/pjsip-health-check/pjsip-health-check.sh.sha256 | sha256sum -c
Download and verify in Windows PowerShell
Invoke-WebRequest -Uri 'https://scr.srvscripts.com/pjsip-health-check/pjsip-health-check.sh' -OutFile 'pjsip-health-check.sh'; if ((Get-FileHash 'pjsip-health-check.sh' -Algorithm SHA256).Hash -eq 'B388B3C9FBD4871542FDED1B647C8B717AF5B3BBF3C0DD08F5D72C1E19D38D33') { 'OK: the file is intact' } else { 'MISMATCH: do not run this file' }
Copy the whole line. In Windows PowerShell, curl and sha256sum are not the Linux tools, so use the PowerShell line there.
Also on GitHub: github.com/srvscripts/scripts

Frequently asked questions

How do I check if my Asterisk trunk is registered?

Run asterisk -rx "pjsip show registrations". A working trunk shows Registered. This script runs that command for you, plus the contact and endpoint checks, and turns the result into one status line and an exit code.

Does the script change anything on the PBX?

No. It only runs Asterisk show commands. It does not reload Asterisk, edit configuration or write to the database.

Why are my phones listed but the status is still OK?

Desk phones are often switched off outside office hours, so unreachable phones are listed for information only. Add --phones-warn if every phone should always be online.

Can it alert me only once instead of every five minutes?

Yes. Use --notify-on-change with --mail or --webhook. It remembers the last set of problems and only sends a new alert when that set changes or clears.

Does it work with FreePBX 16 and VitalPBX?

Yes, with any Asterisk 18 or newer that uses chan_pjsip. It reads Asterisk directly, so it does not depend on the GUI. It was tested on FreePBX 17 with Asterisk 22.

Share this script

Changelog

  • 1.0.1 — A failed or malformed CLI query is UNKNOWN (exit 3); 1.0.0 could report OK with zero objects. Endpoints without a caller ID are counted. New --require-trunk. Alert state saved per channel only after delivery. Found in an independent review (V17-03).
  • 1.0.0 — Initial release

Free website test

Is your website set up right?

Check SSL, security headers, redirects, robots.txt, sitemap, llms.txt and security.txt in one test. It takes about 30 seconds.