Skip to main content

GoHighLevel (GHL)

Audience: Sales, management, support, engineering · Where in app: Settings → Integrations → GoHighLevel · Plan availability: All plans (verify) · agency white-label supported

Naming: the UI says "LeadConnector". All user-facing copy was renamed GoHighLevel → LeadConnector (frontend commits 48a9f133/0b62b6b6). Code, config keys, and routes remain go_high_level / ghl / goHighLevel* — internal docs keep GHL as the technical name.

GoHighLevel is the deepest CRM integration and the dominant CRM among Knock Knock's agency customers. It does everything HubSpot does plus pipeline/opportunity sync, in-widget calendar booking, automation-event mirroring, and a Nox "action layer" that can draft and send follow-up SMS/email from inside the agent. Visitors arriving from a GHL email/SMS campaign (carrying ?contact_id=) are recognized instantly.

What it does

  • Bi-directional contact sync (Knock Knock ↔ GHL Contacts).
  • Opportunity & pipeline-stage read/filter.
  • In-widget calendar booking via GHL Calendar.
  • Form intercept for GHL embedded forms (V1 + V2, white-label domains).
  • Identity verification via the ?contact_id= URL parameter.
  • Automation-event mirroring (GHL workflows → Nox audit log).
  • Nox-drafted follow-ups (draft → approve → send SMS/email) — see the action layer below.
  • Agency white-label support across many tenants.

How it works

Auth model: OAuth (v2 marketplace app), with two flavors.

FlavorWhatStored on
OAuth (location)Standard tenant connection to a GHL locationcompanies.go_high_level.*
Self-accountAgency/web-owner install without a location (used for custom-menu creation)companies.ghl_self_account.*

companies.go_high_level.* fields include: access_token, refresh_token, access_token_expires_at, location_id, company_id (the GHL company), user_type, app_version (v1/v2), calendar_id (selected booking calendar), custom_menu_id (iframe portal), and secret_key (legacy webhook secret, deprecated).

v1 → v2 dual-app migration

There's a live migration from the v1 GHL marketplace app to the v2 app:

  • getInstallUrl() builds the marketplace.leadconnectorhq.com/oauth/chooselocation URL with the credentials of the requested version, defaulting to new_install_app_version.
  • Token refresh is per stored app_version — a v1 token must refresh with v1 credentials and a v2 token with v2 credentials, otherwise GHL returns invalid_grant. The app_version is carried through the OAuth state.
  • v2 is now the default for new installsgoHighLevel.new_install_app_version reads GO_HIGH_LEVEL_NEW_INSTALL_APP_VERSION with default v2 (app/configs/goHighLevel.js); an agency-level go_high_level_install_version override still takes precedence. The frontend v2 UI is gated by VITE_GHL_V2_LIVE.

Marketplace-directory (stateless) install

The app is public-listed on the GHL marketplace, so installs can start GHL-side with no OAuth state:

  • A no-state callback on GET /ghlinstallation/install redirects to the SPA with ?ghl_code=<single-use code>; the code survives login/register/social auth and is bound to the logged-in user via the authenticated complete-install endpoint (GoHighLevelController.completeInstall).
  • Directory installs are two-step: an agency-level token (no locationId) lands on ghl_self_account / the agency's go_high_level and the bind returns needs_mapping: true; the sub-account step maps a location onto company.go_high_level and creates the custom menu.
  • The install code carries no app identity — _exchangeDetectingApp() tries the configured app version, falls back to the other, and stamps whichever validated.
  • Custom menu: _createSubAccountMenu() (best-effort, never breaks the bind) creates an iframe menu pointing at <portal_url>/go-high-level/<secret>, titled with the company name (web owner) or the agency's go_high_level_name.

v2 scopes are broad: contacts.readonly/.write, conversations.*, opportunities.readonly/.write, calendars.readonly, calendars/events.readonly/.write, locations.readonly, locations/customFields.*, locations/customValues.readonly, locations/tags.*, users.readonly, medias.*, custom-menu-link.*, oauth.*. (v1 scope set is narrower.)

Direction: bi-directional.

?contact_id= query-param landing

GHL workflows commonly send links like https://yoursite.com/page?contact_id=abc123. The widget reads the URL params; if contact_id is present it's sent to the backend, which looks up the contact in GHL and resolves it to a Person + PersonProfile. The session becomes directly verified (identity-graph direct trigger), so campaign visitors are instantly known. (If verification fails: the contact may have been deleted, the token expired, or the contact_id belongs to a different location.)

