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 remaingo_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.
| Flavor | What | Stored on |
|---|---|---|
| OAuth (location) | Standard tenant connection to a GHL location | companies.go_high_level.* |
| Self-account | Agency/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 themarketplace.leadconnectorhq.com/oauth/chooselocationURL with the credentials of the requested version, defaulting tonew_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 returnsinvalid_grant. Theapp_versionis carried through the OAuthstate. - v2 is now the default for new installs —
goHighLevel.new_install_app_versionreadsGO_HIGH_LEVEL_NEW_INSTALL_APP_VERSIONwith defaultv2(app/configs/goHighLevel.js); an agency-levelgo_high_level_install_versionoverride still takes precedence. The frontend v2 UI is gated byVITE_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/installredirects 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 onghl_self_account/ the agency'sgo_high_leveland the bind returnsneeds_mapping: true; the sub-account step maps a location ontocompany.go_high_leveland 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'sgo_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 messages —
GoHighLevelService.sendCallRecording()posts an inboundLive_Chatmessage with the recording URL as an attachment; GHL fetches, re-hosts, and renders an inline audio player. Needs onlyconversations/message.write(notmedias.*, which v1 installs lack). The play URL carries a deliberate.mp3suffix (/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;OutboundCallGhlSyncJobwrites the call summary into that GHL custom field. - Post-call outcome tags —
GhlPostCallTagsService(shared byOutboundCallGhlSyncJoband the inboundAiCallContactExtractedController) tags the contact peragent_key→post_call_tags: builtin outcomesno_answer/voicemail/booked/qualified(outbound) orbooked/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
| Action | Route |
|---|---|
| OAuth install callback | GET /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 refresh | POST /go-high-level/refresh-access-token/company |
| List / select calendars | GET / PUT /go-high-level/calendars |
| Contact profile | GET /go-high-level/contacts/:contact_id/profile |
| Booking availability / book | POST /go-high-level/booking-available-dates, /book-calendar |
| Inbound webhook | POST /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 = trueused to push Clearbit-enriched contacts to GHL — currently disabled globally:LeadContactSyncJob(the job pushing enrichment-guessed contacts into GHL/HubSpot) is commented out ofSchedulerService. - Permissions: team members granted
settings.integrationscan manage all integrations (was owner-only 403s) —GeneralHelpercheck. - 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-Signatureheader, verified againstgoHighLevel.webhook_public_keyPEM SPKI); a legacy fallback comparescustomData.secret_keyto the storedsecret_key(deprecated). Verification requires the raw body — re-stringified JSON won't match. - Rate limiting / resilience:
ghlResilience.jsenforces a per-location sliding window (~90 req/10s, under GHL's ~100/10s), retries 429/5xx with exponential backoff + jitter (honoringRetry-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) andAiCallGhlTranscriptSyncJob(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_breakdownaggregation; 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-channel —
dndSettings.Email/dndSettings.SMSblock just that channel; check the channel, not only the globaldndflag (NoxGhlActionService._channelDnd). - Disconnect now fully uninstalls the app GHL-side:
uninstallApp()removes both the sub-account (location) and company-level (ghl_self_account) installs viamarketplace.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. GhlTipsEmailJobis stopped (commented out ofSchedulerService) — 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:
backend—app/services/GoHighLevelService.js(~61 KB),NoxGhlActionService.js,IdentityGhlReconcileService.js; routesapp/routes/goHighLevelRoutes.js; webhook verifyapp/lib/ghlWebhookVerify.js; resilienceapp/lib/ghlResilience.js. Automation/stage mirroring + Nox tools inms-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.