Skip to content

3CX Integration Developer Guide

Ed Mozley edited this page Oct 24, 2026 · 1 revision

3CX β€” 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.


The shape

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.

πŸ“ Files

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.

The template

3CX links to a CRM through an XML template (Admin Console β†’ Integrations β†’ CRM). threecxTemplateXml() writes it, following 3CX's CRM template reference:

  • Parameters Url and Token default to this install's address and token, so the uploaded file is ready; ReportCallEnabled lets an administrator switch call reports off in 3CX.
  • <Authentication Type="No" /> plus an Authorization: Bearer [Token] header on each request. 3CX's Basic type base64-encodes its value, and 3CX's own forum reports Bearer under Basic failing; a manual header under No is the pattern that works.
  • The lookup scenario has an empty Id. It must return ContactUrl (3CX's identifier for the contact, and the address it opens), one of FirstName / LastName / CompanyName, and a phone output equal to the number searched - so the endpoint hands back phone = the number 3CX sent. EntityId / EntityType are returned too: some 3CX versions only journal a call that matched an entity.
  • ReportCall posts call_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 Name is plain FreeITSM: a forum report has 3CX refusing an upload over characters in the name.

⚠️ The file carries the token. export is administrators-only and sends Cache-Control: no-store.

Who is calling

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.

⚠️ That is looser than 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.

What a report does

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.

  1. The caller - exactly one match, or none.
  2. The ticket - threecxTicketFor(), by the rule. tickets/caller.php uses the same function, so the screen that pops is the ticket the call will be noted on.
  3. The author - the active analyst whose email is agent_email (case-insensitive, and only if exactly one has it), else the fallback analyst.
  4. noted (a ticket and an author) β†’ an internal note through TicketsService::createNote(), with an ActorContext for that analyst and companyScope: null - the call is not limited to the companies the analyst can open.
  5. 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() with user_id (a known caller, in their own company) or a <digits>@phone.invalid requester (an unknown one, in the configured company); assigned to the agent if they are an analyst - never to the fallback, who only signs notes.
  6. 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.

The traps

  • TRAP: a call note needs a real analyst. ticket_notes.analyst_id is a foreign key to analysts. The contributed version wrote notes as ActorContext::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.php fails 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.
  • Authorization can be stripped before PHP sees it on Apache + FastCGI. api/external/3cx/.htaccess hands it through, and crm.php reads getallheaders(), then HTTP_AUTHORIZATION, then REDIRECT_HTTP_AUTHORIZATION - as api/v1 does. The body may carry token instead, for a client that cannot set headers.
  • The dial address is admin text that becomes a link. phoneDialTemplateValid() allows only https://, tel:, callto: and sip: 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 with hash_equals; no token made means everything is refused.

What changed from the contribution, and why

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

Testing

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

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally