Module 14 · business · intermediate
Solutions 14 · GoHighLevel to n8n Bridge
Exercise 1
- In
Normalize GHL Payload,location_idis already extracted. Map{{location.id}}into the GHL webhook body aslocation_idif your payload doesn't include it. - In
Combine Scores, addlocation_id: lead.location_idandphone: lead.phoneto the returned object. - Replace the No Operation node with Slack/Telegram:
🔥 Hot lead ({{ $('Combine Scores').item.json.score }}/100): {{ $('Combine Scores').item.json.name }}
{{ $('Combine Scores').item.json.summary }}
Next: {{ $('Combine Scores').item.json.next_step }}
https://app.gohighlevel.com/v2/location/{{ $('Combine Scores').item.json.location_id }}/contacts/detail/{{ $('Combine Scores').item.json.contact_id }}
Check the contact URL format in your own GHL account; white-labelled agencies use their own domain.
Exercise 2
Between Dry Run? (false) and AI Qualify, add Remove Duplicates:
- Operation: Remove Items Processed in Previous Executions
- Keep Items Where: Value Is New
- Value to Dedupe On:
{{ $json.contact_id }}
Remove Duplicates outputs nothing for a repeat, so the webhook would get an empty response. Turn on Always Output Data on it and add an IF Is Repeat? ({{ $json.contact_id }} is empty) → Respond { "skipped": "already qualified" }; otherwise continue to AI Qualify. Production executions keep the dedupe history; manual test runs use a separate history. (This solution has not been run end-to-end yet; verify it with the mock API from Exercise 3.)
Exercise 3
Mock workflow (verified on n8n 2.43.6):
Webhook "Mock Contacts API" POST mock-ghl/contacts/:contactId/:kind (respond: when last node finishes)
→ Code "Record":
const db = $getWorkflowStaticData('global'); db.calls = db.calls ?? [];
const j = $input.first().json;
db.calls.push({ path: `/contacts/${j.params.contactId}/${j.params.kind}`, version: j.headers.version,
auth: j.headers.authorization ?? null, body: j.body });
return [{ json: { succeded: true } }];
Webhook "Read Calls" GET mock-ghl/calls
→ Code "Dump":
const db = $getWorkflowStaticData('global'); const calls = db.calls ?? []; db.calls = [];
return [{ json: { calls } }];
Publish it. In a copy of the bridge:
Settings → ghl_base_url=http://localhost:5678/webhook/<Mock Contacts API webhook id>/mock-ghl. Webhooks with path parameters are registered under their webhook id, so the plain/webhook/mock-ghl/…returns 404 "webhook not registered".- HTTP nodes: Authentication None, add a header
Authorization: Bearer test-token. - Replace
AI Qualifywith an Edit Fields node (same name) that setsoutput(Object) to a fixed{ fit_score: 85, intent: …, summary: …, next_step: … }.
Post a lead without dry_run, then curl http://localhost:5678/webhook/mock-ghl/calls. Expected:
{ "calls": [
{ "path": "/contacts/abc123/tags", "version": "2021-07-28", "auth": "Bearer test-token",
"body": { "tags": ["ai-hot", "ai-qualified"] } },
{ "path": "/contacts/abc123/notes", "version": "2021-07-28", "auth": "Bearer test-token",
"body": { "body": "🤖 AI lead qualification: HOT (87/100)\n…" } } ] }
Mock APIs like this are how you build and demo client integrations before you get access to their accounts.
Stretch
Get your pipeline and stage ids once (GET /opportunities/pipelines?locationId=… with the same headers), store them in Settings, then for hot leads POST /opportunities/ with pipelineId, pipelineStageId, locationId, contactId, name, status: "open". Check the current LeadConnector API reference for the exact field names and required scopes before relying on them; this solution hasn't been run against a live account.