Skip to content

VoIP & Telephony

The VoIP & Telephony module (id: voip, v1.0.0) delivers two complementary capabilities under one interface:

  • Phone fleet management - discover, onboard, provision, reboot, factory-reset, and monitor SIP desk phones; push SIP credentials; track firmware compliance across the fleet.
  • PBX integration - connect to a FreePBX or Asterisk PBX and manage extensions, trunks, ring groups, queues, IVR menus, DIDs, active calls, and call-detail records (CDR) without touching the PBX admin panel.

All API paths in this module are prefixed with /api/v1/voip.


Adapter Transport Notes
Grandstream CGI/HTTP (challenge-response; CookieJar(unsafe=True) for IP URLs) Phone lifecycle, provisioning, live-status, reboot, factory-reset, push-SIP
FreePBX / Asterisk AMI + ARI + REST/OAuth2 Full PBX management; Asterisk-only (no FreeSWITCH, 3CX, or other PBX types)
Yealink HTTP Discovery probe + provisioning XML generation (_generate_yealink_xml) - reboot, factory-reset, live-status, and push-SIP-config are Grandstream-only

Discovery also detects Polycom phones by MAC OUI, but no adapter ships for Polycom.


Permission code What it gates Effective minimum role
voip.view Read phones, templates, fleet, PBX, voicemail viewer
voip.manage_phones Add/edit/provision phones; PBX create/update/delete; trunk/extension writes; discovery scans; template CRUD operator
voip.manage_extensions Create/delete ring groups operator
voip.manage_pbx Declared; gates nav visibility only - PBX writes actually require voip.manage_phones operator
voip.view_calls CDR search and active-call list viewer
voip.discovery Declared; discovery write endpoints actually require voip.manage_phones operator

Two fleet operations are role-gated regardless of permissions:

Operation Minimum role
POST /fleet/bulk/reboot site_admin
POST /fleet/bulk/firmware site_admin

Phones move through a defined lifecycle. You cannot skip states.

discovered → onboarding → managed → maintenance → firmware_updating → decommissioned

Status values (real-time): online, offline, ringing, in_call, dnd.

Provision status: pending, generated, pushed, applied, failed, stale.

Before you can reboot, factory-reset, push SIP config, or read live status, a phone needs saved admin web credentials. Use the per-phone test-connection endpoint with save_credentials: true - this is the only endpoint that persists credentials to the phone record.

Terminal window
# Test connection and save credentials (phone record must already exist)
curl -s -X POST https://<freesdn>/api/v1/voip/phones/<phone_id>/test-connection \
-H "Cookie: freesdn_access=<token>" \
-H "X-CSRF-Token: <csrf>" \
-H "Content-Type: application/json" \
-d '{
"ip_address": "10.0.1.101",
"username": "admin",
"password": "<web-admin-password>",
"save_credentials": true
}'

To probe a phone before creating a record, use POST /phones/test-connection (no phone_id in the path). That ad-hoc endpoint accepts the same body fields (ip_address, username, password) but ignores save_credentials - it runs the probe and returns without persisting anything. To actually save credentials, you must use the per-phone endpoint above (/{phone_id}/test-connection) with the phone ID in the URL path.

If you skip credential saving, reboot/factory-reset/live-status/push-SIP return:

Phone has no saved admin credentials - run Test Phone Connection with 'Save credentials' enabled first
Method Path Purpose
GET /phones/ List phones; filters: site_id, pbx_id, status, lifecycle_state, vendor, config_template_id, search; limit 1-500 (default 50)
GET /phones/stats Count phones by status, lifecycle, vendor
GET /phones/{phone_id} Single phone enriched with extension + PBX name
POST /phones/ Create phone record
PATCH /phones/{phone_id} Update phone; triggers device-registry sync
DELETE /phones/{phone_id} Delete phone (204); removes from device registry
POST /phones/{phone_id}/onboard Promote discovered → managed; assign PBX, extension, template, location, tags
POST /phones/{phone_id}/migrate Move phone to another site (target_site_id, dry_run)
POST /phones/{phone_id}/reboot Reboot via Grandstream adapter (202) - Grandstream only
POST /phones/{phone_id}/factory-reset Factory-reset, wipes all config - Grandstream only
GET /phones/{phone_id}/live-status Quick probe: phone_state, line_status, lockout, ts (~150-300 ms) - Grandstream only
POST /phones/{phone_id}/push-sip-config Push extension SIP creds to phone - Grandstream only
POST /phones/{phone_id}/provision Generate and push provisioning XML; records provision action
GET /phones/{phone_id}/config-preview Preview generated provisioning XML (application/xml)
POST /phones/{phone_id}/test-connection Full probe + login + optional credential save
POST /phones/test-connection Ad-hoc connection test by IP (no existing phone record)
PUT /phones/{phone_id}/credentials Save credentials for a phone
POST /phones/{phone_id}/decommission Move phone to decommissioned lifecycle state
POST /phones/{phone_id}/maintenance Toggle maintenance mode
POST /phones/auto-link Auto-link discovered phones to FreePBX extensions by matching SIP registrar IP and user ID

