company logo

Help center

Go to Symbo
HomeSymbo App
All collectionsCRMManually Install Symbo Dialer in GoHighLevel

Manually Install Symbo Dialer in GoHighLevel

Step-by-step guide to manually installing the Symbo Dialer widget in GoHighLevel using the Custom JS section in your agency whitelabel settings.

Step 1. Open your Whitelabel settings

In GHL, go to Settings → Company → Whitelabel, or jump straight there:

→ app.gohighlevel.com/settings/company?tab=whitelabel

Scroll down until you see the Custom JS and Custom CSS sections.

Step 2. Paste the Custom JS

Copy the code below and paste it into the Custom JS field.

<script>
/* Double-load guard.
 * GHL's Custom-JS slot can inject the script more than once across SPA
 * navigations. A second execution re-declares top-level `const`s and runs a
 * second set of observers/iframe — leading to duplicate icons, duplicate
 * fetches, and (worst) duplicate auto-dials. Short-circuit early. */
if (window.__symboGhlInitialized) {
  throw new Error(
    'Symbo GHL script already initialized — skipping duplicate load.'
  )
}
window.__symboGhlInitialized = true

/* Data/State */
const DIAL_ICON = `<svg fill="#143FDE" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>dialpad</title><path d="M12,19A2,2 0 0,0 10,21A2,2 0 0,0 12,23A2,2 0 0,0 14,21A2,2 0 0,0 12,19M6,1A2,2 0 0,0 4,3A2,2 0 0,0 6,5A2,2 0 0,0 8,3A2,2 0 0,0 6,1M6,7A2,2 0 0,0 4,9A2,2 0 0,0 6,11A2,2 0 0,0 8,9A2,2 0 0,0 6,7M6,13A2,2 0 0,0 4,15A2,2 0 0,0 6,17A2,2 0 0,0 8,15A2,2 0 0,0 6,13M18,5A2,2 0 0,0 20,3A2,2 0 0,0 18,1A2,2 0 0,0 16,3A2,2 0 0,0 18,5M12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17A2,2 0 0,0 14,15A2,2 0 0,0 12,13M18,13A2,2 0 0,0 16,15A2,2 0 0,0 18,17A2,2 0 0,0 20,15A2,2 0 0,0 18,13M18,7A2,2 0 0,0 16,9A2,2 0 0,0 18,11A2,2 0 0,0 20,9A2,2 0 0,0 18,7M12,7A2,2 0 0,0 10,9A2,2 0 0,0 12,11A2,2 0 0,0 14,9A2,2 0 0,0 12,7M12,1A2,2 0 0,0 10,3A2,2 0 0,0 12,5A2,2 0 0,0 14,3A2,2 0 0,0 12,1Z" /></svg>`
const dialIconUrl = `data:image/svg+xml;base64,${btoa(DIAL_ICON)}`
const BASE_URL = 'https://app.symbo.ai'
let isOpen = false
let dialerPosX = 0
let dialerPosY = 0
let isDragging = false
let topBarBtnRendered = false
let floatingBtnRendered = false
let ongoingCall = false
let currentContact = null
let fetchingCurrentContact = false
let currentContactPhones = []
let fetchedContact = null
let pendingIconAction = null
let iframeReady = false
let lastSaveToastAt = 0
const SAVE_TOAST_COOLDOWN_MS = 5000
const messageQueue = []
const PHONES_CACHE_KEY = 'symbo-ghl-phones-cache'
let phonesCache = {}
try {
  phonesCache = JSON.parse(sessionStorage.getItem(PHONES_CACHE_KEY) || '{}')
} catch (_) {
  phonesCache = {}
}

const VARIANT_MAP = {
  '': { iconClass: 'symbo-call-contact-action', menuClass: 'phones-menu' },
  v2: {
    iconClass: 'symbo-call-contact-action-v2',
    menuClass: 'phones-menu-v2',
  },
  v3: {
    iconClass: 'symbo-call-contact-action-v3',
    menuClass: 'phones-menu-v3',
  },
}

/* Helpers */
const postMessage = (msg) => {
  const iframeEl = document.getElementById('symbo-dialer-iframe')
  if (!iframeEl || !iframeReady) {
    // Only queue idempotent prefetches. Never queue user-action messages like
    // `call-number` — a delayed flush would auto-dial without intent.
    if (msg && msg.type === 'fetch-contact') {
      // Coalesce: keep only the most recent fetch-contact per externalId.
      const externalId = msg.data?.externalId
      for (let i = messageQueue.length - 1; i >= 0; i--) {
        if (
          messageQueue[i]?.type === 'fetch-contact' &&
          messageQueue[i]?.data?.externalId === externalId
        ) {
          messageQueue.splice(i, 1)
        }
      }
      messageQueue.push(msg)
    } else {
      console.warn(
        '[Symbo] dropping message — iframe not ready yet:',
        msg?.type
      )
    }
    return
  }
  iframeEl.contentWindow.postMessage(msg, BASE_URL)
}

