Documentation / GHL Connector

GHL Connector

The GHL Connector module integrates your Luminal CMS sites with Go High Level (GHL) CRM. It captures form submissions, stores leads locally, pushes them to GHL via API, and supports a hub-and-node architecture for managing leads across multiple sites from a single dashboard.

Note: GHL Connector is a server-only module. It appears under the Server Tools section of the admin menu and is only deployed to servers that manage external client sites.

How It Works

  1. A visitor submits a contact form on your site.
  2. The frontend script (ghl-form-hook.js) intercepts the form and sends the data via AJAX to /panels/ghl-submit.php.
  3. The submission endpoint stores the lead locally as JSON, pushes it to the GHL API, and sends an email notification via Mailgun.
  4. The admin dashboard shows all leads with status, filters, and export options.

Hub and Node Architecture

When managing multiple sites (e.g., several client domains on one server), GHL Connector supports a hub-and-node model:

Hub Mode

The hub site (e.g., charlesaltman.com) holds the GHL API key and receives leads from all node sites. It:

  • Stores all leads in its local leads/ directory.
  • Pushes each lead to GHL using its API key.
  • Shows a Source Domain column identifying which site originated each lead.
  • Has the Push unsent to GHL via API button to bulk-push any pending leads.

Node Mode

Node sites (e.g., avrentalmiami.com, streamteam123.com) don't need a GHL API key. They:

  • Store each lead locally as a backup.
  • Forward the lead to the hub site's /panels/ghl-submit.php via cURL.
  • Track delivery status: delivered, hub_failed, hub_error, or pending.
  • Show a Sent Leads tab with hub delivery status and retry controls.
  • Have a Forward to Hub button to retry failed deliveries.

How Mode Is Determined

Mode is set by the hub_url field in admin/data/GHLConnector/config.json:

  • Hub mode: No hub_url in config (or empty).
  • Node mode: hub_url is set to the hub site's URL (e.g., https://charlesaltman.com).

Setting Up the Hub

  1. Open GHL Leads from the Server Tools menu.
  2. Go to the Configuration tab.
  3. Enter your Go High Level API key and Location ID.
  4. Optionally configure Mailgun settings for email notifications.
  5. Add per-site overrides if different sites need different GHL location IDs or tags.
  6. Click Save Configuration.

Setting Up a Node

The easiest way to configure a site as a node is through the admin panel:

  1. Open GHL Leads from the Server Tools menu on the node site.
  2. Go to the Configuration tab.
  3. In the Hub / Node Mode card, check the Forward leads to a Hub site checkbox.
  4. Enter the hub site's URL in the Hub URL field (e.g., https://charlesaltman.com).
  5. Click Save Configuration.

The page will show a gold Node Mode bar, the GHL API key section will be greyed out (not needed on nodes), and the Sent Leads tab will appear.

Manual Method (SSH)

Optionally, if the site doesn't yet have a config.json (e.g., a freshly deployed site), you can create one manually via SSH:

  1. SSH to the node site's server.
  2. Create or edit admin/data/GHLConnector/config.json:
{
  "enabled": true,
  "store_leads": true,
  "hub_url": "https://your-hub-domain.com",
  "hub_enabled": true
}
  1. Ensure the file is owned by www-data: chown www-data:www-data config.json

Important: The admin/data/ directory is excluded from deployment rsync, so config files are not pushed automatically to new sites. Either use the admin panel to configure after deploy, or create the config file manually via SSH.

Admin Dashboard

The GHL Leads admin page has three tabs:

Leads Tab (Default)

  • Stats bar: Total, Synced, Failed, Skipped, and Queued lead counts.
  • Filters: Status filter, domain filter, and source domain filter (hub mode).
  • Leads table: Day, Date, Source Domain (hub only), Name, Email, Phone, GHL Status.
  • Click any row to open the lead detail viewer.
  • Push unsent to GHL via API button (hub) or Forward to Hub (node) for bulk retries.
  • Export: CSV and Markdown export buttons.

Sent Tab (Node Mode Only)

  • Shows leads forwarded to the hub with delivery status.
  • Stats: Total Sent, Delivered, Failed, Pending.
  • Forward to Hub button retries all failed deliveries.

Configuration Tab

  • Hub/Node Mode: Toggle between hub and node. Checking "Forward to Hub" enables hub URL input and greys out the API key section.
  • GHL API Settings (hub only): API version selector (v1 or v2), API key, Location ID.
  • Mailgun Settings (hub only): Domain, API key, sender, recipients.
  • Per-Site Overrides: Expandable sections for each domain with custom Location ID, tags, and notification email.

