3CX Integration Developer Guide - edmozley/freeitsm GitHub Wiki
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 [email protected]
|
<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