const flushMessageQueue = () => {
  const iframeEl = document.getElementById('symbo-dialer-iframe')
  if (!iframeEl) return
  while (messageQueue.length) {
    iframeEl.contentWindow.postMessage(messageQueue.shift(), BASE_URL)
  }
}

const cachePhonesForContact = (id, phones) => {
  if (!id) return
  phonesCache[id] = phones
  try {
    sessionStorage.setItem(PHONES_CACHE_KEY, JSON.stringify(phonesCache))
  } catch (_) {}
}

const invalidatePhonesCacheForContact = (id) => {
  if (!id || !phonesCache[id]) return
  delete phonesCache[id]
  try {
    sessionStorage.setItem(PHONES_CACHE_KEY, JSON.stringify(phonesCache))
  } catch (_) {}
}

/* Imports */
document.body.style.fontFamily = 'Roboto, sans-serif'
const fontAwesome = document.createElement('link')
fontAwesome.rel = 'stylesheet'
fontAwesome.href =
  'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css' // Font Awesome CDN
document.head.appendChild(fontAwesome)

/* Methods */
// Function to handle the start of dragging
const startDrag = (e) => {
  e.preventDefault() // Prevent text selection

  const dialerContainer = document.getElementById('symbo-dialer')

  dialerPosX = e.clientX - dialerContainer.getBoundingClientRect().left
  dialerPosY = e.clientY - dialerContainer.getBoundingClientRect().top

  isDragging = true

  document.addEventListener('mousemove', onDrag)
  document.addEventListener('mouseup', stopDrag)
  document.addEventListener('mouseleave', stopDrag) // Stop dragging when cursor leaves viewport
}

// Function to handle dragging
const onDrag = (e) => {
  const dialerContainer = document.getElementById('symbo-dialer')

  if (!isDragging) return

  const viewportWidth = window.innerWidth
  const viewportHeight = window.innerHeight

  let newLeft = e.clientX - dialerPosX
  let newTop = e.clientY - dialerPosY

  // Constrain the div within the viewport
  const divWidth = dialerContainer.offsetWidth
  const divHeight = dialerContainer.offsetHeight

  // Ensure the div doesn't move out of the viewport
  newLeft = Math.max(0, Math.min(newLeft, viewportWidth - divWidth))
  newTop = Math.max(0, Math.min(newTop, viewportHeight - divHeight))

  // Apply the constrained position
  dialerContainer.style.left = `${newLeft}px`
  dialerContainer.style.top = `${newTop}px`
}

// Function to stop dragging
const stopDrag = () => {
  if (!isDragging) return

  isDragging = false

  document.removeEventListener('mousemove', onDrag)
  document.removeEventListener('mouseup', stopDrag)
  document.removeEventListener('mouseleave', stopDrag)
}

// Create and return drag handler element
// This method creates and returns
// the element with a grip/dots icon to drag the dialer around
const createDragHandler = () => {
  const dragHandler = document.createElement('div')
  dragHandler.classList = ['drag-handler']
  dragHandler.innerHTML = `<i class="fa-solid fa-grip drag-icon" ></i>`

  dragHandler.onmousedown = startDrag

  return dragHandler
}

// Render dialer
// This method creates the Symbo Dialer frame and adds it into the DOM
// This method does not open the dialer it only renders/adds to the DOM
const renderDialer = () => {
  // Create a container for the iframe
  const container = document.createElement('div')
  container.id = 'symbo-dialer'
  container.style.width = '420px' // Initial width
  container.style.height = '485px' // Initial height
  container.classList = ['dialer-container']

  const dialerFrame = document.createElement('iframe')
  dialerFrame.src = `${BASE_URL}/dial?ghl=true`
  dialerFrame.id = 'symbo-dialer-iframe'
  dialerFrame.classList = ['dialer-frame']
  dialerFrame.setAttribute('allow', 'microphone')

  const dragHandler = createDragHandler()
  container.appendChild(dragHandler)
  container.appendChild(dialerFrame)

  // Append the container to the body
  document.body.appendChild(container)
}

// Toggle dialer
const toggleDialer = () => {
  const dialer = document.getElementById('symbo-dialer')
  const floatingBtn = document.getElementById('dialer-floating-btn')

  const defaultTop = window.innerHeight - 485 - 18
  const defaultLeft = window.innerWidth - 420 - 100
  dialerPosY = defaultTop
  dialerPosX = defaultLeft
  dialer.style.top = defaultTop
  dialer.style.left = defaultLeft

  if (isOpen) {
    dialer.style.display = 'none'
    if (floatingBtn)
      floatingBtn.innerHTML = `
        <img src="https://marketing.symbo.ai/symbo-icon.svg" class="logo" />
        <div>O</div>
        <div>P</div>
        <div>E</div>
        <div>N</div>
      `
  } else {
    dialer.style.display = 'block'
    if (floatingBtn)
      floatingBtn.innerHTML = `
        <img src="https://marketing.symbo.ai/symbo-icon.svg" class="logo" />
        <div>C</div>
        <div>L</div>
        <div>O</div>
        <div>S</div>
        <div>E</div>
      `
  }
  isOpen = !isOpen
}

