n8n Lead Qualification Workflow (2026): AI Scoring, CRM Sync and the Template

17 min read
Diagram of an n8n lead qualification workflow: webhook, website fetch, AI scoring with a fixed JSON schema, tier decided in code, then Slack, CRM and a fixed acknowledgement email

A lead fills in your contact form at 4:50 on a Friday. It lands in a shared inbox. The first person to open it is you, on Monday, after coffee. By then the prospect has heard back from two of your competitors, and the one who replied first is the one they're talking to.

This is the n8n workflow we build to stop that happening, written up the way you'd want it if you were paying for it. What it does. What each step costs you. Where the model is allowed to decide and where it isn't. And the actual template you can paste into your own n8n. It's the same shape as the pipeline we migrated for a client from Make, where the bill went from about $109 a month to about $12. It uses only what you already have: a form, a CRM, Slack, and one API key.

Quick answer

The model scores. Your code decides. Your team is first.

A form submission hits a webhook, the workflow fetches the lead's website, sends the form and the page text to a model with a fixed answer format, gets back a score from 0 to 100 with reasons, and then decides the tier in code from your thresholds. Hot leads reach your team in Slack with a five-second brief, every lead lands in your CRM, and the prospect gets a fixed acknowledgement within a minute, at any hour. The scoring call costs about a cent and a half. The person it replaces costs you about $2.93 a lead and gets there three hours later.

<60sForm to Slack brief, any hour
$0.014Scoring call per lead, Opus 5
7xQualification odds inside the first hour (HBR)

What the workflow does, in one screen

Thirteen working nodes, six stages. Every stage writes what it found onto the lead, so nothing downstream has to guess, and you can see at any node exactly what the next one will receive.

  1. Catch the form
    Where does the lead arrive?A webhook takes the POST from your form, replies instantly so the form never hangs, and a Settings node holds the six things you'll edit: company name, ideal-customer paragraph, two thresholds, Slack channel, reply promise.
  2. Normalise and check
    Is there enough to work with?Name, email, company, website and message are cleaned into fixed fields. No email address or no message means the lead goes straight to a person, with the raw form attached.
  3. Fetch the website
    What does this company actually do?A plain HTTP request to the lead's site, eight-second timeout, allowed to fail. A Code node strips it to a title, a description and about 3,000 characters of text, and flags free-mail addresses.
  4. Score with a model
    How good is this lead, and why?One HTTP call to the Messages API with a JSON schema the answer must match: score, fit reasons, concerns, next step, two-sentence summary. Low effort setting, one-second-ish latency, a cent and a half.
  5. Decide the tier in code
    Hot, warm, cold, or review?A Code node compares the score with your thresholds. The model never picks the route. Anything that isn't a clean integer score becomes "review" and goes to a person.
  6. Route
    Who needs to know, and what do they see?Hot leads: Slack brief, CRM record, acknowledgement email. Warm: CRM and acknowledgement. Cold: CRM only. Review: a Slack message that says why.

That's the whole thing. If you've read our breakdown of the five processes worth automating first, this is process one built out in full, with the parts that post could only sketch for you.

Why speed is the whole point

You'll be sold lead scoring as an intelligence feature. It's a clock feature. The intelligence is nice. The minutes are your money.

In 2011, Harvard Business Review published an audit of 2,241 US companies, each sent a web-generated test lead. Only 37% responded within an hour. Another 16% took between one and 24 hours, 24% took more than a day, and 23% never responded at all. Among the companies that did respond within 30 days, the average first response took 42 hours. The same authors then looked at 1.25 million leads across 42 companies. Firms that tried to contact a prospect within an hour of the enquiry were nearly seven times as likely to qualify the lead as those that tried even an hour later. They were more than 60 times as likely as companies that waited a day or more.

Fifteen years on, the queue in most small businesses looks the same: a submissions folder someone checks two or three times a day. Every lead in that folder crosses HBR's one-hour line. If your pipeline acknowledges, scores and routes in under a minute, none of them do, including the ones that arrive at 4:50 on a Friday.

What one lead costs to score

Here's the arithmetic on a single inbound lead, using the same $22 loaded hour we use across the agent cost article. Handling one lead by hand means reading the form, looking the company up, deciding who should have it, creating the record and telling the rep. We take that as eight minutes. That's our working assumption, and you should substitute yours.

One inbound lead, costed
$22 loaded hour
Who or what handles the leadPer lead500 leads a month
A person, eight minutes at $22 an hour$2.93$1,467 and 67 hours
Scoring call, Claude Opus 5 (about 1,500 tokens in, 260 out)$0.014$7.07
Scoring call, Claude Haiku 4.5, same tokens$0.003$1.42
Website fetch and text extraction$0$0

Notice what the table doesn't say. It doesn't say the pipeline saves you $1,467 a month, because the eight minutes don't disappear. Your salesperson still reads the brief and still makes the call. What changes for you is that the reading happens within a minute of the form arriving, and it starts with a summary instead of a blank record.

What the model decides, and what it doesn't

This is the part the template libraries skip, so we'll be precise about it for you, because it's what makes the workflow safe to leave running.

The model gets the form, the page text and your ideal-customer paragraph. It returns exactly one thing: a JSON object that matches a schema you set. A score from 0 to 100, up to three fit reasons citing evidence, up to three concerns, a recommended first step, and a two-sentence summary. The request uses the API's structured-output format, so the answer is valid JSON that fits the schema or it isn't returned at all. That matters in a pipeline. A free-text answer with a stray sentence in it breaks your next node at 2am.

Then the model's job ends. A few lines of code decide the tier by comparing the score with the two thresholds in your Settings node. A Switch node decides the route by reading the tier. The CRM write is a fixed field mapping. The acknowledgement email is a template with three substitutions: first name, your company name, and your reply promise. Nothing the model wrote reaches your prospect.

Which actions run on their own
Review costed at $0.73
Action in the workflowIf it's wrong, it costs youExpected loss at 95% accuracyRuns unsupervised?
Create or update the CRM contactAbout $0.50, someone corrects a field$0.03Yes
Post the brief to SlackAbout $0.20, a rep ignores it$0.01Yes
Send the fixed acknowledgementAbout $1, a templated line$0.05Yes
Send an AI-written personalised replyAbout $40, a wrong claim loses the lead$2.00No, gate it
Book a meeting on a rep's calendarAbout $15, a rep's half hour$0.75No, gate it

The same logic covers the model declining to answer. If the API returns a refusal, or anything that isn't a clean integer score, the Code node doesn't guess. It marks the lead "review" and sends you a Slack message saying why. Silent failure is the one thing your lead pipeline must never do, because a lead that vanishes looks exactly like a quiet week.

The template, node by node

Thirteen working nodes plus four sticky notes with the setup instructions. You edit one node; the rest run as they are.

The thirteen working nodes
You edit one
NodeWhat it doesYou edit it?
Lead formWebhook, POST, replies immediately. Carries a pinned sample submission so you can test without a form.Only the path
SettingsCompany name, ideal-customer paragraph, hot and warm thresholds, Slack channel, reply promise.Yes, this is the one
Normalise the leadCleans name, email, company, website (adds https), message, source, timestamp.If your form's field names differ
Has the basics?Email contains @ and the message isn't empty. Otherwise straight to a person.No
Fetch the company websiteGET the site, 8-second timeout, follows redirects, allowed to fail.No
Prepare the briefStrips the HTML to title, description and 3,000 characters. Flags free-mail domains.No
Score with ClaudeOne POST to the Messages API with the schema. Header Auth credential holds your key.Credential only
Read the scoreParses the JSON, sets the tier from your thresholds, routes refusals to review.No
Route by tierSwitch on hot / warm / cold, with review as the fallback output.No
Alert the teamSlack brief for hot leads: score, summary, reasons, concerns, first step, the original message.Channel comes from Settings
Create or update the contactHubSpot upsert on email: first name, last name, company, website, lifecycle stage.Swap for your CRM
Acknowledge the leadGmail, fixed text, three substitutions, sent to hot and warm leads.The wording, if you like
Needs a human lookSlack message for incomplete forms and review-tier leads, with the reason.No

The scoring call is deliberately a plain HTTP Request node rather than a vendor node. Two reasons. It survives n8n upgrades, because the HTTP node's shape hasn't changed in years while the AI nodes get rewritten every few months. And it's provider-neutral. To use a different model you change a URL, two headers and a body, and you keep the JSON schema so the tier code still works.

Get the template

Copy the JSON below, open a new workflow in n8n, and paste it onto the canvas. n8n creates all seventeen nodes with the connections and the sticky notes. Then follow the yellow overview note. Edit Settings, add a Header Auth credential named x-api-key for the scoring node, select your Slack, HubSpot and Gmail credentials, and click Test workflow. The pinned sample lead runs the whole thing end to end before you've connected a real form.