Config generation (provisioning.py) writes XML to /data/provisioning/voip (maximum 128 KB per file). Vendor dispatch:

Vendor value Builder
grandstream _generate_grandstream_xml - full GDMS-style XML
yealink _generate_yealink_xml - template-based XML
anything else _generate_generic_xml - minimal fallback

Templates (ConfigTemplateCreate) carry: sip_settings, network_settings, provisioning_settings, feature_settings, line_key_settings (list), raw_overrides, firmware_version. Set is_default: true to apply a template automatically during discovery onboarding.

Phones pull their config at boot from:

GET /api/v1/voip/provisioning/cfg{mac_address}.xml

This endpoint has no cookie or JWT auth - the phone has no session. Instead it uses a two-factor device-auth scheme:

  1. Source-IP allowlist (LAN path) - the phone’s source IP (resolved from request.client.host, never from X-Forwarded-For) must fall inside one of the CIDRs listed in Site.subnets. This is the zero-touch path for phones on the managed LAN.
  2. HMAC fallback - if the IP is outside all site subnets, the request must carry ?sig=<hex> (URL parameter) or X-Provisioning-Signature header equal to HMAC-SHA256(SECRET_KEY + ENCRYPTION_SALT, mac.lower()), constant-time compared.

Any failure returns a generic 404 (No config for this device) - the error is the same whether the MAC is unknown or the auth check failed, so an attacker cannot enumerate known phones.

Generate a signed URL for a phone from the onboarding flow using generate_provisioning_signature(mac).

Set provisioning_base_url in Settings → VoIP (e.g., http://freesdn:8000/api/v1/voip/provisioning) so the module can build the correct DHCP option-66 value.

Run a network scan to find phones without manual registration.

Terminal window
# Trigger an async discovery scan (returns 202 + scan_id)
curl -s -X POST https://<freesdn>/api/v1/voip/discovery/scan \
-H "Cookie: freesdn_access=<token>" \
-H "X-CSRF-Token: <csrf>" \
-H "Content-Type: application/json" \
-d '{
"scan_type": "full",
"subnet": "10.0.1.0/24",
"auto_onboard": false,
"credentials": {"username": "admin", "password": "<default-pw>"}
}'
Method Path Purpose
POST /discovery/scan Start async Celery scan (202); credentials are transient and encrypted - never persisted
GET /discovery/scans List scan history
GET /discovery/scans/{scan_id} Full scan results
GET /discovery/scans/{scan_id}/status Lightweight progress poll (phase, percent, last 20 log lines)
POST /discovery/scans/{scan_id}/cancel Cancel running scan (best-effort SIGTERM via Celery revoke)
DELETE /discovery/scans/{scan_id} Delete completed/failed/cancelled scan (204)

scan_type values: full (all probes), arp, sip, http.

Vendor detection uses MAC OUI prefixes (Grandstream 000b82, c074ad, 7c2f80; Yealink 805ec0, 001565, 805e4a; Polycom 0004f2, 64167f) and HTTP fingerprint paths. Scan credentials are passed transiently and encrypted; they are not written to the database.

Method Path Purpose Auth
GET /fleet/dashboard Fleet metrics: counts by lifecycle, vendor, model, firmware, SIP-registered voip.view
POST /fleet/bulk/reboot Reboot 1-200 phones; all IDs validated org-wide in a single query - rejects the whole batch if any ID is foreign role site_admin
POST /fleet/bulk/provision Generate provisioning configs for a selection voip.manage_phones
POST /fleet/bulk/firmware Schedule bulk firmware upgrade (irreversible; supply target_version, schedule_at) role site_admin
POST /fleet/bulk/connect Bulk credential set + probe + persist for a list of phones voip.manage_phones
Method Path Purpose
GET /firmware/ List registered firmware versions; filter by vendor, model
POST /firmware/ Register a firmware version (201); download_url is SSRF-validated
GET /firmware/compliance Fleet compliance report - phones not on a tracked stable version

