How to Automate Lead Scoring with n8n

By The LightCube Labs team

This guide covers periodic batch scoring: a scheduled n8n workflow re-scores every existing HubSpot lead once a day from engagement, firmographic and behavioral signals. It is not the real-time enrichment workflow, which scores a single new lead the moment it is created; here the goal is to keep the scores of the whole lead database current, including leads that have gone quiet.

The Problem

Static lead scores age badly, and n8n can refresh them daily. A lead who opened five emails last spring still looks hot in HubSpot, while a company that started visiting your pricing page yesterday sits low on the list. Reps lose trust in the number and go back to guessing. A single score from one source also hides why a lead ranks where it does. An n8n workflow can recompute a multi-dimensional score for every lead each morning, reduce it when activity stops, and email the owner only when a lead crosses the line, with the breakdown attached.

What You Will Need

Step-by-Step Workflow Setup

1 Run the scoring job every morning with a Schedule Trigger

Batch scoring starts with the n8n Schedule Trigger node. Unlike the real-time enrichment workflow, this n8n job wakes up once a day and re-scores the whole lead table, so scores keep changing even when a lead does nothing new.

Node: Schedule Trigger
Trigger Interval: Days
Days Between Triggers: 1
Trigger at Hour: 6am
Trigger at Minute: 0
Workflow Settings > Timezone: America/Los_Angeles

Set the timezone in the workflow settings, not only on the server. Otherwise n8n runs the job at 6am in the server timezone, which may be the middle of the afternoon for your sales team. Running before the workday means reps see fresh scores and hot lead emails first thing.

Sample:
{
  "timestamp": "2026-11-05T06:00:00.000-08:00",
  "Readable date": "November 5th 2026, 6:00:00 am",
  "Day of week": "Thursday"
}

The n8n trigger output is only a timestamp. Later nodes use $now to calculate how many days have passed since each lead last did something.

2 Fetch every lead from HubSpot with pagination

The n8n HubSpot node pulls all contacts whose lifecycle stage is Lead. Turning on Return All makes n8n follow HubSpot paging cursors until the full list is loaded, so you do not write pagination code in n8n.

Node: HubSpot
Resource: Contact
Operation: Search
Return All: On
Filter Groups:
  Property Name: lifecyclestage
  Condition: Equal to
  Value: lead
Additional Fields > Properties:
  email, firstname, lastname, company, annualrevenue,
  numberofemployees, industry, hubspot_owner_id,
  email_opens, email_clicks, form_submissions,
  hs_analytics_num_page_views, hs_analytics_num_visits,
  hs_analytics_last_timestamp, sales_rep_email

HubSpot search returns 100 contacts per page and limits requests per second, so a large database takes a while. Add an n8n Loop Over Items or a short Wait node if you see rate limit errors. The email count fields shown here (email_opens, email_clicks, form_submissions) are custom properties; rename them to match the engagement properties in your portal, then rerun the n8n node.

Sample:
{
  "id": "5120",
  "properties": {
    "email": "sam@harborlogistics.com",
    "company": "Harbor Logistics",
    "annualrevenue": "12000000",
    "numberofemployees": "180",
    "industry": "Logistics",
    "email_opens": "9",
    "email_clicks": "3",
    "form_submissions": "1",
    "hs_analytics_num_page_views": "14",
    "hs_analytics_num_visits": "5",
    "hs_analytics_last_timestamp": "2026-10-29T17:40:00Z",
    "sales_rep_email": "jo@yourcompany.com"
  }
}

3 Calculate the engagement score

The first n8n Code node scores what the lead did with your marketing. The weights match the plan: email opens are worth 5 points each, link clicks 10, form submissions 20 and page views 2. The result is capped at 100 so one very active lead cannot dominate the composite.

Node: Code
Name: Engagement Score
Mode: Run Once for Each Item
const p = $json.properties;
const n = (v) => Number(v || 0);
const raw = n(p.email_opens) * 5 + n(p.email_clicks) * 10
          + n(p.form_submissions) * 20 + n(p.hs_analytics_num_page_views) * 2;