{ }workflow.json
17 KB · paste into n8n

workflow.json, 13 working nodes and 4 notes, checked node by node against the n8n package it runs in.

{
  "name": "Score inbound leads with Claude and route them to HubSpot, Slack and Gmail",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "new-lead",
        "responseMode": "onReceived",
        "options": {}
      },
      "id": "amp-lead-01",
      "name": "Lead form",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        300
      ],
      "webhookId": "2c9a6f1e-lead-routing-amplence"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "cond-01",
              "name": "companyName",
              "type": "string",
              "value": "Your Company"
            },
            {
              "id": "cond-02",
              "name": "icp",
              "type": "string",
              "value": "Ideal customer: a business with 10 to 250 staff that runs a repetitive, document- or email-heavy process (quotes, intake, support, bookings) and wants it automated with n8n or a custom AI agent. Good signs: a named process, a volume figure, a timeline, a business email domain. Weak signs: students, job seekers, agencies offering their own services, free-mail addresses with no company, requests for a free tool."
            },
            {
              "id": "cond-03",
              "name": "hotThreshold",
              "type": "number",
              "value": 70
            },
            {
              "id": "cond-04",
              "name": "warmThreshold",
              "type": "number",
              "value": 40
            },
            {
              "id": "cond-05",
              "name": "slackChannel",
              "type": "string",
              "value": "#leads"
            },
            {
              "id": "cond-06",
              "name": "replyWithin",
              "type": "string",
              "value": "one working day"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "amp-lead-02",
      "name": "Settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        300
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "cond-07",
              "name": "name",
              "type": "string",
              "value": "={{ String($json.body.name || $json.body.full_name || '').trim() }}"
            },
            {
              "id": "cond-08",
              "name": "email",
              "type": "string",
              "value": "={{ String($json.body.email || '').trim().toLowerCase() }}"
            },
            {
              "id": "cond-09",
              "name": "company",
              "type": "string",
              "value": "={{ String($json.body.company || $json.body.organisation || '').trim() }}"
            },
            {
              "id": "cond-10",
              "name": "websiteUrl",
              "type": "string",
              "value": "={{ (function (w) { w = String(w || '').trim(); if (!w) return ''; return /^https?:\\/\\//i.test(w) ? w : 'https://' + w; })($json.body.website) }}"
            },
            {
              "id": "cond-11",
              "name": "message",
              "type": "string",
              "value": "={{ String($json.body.message || $json.body.details || '').trim() }}"
            },
            {
              "id": "cond-12",
              "name": "source",
              "type": "string",
              "value": "={{ $json.body.source || 'website form' }}"
            },
            {
              "id": "cond-13",
              "name": "receivedAt",
              "type": "string",
              "value": "={{ $now.toISO() }}"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "amp-lead-03",
      "name": "Normalise the lead",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        440,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "cond-14",
              "leftValue": "={{ $json.email }}",
              "rightValue": "@",
              "operator": {
                "type": "string",
                "operation": "contains"
              }
            },
            {
              "id": "cond-15",
              "leftValue": "={{ $json.message }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "amp-lead-04",
      "name": "Has the basics?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        660,
        300
      ]
    },
    {
      "parameters": {
        "url": "={{ $json.websiteUrl }}",
        "options": {
          "redirect": {
            "redirect": {
              "followRedirects": true
            }
          },
          "response": {
            "response": {
              "responseFormat": "text"
            }
          },
          "timeout": 8000
        }
      },
      "id": "amp-lead-05",
      "name": "Fetch the company website",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        880,
        200
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Turns the fetched website into a short text sample and joins it to the lead.\n// Nothing here calls a model; it only prepares what the model will read.\nconst lead = $('Normalise the lead').first().json;\nconst page = $input.first().json;\nconst html = typeof page.data === 'string' ? page.data : '';\n\nconst strip = (h) => h\n  .replace(/<script[\\s\\S]*?<\\/script>/gi, ' ')\n  .replace(/<style[\\s\\S]*?<\\/style>/gi, ' ')\n  .replace(/<[^>]+>/g, ' ')\n  .replace(/&[a-z#0-9]+;/gi, ' ')\n  .replace(/\\s+/g, ' ')\n  .trim();\n\nconst title = strip((html.match(/<title[^>]*>([\\s\\S]*?)<\\/title>/i) || [])[1] || '');\nconst description = ((html.match(/<meta[^>]+name=[\"']description[\"'][^>]+content=[\"']([^\"']*)[\"']/i) || [])[1] || '').trim();\nconst text = strip(html).slice(0, 3000);\nconst freeMail = /@(gmail|googlemail|yahoo|hotmail|outlook|live|msn|icloud|me|aol|proton|protonmail|gmx|yandex)\\./i.test(lead.email || '');\n\nreturn [{ json: {\n  ...lead,\n  freeMail,\n  pageFetched: html.length > 0,\n  pageTitle: title,\n  pageDescription: description,\n  pageText: text\n} }];"
      },
      "id": "amp-lead-06",
      "name": "Prepare the brief",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        200
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            },
            {
              "name": "anthropic-beta",
              "value": "server-side-fallback-2026-07-01"
            },
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({\n  model: 'claude-opus-5',\n  max_tokens: 1024,\n  fallbacks: 'default',\n  output_config: { effort: 'low', format: { type: 'json_schema', schema: {\n    \"type\": \"object\",\n    \"properties\": {\n      \"score\": {\n        \"type\": \"integer\",\n        \"description\": \"Fit and intent from 0 to 100. 100 is an ideal customer with a clear, current need and the authority to buy. 0 is spam or wholly outside the ideal customer profile.\"\n      },\n      \"fit_reasons\": {\n        \"type\": \"array\",\n        \"items\": {\n          \"type\": \"string\"\n        },\n        \"description\": \"Up to three concrete reasons the lead fits, each citing evidence from the form or the website.\"\n      },\n      \"concerns\": {\n        \"type\": \"array\",\n        \"items\": {\n          \"type\": \"string\"\n        },\n        \"description\": \"Up to three concrete reasons for caution: missing information, a mismatch, signs of a vendor pitch or spam.\"\n      },\n      \"recommended_next_step\": {\n        \"type\": \"string\",\n        \"description\": \"One sentence for the salesperson: what to do first with this lead.\"\n      },\n      \"summary\": {\n        \"type\": \"string\",\n        \"description\": \"Two sentences a salesperson can read in five seconds: who this is and what they want.\"\n      }\n    },\n    \"required\": [\n      \"score\",\n      \"fit_reasons\",\n      \"concerns\",\n      \"recommended_next_step\",\n      \"summary\"\n    ],\n    \"additionalProperties\": false\n  } } },\n  system: 'You qualify inbound sales leads for ' + $('Settings').first().json.companyName + '. ' + $('Settings').first().json.icp + ' Score fit and buying intent from 0 to 100. Use only the evidence in the form and the website text; never invent facts about the company. If the website could not be fetched, say so in concerns and score on the form alone.',\n  messages: [{ role: 'user', content:\n    'LEAD FORM\\nName: ' + $json.name +\n    '\\nEmail: ' + $json.email + ($json.freeMail ? ' (free-mail address)' : ' (business domain)') +\n    '\\nCompany: ' + ($json.company || 'not given') +\n    '\\nWebsite: ' + ($json.websiteUrl || 'not given') +\n    '\\nSource: ' + $json.source +\n    '\\nMessage: ' + $json.message +\n    '\\n\\nCOMPANY WEBSITE (fetched: ' + $json.pageFetched + ')' +\n    '\\nTitle: ' + $json.pageTitle +\n    '\\nDescription: ' + $json.pageDescription +\n    '\\nText: ' + $json.pageText\n  }]\n}) }}",
        "options": {
          "timeout": 60000
        }
      },
      "id": "amp-lead-07",
      "name": "Score with Claude",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1320,
        200
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Reads the model's JSON, decides the tier with your thresholds, and refuses\n// to guess: anything that is not a clean score goes to a person as \"review\".\nconst res = $input.first().json;\nconst lead = $('Prepare the brief').first().json;\nconst settings = $('Settings').first().json;\n\nlet out = {\n  score: -1,\n  tier: 'review',\n  summary: 'The model declined or returned no usable score. A person should read this lead.',\n  fit_reasons: [],\n  concerns: [],\n  recommended_next_step: 'Read the original message and decide by hand.'\n};\n\nif (res && res.stop_reason !== 'refusal' && Array.isArray(res.content)) {\n  const block = res.content.find((b) => b.type === 'text');\n  try {\n    const parsed = JSON.parse(block.text);\n    if (Number.isInteger(parsed.score)) out = { ...out, ...parsed };\n  } catch (e) {\n    // leave the review defaults in place\n  }\n}\n\nif (out.score >= 0) {\n  out.tier = out.score >= Number(settings.hotThreshold) ? 'hot'\n    : out.score >= Number(settings.warmThreshold) ? 'warm' : 'cold';\n}\n\nreturn [{ json: {\n  ...lead,\n  ...out,\n  model: res && res.model ? res.model : null,\n  scoredAt: new Date().toISOString()\n} }];"
      },
      "id": "amp-lead-08",
      "name": "Read the score",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        200
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "cond-16",
                    "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": "cond-17",
                    "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": "cond-18",
                    "leftValue": "={{ $json.tier }}",
                    "rightValue": "cold",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "cold"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "id": "amp-lead-09",
      "name": "Route by tier",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        1760,
        200
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Settings').first().json.slackChannel }}"
        },
        "text": "=HOT LEAD  {{ $json.name }} at {{ $json.company || 'unknown company' }}  (score {{ $json.score }}/100)\n{{ $json.summary }}\nWhy: {{ ($json.fit_reasons || []).join('; ') }}\nWatch: {{ ($json.concerns || []).join('; ') || 'nothing flagged' }}\nFirst step: {{ $json.recommended_next_step }}\nEmail: {{ $json.email }}  Site: {{ $json.websiteUrl || 'none' }}  Source: {{ $json.source }}\nThey wrote: {{ $json.message }}",
        "otherOptions": {}
      },
      "id": "amp-lead-10",
      "name": "Alert the team",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [
        2000,
        40
      ]
    },
    {
      "parameters": {
        "authentication": "appToken",
        "resource": "contact",
        "operation": "upsert",
        "email": "={{ $json.email }}",
        "additionalFields": {
          "firstName": "={{ String($json.name || '').split(' ')[0] }}",
          "lastName": "={{ String($json.name || '').split(' ').slice(1).join(' ') }}",
          "companyName": "={{ $json.company }}",
          "websiteUrl": "={{ $json.websiteUrl }}",
          "lifeCycleStage": "lead"
        }
      },
      "id": "amp-lead-11",
      "name": "Create or update the contact",
      "type": "n8n-nodes-base.hubspot",
      "typeVersion": 2.1,
      "position": [
        2000,
        240
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $json.email }}",
        "subject": "=Thanks {{ String($json.name || '').split(' ')[0] || 'there' }}, we have your message",
        "emailType": "text",
        "message": "=Hi {{ String($json.name || '').split(' ')[0] || 'there' }},\n\nThanks for getting in touch with {{ $('Settings').first().json.companyName }}. We've received your message and one of the team will reply within {{ $('Settings').first().json.replyWithin }}.\n\nIf it's urgent, reply to this email and say so.\n\nThe {{ $('Settings').first().json.companyName }} team",
        "options": {}
      },
      "id": "amp-lead-12",
      "name": "Acknowledge the lead",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        2000,
        440
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Settings').first().json.slackChannel }}"
        },
        "text": "=NEEDS A LOOK  {{ $json.name || 'no name' }} <{{ $json.email || 'no email' }}>\nReason: {{ $json.tier === 'review' ? $json.summary : 'the form is missing an email address or a message' }}\nThey wrote: {{ $json.message || '(empty)' }}",
        "otherOptions": {}
      },
      "id": "amp-lead-13",
      "name": "Needs a human look",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [
        2000,
        640
      ]
    },
    {
      "parameters": {
        "content": "## Score inbound leads with Claude and route them to HubSpot, Slack and Gmail\n\nThe lead's website is fetched, Claude scores fit and intent from 0 to 100 against your ideal-customer profile, and the tier is decided in code from your thresholds. Hot leads reach Slack with a five-second brief, every scored lead lands in HubSpot, and the prospect gets a fixed acknowledgement within a minute.\n\n### How it works\n1. A webhook receives the form; the Settings node holds everything you edit.\n2. Fields are normalised; no email or no message sends the lead straight to a person.\n3. The company website is fetched (allowed to fail) and trimmed to a short text sample.\n4. One HTTP call to the Claude Messages API returns score, reasons and summary as JSON matching a fixed schema.\n5. Code decides hot, warm or cold from your thresholds; anything unclear goes to review.\n6. Slack alert for hot, HubSpot upsert for all scored, templated email for hot and warm.\n\n### Setup\n1. Edit **Settings**: company name, ideal-customer paragraph, thresholds, Slack channel, reply promise.\n2. Create a Header Auth credential named `x-api-key` with your Anthropic API key; select it on **Score with Claude**.\n3. Select your Slack, HubSpot and Gmail credentials on the last four nodes.\n4. Click **Test workflow**: a sample submission is pinned on the webhook.\n5. Point your form at the webhook URL (POST JSON: name, email, company, website, message).\n\n### Customization tips\nSwap HubSpot for Pipedrive, Salesforce or a Google Sheet. The scoring call is a plain HTTP Request; keep JSON output or everything goes to review. Nothing AI-written reaches the prospect.\n\nBuilt by Amplence. The numbers behind the design: amplence.com/blog/n8n-lead-qualification-workflow",
        "height": 720,
        "width": 800,
        "color": 1
      },
      "id": "amp-lead-14",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -520
      ]
    },
    {
      "parameters": {
        "content": "## 1. Catch and check the form\nThe webhook takes the POST, Settings holds what you edit, the fields are normalised. No email or no message: straight to a person.",
        "height": 230,
        "width": 830,
        "color": 7
      },
      "id": "amp-lead-15",
      "name": "Section: catch and check",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        230
      ]
    },
    {
      "parameters": {
        "content": "## 2. Enrich and score\nFetch the lead's website (allowed to fail), trim it, send form and page text to Claude with a fixed JSON schema. The tier is decided in code from your thresholds, never by the model.",
        "height": 240,
        "width": 830,
        "color": 7
      },
      "id": "amp-lead-16",
      "name": "Section: enrich and score",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        850,
        120
      ]
    },
    {
      "parameters": {
        "content": "## 3. Route and act\nHot: Slack brief, HubSpot, email. Warm: HubSpot, email. Cold: HubSpot only. Unclear: a person. The email is a fixed template, so nothing AI-written reaches the prospect.",
        "height": 880,
        "width": 520,
        "color": 7
      },
      "id": "amp-lead-17",
      "name": "Section: route and act",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1720,
        -40
      ]
    }
  ],
  "connections": {
    "Lead form": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings": {
      "main": [
        [
          {
            "node": "Normalise the lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalise the lead": {
      "main": [
        [
          {
            "node": "Has the basics?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has the basics?": {
      "main": [
        [
          {
            "node": "Fetch the company website",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Needs a human look",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch the company website": {
      "main": [
        [
          {
            "node": "Prepare the brief",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare the brief": {
      "main": [
        [
          {
            "node": "Score with Claude",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Score with Claude": {
      "main": [
        [
          {
            "node": "Read the score",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read the score": {
      "main": [
        [
          {
            "node": "Route by tier",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by tier": {
      "main": [
        [
          {
            "node": "Alert the team",
            "type": "main",
            "index": 0
          },
          {
            "node": "Create or update the contact",
            "type": "main",
            "index": 0
          },
          {
            "node": "Acknowledge the lead",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Create or update the contact",
            "type": "main",
            "index": 0
          },
          {
            "node": "Acknowledge the lead",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Create or update the contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Needs a human look",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {
    "Lead form": [
      {
        "json": {
          "headers": {
            "content-type": "application/json"
          },
          "params": {},
          "query": {},
          "body": {
            "name": "Dana Whitfield",
            "email": "dana@example.com",
            "company": "Example Packaging",
            "website": "example.com",
            "message": "We run a 40-person packaging plant and get about 60 quote requests a week by email. Two people spend most of their day re-typing them. We'd like to automate the intake before our busy season and want a fixed-price proposal.",
            "source": "website contact form"
          }
        }
      }
    ]
  },
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "templateCredsSetupCompleted": false
  }
}

We validate the file the way you'd want any template validated. Every node type and version is checked against the n8n package it will run in. Every parameter name is checked against what that node actually declares. Every connection is checked against a node that exists. That doesn't prove the workflow will do what you want with your data. It proves it will load and run without you debugging our typos first. The same treatment for an online store's support inbox is in our Shopify customer support workflow.

Where the platform bill lands

You don't need a big volume for the economics to matter, and you don't need to self-host on day one. But the shape of the bill is worth knowing before you pick a platform, because this pipeline is exactly the kind that per-step billing punishes you for.

Each lead through this template is one n8n execution. On a per-operation platform it's about thirteen credits, one per node. At 500 leads a month that's 6,500 credits against 500 executions. At 2,000 leads it's 26,000 against 2,000. The client pipeline we migrated had this shape, with a lead-scoring model call in the middle. At around 90,000 operations a month its Make bill had reached about $109 with extra credit packs. Rebuilt in self-hosted n8n, it ran on a $12 VPS with unlimited executions, plus about two hours a month of maintenance we told the client to budget for. The full comparison has the numbers and the honest caveat: at a few hundred leads a month, managed and zero-maintenance is often the better deal.

Where it goes wrong

  • Duplicates in the CRM. The template upserts on email, so the same person twice is one record. If you swap the CRM node, keep the upsert or you'll have three copies of every contact within a week.
  • The website fetch fails. Bot protection, timeouts, a site that's just a PDF. The node is allowed to fail and the model is told the site couldn't be fetched, so the lead is scored on the form alone and the concern is recorded. Don't make the fetch mandatory.
  • Free-mail addresses treated as junk. A gmail address is a signal, not a verdict. The template flags it and lets the model weigh it against the message; a founder writing from a personal address with a specific problem is still a lead.
  • The model as the router. The moment you let the model return "tier: hot" and route on that, you've handed a business rule to a probabilistic system. Keep the thresholds in code, where you can change them on a Tuesday and see exactly what changed.
  • AI-written replies to prospects. Every template library does it. It's the one send that fails the arithmetic above, and it fails it by a factor of three. If you add it, add a reviewer.
  • Polling instead of a webhook. If your form tool can't call a webhook and you poll it every few minutes, each poll is an execution. Use the webhook, or poll rarely.

The same rules hold for every agent we build for you. The handoff article is the longer version of why the "Needs a human look" node exists and what your Slack message should contain.

What to require from whoever builds it

Whether that's us or anyone else, this is what you should see before a lead pipeline goes live.

  • The ideal-customer paragraph written down, with the two thresholds, and a note of who can change them.
  • The scoring call returning a fixed JSON schema, not free text, and a test of what happens when it returns nothing.
  • The tier decided in code from the thresholds, never by the model.
  • Every failure path ending at a person: incomplete forms, fetch failures, refusals, parse errors.
  • Nothing model-written sent to a prospect without a review step, and approvals per hour on the dashboard if there is one.
  • An upsert on email in the CRM, and a test with the same lead submitted twice.
  • Time from form submission to Slack alert measured on the first week of real leads, with the median and the worst case.
  • A month of overrides reviewed: every lead a rep re-tiered is a lesson for the ideal-customer paragraph.

Frequently Asked Questions

Does this need a paid enrichment API?

No. The template fetches the lead's own website and reads it, which is free and needs no key. A paid enrichment service drops in at the same node if you want firmographics, and the model is told what was and wasn't fetched either way.

Can I use OpenAI or another model instead?

Yes. The scoring call is a plain HTTP Request, so you change the URL, the headers and the body. Keep the JSON schema for the answer, or change the "Read the score" node to match, otherwise every lead goes to review.

Which CRM does it work with?

HubSpot out of the box, because the template uses its upsert. n8n has nodes for Pipedrive, Salesforce, Zoho and most others; the fields you need to map are name, email, company, website, score, tier and summary. A Google Sheet works as the CRM for a small team.

Why does the model use "low" effort?

Because scoring a form against a paragraph is a simple task, and the lower setting answers in about a second at the same accuracy for this kind of work. The reasoning still happens. It's shorter. If your ideal-customer rules are genuinely complex, raise it and measure the difference on your own leads.

What if the lead volume is tiny?

Then the clock still matters to you and the cost doesn't. At twenty leads a month the pipeline costs cents to run, and your Friday-afternoon lead still gets its acknowledgement and its Slack brief within a minute. Use managed n8n and forget about hosting.

Is my form data being sent anywhere else?

To your n8n instance, to the lead's own website (a GET request with nothing attached), to the model provider for scoring, and to your CRM, Slack and email. Nothing else. If the lead is in the EU, the acknowledgement is the natural place to say an automated system handled the first step.

Lead routing review

Want this running on your form by next week?

Send us the form, the CRM and the Slack channel, and we'll install the template, write your ideal-customer paragraph with you, and measure the first week's speed-to-lead, whether you keep us on afterwards or not.

Book a free lead-routing review

Ready to Automate Your Business?

Discover where AI can save time, reduce manual work, and improve your business operations.

Get Free Consultation