FirmwareTrackCreate requires vendor, model, version. Optional fields: release_date, changelog (max 10,000 chars), download_url (max 500 chars, SSRF-validated on creation), file_checksum (hex ≤128 chars), file_size_bytes (≤4 GB), is_stable, is_recommended.


Templates define the provisioning XML structure applied to phones of a given vendor and model.

Method Path Purpose
GET /templates/ List templates with phone_count per template (single GROUP BY, no N+1)
GET /templates/{template_id} Get one template
POST /templates/ Create template (201)
PATCH /templates/{template_id} Update template
DELETE /templates/{template_id} Delete template (204)

ConfigTemplateCreate fields: name, vendor (required), model_pattern (regex match against phone model string), is_default, plus JSONB blobs: sip_settings, network_settings, provisioning_settings, feature_settings, line_key_settings (list), raw_overrides, firmware_version.


The FreePBX adapter uses OAuth2 + GraphQL when api_client_id and api_client_secret are set (FreePBX 16+ Admin API). Without them it falls back to web-session + legacy AJAX.

Terminal window
# Register a PBX system
curl -s -X POST https://<freesdn>/api/v1/voip/pbx/ \
-H "Cookie: freesdn_access=<token>" \
-H "X-CSRF-Token: <csrf>" \
-H "Content-Type: application/json" \
-d '{
"name": "pbx-main",
"ip_address": "10.0.0.5",
"pbx_type": "freepbx",
"api_port": 443,
"api_username": "admin",
"api_password": "<pass>",
"tls_verify_disabled_acknowledged": false
}'

tls_verify_disabled_acknowledged must be explicitly set to true if you want to skip TLS verification. Do not set it unless you have a specific reason; leave it false (the default) to enforce certificate validation.

After registering, run a full sync to pull extensions, ring groups, trunks, queues, IVR menus, and DIDs into the FreeSDN database:

Terminal window
# Kick off background sync (202); watch WS events on pbx.sync.*
curl -s -X POST https://<freesdn>/api/v1/voip/pbx/<pbx_id>/sync \
-H "Cookie: freesdn_access=<token>" \
-H "X-CSRF-Token: <csrf>"

WebSocket sync stages (subscribe to pbx.sync.* filtered by adapter_id='pbx:<pbx_id>'): connectingextensionsring_groupslive_datapersistingdone

Method Path Purpose
GET /pbx/ List PBX systems (credentials stripped)
GET /pbx/{pbx_id} Get one PBX (sanitized)
POST /pbx/ Create PBX (201)
PATCH /pbx/{pbx_id} Update PBX
DELETE /pbx/{pbx_id} Delete PBX (204)
POST /pbx/test-connection Test connectivity to a PBX before saving
POST /pbx/{pbx_id}/sync Full background sync (202); returns task_id
POST /pbx/{pbx_id}/connect Full connection test using the adapter
GET /pbx/{pbx_id}/dashboard Composite dashboard (DB counts + live AMI/ARI/REST status)
GET /pbx/{pbx_id}/system-info Real-time system info via adapter

PBXDashboard shows: ami_connected, ari_connected, rest_available, active_calls, voicemail counts.

PBX inventory reads (live, adapter-backed)

Section titled “PBX inventory reads (live, adapter-backed)”

All reads require voip.view unless noted. All adapter responses are sanitized: *_password_enc columns dropped, redact_secrets (camelCase-aware) applied, and URLs in error messages stripped before surfacing.