const removeFloatingBtn = () => {
  const floatingBtn = document.getElementById('dialer-floating-btn')
  if (floatingBtn) floatingBtn.remove()

  floatingBtnRendered = false
}

// Render dialer button (fallback to renderDialerBtnTopBar)
// This method renders the button to open the Symbo dialer
const renderDialerFloatingBtn = () => {
  floatingBtnRendered = true

  let floatingBtn = document.createElement('div')

  floatingBtn.id = 'dialer-floating-btn'

  floatingBtn.classList = ['dialer-floating-btn']

  floatingBtn.innerHTML = `
        <img src="https://marketing.symbo.ai/symbo-icon.svg" class="logo" />
        <div>O</div>
        <div>P</div>
        <div>E</div>
        <div>N</div>
      `

  floatingBtn.onclick = toggleDialer

  document.body.appendChild(floatingBtn)
}

// Render dialer button on the topbar
// This method renders the button to open the Symbo dialer
const renderDialerBtnTopBar = () => {
  const header = document.querySelector('header .hl_header--controls')

  topBarBtnRendered = true

  let topbarBtn = document.createElement('div')

  topbarBtn.id = 'dialer-btn-topbar'

  topbarBtn.classList = ['dialer-btn-topbar']

  topbarBtn.innerHTML = `<img style="height: 24px; width: 24px;" class="rounded-circle" src="https://marketing.symbo.ai/symbo-icon.svg" />`

  topbarBtn.title = 'Symbo Dialer'

  topbarBtn.onclick = toggleDialer

  const icon =
    document.getElementById('hl_header--help-icon') ||
    document.querySelector('.hl_header--dropdown')

  if (icon) header.insertBefore(topbarBtn, icon)
}

let fetchTimeoutId = null
const FETCH_TIMEOUT_MS = 8000

// This method asks the Symbo app to fetch the contact by external ID.
// Deduped: if a fetch for the same contact is already in flight, skip — the
// existing response will resolve callers waiting on it.
const requestContactPhoneNumbers = (id) => {
  if (fetchingCurrentContact && currentContact === id) return
  currentContact = id
  fetchingCurrentContact = true
  if (fetchTimeoutId) clearTimeout(fetchTimeoutId)
  fetchTimeoutId = setTimeout(() => {
    fetchTimeoutId = null
    onContactFetchFailed(id, 'timed out')
  }, FETCH_TIMEOUT_MS)
  postMessage({ type: 'fetch-contact', data: { externalId: id } })
}

const clearFetchTimeout = () => {
  if (fetchTimeoutId) {
    clearTimeout(fetchTimeoutId)
    fetchTimeoutId = null
  }
}

