diff --git a/voxtelesys_integration/api.py b/voxtelesys_integration/api.py
new file mode 100644
index 0000000..7368fb8
--- /dev/null
+++ b/voxtelesys_integration/api.py
@@ -0,0 +1,127 @@
+"""
+voxtelesys_integration.api
+==========================
+
+Single whitelisted endpoint called by 3CX Call Flow Designer (CF_URLFetch)
+when an inbound call arrives.
+
+3CX sends a POST with these variables set in the Call Flow:
+ caller_id - the inbound caller's number (e.g. +12025551234)
+ called_did - the DID that was dialled (optional, for routing info)
+ call_id - 3CX internal call ID (optional, for deduplication)
+
+The endpoint creates an HD Ticket in Frappe Helpdesk and returns JSON.
+
+Endpoint URL to put in 3CX:
+ https://erp.broadcastmgmt.cloud/api/method/voxtelesys_integration.api.inbound_call
+
+No authentication required (allow_guest=True) — protect via 3CX secret token
+in the request if desired (see WEBHOOK_SECRET below).
+"""
+
+import frappe
+from frappe import _
+
+# Optional: set this in Site Config as voxtelesys_webhook_secret = "yourtoken"
+# 3CX should pass it as ?secret=yourtoken in the URL.
+# Leave blank to accept all requests (fine if ERPNext is behind a firewall).
+
+
+@frappe.whitelist(allow_guest=True)
+def inbound_call(caller_id: str = "", called_did: str = "", call_id: str = "", **kwargs):
+ """
+ Called by 3CX CF_URLFetch on every inbound call.
+ Creates an HD Ticket and returns the ticket name.
+ """
+ _validate_secret()
+
+ caller_id = (caller_id or "").strip()
+ if not caller_id:
+ frappe.local.response["http_status_code"] = 400
+ return {"ok": False, "error": "caller_id is required"}
+
+ # Deduplication: if a ticket was already created for this call_id, return it
+ if call_id:
+ existing = frappe.db.get_value(
+ "HD Ticket",
+ {"custom_3cx_call_id": call_id},
+ "name",
+ )
+ if existing:
+ return {"ok": True, "ticket": existing, "duplicate": True}
+
+ # Look up a Contact matching the caller number so we can pre-fill the ticket
+ contact_name, contact_email = _find_contact(caller_id)
+
+ try:
+ ticket = frappe.new_doc("HD Ticket")
+ ticket.subject = f"Inbound Call from {caller_id}"
+ ticket.description = (
+ f"An inbound call was received from {frappe.utils.escape_html(caller_id)}."
+ + (f"
DID dialled: {frappe.utils.escape_html(called_did)}" if called_did else "")
+ + (f"
3CX Call ID: {frappe.utils.escape_html(call_id)}" if call_id else "")
+ )
+
+ # Pre-fill contact/email if we found a match
+ if contact_email:
+ ticket.raised_by = contact_email
+ if contact_name:
+ ticket.contact = contact_name
+
+ # Store the 3CX call ID for deduplication (requires custom field — see README)
+ if call_id and frappe.db.has_column("HD Ticket", "custom_3cx_call_id"):
+ ticket.custom_3cx_call_id = call_id
+
+ ticket.insert(ignore_permissions=True)
+ frappe.db.commit()
+
+ frappe.logger().info(
+ f"[Voxtelesys] Created HD Ticket {ticket.name} for inbound call from {caller_id}"
+ )
+ return {"ok": True, "ticket": ticket.name}
+
+ except Exception:
+ frappe.log_error(frappe.get_traceback(), "Voxtelesys: Failed to create HD Ticket")
+ frappe.local.response["http_status_code"] = 500
+ return {"ok": False, "error": "Failed to create ticket — check Error Log"}
+
+
+def _validate_secret():
+ """
+ If voxtelesys_webhook_secret is set in site_config.json, require it
+ to be passed as ?secret= in the request URL.
+ """
+ expected = frappe.conf.get("voxtelesys_webhook_secret")
+ if not expected:
+ return # no secret configured — allow all
+ provided = frappe.request.args.get("secret") or frappe.form_dict.get("secret") or ""
+ if provided != expected:
+ frappe.throw(_("Unauthorized"), frappe.PermissionError)
+
+
+def _find_contact(phone_number: str):
+ """
+ Look up a Contact by phone number. Returns (contact_name, email) or (None, None).
+ Strips +, spaces, and dashes for a loose suffix match.
+ """
+ if not phone_number:
+ return None, None
+
+ normalised = phone_number.replace("+", "").replace(" ", "").replace("-", "")
+
+ result = frappe.db.sql(
+ """
+ SELECT cp.parent, c.email_id
+ FROM `tabContact Phone` cp
+ JOIN `tabContact` c ON c.name = cp.parent
+ WHERE REPLACE(REPLACE(REPLACE(cp.phone, '+', ''), ' ', ''), '-', '') LIKE %(num)s
+ LIMIT 1
+ """,
+ {"num": f"%{normalised}"},
+ as_dict=True,
+ )
+
+ if result:
+ return result[0].parent, result[0].email_id
+
+ return None, None