Lead Detail Viewer

Click any lead row to open the detail viewer — a centered popup showing:

  • Contact name in large bold text.
  • Source domain prominently displayed (hub mode).
  • Date and time of submission.
  • Subject or interest if present in the form fields.
  • Contact card with clickable email and phone links.
  • Full message content.
  • GHL Status — a collapsible section showing sync details. Click to expand for GHL Contact ID, error messages, email status, and retry history.

The toolbar at the top provides Print (opens browser print dialog for PDF), Retry (re-send to GHL or hub), and Delete buttons.

Form Integration

The frontend form hook script (ghl-form-hook.js) is automatically loaded on pages when GHL Connector is enabled. It detects forms by CSS class:

  • .ca-form — charlesaltman.com forms
  • .fyh-form — 40yearhelp.com forms
  • .avr-form — avrentalmiami.com forms

The script replaces existing onsubmit handlers with AJAX submission to /panels/ghl-submit.php. Forms can also use the data-ghl-form attribute for explicit opt-in.

Tip: Any HTML form can be connected to GHL by adding the data-ghl-form attribute to the <form> element. The script will auto-detect it regardless of CSS class.

Rate Limiting

The submission endpoint enforces a rate limit of 20 submissions per hour per IP address using file-based tracking in /tmp/ghl_rate_{hash}. This prevents spam without requiring a database.

Data Storage

  • Config: admin/data/GHLConnector/config.json
  • Leads (hub): admin/data/GHLConnector/leads/{YYYY-MM}.json — monthly files
  • Sent (node): admin/data/GHLConnector/sent/{YYYY-MM}.json — monthly files

Each lead record includes: ID, domain, timestamp, form fields, GHL sync status, GHL contact ID, email status, and (on hub) source site.

GHL API Versions (v1 vs v2)

GHL Connector supports both GHL API v1 (legacy) and v2 (current). The version is selected in the Configuration tab via the API version dropdown. The choice of version affects authentication, endpoint URLs, request headers, and how contact data is structured in API calls.

v1 (Legacy)

  • Base URL: https://rest.gohighlevel.com/v1
  • Auth header: Authorization: Bearer {API_KEY}
  • Uses simple API keys obtained from GHL Settings > Business Profile > API Key.
  • GET /contacts/ for listing contacts, POST /contacts/ for creating new contacts.
  • Supports the customField property (singular) as a key-value object for passing extra form data (e.g., "customField": {"interest": "booking", "role": "manager"}).
  • No locationId required in request bodies.
  • No Version header required.

End of Life: GHL v1 API is deprecated and no longer supported for new integrations. Existing v1 keys may stop working without notice. All new setups should use v2.

v2 (Current — Recommended)

  • Base URL: https://services.leadconnectorhq.com
  • Auth header: Authorization: Bearer {PRIVATE_INTEGRATION_TOKEN}
  • Version header: Version: 2021-07-28 — required on ALL requests. Omitting it causes silent failures or 400 errors.
  • Uses Private Integration tokens (not simple API keys). These start with pit- and are long JWT strings.
  • locationId is REQUIRED in POST request bodies (create/upsert). Without it, the API returns a validation error.
  • GET /contacts/ is DEPRECATED — use POST /contacts/search instead for listing/searching contacts.
  • POST /contacts/upsert for creating or updating contacts. This endpoint handles duplicates automatically (see Smart Duplicate Handling below).
  • Does NOT support customField (singular). Sending it causes the API to reject the request with "property customField should not exist". The v2 API uses customFields (plural) with an array of {id, value} objects, where each id must be a pre-configured custom field ID from your GHL location settings.
  • Because custom field IDs are location-specific and require manual GHL setup, the connector instead packs extra form fields (interest, role, message, etc.) into tags and the source field. This approach works out of the box without any GHL-side custom field configuration.

Key Differences Table

Featurev1 (Legacy)v2 (Current)
Base URLhttps://rest.gohighlevel.com/v1https://services.leadconnectorhq.com
Auth headerBearer {API_KEY}Bearer {PIT_TOKEN}
Version headerNot requiredVersion: 2021-07-28 (required)
locationId in bodyNot requiredRequired on all POST requests
Create contactPOST /contacts/POST /contacts/upsert
Search contactsGET /contacts/POST /contacts/search
Custom fieldscustomField (singular, key-value object)customFields (plural, array of {id, value})
Duplicate handlingRejected with error if contact existsUpsert — updates existing contact automatically
Token typeSimple API keyPrivate Integration token (pit- prefix)