Form intercept

Handles standard GHL forms (V1), new V2 forms, white-label GHL domains (auto-detected via dynamic origin discovery), lc-form-submit postMessage events, and contact-id payloads embedded in form events — capturing email/phone/name to the identity graph regardless of how GHL processes the form server-side.

Outbound sync (Knock Knock → GHL)

CrmSyncQueueService (sync-contact, sync-ghl-messages) upserts the contact (find by email/phone via the advanced search API, then upsertContact/saveContact/updateContact). New contacts are stamped with source next-level-app. Visitor intelligence is written as a note (not custom fields, due to scope limits) via the Conversations/contacts notes API — syncContactIntel() posts intent score, pages viewed, last visit, and source, and applies tags: nox-identified, plus nox-intent-hot (score ≥ 50) or nox-intent-warm (≥ 25). ghl_contact_id is persisted on the PersonProfile.

Inbound enrichment (GHL → Knock Knock)

CrmEnrichmentQueueService refresh caches lifecycle/pipeline stage, opportunities (stage + value), tags, custom fields, and recent notes on PersonProfile.crm_enrichment (crm_enrichment.ghl), surfaced in the AI prompt. Refresh is event-driven; the cache is roughly hourly-fresh and force-refreshable by admins.

Calendar booking (embed)

With companies.go_high_level.booking_enabled + a calendar_id, the widget's "Book a meeting" CTA opens the GHL calendar embed. selectBookingCalendar() prefers the configured calendar_id, else the first bookable non-personal active calendar. The AI agent's booking goes through ms-ai's calendar router → backend POST /go-high-level/booking-available-dates and POST /go-high-level/book-calendar; GHL returns timezone-aware slot strings that are used as-is for booking. V2 calendars are supported via the new selection UI.

POST /go-high-level/book-calendar back-fills the contact's phone (mirroring the email back-fill) — only when the CRM field is empty, never overwriting an existing value. Conversational-AI bookings (ms-ai book_meeting) arrive with only email + user_session_id, so phone/name fall back to the identity stamped on the user_session (phone_number/name, ignoring placeholder names like "Guest").

Post-call sync extras

  • Recordings as playable Conversations messagesGoHighLevelService.sendCallRecording() posts an inbound Live_Chat message with the recording URL as an attachment; GHL fetches, re-hosts, and renders an inline audio player. Needs only conversations/message.write (not medias.*, which v1 installs lack). The play URL carries a deliberate .mp3 suffix (/elevenlabs/play-recording/<id>.mp3) so CRMs sniff it as audio instead of a generic document (AiCallGhlTranscriptSyncJob).
  • Per-agent summary field — outbound agents can set post_call_summary_field; OutboundCallGhlSyncJob writes the call summary into that GHL custom field.
  • Post-call outcome tagsGhlPostCallTagsService (shared by OutboundCallGhlSyncJob and the inbound AiCallContactExtractedController) tags the contact per agent_keypost_call_tags: builtin outcomes no_answer/voicemail/booked/qualified (outbound) or booked/qualified (inbound), plus custom rules. A clobber guard refuses to write when the existing-tag read fails, so other tags are never wiped.

Automation event mirroring

GHL workflow webhooks fire to the inbound route POST /go-high-level/webhook. Workflow/automation events (workflow_started, sms_sent, email_sent, tag_added, pipeline_stage_changed, …) are normalized into the ms-ai action vocabulary and forwarded to ms-ai, stored as knox_ghl_automation_event and surfaced through Nox's knox-automation-activity tool — useful for "did the automation actually fire on John's contact?". Pipeline-stage changes are also forwarded to ms-ai's stage event endpoint.

Nox action layer (draft → approve → send)

Nox can act in GHL: NoxGhlActionService drafts a follow-up (draftFollowup, status drafted), selects a channel (auto = SMS if a phone is known, else email), and on approval (approveAndSend) sends via GHL. It's honesty-gated — status flips to sent only when GHL returns a messageId/emailMessageId — and idempotent (deterministic hash key; retries never double-send). Autonomous send is off by default (company.knox.actions.autonomous_send). Sends are mirrored back to ms-ai as nox_write events. See Nox Assistant.

Configuration & options

