Skip to content

Exercise 1

  1. In Normalize GHL Payload, location_id is already extracted. Map {{location.id}} into the GHL webhook body as location_id if your payload doesn't include it.
  2. In Combine Scores, add location_id: lead.location_id and phone: lead.phone to the returned object.
  3. 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 Qualify with an Edit Fields node (same name) that sets output (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.