{
  "name": "AcquisitionIQ - 03 Enrich & Deliver",
  "nodes": [
    {
      "parameters": {
        "workflowInputs": {
          "mappingMode": "passThrough"
        }
      },
      "id": "c3000000-0000-4000-8000-000000000001",
      "name": "When Called",
      "type": "n8n-nodes-base.executeWorkflowTrigger",
      "typeVersion": 1.1,
      "position": [
        -640,
        320
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.ACQIQ_API_URL }}/enrich",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-API-Key",
              "value": "={{ $env.ACQIQ_API_KEY }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ owner_name: $json.owner_name, mailing_address: $json.mailing_address, property_address: $json.property_address, state: $json.state, market_key: $json.market_key }) }}",
        "options": {
          "timeout": 45000
        }
      },
      "id": "c3000000-0000-4000-8000-000000000002",
      "name": "Skip Trace",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -400,
        320
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 4000,
      "onError": "continueRegularOutput",
      "notes": "The vendor key lives in the API service, not in this node. onError continues so a vendor outage yields a lead with no phone rather than losing the lead. maxTries is 2, not 5 - each attempt is a paid credit."
    },
    {
      "parameters": {
        "jsCode": "// Merge the qualification payload with the contact result into the row shape\n// Airtable expects. `Dedup Key` is the normalized property address: a house\n// that appears on the tax roll in March and the foreclosure docket in June is\n// one lead with two filings, not two rows for the team to call twice.\nconst normalizeAddress = (value) =>\n  (value || '')\n    .toLowerCase()\n    .replace(/[^a-z0-9#\\s]/g, ' ')\n    .replace(/\\bnorth\\b/g, 'n').replace(/\\bsouth\\b/g, 's')\n    .replace(/\\beast\\b/g, 'e').replace(/\\bwest\\b/g, 'w')\n    .replace(/\\bstreet\\b/g, 'st').replace(/\\bavenue\\b/g, 'ave')\n    .replace(/\\bdrive\\b/g, 'dr').replace(/\\broad\\b/g, 'rd')\n    .replace(/\\bboulevard\\b/g, 'blvd').replace(/\\bcourt\\b/g, 'ct')\n    .replace(/\\s+/g, ' ')\n    .trim();\n\nreturn $input.all().map((item) => {\n  const record = item.json.record || item.json;\n  const qualification = item.json.result || item.json.qualification || {};\n  const contact = item.json.contact || item.json;\n  const phones = contact.phones || [];\n  const emails = contact.emails || [];\n\n  return {\n    json: {\n      'Dedup Key': normalizeAddress(record.property_address),\n      'Record ID': record.record_id,\n      'Owner Name': record.owner_name,\n      'Property Address': record.property_address,\n      'Mailing Address': record.mailing_address || '',\n      'County': record.county,\n      'State': record.state,\n      'Market': record.market_key,\n      'Source Type': record.source_type,\n      'Source': record.source_name,\n      'Filing Date': record.filing_date || null,\n      'Estimated Value': record.signals?.estimated_value ?? null,\n      'Amount Owed': record.signals?.amount_owed ?? null,\n      'Equity %': record.signals?.equity_pct ?? null,\n      'Year Built': record.signals?.year_built ?? null,\n      'Absentee Owner': record.signals?.absentee_owner === true,\n      'Motivation Score': qualification.motivation_score ?? null,\n      'Confidence': qualification.confidence ?? null,\n      'Qualification Reasons': (qualification.reasons || []).map((r) => '- ' + r).join('\\n'),\n      'Engine': qualification.engine || 'unknown',\n      'Rules Version': qualification.rules_version ?? null,\n      'Phone': phones[0] || '',\n      'Alt Phones': phones.slice(1).join(', '),\n      'Email': emails[0] || '',\n      'DNC Flagged': contact.dnc_flagged === true,\n      'Skip Trace Provider': contact.provider || 'none',\n      'Contactable': phones.length > 0 || emails.length > 0,\n      'Narrative': (record.narrative_text || '').slice(0, 4000),\n      'Delivered At': new Date().toISOString()\n    }\n  };\n});"
      },
      "id": "c3000000-0000-4000-8000-000000000003",
      "name": "Build Lead Row",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -160,
        320
      ]
    },
    {
      "parameters": {
        "operation": "upsert",
        "base": {
          "__rl": true,
          "value": "REPLACE_WITH_AIRTABLE_BASE_ID",
          "mode": "id"
        },
        "table": {
          "__rl": true,
          "value": "Leads",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "matchingColumns": [
            "Dedup Key"
          ],
          "value": {}
        },
        "options": {
          "typecast": true
        }
      },
      "id": "c3000000-0000-4000-8000-000000000004",
      "name": "Upsert Airtable Lead",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.1,
      "position": [
        100,
        320
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "notes": "Upsert matched on Dedup Key. Airtable caps at 5 requests/second, so the batching in workflow 02 matters here."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "ghl-enabled",
              "leftValue": "={{ $env.GHL_ENABLED }}",
              "rightValue": "true",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            },
            {
              "id": "has-contact",
              "leftValue": "={{ $('Build Lead Row').item.json.Contactable }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "c3000000-0000-4000-8000-000000000005",
      "name": "Push to CRM?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        360,
        320
      ],
      "notes": "Both conditions matter. A CRM contact with no phone and no email is a row somebody has to clean up later."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://rest.gohighlevel.com/v1/contacts/",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "<set this in n8n after import>"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ locationId: $env.GHL_LOCATION_ID, name: $('Build Lead Row').item.json['Owner Name'], phone: $('Build Lead Row').item.json.Phone || null, email: $('Build Lead Row').item.json.Email || null, address1: $('Build Lead Row').item.json['Property Address'], state: $('Build Lead Row').item.json.State, source: 'AcquisitionIQ / ' + $('Build Lead Row').item.json.Source, tags: ['acquisitioniq', $('Build Lead Row').item.json.Market, $('Build Lead Row').item.json['Source Type'], $('Build Lead Row').item.json['DNC Flagged'] ? 'dnc' : 'callable'] }) }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "c3000000-0000-4000-8000-000000000006",
      "name": "Create GHL Contact",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        620,
        220
      ],
      "onError": "continueRegularOutput",
      "notes": "Non-fatal on purpose. A CRM outage must not cost the run its Airtable delivery."
    },
    {
      "parameters": {},
      "id": "c3000000-0000-4000-8000-000000000007",
      "name": "CRM Disabled",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        620,
        420
      ]
    },
    {
      "parameters": {
        "mode": "combine",
        "combineBy": "combineAll",
        "options": {}
      },
      "id": "c3000000-0000-4000-8000-000000000008",
      "name": "Merge Branches",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3,
      "position": [
        860,
        320
      ]
    },
    {
      "parameters": {
        "jsCode": "// The digest reports rejections and skip-trace misses alongside the wins. A\n// summary that only counts new leads hides a filter that has quietly stopped\n// passing anything, which is the failure mode nobody notices for a month.\nconst rows = $('Build Lead Row').all().map((i) => i.json);\nconst contactable = rows.filter((r) => r.Contactable).length;\nconst dnc = rows.filter((r) => r['DNC Flagged']).length;\nconst market = rows[0]?.Market || 'unknown';\n\nconst top = rows\n  .slice()\n  .sort((a, b) => (b['Motivation Score'] || 0) - (a['Motivation Score'] || 0))\n  .slice(0, 5)\n  .map((r) => `• *${r['Property Address']}* — ${r['Owner Name']} (score ${Math.round(r['Motivation Score'] || 0)}, ${r.Phone || 'no number'})`)\n  .join('\\n');\n\nconst matchRate = rows.length ? Math.round((contactable / rows.length) * 100) : 0;\n\nreturn [{\n  json: {\n    market,\n    delivered: rows.length,\n    contactable,\n    dnc_flagged: dnc,\n    match_rate_pct: matchRate,\n    text: `*AcquisitionIQ — ${market}*\\n${rows.length} qualified lead(s) delivered · ${contactable} contactable (${matchRate}% match) · ${dnc} DNC flagged\\n\\n${top}`\n  }\n}];"
      },
      "id": "c3000000-0000-4000-8000-000000000009",
      "name": "Build Digest",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        320
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_CHANNEL_ID",
          "mode": "id"
        },
        "text": "={{ $json.text }}",
        "otherOptions": {
          "mrkdwn": true
        }
      },
      "id": "c3000000-0000-4000-8000-000000000010",
      "name": "Slack Digest",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [
        1340,
        320
      ],
      "onError": "continueRegularOutput",
      "notes": "A failed notification is not a failed run."
    }
  ],
  "connections": {
    "When Called": {
      "main": [
        [
          {
            "node": "Skip Trace",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Skip Trace": {
      "main": [
        [
          {
            "node": "Build Lead Row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Lead Row": {
      "main": [
        [
          {
            "node": "Upsert Airtable Lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upsert Airtable Lead": {
      "main": [
        [
          {
            "node": "Push to CRM?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Push to CRM?": {
      "main": [
        [
          {
            "node": "Create GHL Contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "CRM Disabled",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create GHL Contact": {
      "main": [
        [
          {
            "node": "Merge Branches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "CRM Disabled": {
      "main": [
        [
          {
            "node": "Merge Branches",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Merge Branches": {
      "main": [
        [
          {
            "node": "Build Digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Digest": {
      "main": [
        [
          {
            "node": "Slack Digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "templateCredsSetupCompleted": false
  }
}