return { json: { id: $json.id, properties: p, engagement: Math.min(raw, 100), engagementRaw: raw } };
Sample:
{
  "id": "5120",
  "engagementRaw": 123,
  "engagement": 100
}

The JSON above shows the arithmetic: 45 + 30 + 20 + 28 gives 123, which the cap reduces to 100. In n8n, a cap keeps each dimension on the same 0 to 100 scale, which the weighted composite needs.

4 Calculate the firmographic score

The second n8n Code node scores who the company is, using tiers for revenue and headcount plus an industry match. Tiers are easier to tune in n8n than raw numbers and make the scoring model readable by sales.

Node: Code
Name: Firmographic Score
Mode: Run Once for Each Item
const p = $json.properties;
const rev = Number(p.annualrevenue || 0);
const emp = Number(p.numberofemployees || 0);
const fit = ['software', 'logistics', 'financial services'];
const revTier = rev >= 50000000 ? 40 : rev >= 10000000 ? 30 : rev >= 1000000 ? 15 : 0;
const empTier = emp >= 500 ? 35 : emp >= 100 ? 25 : emp >= 20 ? 10 : 0;
const industry = fit.includes(String(p.industry || '').toLowerCase()) ? 25 : 0;
return { json: { ...$json, firmographic: revTier + empTier + industry } };
Sample:
{
  "id": "5120",
  "revenueTier": 30,
  "employeeTier": 25,
  "industryMatch": 25,
  "firmographic": 80
}

In this n8n node, revenue, headcount and industry add up to a maximum of 100. Adjust the tier boundaries to your market; a team that sells to small shops needs different cut points than one selling to enterprise accounts.

5 Calculate the behavioral score

The third n8n Code node measures how the lead behaves over time: how recently they were active, how often they visit, and how deep they go into content. Recency is the strongest signal in the n8n model, so it carries the largest share.

Node: Code
Name: Behavioral Score
Mode: Run Once for Each Item
const p = $json.properties;
const last = new Date(p.hs_analytics_last_timestamp || 0).getTime();
const daysSince = last > 0 ? Math.floor(($now.toMillis() - last) / 86400000) : 999;
const visits = Number(p.hs_analytics_num_visits || 0);
const pages = Number(p.hs_analytics_num_page_views || 0);
const recency = daysSince <= 7 ? 50 : daysSince <= 30 ? 30 : daysSince <= 90 ? 10 : 0;
const frequency = Math.min(visits * 5, 25);
const depth = visits > 0 ? Math.min((pages / visits) * 5, 25) : 0;
return { json: { ...$json, daysSince, behavioral: Math.round(recency + frequency + depth) } };
Sample:
{
  "id": "5120",
  "daysSince": 7,
  "behavioral": 71
}

A lead with no recorded activity gets daysSince = 999, which scores zero for recency and later triggers the heaviest decay in the n8n composite.

6 Combine the scores with weights and apply decay

The fourth n8n Code node produces the composite. The weights are 0.4 for engagement, 0.3 for firmographic and 0.3 for behavioral. An n8n decay factor then reduces the result for leads that have gone quiet, so old interest does not keep a lead on the hot list forever.

Node: Code
Name: Composite Score
Mode: Run Once for Each Item
const base = 0.4 * $json.engagement + 0.3 * $json.firmographic + 0.3 * $json.behavioral;
const d = $json.daysSince;
const decay = d <= 14 ? 1 : Math.max(0.4, 1 - (d - 14) * 0.01);
const score = Math.round(base * decay);
return { json: {
  id: $json.id,
  email: $json.properties.email,
  repEmail: $json.properties.sales_rep_email,
  engagement: $json.engagement,
  firmographic: $json.firmographic,
  behavioral: $json.behavioral,
  decay: Number(decay.toFixed(2)),
  lead_score: score
}};