Private Integration Token Setup

Getting the Private Integration token right is the single most important step when setting up GHL Connector v2. An incorrect token type, wrong scope selection, or Agency-vs-Sub-Account confusion accounts for the vast majority of setup failures. Follow these instructions carefully.

Step-by-Step Setup

  1. Log into app.gohighlevel.com with your GHL account.
  2. Navigate to Settings (gear icon in left sidebar).
  3. Click Integrations in the left menu, then select Private Integrations.
  4. Click Create Private Integration.
  5. CRITICAL: When prompted for integration level, choose Sub-Account (NOT Agency). This is the most common mistake — see the Agency vs Sub-Account section below for why this matters.
  6. Select the specific sub-account/location you want the connector to manage. Each Private Integration token is scoped to one location.
  7. On the scopes screen, enable the following:
    • contacts.readonly — Required for searching and viewing contacts.
    • contacts.write — Required for creating and updating contacts (upsert endpoint).
    • locations.readonly — Used by the Test Connection diagnostic to verify the token and retrieve the location name.
  8. Click Save to create the integration.
  9. Copy the token immediately. GHL displays the Private Integration token only once at creation time. If you lose it, you must delete the integration and create a new one.
  10. Paste the token into the GHL Connector Configuration tab as the API key. It will start with pit-.
  11. Enter the Location ID from your GHL location settings (Settings > Business Profile > Location ID, or visible in the URL when viewing the sub-account).
  12. Click Test Connection to verify everything is working.

Agency vs Sub-Account — The Critical Gotcha

This is the #1 source of confusion when setting up GHL v2 integration. GHL offers two levels of Private Integration:

Agency-level tokens have administrative scopes only. Their available scopes include: companies.readonly, companies.write, locations.readonly, locations.write, snapshots.readonly, users.readonly, users.write, twilio.readonly, phone-numbers.readonly, documents.readonly, and similar administrative operations. They do NOT have CRM-level scopes like contacts.readonly, contacts.write, conversations.readonly, calendars.readonly, opportunities.readonly, or any other scopes needed for day-to-day CRM operations.

Sub-Account-level tokens have the CRM scopes needed for contact management: contacts.readonly, contacts.write, conversations.readonly, conversations.write, calendars.readonly, calendars.write, opportunities.readonly, opportunities.write, and more.

The trap: If you create an Agency-level Private Integration, the Test Connection location check will pass (because Agency tokens DO have locations.readonly). But ALL contacts endpoints will fail with "The token is not authorized for this scope". This makes it look like your token is valid but contacts are broken — when the real problem is that you chose the wrong integration level.

If you already created an Agency-level integration by mistake, you cannot convert it. You must:

  1. Delete the Agency-level Private Integration in GHL.
  2. Create a new Private Integration, this time selecting Sub-Account.
  3. Select your location and enable the required scopes.
  4. Copy the new token and update it in GHL Connector configuration.

Required Scopes

ScopePurposeRequired?
contacts.readonlySearch and view existing contacts via POST /contacts/searchYes
contacts.writeCreate and update contacts via POST /contacts/upsertYes
locations.readonlyVerify token validity and retrieve location name during Test ConnectionRecommended

Without contacts.readonly, the Test Connection diagnostic cannot verify contact access. Without contacts.write, lead pushes will fail even though the connection test passes.

Token Format and Handling

  • Private Integration tokens start with the prefix pit- followed by a long JWT string (typically 200+ characters).
  • The Authorization header must include the Bearer prefix: Authorization: Bearer pit-xxxx.... Removing or omitting Bearer causes "Invalid JWT" errors.
  • Private Integration tokens do not expire automatically. However, GHL recommends rotating tokens every 90 days as a security best practice.
  • Tokens can be revoked at any time from the GHL Private Integrations settings page, which will immediately break all API calls using that token.
  • Each token is scoped to a single sub-account/location. If you manage multiple GHL locations, you need separate Private Integrations (and therefore separate tokens) for each.

Smart Duplicate Handling (Upsert)

When the same person submits forms on multiple sites in your network, you don't want duplicate contacts cluttering your GHL CRM. The v2 connector handles this automatically using GHL's upsert endpoint.

