Module 14 · business · intermediate
Lesson 14 · GoHighLevel to n8n Bridge
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, anAuthorization: Bearer <token>header (from a Private Integration in the sub-account) and aVersion: 2021-07-28header 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=trueskips 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: POSTghl/lead, respond via node. - Edit Fields
Settings (edit me)(include other fields):ghl_base_url,ghl_api_version=2021-07-28,hot_threshold70,warm_threshold40, the three tag names, andoffer: 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): apick()helper reads each field fromcustomData, the body, orbody.contact, under several possible key names (contact_id/contactId/id,first_name/firstName…), and returns one clean lead plusdry_runfrom 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, headersVersion: 2021-07-28andAccept: 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 IFHot 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-28header is missing. - Everything is "cold": the message field isn't mapped (check the normalizer's output), or your
offeris 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.