ActionRoute
OAuth install callbackGET /ghlinstallation/install
Get install URL (tenant / agency)POST /go-high-level/install-url, /install-url/agency
Uninstall (tenant / agency)POST /go-high-level/uninstall, /uninstall/agency
Manual token refreshPOST /go-high-level/refresh-access-token/company
List / select calendarsGET / PUT /go-high-level/calendars
Contact profileGET /go-high-level/contacts/:contact_id/profile
Booking availability / bookPOST /go-high-level/booking-available-dates, /book-calendar
Inbound webhookPOST /go-high-level/webhook
GHL custom action (agents / outbound call)POST /go-high-level/action/agents, /action/outbound-call
  • Custom-field syncing requires explicit per-company mapping.
  • Clearbit → GHL enrichment: companies.clearbit_contact_ghl.enable = true used to push Clearbit-enriched contacts to GHL — currently disabled globally: LeadContactSyncJob (the job pushing enrichment-guessed contacts into GHL/HubSpot) is commented out of SchedulerService.
  • Permissions: team members granted settings.integrations can manage all integrations (was owner-only 403s) — GeneralHelper check.
  • Agency / white-label: agencies can configure GHL globally and reuse the connection with per-tenant overrides; white-label GHL domains (e.g. crm.youragency.com) are recognized by form intercept and the OAuth callback.

Behaviors & edge cases

  • Inbound webhook verification: primary is Ed25519 over the raw request body (X-GHL-Signature header, verified against goHighLevel.webhook_public_key PEM SPKI); a legacy fallback compares customData.secret_key to the stored secret_key (deprecated). Verification requires the raw body — re-stringified JSON won't match.
  • Rate limiting / resilience: ghlResilience.js enforces a per-location sliding window (~90 req/10s, under GHL's ~100/10s), retries 429/5xx with exponential backoff + jitter (honoring Retry-After), caches custom-field/calendar/pipeline IDs (1h TTL), and uses a Redis-backed idempotency store (24h) to dedupe sends/bookings.
  • Cron sweepers: OutboundCallGhlSyncJob (1 min) and AiCallGhlTranscriptSyncJob (5 min) post call transcripts as notes; KnoxGhlSyncJob (nightly 03:00) does pipeline-stage sync + new-visitor backfill.
  • Stage queries currently rely on a manual pipeline_stage_breakdown aggregation; full deal-flow joins are roadmapped (see capabilities status).
  • Dormant-contact search must filter on dateUpdated — other field names 422 (probed + cached in prod, GoHighLevelService).
  • DND is per-channeldndSettings.Email / dndSettings.SMS block just that channel; check the channel, not only the global dnd flag (NoxGhlActionService._channelDnd).
  • Disconnect now fully uninstalls the app GHL-side: uninstallApp() removes both the sub-account (location) and company-level (ghl_self_account) installs via marketplace.uninstallApplication; uninstallAgencyApp() removes the agency-level install, and agency uninstall iterates sub-accounts. Failures are logged, never swallowed. Historical GHL data stays; cached enrichment IDs remain but stop refreshing.
  • GhlTipsEmailJob is stopped (commented out of SchedulerService) — no more weekday GHL tips emails.

Plan & limits

  • Available on all plans (verify exact tier gating).
  • High-volume tenants may see sync lag under GHL rate limits — the queue backs off gracefully rather than dropping work.

Technical implementation

  • Owning services: backendapp/services/GoHighLevelService.js (~61 KB), NoxGhlActionService.js, IdentityGhlReconcileService.js; routes app/routes/goHighLevelRoutes.js; webhook verify app/lib/ghlWebhookVerify.js; resilience app/lib/ghlResilience.js. Automation/stage mirroring + Nox tools in ms-ai.
  • Sync queues: CrmSyncQueueService.js (crm-sync), CrmEnrichmentQueueService.js (crm-enrichment).
  • Subsystem: CRM & External Integration · Identity Graph · Nox Assistant.

What Nox can tell you

  • Connection state and pipeline structure (knox-crm(query=pipelines)).
  • Hot leads silent (in pipeline but not visited) — knox-crm(query=hot_leads_silent).
  • Identified visitors on site right now — knox-crm(query=presence_identified).
  • A contact's full session history — knox-crm(query=contact_sessions, contact_id).
  • Automation events for a contact — knox-automation-activity.

Tools: knox-crm, knox-automation-activity, knox-action-feed.