How It Works

  1. When a lead is pushed to GHL, the connector sends a POST /contacts/upsert request instead of a plain POST /contacts/ create.
  2. GHL checks if a contact with the same email address already exists in the location.
  3. If the contact does not exist: GHL creates a new contact record. The lead gets status synced (green badge).
  4. If the contact already exists: GHL updates the existing record with any new data and appends new tags. The lead gets status synced_updated (blue badge) to indicate an existing contact was enriched rather than a new one created.

This means a person who fills out forms on avrentalmiami.com and streamteam123.com will appear as one contact in GHL with tags from both sites, not as two separate duplicates.

Lead Status Badges

The Leads tab displays a colored badge for each lead indicating its GHL sync status:

BadgeStatusMeaning
syncedsyncedNew contact successfully created in GHL.
synced_updatedsynced_updatedExisting contact found and updated with new tags/data (v2 upsert).
dupe_rejecteddupe_rejectedv1 only — contact already existed and GHL rejected the duplicate. Does not occur on v2 (upsert handles it).
failedfailedAPI error — check the lead detail viewer for the error message.
skippedskippedLead has not been pushed to GHL yet (queued or awaiting retry).

What Gets Sent to GHL (v2 Payload)

When the connector pushes a lead to GHL via the v2 API, the contact payload includes:

{
  "firstName": "Jane",
  "lastName": "Doe",
  "email": "jane@example.com",
  "phone": "+13055551234",
  "companyName": "Acme Corp",
  "source": "Website - avrentalmiami.com | Interest: booking | Role: manager",
  "tags": [
    "website-lead",
    "avrentalmiami.com",
    "interest:booking",
    "role:manager"
  ],
  "locationId": "abc123def456"
}
  • Standard fields (firstName, lastName, email, phone, companyName) are mapped directly from form inputs.
  • Source field: Formatted as "Website - {domain} | {field}: {value} | ..." with all extra form fields appended. This appears in the GHL contact's source/attribution section.
  • Tags: Always includes website-lead and the source domain. Extra form fields (interest, role, subject, etc.) are added as tags in field:value format if the combined string is 60 characters or fewer. Longer values are included in the source field only.
  • locationId: Required by v2. Pulled from the GHL Connector configuration or per-site override.

Why tags instead of custom fields? GHL v2's customFields requires pre-configured field IDs that are specific to each GHL location. Tags work universally without any GHL-side setup, and they're searchable in the GHL CRM. For most lead capture use cases, tags provide all the metadata you need.

Flight Recorder (API Diagnostic Tool)

When you click Test Connection in the Configuration tab, a Flight Recorder modal automatically pops up showing the full API conversation between the connector and GHL. This is your primary diagnostic tool for troubleshooting authentication, scope, and payload issues.

Diagnostic Sequence

The Flight Recorder runs a multi-step diagnostic that tests each API capability in sequence:

Step 1: Location Verification

GET /locations/{locationId} — Verifies that the token is valid and the Location ID is correct. On success, it displays the location name (e.g., "ClickHappens") confirming the connection is live. If this step fails:

  • 401 Unauthorized: The token is invalid, expired, or missing the Bearer prefix.
  • 404 Not Found: The Location ID doesn't match any location accessible by the token.
  • 400 Bad Request: The Version: 2021-07-28 header may be missing (v2 only).

Step 2: Contacts Endpoint Testing

The diagnostic tests multiple contacts endpoints to determine which scopes are active:

  • GET /contacts/ — The deprecated v1-style listing endpoint. Expected to fail on v2 with a deprecation notice.
  • POST /contacts/search — The v2 contacts search endpoint. Requires contacts.readonly scope. Sends an empty search to verify access.
  • POST /contacts/upsert — The v2 create/update endpoint. Requires contacts.write scope. The test does not create a real contact — it verifies the endpoint is accessible and the scope is authorized.

What the Flight Recorder Shows

For each API call in the diagnostic sequence, the Flight Recorder displays:

  • Timestamp — When the request was made.
  • Method + URL — e.g., GET https://services.leadconnectorhq.com/locations/abc123.
  • Request headers — Including Authorization (with the API key masked for security, showing only the first and last few characters), Version, and Content-Type.
  • Request body — The JSON payload sent (if any), formatted for readability.
  • HTTP response code — Color-coded: green for 2xx success, yellow for 4xx client errors, red for 5xx server errors.
  • Response headers — Collapsible section showing all response headers from GHL.
  • Response body — The full JSON response from GHL, which typically contains descriptive error messages when something goes wrong.

