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.


AdapterTransportNotes
GrandstreamCGI/HTTP (challenge-response; CookieJar(unsafe=True) for IP URLs)Phone lifecycle, provisioning, live-status, reboot, factory-reset, push-SIP
FreePBX / AsteriskAMI + ARI + REST/OAuth2Full PBX management; Asterisk-only (no FreeSWITCH, 3CX, or other PBX types)
YealinkHTTPDiscovery 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 codeWhat it gatesEffective minimum role
voip.viewRead phones, templates, fleet, PBX, voicemailviewer
voip.manage_phonesAdd/edit/provision phones; PBX create/update/delete; trunk/extension writes; discovery scans; template CRUDoperator
voip.manage_extensionsCreate/delete ring groupsoperator
voip.manage_pbxDeclared; gates nav visibility only - PBX writes actually require voip.manage_phonesoperator
voip.view_callsCDR search and active-call listviewer
voip.discoveryDeclared; discovery write endpoints actually require voip.manage_phonesoperator

Two fleet operations are role-gated regardless of permissions:

OperationMinimum role
POST /fleet/bulk/rebootsite_admin
POST /fleet/bulk/firmwaresite_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
MethodPathPurpose
GET/phones/List phones; filters: site_id, pbx_id, status, lifecycle_state, vendor, config_template_id, search; limit 1-500 (default 50)
GET/phones/statsCount 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}/onboardPromote discovered → managed; assign PBX, extension, template, location, tags
POST/phones/{phone_id}/migrateMove phone to another site (target_site_id, dry_run)
POST/phones/{phone_id}/rebootReboot via Grandstream adapter (202) - Grandstream only
POST/phones/{phone_id}/factory-resetFactory-reset, wipes all config - Grandstream only
GET/phones/{phone_id}/live-statusQuick probe: phone_state, line_status, lockout, ts (~150-300 ms) - Grandstream only
POST/phones/{phone_id}/push-sip-configPush extension SIP creds to phone - Grandstream only
POST/phones/{phone_id}/provisionGenerate and push provisioning XML; records provision action
GET/phones/{phone_id}/config-previewPreview generated provisioning XML (application/xml)
POST/phones/{phone_id}/test-connectionFull probe + login + optional credential save
POST/phones/test-connectionAd-hoc connection test by IP (no existing phone record)
PUT/phones/{phone_id}/credentialsSave credentials for a phone
POST/phones/{phone_id}/decommissionMove phone to decommissioned lifecycle state
POST/phones/{phone_id}/maintenanceToggle maintenance mode
POST/phones/auto-linkAuto-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 valueBuilder
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>"}
}'
MethodPathPurpose
POST/discovery/scanStart async Celery scan (202); credentials are transient and encrypted - never persisted
GET/discovery/scansList scan history
GET/discovery/scans/{scan_id}Full scan results
GET/discovery/scans/{scan_id}/statusLightweight progress poll (phase, percent, last 20 log lines)
POST/discovery/scans/{scan_id}/cancelCancel 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.

MethodPathPurposeAuth
GET/fleet/dashboardFleet metrics: counts by lifecycle, vendor, model, firmware, SIP-registeredvoip.view
POST/fleet/bulk/rebootReboot 1-200 phones; all IDs validated org-wide in a single query - rejects the whole batch if any ID is foreignrole site_admin
POST/fleet/bulk/provisionGenerate provisioning configs for a selectionvoip.manage_phones
POST/fleet/bulk/firmwareSchedule bulk firmware upgrade (irreversible; supply target_version, schedule_at)role site_admin
POST/fleet/bulk/connectBulk credential set + probe + persist for a list of phonesvoip.manage_phones
MethodPathPurpose
GET/firmware/List registered firmware versions; filter by vendor, model
POST/firmware/Register a firmware version (201); download_url is SSRF-validated
GET/firmware/complianceFleet 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.

MethodPathPurpose
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

MethodPathPurpose
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-connectionTest connectivity to a PBX before saving
POST/pbx/{pbx_id}/syncFull background sync (202); returns task_id
POST/pbx/{pbx_id}/connectFull connection test using the adapter
GET/pbx/{pbx_id}/dashboardComposite dashboard (DB counts + live AMI/ARI/REST status)
GET/pbx/{pbx_id}/system-infoReal-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.