// Read the contact's phone number directly from the GHL DOM as a fallback when
// the Symbo backend can't resolve the contact (e.g. user is in a sub-account
// that isn't connected to Symbo, so the externalId isn't known to Symbo's CRM).
const getNumberFromContactDetailDom = () => {
  // Native phone-call button often carries the number on its tooltip
  const nativeBtn = document.querySelector(
    '#contact-conversation-panel #phone-calls'
  )
  const tooltipText =
    nativeBtn?.title || nativeBtn?.getAttribute('aria-label') || ''
  const fromTooltip = tooltipText.match(/[\+\d][\d\s\(\)\-]{6,}/)
  if (fromTooltip) return fromTooltip[0].replace(/\s+/g, '').trim()

  // Phone input in the contact details panel
  const phoneInput = document.querySelector(
    'input[type="tel"], [data-field-key="phone"] input, [data-field="phone"] input'
  )
  if (phoneInput?.value) return phoneInput.value.trim()

  // Generic text-content match against a phone pattern in the contact panel
  const panel = document.querySelector(
    '#contact-detail-panel, .contact-detail-panel, .hl_contact-details'
  )
  if (panel) {
    const m = panel.textContent?.match(
      /\+?1?[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{4}/
    )
    if (m) return m[0].trim()
  }
  return null
}

// Lightweight toast — appended to body, auto-dismisses after 8s but the user
// can also close it manually with the × button (important because the toast
// sits above the dialer and would otherwise block clicks until it auto-fades).
const showSymboToast = (message) => {
  let toast = document.getElementById('symbo-toast')
  if (!toast) {
    toast = document.createElement('div')
    toast.id = 'symbo-toast'
    toast.classList = ['symbo-toast']
    const messageEl = document.createElement('div')
    messageEl.classList = ['symbo-toast-message']
    const closeBtn = document.createElement('button')
    closeBtn.type = 'button'
    closeBtn.classList = ['symbo-toast-close']
    closeBtn.setAttribute('aria-label', 'Dismiss')
    closeBtn.textContent = '×'
    closeBtn.onclick = () => {
      toast.style.display = 'none'
      if (toast._dismissTimer) {
        clearTimeout(toast._dismissTimer)
        toast._dismissTimer = null
      }
    }
    toast.appendChild(messageEl)
    toast.appendChild(closeBtn)
    document.body.appendChild(toast)
  }
  toast.querySelector('.symbo-toast-message').textContent = message
  toast.style.display = 'flex'
  if (toast._dismissTimer) clearTimeout(toast._dismissTimer)
  toast._dismissTimer = setTimeout(() => {
    toast.style.display = 'none'
    toast._dismissTimer = null
  }, 8000)
}

// Fallback path for when the Symbo backend can't resolve the contact (e.g.
// wrong sub-account). Toasts only when there was an explicit user click —
// either via the contact-detail Symbo icon (pendingIconAction) or via the
// contact-list call icon (recentListClickValidations). Silent otherwise.
const onContactFetchFailed = (failedId, reason) => {
  clearFetchTimeout()
  if (failedId !== currentContact) return
  fetchingCurrentContact = false
  // Sentinel so the next framePhoneNumbers tick doesn't re-trigger the fetch
  // in a loop. Cleared when the user navigates to a different contact (because
  // contactId !== currentContact takes over) or when the phone-numbers-updated
  // toast invalidates the cache.
  fetchedContact = { external_id: failedId, failed: true, phoneNumbers: [] }
  currentContactPhones = []

  // Case 1: user clicked our Symbo icon on the contact-detail page. We
  // promised to dial; fall back to reading the number from the GHL DOM and
  // surface the toast.
  if (pendingIconAction && pendingIconAction.contactId === failedId) {
    const { iconEl } = pendingIconAction
    clearIconLoadingState(iconEl)
    pendingIconAction = null

    const fallbackNumber = getNumberFromContactDetailDom()
    if (fallbackNumber) {
      callNumber(fallbackNumber, null)
      showSymboToast(WRONG_SUBACCOUNT_TOAST)
    } else {
      showSymboToast(
        WRONG_SUBACCOUNT_TOAST +
          " We also couldn't read a phone number from the page to dial."
      )
    }
    return
  }

  // Case 2: user clicked a Symbo call icon in the contact-list table. The
  // dial was already dispatched with the externalId (and the phone number was
  // read from the table row, so the call still goes through). We just surface
  // the toast so the user knows the call won't be linked.
  if (recentListClickValidations.has(failedId)) {
    clearListClickValidation(failedId)
    showSymboToast(WRONG_SUBACCOUNT_TOAST)
    return
  }

  // Case 3: silent background prefetch failure — no user intent, no UI.
}

const createPhoneMenu = (variant = '') => {
  const phonesMenu = document.createElement('div')
  phonesMenu.classList = [variant ? `phones-menu-${variant}` : 'phones-menu']
  phonesMenu.style.display = 'none'
  blockGhlPointerHandlers(phonesMenu)

  currentContactPhones.forEach((phone) => {
    const phoneRow = document.createElement('div')

    const phoneNumber = document.createElement('div')
    phoneNumber.innerText = phone.number

    const phoneType = document.createElement('div')
    phoneType.innerText = `(${phone.phone_type})`

    phoneRow.appendChild(phoneNumber)
    phoneRow.appendChild(phoneType)

    phoneRow.classList = ['phone-row']

    phoneRow.onclick = (e) => {
      e.stopPropagation()
      callNumber(phone.number, currentContact)
      phonesMenu.style.display = 'none'
    }
    blockGhlPointerHandlers(phoneRow)

    phonesMenu.appendChild(phoneRow)
  })
  return phonesMenu
}

const showOrToggleMenu = (variant, actionBarEl, iconEl) => {
  const { menuClass } = VARIANT_MAP[variant] || VARIANT_MAP['']
  // For v3 we host the menu inside the icon so the menu's `top: 100%` resolves
  // against the icon (not the action bar), keeping it visually anchored just
  // below the icon regardless of action-bar height.
  const host = variant === 'v3' && iconEl ? iconEl : actionBarEl
  let menu = host.querySelector('.' + menuClass)
  if (menu && menu.childElementCount !== currentContactPhones.length) {
    menu.remove()
    menu = null
  }
  if (!menu) {
    menu = createPhoneMenu(variant)
    host.appendChild(menu)
  }
  menu.style.display = menu.style.display === 'block' ? 'none' : 'block'
}

// Unified click handler for the contact-detail Symbo icon (v1/v2/v3).
// Reads currentContactPhones at click time so re-renders/cache hits don't need onclick rebinding.
const handleSymboIconClick = (e, variant, actionBarEl, iconEl) => {
  e.stopPropagation()
  const url = (window.location.href || '').split('?')[0]
  const contactId = url.includes('/contacts/detail/')
    ? url.split('/contacts/detail/')[1]
    : null
  if (!contactId) return

  // Promote session cache to live state if we haven't loaded this contact yet
  if (currentContact !== contactId && phonesCache[contactId]) {
    currentContact = contactId
    currentContactPhones = phonesCache[contactId]
    fetchedContact = {
      external_id: contactId,
      phoneNumbers: currentContactPhones,
    }
  }

  if (currentContact === contactId && currentContactPhones.length) {
    if (currentContactPhones.length === 1) {
      callNumber(currentContactPhones[0]?.number, contactId)
    } else {
      showOrToggleMenu(variant, actionBarEl, iconEl)
    }
    return
  }

  // Phones not loaded yet — show loading state and dispatch after fetch returns
  iconEl.style.opacity = '0.6'
  iconEl.style.cursor = 'wait'
  pendingIconAction = { contactId, variant, actionBarEl, iconEl }
  if (!fetchingCurrentContact || currentContact !== contactId) {
    requestContactPhoneNumbers(contactId)
  }
}

const clearIconLoadingState = (iconEl) => {
  if (!iconEl) return
  iconEl.style.opacity = ''
  iconEl.style.cursor = 'pointer'
}

// Track external IDs the user dialed from the contact-list view. Used so that
// onContactFetchFailed can show the wrong-sub-account toast even though the
// list-view path doesn't go through pendingIconAction. Auto-expires after 30s
// to bound memory and avoid stale toasts on a delayed fetch response.
const recentListClickValidations = new Map() // externalId -> setTimeout id

const WRONG_SUBACCOUNT_TOAST =
  "We couldn't find this contact in Symbo — the call won't be linked. " +
  'Make sure Symbo is connected to the current sub-account.'

const clearListClickValidation = (externalId) => {
  if (!externalId) return
  const tid = recentListClickValidations.get(externalId)
  if (tid) clearTimeout(tid)
  recentListClickValidations.delete(externalId)
}

// Fired from the contact-list icon click. Surfaces a toast if we already know
// (or learn) that Symbo can't resolve this externalId — typical when the user
// is browsing the GHL list in a sub-account where Symbo isn't connected. The
// actual dial has already been dispatched by the caller; this only handles the
// UX warning.
const validateListClickContact = (externalId) => {
  if (!externalId) return

  // Known good — Symbo already resolved this contact in this session.
  if (phonesCache[externalId]) return

  // Known bad — last lookup failed. Toast immediately.
  if (fetchedContact?.external_id === externalId && fetchedContact?.failed) {
    showSymboToast(WRONG_SUBACCOUNT_TOAST)
    return
  }

  // Unknown — track this click and fire a background validation fetch. If the
  // fetch fails, onContactFetchFailed will pick this up via the map below and
  // show the toast.
  clearListClickValidation(externalId)
  const tid = setTimeout(
    () => recentListClickValidations.delete(externalId),
    30000
  )
  recentListClickValidations.set(externalId, tid)

  if (!fetchingCurrentContact || currentContact !== externalId) {
    requestContactPhoneNumbers(externalId)
  }
}

// This method frames phone numbers on the page
// and adds call action buttons to trigger Symbo calls
const framePhoneNumbers = () => {
  const url = (window.location.href || '').split('?')[0] // removing any query params from the url
  const isContactDetailPage = url.includes('/contacts/detail/')

  // List-view icons must never run on contact-detail pages: the .phone-call-icon
  // selector also matches the icons inside GHL's native phone popover, and
  // injecting our action node into that popover causes GHL to close it.
  if (!isContactDetailPage) {
    // For old GHL contact list view
    const phoneDivs = [...document.querySelectorAll('.phone')]
    phoneDivs.forEach((div) => {
      const rowContact = div.parentElement?.parentElement?.id
      const phoneIcon = div.querySelector('i')
      const phoneNumber = div.querySelector('span')?.innerText || ''
      const symboCallAction = div.querySelector('.symbo-call-action')
      if (phoneIcon && phoneNumber && !symboCallAction) {
        const callThruSymbo = document.createElement('img')
        callThruSymbo.src = dialIconUrl
        callThruSymbo.classList = ['symbo-call-action']
        callThruSymbo.onclick = (e) => {
          e.stopPropagation()
          callNumber(phoneNumber, rowContact)
          validateListClickContact(rowContact)
        }

        div.insertBefore(callThruSymbo, phoneIcon)
      }
    })

    // For new GHL contact list view
    const phoneIcons = [...document.querySelectorAll('.phone-call-icon')]
    const phoneCells = phoneIcons
      .map((icon) => icon.parentElement?.parentElement)
      .filter((i) => i)
    phoneCells.forEach((cell) => {
      // GHL has a div inside every cell element
      const phoneCellInnerDiv = cell.children[0]
      const rowContact = cell.parentElement
      const contactNameCell = rowContact.children[1]?.children[0]
      const contactExternalId = contactNameCell?.getAttribute('data-id')
      const phoneNumber =
        phoneCellInnerDiv.children[phoneCellInnerDiv.children.length - 2]
          ?.innerText || ''

      const symboCallAction = cell.querySelector('.symbo-call-action')
      if (phoneNumber && !symboCallAction) {
        const callThruSymbo = document.createElement('img')
        callThruSymbo.src = dialIconUrl
        callThruSymbo.classList = ['symbo-call-action']
        callThruSymbo.style.cursor = 'pointer'
        callThruSymbo.style.marginLeft = '12px'
        callThruSymbo.onclick = (e) => {
          e.stopPropagation()
          callNumber(phoneNumber, contactExternalId)
          validateListClickContact(contactExternalId)
        }

        phoneCellInnerDiv.insertBefore(
          callThruSymbo,
          phoneCellInnerDiv.firstChild
        )
      }
    })
  }

  // Contact details page
  if (isContactDetailPage) {
    const contactId = url.split('/contacts/detail/')[1]
    if (!contactId) return
    const notif = document.querySelector('.n-notification-main')
    const newNotif = document.querySelector('.hr-alert-container')
    const newNotifTextElement = newNotif?.querySelector(
      '.hr-alert-body__content > #hr-ellipsis-id'
    )

    const phoneUpdated =
      (notif &&
        notif.innerText?.toLowerCase().includes('phone numbers updated')) ||
      (newNotifTextElement &&
        newNotifTextElement.innerText
          ?.toLowerCase()
          .includes('changes saved successfully'))

    // Edge-detect the save toast: it stays visible for several seconds, so a
    // steady-state `phoneUpdated === true` would otherwise re-invalidate the
    // cache and re-fire a fetch on every animation frame (~15+ duplicate GETs
    // per save). Treat only the rising edge as a refresh trigger, then cool
    // down for SAVE_TOAST_COOLDOWN_MS before honoring it again.
    const now = Date.now()
    const phoneUpdatedFresh =
      phoneUpdated && now - lastSaveToastAt > SAVE_TOAST_COOLDOWN_MS
    if (phoneUpdatedFresh) {
      lastSaveToastAt = now
      invalidatePhonesCacheForContact(contactId)
    }

    const needsFetch =
      contactId !== currentContact ||
      (!fetchedContact && !fetchingCurrentContact) ||
      phoneUpdatedFresh

    if (needsFetch) {
      // Promote session cache to live state before triggering network
      if (!phoneUpdatedFresh && phonesCache[contactId]) {
        currentContact = contactId
        currentContactPhones = phonesCache[contactId]
        fetchedContact = {
          external_id: contactId,
          phoneNumbers: currentContactPhones,
        }
      } else {
        requestContactPhoneNumbers(contactId)
      }
    }

    renderContactDetailIcon({
      anchorEl: document.querySelector(
        '.hl_conversations--message-header-new .contact-detail-actions'
      ),
      variant: '',
      iconStyle: 'width: 20px; height: 20px; display: inline-block;',
      iconWrapperStyle: { display: 'inline-block' },
      insert: (icon, parent) => parent.insertBefore(icon, parent.firstChild),
    })

    const contactConversationPanel = document.querySelector(
      '#contact-conversation-panel'
    )
    renderContactDetailIcon({
      anchorEl: contactConversationPanel?.querySelector(
        '.flex.items-center.gap-1'
      ),
      variant: 'v2',
      iconStyle: 'width: 16px; height: 16px; display: inline-block;',
      iconWrapperStyle: { display: 'inline-block' },
      insert: (icon, parent) => parent.insertBefore(icon, parent.firstChild),
    })

    const nativeCallBtn = document.querySelector(
      '#contact-conversation-panel #phone-calls'
    )
    renderContactDetailIcon({
      anchorEl: nativeCallBtn?.parentElement,
      variant: 'v3',
      iconStyle: 'width: 24px; height: 24px; display: inline-block;',
      iconWrapperStyle: {
        display: 'inline-flex',
        alignItems: 'center',
        cursor: 'pointer',
      },
      insert: (icon) => nativeCallBtn.insertAdjacentElement('afterend', icon),
    })
  }
}

// Stop GHL from also reacting to our clicks. GHL's native call button opens
// its popover on pointerdown/mousedown (which fire before `click`), so a plain
// `onclick + e.stopPropagation()` is too late and GHL's menu pops up alongside
// ours. Intercept the early pointer events too.
const stopBubble = (e) => e.stopPropagation()
const blockGhlPointerHandlers = (el) => {
  el.addEventListener('pointerdown', stopBubble)
  el.addEventListener('mousedown', stopBubble)
  el.addEventListener('pointerup', stopBubble)
  el.addEventListener('mouseup', stopBubble)
}

// Insert the Symbo dial-pad icon into the given contact-detail action bar.
// Renders optimistically — does not wait for phone-number fetch to complete.
// Re-uses the existing icon if present (so per-tick MutationObserver runs are cheap).
const renderContactDetailIcon = ({
  anchorEl,
  variant,
  iconStyle,
  iconWrapperStyle,
  insert,
}) => {
  if (!anchorEl) return
  const { iconClass } = VARIANT_MAP[variant] || VARIANT_MAP['']
  if (anchorEl.querySelector('.' + iconClass)) return

  const callThruSymbo = document.createElement('div')
  callThruSymbo.classList.add('pointer', 'relative', iconClass)
  Object.assign(callThruSymbo.style, iconWrapperStyle || {})
  if (!callThruSymbo.style.cursor) callThruSymbo.style.cursor = 'pointer'
  callThruSymbo.innerHTML = `<img src="${dialIconUrl}" style="${iconStyle}" />`
  callThruSymbo.title = 'Call with Symbo Dialer'
  callThruSymbo.onclick = (e) =>
    handleSymboIconClick(e, variant, anchorEl, callThruSymbo)
  blockGhlPointerHandlers(callThruSymbo)

  insert(callThruSymbo, anchorEl)
}

/* Event Listeners */

// Event listener for call action onclick
// This method relays a message to the symbo iframe to make a call to the number
const callNumber = (number, externalId) => {
  if (!isOpen) toggleDialer()

  postMessage({
    type: 'call-number',
    data: { number, externalId },
  })
}

// Coalesce mutation bursts to one execution per frame.
// Single frame (~16ms) is short enough that GHL re-renders never leave the
// injected icon missing for a perceptible time.
let rafScheduled = false
const rafCallback = () => {
  if (rafScheduled) return
  rafScheduled = true
  requestAnimationFrame(() => {
    rafScheduled = false
    const header = document.querySelector('header .hl_header--controls')
    if (!header) return // app is not loaded completely

    const topBarBtn = document.getElementById('dialer-btn-topbar')
    framePhoneNumbers()
    if (topBarBtnRendered && topBarBtn) return
    removeFloatingBtn()
    renderDialerBtnTopBar()
  })
}

// Function to observe mutations
let observer
const observeMutations = () => {
  if (observer) observer.disconnect()
  observer = new MutationObserver(rafCallback)
  observer.observe(document.body, { childList: true, subtree: true })
}

// Messages listeners from Symbo iframe
window.addEventListener(
  'message',
  (e) => {
    if (e.origin !== BASE_URL) return
    if (e.data == 'minimize-dialer') toggleDialer()
    if (e.data == 'call:incoming') {
      if (!isOpen) toggleDialer()
    }
    if (e.data.type == 'contact-fetched') {
      clearFetchTimeout()
      fetchingCurrentContact = false
      fetchedContact = e.data.data
      if (e.data.data.external_id !== currentContact) return
      currentContactPhones = e.data.data?.phoneNumbers || []
      cachePhonesForContact(currentContact, currentContactPhones)
      clearListClickValidation(currentContact)
      framePhoneNumbers()
      resolvePendingIconAction()
    }
    if (e.data.type == 'contact-fetch-failed') {
      onContactFetchFailed(
        e.data.data?.externalId,
        e.data.data?.status === 404
          ? 'not found in Symbo'
          : e.data.data?.message || 'lookup failed'
      )
    }
    if (e.data == 'dialer:mounted') {
      iframeReady = true
      flushMessageQueue()
      framePhoneNumbers()
    }
  },
  false
)

const resolvePendingIconAction = () => {
  if (!pendingIconAction) return
  if (pendingIconAction.contactId !== currentContact) {
    clearIconLoadingState(pendingIconAction.iconEl)
    pendingIconAction = null
    return
  }
  const { variant, actionBarEl, iconEl } = pendingIconAction
  pendingIconAction = null
  clearIconLoadingState(iconEl)
  if (currentContactPhones.length === 1) {
    callNumber(currentContactPhones[0]?.number, currentContact)
  } else if (currentContactPhones.length > 1) {
    showOrToggleMenu(variant, actionBarEl, iconEl)
  }
}

// React to SPA URL changes immediately so the icon prefetch isn't gated on
// the next MutationObserver fire (and so cache hits render instantly).
const onUrlChange = () => framePhoneNumbers()
const wrapHistoryFn = (type) => {
  const orig = history[type]
  if (!orig || orig.__symboWrapped) return
  const wrapped = function (...args) {
    const ret = orig.apply(this, args)
    window.dispatchEvent(new Event('symbo:urlchange'))
    return ret
  }
  wrapped.__symboWrapped = true
  history[type] = wrapped
}
wrapHistoryFn('pushState')
wrapHistoryFn('replaceState')
window.addEventListener('symbo:urlchange', onUrlChange)
window.addEventListener('popstate', onUrlChange)

observeMutations()
renderDialer()

// GHL changes customJS display to none/inline when workflows are opened and closed
// Adding mutation observer to switch the widget back to its previous open state
let target = document.getElementById('symbo-dialer')
const widgetObserver = new MutationObserver(() => {
  const displayValue = target ? target.style.display : null
  if (displayValue === 'inline') {
    console.log('GHL changed display property!')
    target.style.display = isOpen ? 'block' : 'none'
  }
})
// Start observing for style attribute changes
widgetObserver.observe(target, {
  attributes: true, // watch for attribute changes
  attributeFilter: ['style'], // only watch style changes
})
</script>

Step 3. Paste the Custom CSS

Copy the code below and paste it into the Custom CSS field. Click Save Changes.

.dialer-floating-btn {
  border-radius: 4px 0 0 4px;
  width: 24px;
  background: #46bcff;
  position: absolute;
  bottom: 30px;
  right: 0px;
  cursor: pointer;
  z-index: 10000;
  color: white;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: space-between;
  font-weight: bold;
  font-size: 10px;
  padding-bottom: 4px;
}
.dialer-btn-topbar {
  border-radius: 50%;
  height: 32px;
  width: 32px;
  background: #143fde;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.rounded-circle {
  border-radius: 50%;
}
.dialer-container {
  border: thin solid rgba(0, 0, 0, 0.12);
  z-index: 10000;

  display: flex;
  flex-direction: column;
  align-items: end;

  box-shadow: 0px 2px 4px -1px rgba(0, 0, 0, 0.1),
    0px 4px 5px 0px rgba(0, 0, 0, 0.07), 0px 1px 10px 0px rgba(0, 0, 0, 0);
  border: none;
  display: none;
  position: fixed;
  bottom: 18px;
  right: 100px;
}
.dialer-container:hover .drag-handler {
  display: flex !important;
  width: 100%;
  align-items: center;
  justify-content: center;
}
.dialer-container:hover .drag-icon {
  display: block !important;
  align-self: center;
}
.drag-icon {
  display: none !important;
  align-self: center;
  color: #46bcff;
}
.drag-handler {
  display: none;
  width: 100%;
  height: 24px;
  cursor: move;
  position: absolute;
  margin-top: -20px;
  align-items: center;
  border-radius: 4px 4px 0 0;
  background: #46bbff2a;
  justify-content: center;
}
.dialer-frame {
  height: 100%;
  width: 100%;
  border: none;
}
.logo {
  height: 24px;
  width: 24px;
  border-radius: 4px 0 0 0;
  margin-bottom: 4px;
}
.symbo-call-action {
  height: 14px !important;
  width: 14px !important;
  border-radius: 2px;
  cursor: pointer;
  color: #143fde;
}
.symbo-call-contact-action {
  padding-top: 11px;
  padding-right: 2px;
  cursor: pointer;
  display: inline-block;
}
.symbo-call-contact-action-v2 {
  height: 24px;
  width: 24px;
  cursor: pointer;
  display: inline-block;
}
.phones-menu {
  width: 210px;
  max-height: 200px;
  overflow-y: auto;
  padding-inline: 16px;
  padding-block: 12px;
  position: absolute;
  background: white;
  margin-top: 48px;
  margin-left: -24px;
  z-index: 1;
  border-radius: 4px;
  border: 1px solid grey;
}
.phones-menu-v2 {
  width: 210px;
  max-height: 200px;
  overflow-y: auto;
  padding-inline: 16px;
  padding-block: 12px;
  position: absolute;
  background: white;
  margin-top: 134px;
  margin-left: -24px;
  z-index: 1;
  border-radius: 4px;
  border: 1px solid grey;
}
.symbo-call-contact-action-v3 {
  position: relative;
  cursor: pointer;
}
.phones-menu-v3 {
  width: 240px;
  max-height: 240px;
  overflow-y: auto;
  padding-inline: 16px;
  padding-block: 12px;
  position: absolute;
  top: 100%;
  right: 0;
  margin-top: 8px;
  background: white;
  z-index: 1000;
  border-radius: 6px;
  border: 1px solid rgba(0, 0, 0, 0.12);
  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
.phone-row {
  padding-block: 6px;
  font-weight: medium;
  cursor: pointer;
  display: flex;
  justify-content: space-between;
}
.phone-row:hover {
  color: #143fde;
}
.symbo-toast {
  position: fixed;
  bottom: 24px;
  right: 24px;
  max-width: 420px;
  background: white;
  border-left: 4px solid #f59e0b;
  padding: 12px 12px 12px 16px;
  border-radius: 6px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16);
  z-index: 99999;
  font-size: 14px;
  line-height: 1.4;
  color: #1f2937;
  display: none;
  align-items: flex-start;
  gap: 12px;
}
.symbo-toast-message {
  flex: 1 1 auto;
}
.symbo-toast-close {
  flex: 0 0 auto;
  background: transparent;
  border: 0;
  padding: 0;
  margin: 0;
  width: 24px;
  height: 24px;
  font-size: 20px;
  line-height: 1;
  color: #6b7280;
  cursor: pointer;
  border-radius: 4px;
}
.symbo-toast-close:hover {
  color: #1f2937;
  background: rgba(0, 0, 0, 0.06);
}

Step 4. Verify it's working

Refresh GoHighLevel. You should see a floating dial button on the right edge of the screen. Click it to open the Symbo Dialer.

If you don't see the button, hard refresh (Cmd+Shift+R or Ctrl+Shift+R) to clear cached styles.

Troubleshooting

  • Button doesn't appear: Make sure both snippets saved successfully and refresh with cache cleared.

  • Button shows but dialer won't open: Confirm you're signed into Symbo in another tab.

  • Need help? Reach out to [email protected]

Did this answer your question?
😞
😐
😁