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.