3CX integration — administrator & developer guide
Call Helm integrates with 3CX as an external CRM. Once connected, your 3CX phone system looks up callers in your Call Helm contacts (screen pop) and searches your contacts from the 3CX client.
What works today, in one line: contact lookup / screen pop and contact search are functional. Contact creation from 3CX and call journaling are wired into the CRM template but are not functional in the current release — the handlers write fields that don't match the current database schema and return an error. They are documented below as the intended contract and marked Not yet working; see Known issues and limitations.
This guide is written against the shipped integration code (src/app/api/3cx/*, src/lib/services/threeCX.ts, the Settings → Integrations → 3CX screen) and the generated database schema. For the short, non-technical setup steps, see the Help Center user guide's 3CX page; this page goes deeper: exact endpoints, payloads, auth, and troubleshooting.
How it works
Call Helm publishes a 3CX CRM template (an XML file) that tells 3CX which HTTP requests to make and how to read the responses. 3CX is the caller; Call Helm is the server. There is no reverse connection from Call Helm into 3CX.
| 3CX event | Request 3CX makes | Status | What Call Helm does |
|---|---|---|---|
| Call rings / is placed | GET /api/3cx/contacts/lookup?number=… |
Works | Finds contacts whose stored number matches; returns name, company, email and a contactUrl 3CX opens for screen pop (Call Helm's Active Call panel for that number). |
| Agent searches the CRM | GET /api/3cx/contacts/search?query=… |
Works | Free-text search over first/last name, email, company and phone. |
| Agent creates a contact in 3CX | POST /api/3cx/contacts/create |
Not yet working | Intended: create the contact in your organization. Today the insert fails and 3CX receives 500. |
| Call ends | POST /api/3cx/calls/journal |
Not yet working | Intended: record the call in Call Helm with direction, duration, status and agent attribution. Today the write fails and 3CX receives 500. |
| Template download (setup only) | GET /api/3cx/template?apiKey=… |
Works | Returns the XML template pre-filled with your key and URL. |
Every device request is authenticated with your organization's 3CX API key in the x-api-key header. Successful lookups and searches are logged to a per-organization event table and summarized as counters on the 3CX settings page.
Prerequisites
- Call Helm: an account with the Organization Admin (or Super Admin) role. Generating the key and managing agent mappings require admin; viewing status does not.
- 3CX: admin access to the 3CX Management Console (the CRM Integration settings), on a 3CX version that supports REST-type CRM templates (the template is
Version="1"of the 3CX CRM XML format). - Network: your 3CX server must be able to reach your Call Helm URL over HTTPS. Call Helm makes no calls back into 3CX, so no inbound firewall rules are required on the 3CX side.
Setup
Step 1 — Generate the API key (Call Helm)
- Sign in as an Organization Admin and open Settings → Integrations.
- On the 3CX card, click Configure.
- Click Generate API Key. The key (
3cx_followed by 64 hex characters) is displayed with a Copy button.
Copy it now. The key is only shown in the response to generation. Reloading the page shows the integration as Active / configured but does not display the key again (the status endpoint deliberately omits it). If you lose it, click Regenerate Key — this issues a new key and invalidates the old one immediately, so you must update 3CX afterwards.
Generating a key also enables the integration (enabled = true) and records your Call Helm URL as the CRM URL.
Step 2 — Download the XML template (Call Helm)
Click Download callhelm.xml. The browser saves the 3CX CRM template with your API key and Call Helm URL already filled in as parameter defaults.
The file is generated by GET /api/3cx/template?apiKey=<your key> — the same URL is returned as downloadUrl by the key-generation call, so you can also fetch it with curl:
curl -o callhelm.xml "https://<your-call-helm-host>/api/3cx/template?apiKey=3cx_…"
(The server names the attachment call-helm-3cx.xml; the in-app button saves it as callhelm.xml. Either name is fine — 3CX reads the content.)
Step 3 — Install the template in 3CX
-
Open the 3CX Management Console → Settings → CRM Integration.
-
Click + Add CRM Template and upload the XML file.
-
Select CallHelm in the CRM drop-down (the template's
NameisCallHelm). -
Review the parameters the template defines:
Parameter (3CX label) Default Notes Call-Helm API Key ( ApiKey)your key Sent as the x-api-keyheader on every request.Call-Helm URL ( BaseUrl)your Call Helm URL Must be the public HTTPS origin of your Call Helm instance, no trailing slash. Enable Call Journaling ( ReportCallEnabled)True Controls whether 3CX posts to the journaling endpoint. Journaling is not yet working — leaving this on is harmless (3CX logs the failed request) but produces no call records. Set to False if you want to keep the 3CX log clean. Call Subject ( CallSubject)"Call-Helm Call Log" Defined by the template; Call Helm does not currently read it. Allow contact creation from 3CX ( CreateContactEnabled)True Controls whether 3CX offers "create contact" against Call Helm. Not yet working — creation currently fails with an error. Recommended: set to False until fixed. -
Click Test. 3CX runs the contact-lookup scenario; a
200response means the key and URL are correct. -
Click Save.
Template facts worth knowing: numbers are passed as-is (<Number Prefix="AsIs" MaxLength="15" />, no prefix stripping); up to 16 concurrent requests are allowed (MaxConcurrentRequests="16"); the template declares no 3CX-side authentication (<Authentication Type="No" />) because auth is the header.
Step 4 — Map extensions to agents (optional)
Agent mappings exist to attribute journaled calls to a Call Helm agent by the 3CX extension that handled them. Because journaling is not yet working, mappings have no effect today; you can still set them up so attribution works once journaling is fixed. In Settings → Integrations → 3CX → Show Agent Mappings, add one row per agent:
- Extension (required) — e.g.
101 - Agent Email (required by the form)
- First / Last name (optional)
Mappings are keyed on (organization, extension); adding the same extension again updates the existing row. Delete a mapping with the trash icon.
Monitoring
Once configured, the 3CX settings page shows counters for the last 30 days: Total Events, Contact Lookups, Calls Journaled, Contacts Created (searches are counted in the total but not shown separately). They come from GET /api/3cx/stats and the three_cx_call_events table, which stores every successful lookup / search with its raw payload — the first place to look when troubleshooting.
Because contact creation and journaling fail before their event is logged, Calls Journaled and Contacts Created stay at 0 in the current release.
API key
- Format:
3cx_+ 64 lowercase hex characters (32 random bytes). Requests whose key does not start with3cx_are rejected before any database lookup. - Scope: authenticates only the device endpoints listed below. It cannot be used for any other Call Helm route, and it is not a general API credential.
- Validation: the key must match an integration row that is
enabled. Disabling the integration or regenerating the key causes401 Invalid API keyon all device endpoints. - Rotation: Regenerate Key in Settings replaces the key atomically (one integration row per organization). Update the Call-Helm API Key parameter in 3CX right after, or download a fresh template.
- Storage & transport: the key is stored as an opaque unique value in the integration table (it is not hashed or encrypted at rest — treat it like a password). Always use HTTPS for the Call Helm URL. There is currently no per-key rate limiting or IP allow-listing.
API reference
Base URL: your Call Helm origin (the BaseUrl parameter). All responses are JSON unless noted.
Device endpoints (called by 3CX; auth = x-api-key header)
Common error responses:
| Status | Body | Cause |
|---|---|---|
401 |
{ "error": "Missing API key", "contacts": [] } |
No x-api-key header (lookup/search); { "error": "Missing API key" } on create/journal. |
401 |
{ "error": "Invalid API key", … } |
Key unknown, malformed, or integration disabled. |
500 |
{ "error": "Database error" … } / "Internal server error" |
Server-side failure; details are in Call Helm's logs. |
GET /api/3cx/contacts/lookup — works
Find contacts by phone number (used for screen pop).
| Query param | Required | Description |
|---|---|---|
number |
yes | The phone number as 3CX has it. The server also tries a normalized form (digits only; a leading 1 on 11-digit numbers is removed). |
Matching is an exact comparison of the contact's phone_number against the raw and the normalized value; up to 10 contacts are returned.
curl -H "x-api-key: 3cx_…" \
"https://<host>/api/3cx/contacts/lookup?number=%2B15551234567"
Response 200:
{
"success": true,
"contacts": [
{
"id": "8f3c…",
"firstName": "Ada",
"lastName": "Lovelace",
"company": "Analytical Engines",
"email": "ada@example.com",
"phoneBusiness": "+15551234567",
"phoneMobile": "+15551234567",
"phoneHome": "",
"contactUrl": "https://<host>/dashboard/active-call/%2B15551234567"
}
]
}
No match returns 200 with "contacts": [] (3CX then treats the caller as unknown). 400 { "error": "Missing phone number", "contacts": [] } if number is absent. contactUrl opens Call Helm's Active Call panel for that number, so the agent can take notes during the call.
GET /api/3cx/contacts/search — works
Free-text contact search (the 3CX "Search CRM" action).
| Query param | Required | Description |
|---|---|---|
query |
yes, ≥ 2 characters | Case-insensitive substring match against first name, last name, email, company, and phone number. |
Up to 20 results. 400 { "error": "Query too short", "contacts": [] } for a missing or 1-character query.
curl -H "x-api-key: 3cx_…" "https://<host>/api/3cx/contacts/search?query=lovelace"
Response shape is the same as lookup, minus phoneHome, and contactUrl points at the contact record: https://<host>/dashboard/contacts/<id>.
POST /api/3cx/contacts/create — not yet working
Current behaviour: the request is authenticated and validated, then the database insert fails (the handler omits the required
phone_numberfield) and the response is500 { "error": "Failed to create contact" }. No contact is created and no event is logged. The contract below is the intended one, kept here so integrators know what to expect once it is fixed.
Intended request: JSON with the field names 3CX's CreateContactRecordFromClient scenario sends:
| Field | Required | Notes |
|---|---|---|
FirstName |
one of these three | |
LastName |
one of these three | |
Company |
one of these three | |
Number |
no | The contact's phone number. |
Email |
no |
400 { "error": "At least one of FirstName, LastName, or Company is required" } if none of the three name fields is present (this validation does run today).
Intended response 200:
{
"success": true,
"id": "8f3c…",
"firstName": "Ada",
"lastName": "Lovelace",
"email": "ada@example.com",
"company": "Analytical Engines",
"phoneMobile": "+15551234567",
"contactUrl": "https://<host>/dashboard/contacts/8f3c…"
}
POST /api/3cx/calls/journal — not yet working
Current behaviour: the request is authenticated, then the handler's contact match and call insert both fail against the current schema (see Known issues) and the response is
500 { "error": "Failed to create call record" }. No call record, no contact, and no event is written. The contract below is the intended one.
3CX posts this from the ReportCall scenario when Enable Call Journaling is on. Intended JSON body:
| Field | Type / values | Intended use |
|---|---|---|
CallType |
Inbound, Outbound, Missed, Notanswered |
Missed/Notanswered → status no-answer; anything else → completed. |
Number |
string | The external party's number; used to find (or auto-create) the contact. |
CallDirection |
Inbound / Outbound |
Call direction and which side is the external number. |
Name |
string | Name 3CX matched; first name for an auto-created unknown caller. |
EntityId |
string | Contact ID from a prior lookup; if present, skips the contact search. |
Agent |
string | Extension number → agent mapping for attribution; also recorded in notes. |
AgentEmail, AgentFirstName, AgentLastName |
string | Recorded in the call notes. |
Duration |
hh:mm:ss |
Converted to seconds (other formats → 0). |
CallStartTimeUTC, CallEndTimeUTC |
ISO 8601, e.g. 2026-08-29T14:03:00Z |
Start/end timestamps (start defaults to now). |
QueueExtension |
string | Recorded in notes. |
Intended response 200: { "success": true, "callId": "…", "contactId": "…", "message": "Call journaled successfully" }.
GET /api/3cx/template — works
Returns the CRM XML template (Content-Type: application/xml, attachment call-helm-3cx.xml).
| Query param | Required | Description |
|---|---|---|
apiKey |
yes | Your 3cx_ key. 400 Missing apiKey parameter / 401 Invalid API key otherwise. |
This is the one device endpoint that takes the key as a query parameter (so the settings page can offer it as a download link). Avoid pasting that URL into chat or tickets — it contains your key.
Dashboard endpoints (called by the Call Helm settings page; auth = signed-in session)
These power the Settings screen. They use the signed-in user's session cookie, resolve the organization from that user's active membership (a client-supplied organizationId is ignored), and are not usable with the 3cx_ key. Listed for completeness; the UI is the supported way to use them.
| Endpoint | Role | Purpose | Response |
|---|---|---|---|
GET /api/3cx/setup |
any active member | Integration status | { enabled, configured, crmUrl, threeCxServerUrl, settings } — never includes the key; { enabled:false, configured:false } if not set up. |
POST /api/3cx/setup |
admin | Generate / regenerate the key | { success, apiKey, crmUrl, downloadUrl } |
GET /api/3cx/agents |
any active member | List extension mappings | { mappings: [{ id, three_cx_extension, agent_id, agent_email, agent_first_name, agent_last_name, … }] } |
POST /api/3cx/agents |
admin | Upsert a mapping. Body: { extension (required), agentEmail, agentFirstName, agentLastName, agentId? }. agentId, if given, must be a member of your organization (400 Invalid agent for this organization otherwise). |
{ success, mapping } |
DELETE /api/3cx/agents/{id} |
admin | Remove a mapping (scoped to your organization) | { success: true } |
GET /api/3cx/stats?days=30 |
any active member | Event counters for the last days days (default 30) |
{ total_events, lookups, journals, contacts_created, searches } |
Errors: 401 Unauthorized (no session), 403 No active organization membership, 403 Admin access required.
Data model (for reference)
| Table | Purpose |
|---|---|
three_cx_integrations |
One row per organization: api_key (unique), enabled, crm_url, optional three_cx_server_url, settings JSON (call_journaling_enabled, contact_creation_enabled, auto_create_contacts — informational; the template parameters in 3CX are what actually gate 3CX's behaviour). |
three_cx_agent_mappings |
(organization_id, three_cx_extension) → agent_id (organization member), plus email/name. |
three_cx_call_events |
Log of every successful lookup and search (and, once fixed, create_contact / journal) with phone number, contact/call ids, agent extension, timings, and the raw payload. Readable by members of the organization. |
Troubleshooting
"Test" fails in 3CX
- The Call-Helm URL must be the public HTTPS origin your 3CX server can reach (no trailing slash, no path). Try the lookup URL from the 3CX server with curl and your key.
401→ the key is wrong, was regenerated, or the integration is disabled. Regenerate and re-enter it.- Development instances behind tunnels (e.g. ngrok) may require the tunnel to be running; the in-app template download adds an
ngrok-skip-browser-warningheader for that case, but 3CX itself does not.
Screen pop shows "unknown caller" for a known contact
- Lookup matches the contact's
phone_numberfield exactly against the number 3CX sends and its digits-only form (with a leading1dropped for 11-digit numbers).+1 (555) 123-4567stored vs15551234567sent will match only if the stored value equals one of those two strings. Store numbers in the format your PBX sends (E.164 recommended).
3CX shows an error when creating a contact, or calls never appear in Call Helm
- Expected in the current release: contact creation and call journaling return
500(see Known issues). Nothing is written. Set Allow contact creation from 3CX and Enable Call Journaling to False in the 3CX CRM parameters if you want to suppress the errors until the fix ships.
Known issues and limitations
These were established by reading the integration code against the generated database schema (src/lib/database.generated.ts). They have not been reproduced against a live 3CX system, but the schema mismatch is unambiguous; if you observe different behaviour, please report it.
- Contact creation from 3CX fails (
500 Failed to create contact).POST /api/3cx/contacts/createinserts the number intocontacts.phoneand never setscontacts.phone_number, which is required (NOT NULL) in the current schema. The insert is rejected. (Even if it succeeded,lookup/searchreadphone_number, so the contact would not screen-pop.) - Call journaling fails (
500 Failed to create call record).POST /api/3cx/calls/journal(a) looks up existing contacts by acontacts.mobilecolumn that does not exist, (b) auto-creates unknown callers with the same missing-phone_numberinsert as above, and (c) writes the call with columns thecallstable does not have (user_id,from_number,to_number,started_at,ended_at,source; the table usesmember_id,caller_number,called_number,start_time,end_time). Nothing is written, including the event-log row. - Agent mappings therefore have no observable effect until journaling is fixed.
- Key is shown once and stored in clear. No re-display, no hashing, no rate limiting, no IP allow-list. Rotate it if it leaks.
- Lookup/search only; no click-to-dial or presence. The template has no dial-out or reverse scenarios, and
three_cx_server_urlis stored but unused. - One integration per organization (one key, one template) and one 3CX extension per mapping row.
settingsflags are informational.call_journaling_enabled/contact_creation_enabled/auto_create_contactsare saved on key generation but not enforced server-side; the corresponding 3CX template parameters are the real switches.- In-app "View 3CX Integration Guide" link opens this page (
/help/integrations/3cx).