Decay is flat for the first 14 days, then loses one percentage point per day and never drops below 40 percent. For lead 5120: 0.4 x 100 + 0.3 x 80 + 0.3 x 71 = 85.3, and with 7 days of inactivity the decay is 1, so the score is 85.

Sample:
{
  "id": "5120",
  "engagement": 100,
  "firmographic": 80,
  "behavioral": 71,
  "decay": 1,
  "lead_score": 85
}

7 Write the score back and notify reps about hot leads

First, an n8n HubSpot node saves the number to a custom lead_score property so lists, reports and workflows inside HubSpot can use it. Create the property as a number field before the first n8n run.

Node: HubSpot
Resource: Contact
Operation: Update
Contact ID: {{ $json.id }}
Update Fields > Custom Properties:
  lead_score: {{ $json.lead_score }}

Next, an n8n Filter node keeps only leads worth a rep's time, and a Send Email node tells the assigned rep with the score breakdown. The n8n Filter node drops the rest, so no email goes out for them.

Node: Filter
Conditions:
  Value 1: {{ $('Composite Score').item.json.lead_score }}
  Operation: Number > Larger
  Value 2: 75
Node: Send Email
Credential: SMTP
From Email: Lead Alerts <alerts@yourcompany.com>
To Email: {{ $('Composite Score').item.json.repEmail }}
Subject: Hot lead: {{ $('Composite Score').item.json.email }} scored {{ $('Composite Score').item.json.lead_score }}
Email Format: Text
Text: Score {{ $('Composite Score').item.json.lead_score }}
Engagement: {{ $('Composite Score').item.json.engagement }}
Firmographic: {{ $('Composite Score').item.json.firmographic }}
Behavioral: {{ $('Composite Score').item.json.behavioral }}
Decay factor: {{ $('Composite Score').item.json.decay }}
Sample:
{
  "accepted": ["jo@yourcompany.com"],
  "rejected": []
}

A daily email for the same lead gets noisy. To send only when a lead first crosses 75, compare with the previous lead_score value read in step 2 and filter on the change. Activate the n8n workflow after a dry run on a small list.

Troubleshooting

The scheduled n8n run times out on a large lead list

Loading tens of thousands of contacts in one n8n run can exceed memory or the execution timeout. Process in batches with a Loop Over Items node, set a batch size of 200, and add a short Wait between pages. On self-hosted n8n you can also raise EXECUTIONS_TIMEOUT.

Every score is the same low number

Property values arrive from HubSpot as strings, and missing properties return empty values. The n8n Code nodes wrap each read in Number() with a default of 0. If everything still lands low, the property names in the search step do not match your portal, so confirm the internal names in HubSpot settings and in the n8n node.

Hot lead emails repeat every day

The n8n Filter node passes any lead above 75 on every run. Store the previous score, compare it with the new one, and notify only when the old score was 75 or lower. Alternatively write a hot_notified date property and filter on it.

Free n8n Workflow Resources

Get started with the free workflow pack on the LightCube Labs templates page.

Production-Ready Workflow Packs

Get this workflow and more, with setup guides, editable configurations, and a shared failure-alert system.

View the Starter ($29) and Pro ($99) Packs

Frequently Asked Questions

How is batch scoring different from enrichment scoring?

Enrichment scoring runs once when a contact is created and uses data fetched from a provider. This n8n workflow runs on a schedule over all leads and uses data already in HubSpot, so the scores keep moving as behavior changes.

Why does n8n use a decay factor instead of just recent activity?

Recency already appears in the behavioral score, but decay applies to the whole composite. A lead with strong firmographics and old engagement should still fall over time, and decay does that without erasing the other dimensions.

Can I change the 0.4, 0.3, 0.3 weights in n8n?

Yes. Edit the one line in the composite n8n Code node. Check the new weights against past closed deals in the n8n output before you ship them, and keep the three weights summing to 1 so the score stays on a 0 to 100 scale.

Should the score threshold be 75?

Treat 75 as a starting point. After a few weeks, compare the leads above it with the ones reps actually worked, then move the n8n Filter value until the daily hot list matches what the team can handle.