Method Path Purpose
GET /pbx/{pbx_id}/extensions Extensions for this PBX with bound phone info
GET /pbx/{pbx_id}/trunks SIP trunks (secret/auth_password redacted)
GET /pbx/{pbx_id}/trunks/{trunk_id} Single trunk detail
GET /pbx/{pbx_id}/queues Call queues
GET /pbx/{pbx_id}/ivrs IVR menus
GET /pbx/{pbx_id}/dids DIDs / inbound routes
GET /pbx/{pbx_id}/active-calls Real-time active calls - requires voip.view_calls
GET /pbx/{pbx_id}/voicemail-boxes Voicemail boxes
GET /pbx/{pbx_id}/config Full synced config snapshot (redacted)
GET /pbx/{pbx_id}/outbound-routes Outbound routes
GET /pbx/{pbx_id}/ring-groups Ring groups for this PBX
GET /pbx/{pbx_id}/call-logs Live CDR via adapter - requires voip.view_calls; params: start_date, end_date, src (≤64), dst (≤64), limit 1-1000
GET /pbx/{pbx_id}/followme Follow-Me entries
GET /pbx/{pbx_id}/announcements Announcements
GET /pbx/{pbx_id}/paging Paging/intercom groups
GET /pbx/{pbx_id}/daynight Day/night call-flow controls
GET /pbx/{pbx_id}/blacklist Blacklisted numbers
GET /pbx/{pbx_id}/certificates SSL/TLS certificates
GET /pbx/{pbx_id}/admin-users FreePBX AMP admin users (redacted)
Method Path Purpose
GET /pbx/{pbx_id}/extensions/{ext_number} Single extension (ext_number: digits, 1-20 chars)
POST /pbx/{pbx_id}/extensions Create extension (201)
PATCH /pbx/{pbx_id}/extensions/{ext_number} Update extension
DELETE /pbx/{pbx_id}/extensions/{ext_number} Delete extension (204)
GET /extensions/ List extensions across all PBX systems; filter by site_id

ExtensionCreate fields - required: extension_number (digits, 1-20 chars), display_name (1-255 chars); optional: caller_id_name, caller_id_number, voicemail_enabled (default true), voicemail_pin (4-10 digits), password (8-128 chars, default null), settings.

Method Path Purpose
POST /pbx/{pbx_id}/trunks Create SIP trunk (201)
PATCH /pbx/{pbx_id}/trunks/{trunk_id} Update SIP trunk
DELETE /pbx/{pbx_id}/trunks/{trunk_id} Delete SIP trunk (204)

Trunk bodies are free-form dicts (PJSIP/chan_sip options vary too much to enumerate). Validation enforces: ≤128 keys, ≤4,096 chars per string value, ≤64 KB total body size.

Method Path Purpose
GET /ring-groups/ List ring groups; filter by pbx_id, site_id
POST /ring-groups/ Create ring group (201) - requires voip.manage_extensions
DELETE /ring-groups/{ring_group_id} Delete ring group (204) - requires voip.manage_extensions

RingGroupCreate fields: pbx_id, group_number (digits, 1-20 chars), name, ring_strategy (ringall, ringall-prim, hunt, memoryhunt, firstavail, firstnotonphone, random, rrmemory, rrordered; default ringall), ring_time 1-300 s (default 20), members (list of extension numbers), settings.

Method Path Body Purpose
POST /pbx/{pbx_id}/call/originate {extension, destination, caller_id, context} Originate outbound call; context must match ^from-internal(-additional)?$
POST /pbx/{pbx_id}/call/hangup {channel} Hang up an active channel
POST /pbx/{pbx_id}/call/transfer {channel, destination, context} Transfer active call to another extension
POST /pbx/{pbx_id}/reload - Apply pending config (equivalent to FreePBX “Apply Config”; briefly interrupts active calls)
POST /pbx/{pbx_id}/queue/add-member {queue_name, interface, member_name} Add member to a call queue
POST /pbx/{pbx_id}/queue/remove-member {queue_name, interface, member_name} Remove member from a call queue

queue_name must match ^[\w\-]+$. The FreePBX adapter enforces allowed_outbound_prefixes when originating calls.


The module exposes two distinct CDR sources that can diverge:

Endpoint Source Notes
GET /call-logs/ DB-stored call_logs table Populated by background sync; filters: pbx_id, start_time, end_time, direction, call_status, caller, callee, site_id; limit 1-1,000
GET /call-logs/stats DB-stored call_logs table Aggregate stats for a time range
GET /pbx/{pbx_id}/call-logs Live adapter CDR Real-time query to FreePBX; result may differ from DB until next sync

Call direction values: inbound, outbound, internal.
Call status values: answered, missed, voicemail, failed.

Both CDR endpoints require voip.view_calls.


Method Path Purpose
GET /voicemails/ List voicemail messages; filter by extension_number, folder, is_read, site_id
GET /voicemails/stats Voicemail statistics
GET /voicemails/{vm_id} Get single voicemail record
PATCH /voicemails/{vm_id} Mark read / move to folder
POST /voicemails/{vm_id}/mark-read Mark as read
DELETE /voicemails/{vm_id} Delete voicemail record (204)
GET /voicemails/{vm_id}/download 501 Not Implemented - see below

Configure in Settings → VoIP in the UI, or via the module settings API.

