---
title: "Manually Install Symbo Dialer in GoHighLevel"
description: "Step-by-step guide to manually installing the Symbo Dialer widget in GoHighLevel using the Custom JS section in your agency whitelabel settings."
url: https://help.symbo.ai/en/articles/10404561-manually-install-symbo-dialer-in-gohighlevel
collection: "CRM"
updated: 2026-08-14
language: en
---

# 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](https://app.gohighlevel.com/settings/company?tab=whitelabel)

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

![](https://staticfiles.gleap.io/gattachments/vGkdA36b18vdyNpU92zCEeCYGgYmen9Rue7bWomadEisZb1iJCZR9V4VT001Fen8qpv1zb482py.png)

#### Step 2. Paste the Custom JS

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

```javascript
<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**.

```javascript
.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 team@symbo.ai