Tip: The Flight Recorder is invaluable for diagnosing the Agency-vs-Sub-Account scope issue. If Step 1 (location check) passes but Step 2 (contacts search or upsert) fails with "The token is not authorized for this scope", you almost certainly created an Agency-level integration instead of a Sub-Account one. See the Private Integration Token Setup section above.

Troubleshooting

"The token is not authorized for this scope"

This is the most common v2 error. It means you created an Agency-level Private Integration instead of a Sub-Account one. Agency tokens have administrative scopes (companies, locations, users) but lack CRM scopes (contacts, conversations, calendars). The fix:

  1. Go to GHL Settings > Integrations > Private Integrations.
  2. Delete the Agency-level integration.
  3. Create a new Private Integration at the Sub-Account level.
  4. Select your specific location/sub-account.
  5. Enable contacts.readonly, contacts.write, and locations.readonly scopes.
  6. Copy the new pit- token and paste it into GHL Connector's Configuration tab.
  7. Click Test Connection — all three diagnostic steps should now pass.

"Invalid JWT"

The token is malformed or the Authorization header format is incorrect. GHL v2 Private Integration tokens require the Bearer prefix in the header: Authorization: Bearer pit-xxxx.... Common causes:

  • The token was not fully copied (truncated during paste).
  • Extra whitespace or newline characters were included when pasting.
  • The Bearer prefix was manually removed or not included.
  • The token was revoked in GHL's Private Integrations settings.

Open the Flight Recorder and check the Authorization header in the request — verify the token format is correct.

"property customField should not exist"

This error occurs when the v2 API receives a request body containing the v1-style customField property (singular). The v2 API strictly rejects unknown properties. This was fixed in the February 2026 GHL Connector update — extra form fields are now packed into tags and the source field instead of custom fields. To resolve:

  • Ensure your GHL Connector module code is up to date (check the CMS version in the admin dashboard).
  • If you recently updated, clear any cached API payloads by retrying the failed leads.
  • If you're running custom form integration code outside the connector, update it to omit customField from the v2 payload.

"This location does not allow duplicated contacts"

This error only appears on the v1 API, which uses POST /contacts/ (plain create). v1 cannot update existing contacts — if the email already exists, the request is rejected. The v2 connector avoids this entirely by using the upsert endpoint, which updates existing contacts instead of rejecting them. If you see this error while configured for v2, your module code may not be up to date — verify you're running the latest version.

Test Connection works but leads fail

The Test Connection diagnostic checks auth (location verification) and contacts search (contacts.readonly scope). However, actually creating/updating contacts via lead push requires the contacts.write scope. If Test Connection passes but lead pushes fail with a scope error:

  1. Go to GHL Settings > Integrations > Private Integrations.
  2. Edit your Private Integration.
  3. Verify that both contacts.readonly AND contacts.write are enabled.
  4. Save and re-test. You do not need to create a new token — scope changes apply to the existing token.

Flight Recorder shows errors

Click Test Connection and examine the Flight Recorder modal. GHL's API responses almost always contain descriptive error messages in the JSON body. Common HTTP status codes and their meanings:

  • 401 Unauthorized — Authentication issue. The token is invalid, expired, revoked, or missing the Bearer prefix. Re-check the token.
  • 400 Bad Request — The request payload is malformed. Check for missing required fields (locationId, Version header). The response body will specify which field is problematic.
  • 422 Unprocessable Entity — Validation error. GHL accepted the request format but a field value is invalid (e.g., malformed email, phone number format). Check the response body for the specific validation message.
  • 403 Forbidden — The token doesn't have permission for this specific operation. Usually a scope issue — see the scope troubleshooting above.
  • 429 Too Many Requests — GHL rate limit exceeded. The connector will automatically retry on the next push cycle. If you're bulk-pushing many leads, space them out.

Leads showing "Skipped" on hub

The GHL API key may be missing or expired. Go to Configuration tab and verify the API key. Use the Push unsent to GHL via API button after fixing the key.

Node leads stuck at "Pending"

The hub site may be unreachable. Check the hub URL in the node's config. Click Forward to Hub to retry all pending leads.

Forms not being intercepted

Ensure the form has one of the recognized CSS classes or the data-ghl-form attribute. Also verify that config.json exists with "enabled": true on the site — the hook script only loads when the module is enabled.

No GHL Leads menu item

GHL Connector is a server-only module. It requires an entry in the site's admin_menu_local.php under Server Tools. This file is not overwritten by deployments.