Skip to content

Goal

GoHighLevel (GHL) is the CRM most agencies and local-business marketers already pay for, and it shows up in most CRM-automation job posts. GHL's own workflows are great at sequences but weak at AI and custom logic. This module makes n8n GHL's "brain": GHL sends each new lead to n8n, n8n qualifies it, and GHL acts on the tags n8n writes back.

Prerequisites

  • Modules 03 (webhooks + validation), 06 (Information Extractor), 07 (dry runs)
  • For the live part: a GoHighLevel sub-account (agency trial or a client's) and an OpenAI key

Concepts

  • GHL → n8n: outbound webhooks. In a GHL workflow, the Custom Webhook action lets you define the JSON body with merge fields. The older Webhook action sends standard contact fields and puts your extra fields under customData. The normalizer accepts both.
  • n8n → GHL: the LeadConnector API. Base URL https://services.leadconnectorhq.com, an Authorization: Bearer <token> header (from a Private Integration in the sub-account) and a Version: 2021-07-28 header on every call.
  • Two-stage scoring. Rules are free, instant and explainable ("+25 business email"). AI understands the message against your offer. Blending them (40/60) beats either alone, and the rule signals make the AI's verdict auditable.
  • Write back small, act in GHL. n8n only adds tags and a note. GHL workflows triggered by Tag Added handle the rest (pipelines, SMS, assignments), so the client keeps control in the tool they know.
  • Dry run + mock API. You can build and test everything without touching a real CRM: ?dry_run=true skips AI and writes, and a mock API in n8n (Exercise 3) lets you test the write-back.

Build it

Step 1: Send leads from GoHighLevel

In GHL → Automation → Workflows → new workflow:

  • Trigger: Contact Created (or Form Submitted).
  • Action: Custom Webhook, method POST, URL https://<your-n8n>/webhook/ghl/lead, body (JSON) using GHL merge fields, for example:
{
  "contact_id": "{{contact.id}}",
  "first_name": "{{contact.first_name}}",
  "last_name": "{{contact.last_name}}",
  "email": "{{contact.email}}",
  "phone": "{{contact.phone}}",
  "source": "{{contact.source}}",
  "company_name": "{{contact.company_name}}",
  "message": "{{contact.your_message_field}}"
}

Use GHL's merge-field picker to get the exact field keys for your account, especially for custom fields like the message. Your n8n must be reachable from the internet (Module 12 server or a tunnel).

Step 2: Settings and normalize

  • Webhook GHL Lead Webhook: POST ghl/lead, respond via node.
  • Edit Fields Settings (edit me) (include other fields): ghl_base_url, ghl_api_version = 2021-07-28, hot_threshold 70, warm_threshold 40, the three tag names, and offer: a paragraph describing what you sell and your ideal client. The AI scores against this, so make it specific.
  • Code Normalize GHL Payload (workflow/code/normalize.js): a pick() helper reads each field from customData, the body, or body.contact, under several possible key names (contact_id/contactId/id, first_name/firstName…), and returns one clean lead plus dry_run from the query string.
  • IF Has Contact ID? → false: Respond 400 explaining how to map the contact id. Without the id you can't write back.

Step 3: Rule score

Code Rule Score (rule-score.js): add points per signal and keep a human-readable list. Business email +25, phone +15, company/website +10, detailed message +15, high-intent source +10, budget words +15, urgency +10, capped at 100.

Step 4: AI qualification

IF Dry Run? → false: Information Extractor AI Qualify with the offer + lead as text and attributes fit_score (number, with a rubric), intent, budget_signal, summary, next_step. System prompt: skeptical, concise, never invent company details. Model: OpenAI gpt-5-mini.

Step 5: Combine

Code Combine Scores (combine.js), fed by both the dry-run and AI paths: score = 0.4·rule + 0.6·AI (rules only in a dry run), tier by thresholds, tag from Settings, and a multi-line note for the CRM.

Step 6: Write back to GHL

In the sub-account: Settings → Private Integrations → create one with the contacts read/write scopes and copy the token. In n8n create a Header Auth credential: name Authorization, value Bearer <token>.

IF Write to GHL? (not a dry run) →

  • HTTP Request Add Tag in GHL: POST {{ ghl_base_url }}/contacts/{{ contact_id }}/tags, Authentication Generic → Header Auth, headers Version: 2021-07-28 and Accept: application/json, JSON body { "tags": [tag, "ai-qualified"] }. Settings → Retry On Fail (3×, 2 s) for rate limits.
  • HTTP Request Add Note in GHL: POST …/contacts/{{ contact_id }}/notes, body { "body": note }.
  • → Respond with $('Combine Scores').item.json, plus IF Hot Lead? → Alert Sales (Exercise 1).

Step 7: Let GHL act on the tags

Back in GHL, create workflows triggered by Contact Tag added = ai-hot (move to "Call now" stage, assign owner, SMS the rep) and ai-cold (nurture sequence).

Test it

./test/test.sh                                              # 14 dry-run checks
WITH_CREDS=1 GHL_CONTACT_ID=<test contact> ./test/test.sh   # + AI + real tag/note on that contact

Common problems

  • 400 "contact_id is required": the GHL webhook body doesn't include the contact id merge field.
  • 401 / 403 from GHL: wrong token, missing Bearer , missing contacts write scope, or a token from a different sub-account than the contact.
  • 422 / "version header": the Version: 2021-07-28 header is missing.
  • Everything is "cold": the message field isn't mapped (check the normalizer's output), or your offer is too vague for the AI to judge fit.
  • Duplicate notes: GHL retried the webhook, or the trigger fires on every contact update. Use Contact Created, or dedupe on contact_id (Exercise 2).
  • Literal {{contact.id}} in a Respond/Set expression breaks n8n: n8n treats {{ }} as its own expression syntax, so describe merge fields in plain words inside n8n expressions.