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.

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>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);
}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]