MethodPathPurpose
GET/pbx/{pbx_id}/extensionsExtensions for this PBX with bound phone info
GET/pbx/{pbx_id}/trunksSIP trunks (secret/auth_password redacted)
GET/pbx/{pbx_id}/trunks/{trunk_id}Single trunk detail
GET/pbx/{pbx_id}/queuesCall queues
GET/pbx/{pbx_id}/ivrsIVR menus
GET/pbx/{pbx_id}/didsDIDs / inbound routes
GET/pbx/{pbx_id}/active-callsReal-time active calls - requires voip.view_calls
GET/pbx/{pbx_id}/voicemail-boxesVoicemail boxes
GET/pbx/{pbx_id}/configFull synced config snapshot (redacted)
GET/pbx/{pbx_id}/outbound-routesOutbound routes
GET/pbx/{pbx_id}/ring-groupsRing groups for this PBX
GET/pbx/{pbx_id}/call-logsLive CDR via adapter - requires voip.view_calls; params: start_date, end_date, src (≤64), dst (≤64), limit 1-1000
GET/pbx/{pbx_id}/followmeFollow-Me entries
GET/pbx/{pbx_id}/announcementsAnnouncements
GET/pbx/{pbx_id}/pagingPaging/intercom groups
GET/pbx/{pbx_id}/daynightDay/night call-flow controls
GET/pbx/{pbx_id}/blacklistBlacklisted numbers
GET/pbx/{pbx_id}/certificatesSSL/TLS certificates
GET/pbx/{pbx_id}/admin-usersFreePBX AMP admin users (redacted)
MethodPathPurpose
GET/pbx/{pbx_id}/extensions/{ext_number}Single extension (ext_number: digits, 1-20 chars)
POST/pbx/{pbx_id}/extensionsCreate 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.

MethodPathPurpose
POST/pbx/{pbx_id}/trunksCreate 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.

MethodPathPurpose
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.

MethodPathBodyPurpose
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:

EndpointSourceNotes
GET /call-logs/DB-stored call_logs tablePopulated by background sync; filters: pbx_id, start_time, end_time, direction, call_status, caller, callee, site_id; limit 1-1,000
GET /call-logs/statsDB-stored call_logs tableAggregate stats for a time range
GET /pbx/{pbx_id}/call-logsLive adapter CDRReal-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.


MethodPathPurpose
GET/voicemails/List voicemail messages; filter by extension_number, folder, is_read, site_id
GET/voicemails/statsVoicemail statistics
GET/voicemails/{vm_id}Get single voicemail record
PATCH/voicemails/{vm_id}Mark read / move to folder
POST/voicemails/{vm_id}/mark-readMark as read
DELETE/voicemails/{vm_id}Delete voicemail record (204)
GET/voicemails/{vm_id}/download501 Not Implemented - see below

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

SettingDefaultNotes
default_codecg711uOne of g711u, g711a, g722, g729, opus
sip_port5060SIP signaling port
rtp_port_start10000RTP media port range start
rtp_port_end20000RTP media port range end
cdr_retention_days9030-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_discoveryfalseAuto-push provisioning config when a phone is discovered
health_check_interval300Seconds 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 namePurpose
voip.sync_phonesSync phone state from adapters
voip.sync_cdrPull CDR from PBX into DB
voip.sync_extensionsSync extensions from PBX
voip.generate_provisioning_filesRegenerate stale provisioning XML
voip.poll_extension_statesPoll live extension registration status
voip.reboot_phoneAsync single-phone reboot
voip.run_discovery_scanExecute a discovery scan scan (credentials transient, never persisted)
voip.health_checkModule health probe
voip.check_firmware_complianceCompare phone firmware against tracked stable versions
voip.bulk_rebootCoordinate fleet-wide reboot
voip.sync_pbx_fullFull 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.

PageRouteNotes
Fleet dashboard/voipDefault landing; fleet metrics and health at a glance
Phone list/voip/phonesFilters, bulk connect/reboot/provision
Phone detail/voip/phones/:idTest connection, push SIP, reboot/factory-reset, live status, config preview
Discovery/voip/discoveryTrigger and monitor scans; onboard results
Config templates/voip/templatesTemplate CRUD
Firmware tracking/voip/firmwareStub page - use the API for now
PBX list/voip/pbxPBX system list
PBX detail/voip/pbx/:id11 tabs: Overview, Extensions, Trunks, Ring Groups, Queues, IVR, DIDs, Active Calls, Voicemail, Config, Settings
Extensions/voip/extensionsCross-PBX extension list and ring groups
Call logs/voip/callsCDR search
Voicemail/voip/voicemailVoicemail 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.