Repository navigation
3CX Integration Developer Guide
How the 3CX integration works, where it lives, and the traps. The user page is 3CX.
Contributed by Carl Steynvaard (Open Resilience), October 2026, as part of a package of integrations and modules he built and ran in production on FreeITSM 3.8.1. The idea - 3CX asks who is calling, and reports each call when it ends - is his, as is the care over 3CX's unfilled [Variables]. The code was rebuilt to FreeITSM's conventions; what changed and why is worth reading before contributing an integration of your own.
3CX ββCRM templateβββΆ api/external/3cx/crm.php βββ¬β lookup β threecxLookup() who is this number?
(POST JSON, β β { contacts: [ β¦ url: tickets/caller.php?user_id=N ] }
Bearer token) ββ report β threecxHandleCall() a call has ended
threecxPlanCall() decides, then:
ββ noted β TicketsService::createNote (as the analyst)
ββ created β TicketsService::createTicket
ββ logged β nothing on a ticket
ββ always β a row in threecx_calls
3CX opens ContactUrl βββΆ tickets/caller.php βββΆ the caller's ticket (same rule as the note) or Tickets β Users
System β Integrations β 3CX (system/integrations/threecx.php) βββΆ api/system/threecx_settings.php (administrators)
Nothing calls 3CX. It is a telephony-kind provider in the registry (includes/integrations/integrations.php): one endpoint and one token, so the Integrations index shows it as Configured / Not set up, like Zabbix.
| File | What it is |
|---|---|
includes/integrations/threecx.php |
Everything: settings, the token, number matching, the lookup, threecxPlanCall() / threecxHandleCall(), the template |
includes/phone_dial.php |
Click-to-call: the dial address, phoneDialHref(), phoneWithDialHtml(). Dependency-free, so pages that only draw a Call button do not load the 3CX library |
api/external/3cx/crm.php |
The endpoint 3CX calls. POST JSON only; Bearer token; 64 KB limit |
api/external/3cx/.htaccess |
Passes Authorization through to PHP on FastCGI setups that strip it (as api/v1/.htaccess does) |
api/system/threecx_settings.php |
The settings page's API: get, save, rotate, try, export (the template). Administrators only |
system/integrations/threecx.php |
The settings page |
system/integrations/help_threecx.php |
The in-app setup guide, included by help.php
|
tickets/caller.php |
What 3CX opens while the phone rings |
threecx_calls (table) |
Every report: call_key, type, number, agent, analyst_id (the note's author), user_id (the caller), the ticket, action, detail
|
tests/threecx-integration.php |
60 checks through the real endpoint and the settings API |
Settings are system_settings rows: threecx_token (encrypted - in ENCRYPTED_SETTING_KEYS), threecx_ticket_rule (latest_open / only_one / log_only), threecx_create_ticket, threecx_create_unknown, threecx_fallback_analyst_id, threecx_tenant_id, threecx_team_id, threecx_origin_id (empty = the seeded Phone origin, 0 = none), and phone_dial_url (click-to-call - not prefixed, because it is not 3CX's).
Click-to-call is drawn on people/person.php, people/contact.php (server-side, phoneWithDialHtml()), and on Tickets β Users and the ticket's From line from links the APIs build (get_users.php β phone_dial / mobile_dial; get_email_detail.php β requester_dial). The JavaScript never reads a phone field itself, so the rules live in one place.
3CX links to a CRM through an XML template (Admin Console β Integrations β CRM). threecxTemplateXml() writes it, following 3CX's CRM template reference:
-
Parameters
UrlandTokendefault to this install's address and token, so the uploaded file is ready;ReportCallEnabledlets an administrator switch call reports off in 3CX. -
<Authentication Type="No" />plus anAuthorization: Bearer [Token]header on each request. 3CX'sBasictype base64-encodes its value, and 3CX's own forum reports Bearer underBasicfailing; a manual header underNois the pattern that works. -
The lookup scenario has an empty
Id. It must returnContactUrl(3CX's identifier for the contact, and the address it opens), one ofFirstName/LastName/CompanyName, and a phone output equal to the number searched - so the endpoint hands backphone= the number 3CX sent.EntityId/EntityTypeare returned too: some 3CX versions only journal a call that matched an entity. -
ReportCallpostscall_type([CallType]: Inbound / Outbound / Missed / Notanswered),number,agent(the extension),agent_email,agent_name,duration(seconds, from[DurationTimespan]),start([CallStartTimeUTC], ISO),recording_url,summary. -
<Number Prefix="AsIs" MaxLength="[MaxLength]">gives FreeITSM the number cut to the digits set in 3CX (Phonebook β number of digits to match), which the matcher reads either way. - The template's
Nameis plainFreeITSM: a forum report has 3CX refusing an upload over characters in the name.
export is administrators-only and sends Cache-Control: no-store.
threecxNumbersMatch(): the number part of each field (phoneNumberPart() - everything before the first letter, so ext 21 is dropped), digits only, leading zeros dropped, then one must end with the other, the shorter at least 7 digits. So national, international, 00-prefixed and 3CX's cut-down form all agree, and an extension never matches.
messagingFindUsersByPhone() (WhatsApp, Telegram), which wants the digits equal. A phone system sends whatever its trunk gives it, and equality would miss most real callers. The cost: one national number could in principle match the same digits in another country. That comes out as two matches - and two matches are never chosen between.
threecxFindCallers() searches every company (one phone system answers for all of them), active people only, never the @phone.invalid placeholders.
threecxPlanCall() decides; threecxHandleCall() carries it out. The settings page's Try a call shows the plan and writes nothing, so what an administrator tries is the real decision.
- The caller - exactly one match, or none.
-
The ticket -
threecxTicketFor(), by the rule.tickets/caller.phpuses the same function, so the screen that pops is the ticket the call will be noted on. -
The author - the active analyst whose email is
agent_email(case-insensitive, and only if exactly one has it), else the fallback analyst. -
noted (a ticket and an author) β an internal note through
TicketsService::createNote(), with anActorContextfor that analyst andcompanyScope: null- the call is not limited to the companies the analyst can open. -
created - no open ticket at all, a call in (Inbound or Missed), the rule not
log_only, and new tickets ticked (and unknown numbers too, for a stranger) βTicketsService::createTicket()withuser_id(a known caller, in their own company) or a<digits>@phone.invalidrequester (an unknown one, in the configured company); assigned to the agent if they are an analyst - never to the fallback, who only signs notes. - Otherwise logged, with the reason in
detail.
3CX gives a template no call id, so call_key is a hash of number, start, agent, duration and type, unique in threecx_calls. The same report twice (3CX retrying) is recorded once; a GET_LOCK on the key serialises two arriving together.
-
TRAP: a call note needs a real analyst.
ticket_notes.analyst_idis a foreign key toanalysts. The contributed version wrote notes asActorContext::system()- actor id 0. On an install with the key (every fresh one) the note is refused and the call fails; on an older install that never got the key, it is saved with no author. Proven by mutation: with the system actor swapped back in,tests/threecx-integration.phpfails on exactly the two authorship checks. Hence the agent email match and the fallback analyst. - Never guess between two people, or two tickets. A shared number is nobody; only one with several open tickets is none.
- A lookup says nothing about tickets. The contributed version returned open ticket subjects to anything holding the token. 3CX shows a name - that is all it gets.
-
3CX sends a variable it has no value for as its own name (
[RecordingUrl]). The endpoint blanks any value that is exactly[Word]- Carl's catch. -
Everything from 3CX is text. Notes are shown escaped; a recording "link" that is not
http(s)is dropped. -
Authorizationcan be stripped before PHP sees it on Apache + FastCGI.api/external/3cx/.htaccesshands it through, andcrm.phpreadsgetallheaders(), thenHTTP_AUTHORIZATION, thenREDIRECT_HTTP_AUTHORIZATION- asapi/v1does. The body may carrytokeninstead, for a client that cannot set headers. -
The dial address is admin text that becomes a link.
phoneDialTemplateValid()allows onlyhttps://,tel:,callto:andsip:with{number}; only digits and a leading+reach{number}. -
The endpoint is on
CSRF_EXEMPT_PATHS(api/external/): 3CX is cross-origin and carries no login cookie. The token is the credential, compared withhash_equals; no token made means everything is refused.
| The contribution | Here | Why |
|---|---|---|
| Notes written as the system actor (id 0) | The analyst who took the call, or a chosen fallback | The ticket_notes foreign key refuses id 0 (see the trap) |
The token in the address (?token=β¦), GET requests |
A Bearer header, POST only | An address ends up in web server logs, proxies and - for the screen-pop - the analyst's browser history |
A popup action that redirected with the token in the URL |
ContactUrl = tickets/caller.php, an ordinary signed-in page |
3CX opens the contact's address itself; nothing secret needs to be in it |
| The lookup returned open ticket subjects | Name, email, company, phones, address | Least exposure to whoever holds the token |
| First match on a shared number; always the latest open ticket | No guess; the ticket rule is a setting | Ed's call: where opinions differ, a setting |
| A SalesHub CRM fallback that created requesters from CRM contacts | Removed | A table only that install had |
Tickets in "the first company"; origin_id = 2
|
The caller's company (or a setting); the Phone origin by name, or a setting | Multi-company installs; ids differ between installs |
Unknown callers as unknown-caller@example.com
|
<digits>@phone.invalid, one per number |
example.com is a real domain to a mail server; one placeholder for every stranger merges them |
| Last 9 digits | Suffix match on the number part, at least 7 | 9 suits South African numbers; 3CX can cut to its own setting |
Settings, call log and a sync log in SQLite at /var/www/data
|
system_settings and threecx_calls
|
Outside backups and Database Verification; the token readable |
| Its own admin check, CSRF token and English |
admin_api_guard.php, the app's CSRF, t()
|
One implementation of each |
Installed with register-patch.php
|
A registry entry | A patch that edits upstream files breaks on every upgrade |
| A nightly staff sync (3CX extension β analyst phone, via the 3CX API) | Not taken | Not needed: 3CX sends the agent's email, and click-to-call runs in the analyst's own 3CX. It needs an 8SC licence and an API key - it can come later |
php tests/threecx-integration.php
60 checks through the real endpoint and settings API, with requests shaped as the template sends them: the token (none made, wrong, absent, in the address), lookups (national vs international vs last-9, a shared number, an extension, no ticket details), reports (a new ticket assigned to the agent, the next call noted by them, a duplicate, a bad recording link, a call out, a non-analyst with and without a fallback, all three rules, a shared number, unknown numbers), the template (well-formed, token only in headers, the outputs 3CX requires, XML characters escaped), click-to-call, the settings API (admins only, the download, a GET rotate refused, save and a refused dial address), and Try a call writes nothing. It makes two throwaway analysts with .invalid addresses so no real analyst is assigned or notified, pauses the ticket.created workflows, and removes everything at the end.
What it cannot prove: there is no 3CX here - it does not run in Docker the way Zabbix does. The template follows 3CX's reference and templates others have posted, and its request shape is exactly what the tests send; the first real upload is the proof that 3CX accepts it.
Related: 3CX Β· Zabbix β Developer Guide Β· CSRF protection β Developer Guide Β· Developer Tests
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
-
Architecture
- β³ π§Ή Root folder tidy (URL routing)
- β³ π Database Integrity (FKs & constraints)
- β³ π οΈ Database Verification β Developer Guide
- β³ π§© Service Layer (UI + API share code)
- β³ πͺ€ Code traps (the TRAP: comments)
- β³ π Commenting for the Engineering wiki
- β³ π οΈ Engineering wiki scanner - Developer Guide
- β³ π Service-Layer progress
- π€ Contributing (read before a pull request)
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- π± Currencies
- π§ The sidebar (optional module rail)
- π§© Preference templates
- π Display order (modules, Watchtower cards) β Developer Guide
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: Projects
- β³ ποΈ Mobile: Files
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π Proposal: custom fields, inventory import and change evidence (#194)
- β³ π οΈ Custom fields, evidence + inventory - review (#195-#197)
- β³ π οΈ Long forms in a task's Work view - review (#198)
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ π§βπΌ REST API: Analysts
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π§Ύ Custom ticket fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ π Attachment size limits and large emails
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Case reports - a ticket and its work as one PDF
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Leases
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π§© Asset type layouts
- β³ π Inventory freshness
- β³ π Connected assets
- β³ π Asset review
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- ποΈ Files
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- π‘ Zabbix (monitoring)
- π¬ Microsoft Teams alerts
- π 3CX (phone system)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ π A failed DNS lookup read as a missing record
- β³ π A link could change data
- β³ π Access outlived its removal
- β³ π Knowledge articles could run scripts
- β³ π Knowledge writes ignored permissions
- β³ π Company boundaries (Oct 2026)
- β³ π Smaller gaps (Oct 2026)
- β³ π Escaped text could leave its attribute
- β³ π Task descriptions were not cleaned
- β³ π Task documents ignored company scope
- β³ π Repeating tasks ignored company scope
- β³ π¬ #183 Mailbox checks overlapped
- β³ π #188 Transfer lock shown off when unknown
- β³ β #184 Subtask names and Overdue
- β³ π§© #186 Teams: the buttons did nothing
- β³ π Text in a change could run script
- β³ π AI settings changed without the AI permission
- β³ πͺ #178 Deleting an analyst failed
- β³ π« A team with no departments showed 0 tickets
- β³ π Projects: adding a phase did nothing on Save
- β³ π Leavers could still sign in
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96