Setting Default Notes
default_codec g711u One of g711u, g711a, g722, g729, opus
sip_port 5060 SIP signaling port
rtp_port_start 10000 RTP media port range start
rtp_port_end 20000 RTP media port range end
cdr_retention_days 90 30-365; CDR pruned beyond this
discovery_default_subnet "" Pre-fill in the Discovery UI
provisioning_base_url "" E.g. http://freesdn:8000/api/v1/voip/provisioning; used to build DHCP option-66 values
auto_provision_on_discovery false Auto-push provisioning config when a phone is discovered
health_check_interval 300 Seconds between phone health polls (60-3600)

The provisioning auth HMAC key derives from SECRET_KEY + ENCRYPTION_SALT. Both must be set in your environment (see Deployment).


The module registers these Celery tasks on startup:

Task name Purpose
voip.sync_phones Sync phone state from adapters
voip.sync_cdr Pull CDR from PBX into DB
voip.sync_extensions Sync extensions from PBX
voip.generate_provisioning_files Regenerate stale provisioning XML
voip.poll_extension_states Poll live extension registration status
voip.reboot_phone Async single-phone reboot
voip.run_discovery_scan Execute a discovery scan scan (credentials transient, never persisted)
voip.health_check Module health probe
voip.check_firmware_compliance Compare phone firmware against tracked stable versions
voip.bulk_reboot Coordinate fleet-wide reboot
voip.sync_pbx_full Full PBX sync backing POST /pbx/{id}/sync

The VoIP module exposes one read operation in the Fabric catalog:

  • voip.phone.live_status - NATIVE tier, input phone_id, permission voip.view

Emitted events (subscribable via Fabric Connections): pbx.sync.started, pbx.sync.progress, pbx.sync.completed, pbx.sync.failed, pbx.originate_call.ok, pbx.originate_call.failed, pbx.reload.ok, pbx.reload.failed, phone.provision.ok, phone.provision.failed, phone.reboot.ok, phone.reboot.failed.


The Configuration Backup module captures VoIP configuration automatically:

  • Included: PBX records, extensions, ring groups, config templates
  • Excluded: all credentials - phone web passwords, PBX API credentials, SIP trunk secrets

This is intentional; credentials in a portable archive are a credential-exfil risk. Restore a backup and then re-enter credentials.


  • Credential storage: phone admin web passwords are Fernet-encrypted at rest. PBX API and AMI/ARI credentials are also encrypted. All adapter responses run through redact_secrets (89 sensitive key patterns, camelCase-aware) before returning to the client.
  • Cross-tenant isolation: cross-tenant references (a phone, PBX, or extension belonging to another org) return 404 rather than 403 to avoid leaking foreign-row existence. Bulk operations that include any foreign phone ID are rejected with 403 for the entire batch.
  • Provisioning endpoint: returns a generic 404 for both unknown MACs and failed auth - the error does not distinguish the two cases.
  • Error URL redaction: httpx errors embed PBX URLs with credentials in the message. The API redacts these with re.sub(r"https?://\S+", ...) before surfacing errors.
  • FreePBX TLS: you must explicitly set tls_verify_disabled_acknowledged: true to skip TLS verification. The default is false (verification on).
  • Outbound dialing: the FreePBX adapter enforces allowed_outbound_prefixes on call-originate requests as a toll-fraud guard.

Page Route Notes
Fleet dashboard /voip Default landing; fleet metrics and health at a glance
Phone list /voip/phones Filters, bulk connect/reboot/provision
Phone detail /voip/phones/:id Test connection, push SIP, reboot/factory-reset, live status, config preview
Discovery /voip/discovery Trigger and monitor scans; onboard results
Config templates /voip/templates Template CRUD
Firmware tracking /voip/firmware Stub page - use the API for now
PBX list /voip/pbx PBX system list
PBX detail /voip/pbx/:id 11 tabs: Overview, Extensions, Trunks, Ring Groups, Queues, IVR, DIDs, Active Calls, Voicemail, Config, Settings
Extensions /voip/extensions Cross-PBX extension list and ring groups
Call logs /voip/calls CDR search
Voicemail /voip/voicemail Voicemail inbox (audio download button disabled - 501)

Nav visibility is permission-gated: Discovery, Templates, and Firmware require voip.manage_phones; PBX Systems requires voip.manage_pbx; Ring Groups requires voip.manage_extensions; Call Logs and Active Calls require voip.view_calls; the rest require voip.view.


All product names, logos, and brands are property of their respective owners. FreeSDN is an independent project and is not affiliated with or endorsed by the vendors it integrates with. See Trademarks.