{
  "name": "Qualify and Route Inbound Leads with AI, HubSpot and Slack",
  "nodes": [
    {
      "parameters": {
        "content": "# Qualify and route inbound leads\n\nEvery form submission gets read against your ideal customer profile, scored, written to HubSpot, and routed. Hot leads hit Slack while the enquiry is still warm. Everything else is still recorded, so nothing sits in an inbox unanswered.\n\n**Sequence**\n1. A lead arrives (n8n Form, or swap in a Webhook)\n2. Field names are normalised, so any form shape works\n3. An AI model reads the enquiry against your ICP and returns structured fields\n4. A deterministic scoring step turns those fields into a tier\n5. The contact is created or updated in HubSpot\n6. Hot goes to Slack and gets a reply; warm gets a reply; no-fit is logged\n\n**You need**\n- An AI credential (OpenAI, Anthropic, Google or Ollama)\n- HubSpot\n- Slack\n- Gmail (or swap for any send-email node)\n\nBuilt by Better Automations (Melbourne, Australia).\nhttps://www.betterautomations.com.au/services/n8n-workflow-automation",
        "height": 640,
        "width": 460,
        "color": 4
      },
      "id": "a1b2c3d4-0001-4000-8000-000000000001",
      "name": "Sticky Note - Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [-640, -140]
    },
    {
      "parameters": {
        "content": "## Start here\n\nOpen **Configuration** and describe who you actually sell to. Everything downstream reads from it, so this is the only node most people need to edit.\n\nThe ICP description is passed to the model verbatim. Write it the way you would brief a new salesperson: what you sell, who it suits, and the enquiries that are a waste of everyone's time.",
        "height": 320,
        "width": 340,
        "color": 7
      },
      "id": "a1b2c3d4-0001-4000-8000-000000000002",
      "name": "Sticky Note - Config",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [-140, -460]
    },
    {
      "parameters": {
        "content": "## Why scoring is not left to the model\n\nThe model reads and classifies. **Score and Decide** does the arithmetic in plain JavaScript.\n\nThat split matters: the same enquiry gets the same tier every time, you can see exactly why, and changing where the thresholds sit does not mean re-testing a prompt.",
        "height": 300,
        "width": 340,
        "color": 7
      },
      "id": "a1b2c3d4-0001-4000-8000-000000000003",
      "name": "Sticky Note - Scoring",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [800, -460]
    },
    {
      "parameters": {
        "content": "## Routing\n\n**Hot** notifies Slack and replies.\n**Warm** replies, no interruption.\n**No fit** is recorded and stops.\n\nNo-fit leads are deliberately still written to HubSpot before this point. A lead that does not suit you today is worth having on file, and it keeps the enquiry out of anyone's inbox without deleting it.",
        "height": 340,
        "width": 360,
        "color": 7
      },
      "id": "a1b2c3d4-0001-4000-8000-000000000004",
      "name": "Sticky Note - Routing",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [1700, -460]
    },
    {
      "parameters": {
        "content": "### Using your own website form\n\nDelete the Form Trigger and drop in a **Webhook** node set to POST, then point your form at its URL.\n\n**Normalise Lead** already handles the common field names (name / full_name / fullName, email / email_address, company / business, and so on), so a different form shape does not break the rest of the workflow.",
        "height": 300,
        "width": 340,
        "color": 5
      },
      "id": "a1b2c3d4-0001-4000-8000-000000000005",
      "name": "Sticky Note - Webhook Swap",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [-140, 260]
    },
    {
      "parameters": {
        "formTitle": "Get in touch",
        "formDescription": "Tell us what you are trying to solve and we will come back to you.",
        "formFields": {
          "values": [
            { "fieldLabel": "name", "requiredField": true },
            { "fieldLabel": "email", "fieldType": "email", "requiredField": true },
            { "fieldLabel": "company", "requiredField": false },
            { "fieldLabel": "phone", "fieldType": "text", "requiredField": false },
            { "fieldLabel": "team_size", "fieldType": "text", "requiredField": false },
            { "fieldLabel": "budget", "fieldType": "text", "requiredField": false },
            { "fieldLabel": "message", "fieldType": "textarea", "requiredField": true }
          ]
        },
        "options": {}
      },
      "id": "a1b2c3d4-0001-4000-8000-000000000010",
      "name": "New Lead",
      "type": "n8n-nodes-base.formTrigger",
      "typeVersion": 2.2,
      "position": [-140, 0],
      "webhookId": "a1b2c3d4-0001-4000-8000-00000000f001"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "cfg-icp",
              "name": "icp_description",
              "value": "We build custom workflow automation for Australian businesses with 5 to 100 staff. A good fit has a repetitive, high-volume process eating staff hours, owns the tools the process runs through, and has budget of at least $3,000 for a build. A poor fit is a student, a job seeker, an agency reselling our work, anyone wanting free advice, or a business under 5 staff with no budget.",
              "type": "string"
            },
            { "id": "cfg-hot", "name": "score_hot_min", "value": 70, "type": "number" },
            { "id": "cfg-warm", "name": "score_warm_min", "value": 40, "type": "number" },
            { "id": "cfg-slack", "name": "slack_channel", "value": "#leads", "type": "string" },
            { "id": "cfg-from", "name": "reply_from_name", "value": "Better Automations", "type": "string" }
          ]
        },
        "options": {}
      },
      "id": "a1b2c3d4-0001-4000-8000-000000000011",
      "name": "Configuration",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [80, 0]
    },
    {
      "parameters": {
        "jsCode": "// Accepts whatever shape the form or webhook sends and produces one consistent lead object.\n// Add your own aliases here rather than renaming fields on the form itself.\nconst ALIASES = {\n  name:      ['name', 'full_name', 'fullName', 'Name', 'contact_name', 'first_name'],\n  email:     ['email', 'email_address', 'emailAddress', 'Email', 'work_email'],\n  company:   ['company', 'company_name', 'business', 'organisation', 'organization', 'Company'],\n  phone:     ['phone', 'phone_number', 'mobile', 'telephone', 'Phone'],\n  team_size: ['team_size', 'teamSize', 'staff', 'headcount', 'employees'],\n  budget:    ['budget', 'budget_range', 'spend'],\n  message:   ['message', 'enquiry', 'inquiry', 'details', 'comments', 'notes', 'Message'],\n};\n\nconst cfg = $('Configuration').first().json;\n\nreturn $input.all().map((item) => {\n  const src = item.json;\n  const out = {};\n\n  for (const [field, keys] of Object.entries(ALIASES)) {\n    const hit = keys.find((k) => src[k] !== undefined && String(src[k]).trim() !== '');\n    out[field] = hit ? String(src[hit]).trim() : '';\n  }\n\n  // Anything the aliases did not claim is kept, so nothing the form sent is lost.\n  const claimed = new Set(Object.values(ALIASES).flat());\n  out.extra_fields = Object.fromEntries(\n    Object.entries(src).filter(([k]) => !claimed.has(k))\n  );\n\n  out.received_at = new Date().toISOString();\n  out.icp_description = cfg.icp_description;\n\n  // One block of text for the model to read, rather than loose fields.\n  out.lead_summary = [\n    `Name: ${out.name || 'not given'}`,\n    `Email: ${out.email || 'not given'}`,\n    `Company: ${out.company || 'not given'}`,\n    `Phone: ${out.phone || 'not given'}`,\n    `Team size: ${out.team_size || 'not given'}`,\n    `Stated budget: ${out.budget || 'not given'}`,\n    `Enquiry: ${out.message || 'not given'}`,\n  ].join('\\n');\n\n  return { json: out };\n});"
      },
      "id": "a1b2c3d4-0001-4000-8000-000000000012",
      "name": "Normalise Lead",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [300, 0]
    },
    {
      "parameters": {
        "text": "={{ $json.lead_summary }}",
        "attributes": {
          "attributes": [
            { "name": "intent", "type": "string", "description": "One of: buying, pricing, support, partnership, job_seeking, spam, unclear. Pick the closest.", "required": true },
            { "name": "fit_reason", "type": "string", "description": "One sentence, plain language, on why this enquiry does or does not match the ideal customer profile. Quote the enquiry where it helps.", "required": true },
            { "name": "matches_icp", "type": "boolean", "description": "True only if the enquiry plausibly matches the ideal customer profile given below.", "required": true },
            { "name": "has_budget_signal", "type": "boolean", "description": "True if the enquiry states a budget, a price range, or language clearly implying willingness to pay.", "required": true },
            { "name": "urgency", "type": "string", "description": "One of: immediate, this_quarter, exploring, none.", "required": true },
            { "name": "problem_described", "type": "boolean", "description": "True if the enquiry describes a specific business problem rather than a generic request for information.", "required": true },
            { "name": "is_low_quality", "type": "boolean", "description": "True if this is spam, a mass sales pitch, a job application, or an agency offering services rather than seeking them.", "required": true }
          ]
        },
        "options": {
          "systemPromptTemplate": "You classify inbound sales enquiries for a business. Read the enquiry and return only the requested fields.\n\nThe business describes its ideal customer as follows:\n{{ $json.icp_description }}\n\nJudge the enquiry against that description alone. Do not invent detail that is not present. An enquiry that is vague is not automatically a poor fit, it is simply an enquiry with little information, so say so in fit_reason rather than guessing."
        }
      },
      "id": "a1b2c3d4-0001-4000-8000-000000000013",
      "name": "Qualify Against Your ICP",
      "type": "@n8n/n8n-nodes-langchain.informationExtractor",
      "typeVersion": 1,
      "position": [520, 0]
    },
    {
      "parameters": {
        "options": {}
      },
      "id": "a1b2c3d4-0001-4000-8000-000000000014",
      "name": "Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "typeVersion": 1.2,
      "position": [520, 220]
    },
    {
      "parameters": {
        "jsCode": "// Deterministic scoring. The model classified; the arithmetic happens here so the\n// same enquiry always lands in the same tier and you can see exactly why.\nconst cfg = $('Configuration').first().json;\nconst lead = $('Normalise Lead').first().json;\n\nconst WEIGHTS = {\n  matches_icp:       40,\n  problem_described: 20,\n  has_budget_signal: 20,\n  urgency_immediate: 15,\n  urgency_quarter:   10,\n  has_company:        5,\n  has_phone:          5,\n};\n\nreturn $input.all().map((item) => {\n  const q = item.json.output ?? item.json;\n  const reasons = [];\n  let score = 0;\n\n  const add = (points, why) => { score += points; reasons.push(`${points > 0 ? '+' : ''}${points} ${why}`); };\n\n  if (q.is_low_quality === true) {\n    return { json: { ...lead, ...q, score: 0, tier: 'no_fit', score_reasons: ['flagged low quality, scored zero'] } };\n  }\n\n  if (q.matches_icp)       add(WEIGHTS.matches_icp, 'matches the ICP');\n  if (q.problem_described) add(WEIGHTS.problem_described, 'describes a specific problem');\n  if (q.has_budget_signal) add(WEIGHTS.has_budget_signal, 'signalled budget');\n  if (q.urgency === 'immediate')     add(WEIGHTS.urgency_immediate, 'wants to move now');\n  else if (q.urgency === 'this_quarter') add(WEIGHTS.urgency_quarter, 'moving this quarter');\n  if (lead.company) add(WEIGHTS.has_company, 'gave a company');\n  if (lead.phone)   add(WEIGHTS.has_phone, 'gave a phone number');\n\n  if (['job_seeking', 'spam'].includes(q.intent)) {\n    score = 0;\n    reasons.push('intent is job seeking or spam, scored zero');\n  }\n\n  const tier = score >= cfg.score_hot_min ? 'hot'\n             : score >= cfg.score_warm_min ? 'warm'\n             : 'no_fit';\n\n  return { json: { ...lead, ...q, score, tier, score_reasons: reasons } };\n});"
      },
      "id": "a1b2c3d4-0001-4000-8000-000000000015",
      "name": "Score and Decide",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [960, 0]
    },
    {
      "parameters": {
        "authentication": "appToken",
        "resource": "contact",
        "email": "={{ $json.email }}",
        "additionalFields": {
          "firstName": "={{ $json.name.split(' ')[0] }}",
          "lastName": "={{ $json.name.split(' ').slice(1).join(' ') }}",
          "companyName": "={{ $json.company }}",
          "phoneNumber": "={{ $json.phone }}",
          "lifeCycleStage": "lead"
        },
        "options": {}
      },
      "id": "a1b2c3d4-0001-4000-8000-000000000016",
      "name": "Upsert Contact in HubSpot",
      "type": "n8n-nodes-base.hubspot",
      "typeVersion": 2,
      "position": [1180, 0],
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
                "conditions": [
                  { "id": "r-hot", "leftValue": "={{ $json.tier }}", "rightValue": "hot", "operator": { "type": "string", "operation": "equals" } }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "hot"
            },
            {
              "conditions": {
                "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
                "conditions": [
                  { "id": "r-warm", "leftValue": "={{ $json.tier }}", "rightValue": "warm", "operator": { "type": "string", "operation": "equals" } }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "warm"
            },
            {
              "conditions": {
                "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
                "conditions": [
                  { "id": "r-nofit", "leftValue": "={{ $json.tier }}", "rightValue": "no_fit", "operator": { "type": "string", "operation": "equals" } }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "no fit"
            }
          ]
        },
        "options": {}
      },
      "id": "a1b2c3d4-0001-4000-8000-000000000017",
      "name": "Route by Tier",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [1400, 0]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "={{ $('Configuration').first().json.slack_channel }}",
          "mode": "name"
        },
        "text": "=:fire: *Hot lead, score {{ $json.score }}*\n\n*{{ $json.name }}*{{ $json.company ? ' at ' + $json.company : '' }}\n<mailto:{{ $json.email }}|{{ $json.email }}>{{ $json.phone ? ' · ' + $json.phone : '' }}\n\n*Why:* {{ $json.fit_reason }}\n*Urgency:* {{ $json.urgency }}\n*Scored:* {{ $json.score_reasons.join(', ') }}\n\n> {{ $json.message }}",
        "otherOptions": { "includeLinkToWorkflow": false }
      },
      "id": "a1b2c3d4-0001-4000-8000-000000000018",
      "name": "Alert Sales in Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [1660, -180],
      "webhookId": "a1b2c3d4-0001-4000-8000-00000000f002"
    },
    {
      "parameters": {
        "sendTo": "={{ $json.email }}",
        "subject": "=Thanks for getting in touch, {{ $json.name.split(' ')[0] }}",
        "emailType": "text",
        "message": "=Hi {{ $json.name.split(' ')[0] }},\n\nThanks for the enquiry. We have read it properly and it looks like something we can help with, so one of us will come back to you shortly with a straight answer on whether it is worth doing.\n\nIf it is easier to talk it through, reply to this email with a couple of times that suit.\n\n{{ $('Configuration').first().json.reply_from_name }}",
        "options": {}
      },
      "id": "a1b2c3d4-0001-4000-8000-000000000019",
      "name": "Reply to Hot Lead",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [1880, -180],
      "webhookId": "a1b2c3d4-0001-4000-8000-00000000f003"
    },
    {
      "parameters": {
        "sendTo": "={{ $json.email }}",
        "subject": "=Thanks for getting in touch, {{ $json.name.split(' ')[0] }}",
        "emailType": "text",
        "message": "=Hi {{ $json.name.split(' ')[0] }},\n\nThanks for the enquiry, it has landed with us and we will be in touch.\n\nIf anything changes in the meantime, or you want to add detail about what you are trying to solve, just reply to this email.\n\n{{ $('Configuration').first().json.reply_from_name }}",
        "options": {}
      },
      "id": "a1b2c3d4-0001-4000-8000-000000000020",
      "name": "Reply to Warm Lead",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [1660, 40],
      "webhookId": "a1b2c3d4-0001-4000-8000-00000000f004"
    },
    {
      "parameters": {},
      "id": "a1b2c3d4-0001-4000-8000-000000000021",
      "name": "Logged, No Action",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [1660, 240]
    }
  ],
  "connections": {
    "New Lead": { "main": [[{ "node": "Configuration", "type": "main", "index": 0 }]] },
    "Configuration": { "main": [[{ "node": "Normalise Lead", "type": "main", "index": 0 }]] },
    "Normalise Lead": { "main": [[{ "node": "Qualify Against Your ICP", "type": "main", "index": 0 }]] },
    "Chat Model": { "ai_languageModel": [[{ "node": "Qualify Against Your ICP", "type": "ai_languageModel", "index": 0 }]] },
    "Qualify Against Your ICP": { "main": [[{ "node": "Score and Decide", "type": "main", "index": 0 }]] },
    "Score and Decide": { "main": [[{ "node": "Upsert Contact in HubSpot", "type": "main", "index": 0 }]] },
    "Upsert Contact in HubSpot": { "main": [[{ "node": "Route by Tier", "type": "main", "index": 0 }]] },
    "Route by Tier": {
      "main": [
        [{ "node": "Alert Sales in Slack", "type": "main", "index": 0 }],
        [{ "node": "Reply to Warm Lead", "type": "main", "index": 0 }],
        [{ "node": "Logged, No Action", "type": "main", "index": 0 }]
      ]
    },
    "Alert Sales in Slack": { "main": [[{ "node": "Reply to Hot Lead", "type": "main", "index": 0 }]] }
  },
  "settings": { "executionOrder": "v1" },
  "pinData": {},
  "meta": {},
  "tags": []
}
