Skip to content
Home Guides Automation
Automation Intermediate

How to Automate Lead Qualification with AI (No Manual Scoring)

Score inbound enquiries automatically: HubSpot data, Claude for the free text, documented scoring rules and routing to the right person. With code, data protection and limits.

Fabian Weiss, founder of FW Delta Fabian Weiss
10 min 2-3 hours
The Problem

Manual qualification is slow and inconsistent. Good enquiries wait while sales time goes to enquiries that never become customers.

The Fix

A lead scoring system built from HubSpot data, a free-text assessment by Claude and documented rules that scores leads automatically, routes them and learns from deal outcomes.

Why Manual Lead Qualification Does Not Scale

Every enquiry lands in the CRM and someone has to decide: call, nurture or ignore. As long as one person does that by gut feeling, the result is subjective, depends on the day and collapses as soon as more enquiries arrive than there is time for. The fix is not magic but a traceable process: collect the data, score it against documented rules, route it and check the rules regularly against real deal outcomes.

Our Automation service builds exactly this process for B2B companies. In this guide you get the version you can implement yourself: Google Apps Script as the runtime, HubSpot as the CRM, Claude for assessing the free text of the enquiry. Whether you later run it in Apps Script, n8n or Zapier is secondary, our comparison with Zapier shows when which tool fits.

The Architecture

Three layers you can swap independently:

Data layer:

  • CRM data from HubSpot: email, company, lifecycle stage, lead status, create and modified date
  • The free text from the contact form (the person’s message)
  • Website behaviour, as far as you have it per contact in the CRM. With the tracking code installed, HubSpot fills its own analytics properties such as number of sessions and time of last session

Scoring layer:

  • Claude assesses the free text: does the enquiry fit the offer? The result is a fit value from 0 to 100 plus a one-sentence reason
  • A weighted rule set combines fit value, firmographics, behaviour and lifecycle stage into a score from 0 to 100
  • All weights live in one place in the code and in a table in this guide so the sales team can read them

Action layer:

  • Score 80 and above: assigned to the senior rep, lifecycle set to Sales Qualified Lead, Slack message
  • Score 40 to 79: assigned to the second rep, lifecycle set to Marketing Qualified Lead
  • Score below 40: stays in the nurture sequence, no owner
  • Disqualification rules catch test addresses and unsuitable enquiries beforehand

Why not a trained machine learning model? Most teams do not have the data: a few hundred closed deals do not make a robust model, but they are plenty for checking weights. Rules can be read and adjusted by anyone in sales. The AI part sits where rules fail: understanding free text.

If you prefer to build in n8n: the HubSpot node covers reading, searching and updating contacts. You call Claude through an HTTP Request node with the same request body as below. The logic stays identical.

Step 1: Set Up Access to HubSpot

HubSpot has moved private apps created in the UI to legacy status. Existing private apps keep working, for new integrations HubSpot recommends service keys, which you create under Development, Keys, Service Keys. According to HubSpot, service keys are still in public beta. You send both variants as a Bearer token in the Authorization header and HubSpot recommends rotating both every six months. The old API key authentication no longer exists.

Scopes this guide needs:

ScopePurpose
crm.objects.contacts.read and crm.objects.contacts.writeRead contacts and write score, owner and lifecycle
crm.objects.owners.readLook up owner IDs for assignment
crm.objects.deals.readRead deal outcomes for the feedback loop
crm.schemas.contacts.writeCreate the lead_score property once

The scopes are listed in the Contacts API documentation, the Owners API and the Properties API.

Then create the property for the score. HubSpot has its own score property hubspotscore for the built-in lead scoring, which you do not write via the API. You create your own number property with a POST to /crm/v3/properties/contacts:

{
  "groupName": "contactinformation",
  "name": "lead_score",
  "label": "Lead Score",
  "type": "number",
  "fieldType": "number"
}

Rate limits: According to the HubSpot usage guidelines, each app gets 100 requests per 10 seconds (Free and Starter) or 190 requests per 10 seconds (Professional and Enterprise). Each account gets 250,000, 625,000 or 1,000,000 requests per day. When exceeded, HubSpot responds with status 429 and errorType: "RATE_LIMIT". The headers X-HubSpot-RateLimit-Remaining and X-HubSpot-RateLimit-Daily-Remaining show where you stand. The Search API is limited separately to 5 requests per second per account.

Step 2: Sync Contacts to Google Sheets

Google Sheets serves as the staging area because you can see every intermediate value there. All secrets live in the script properties of the Apps Script project: HUBSPOT_TOKEN, SHEET_ID, ANTHROPIC_API_KEY, SLACK_WEBHOOK, SENIOR_REP_EMAIL, JUNIOR_REP_EMAIL. Optional: MESSAGE_PROPERTY with the internal name of the property where your form stores the message. Also optional: SESSIONS_PROPERTY with the internal name of the sessions property. You read internal names with GET /crm/v3/properties/contacts from the Properties API.

First a helper that runs every HubSpot call with error handling. muteHttpExceptions makes Apps Script return the status code on error responses instead of aborting. On 429 or server errors the helper waits for the time given in the Retry-After header or ten seconds and tries up to three times.

function fetchJson(url, options) {
  options.muteHttpExceptions = true;
  for (let attempt = 1; attempt <= 3; attempt++) {
    const response = UrlFetchApp.fetch(url, options);
    const code = response.getResponseCode();
    if (code === 429 || code >= 500) {
      const retryAfter = Number(response.getHeaders()['retry-after'] || response.getHeaders()['Retry-After'] || 10);
      Utilities.sleep(retryAfter * 1000);
      continue;
    }
    if (code >= 400) {
      throw new Error(options.method + ' ' + url + ' responded with ' + code + ': ' + response.getContentText());
    }
    return code === 204 ? null : JSON.parse(response.getContentText());
  }
  throw new Error(url + ' failed three times in a row');
}

function hubspotRequest(path, method, payload) {
  const token = PropertiesService.getScriptProperties().getProperty('HUBSPOT_TOKEN');
  const options = {
    method: method,
    headers: { Authorization: 'Bearer ' + token },
    contentType: 'application/json'
  };
  if (payload) options.payload = JSON.stringify(payload);
  return fetchJson('https://api.hubapi.com' + path, options);
}

Then the sync. The Contacts API returns at most 100 contacts per call and provides the cursor for the next page in the field paging.next.after. Existing rows are updated by contact ID so the columns with the AI assessment and routing status are preserved.

const BASE_PROPERTIES = ['email', 'firstname', 'lastname', 'company', 'lifecyclestage', 'hs_lead_status', 'createdate', 'lastmodifieddate'];

function syncHubSpotLeads() {
  const props = PropertiesService.getScriptProperties();
  const messageProperty = props.getProperty('MESSAGE_PROPERTY');
  const sessionsProperty = props.getProperty('SESSIONS_PROPERTY');
  const properties = BASE_PROPERTIES.concat([messageProperty, sessionsProperty].filter(Boolean)).join(',');
  const sheet = SpreadsheetApp.openById(props.getProperty('SHEET_ID')).getSheetByName('Leads');
  const rows = [];
  let after = '';
  do {
    const page = hubspotRequest('/crm/v3/objects/contacts?limit=100&properties=' + properties + (after ? '&after=' + after : ''), 'get');
    page.results.forEach(contact => {
      const p = contact.properties;
      rows.push([
        contact.id, p.email, p.firstname, p.lastname, p.company, p.lifecyclestage, p.hs_lead_status,
        p.createdate, p.lastmodifieddate,
        messageProperty ? (p[messageProperty] || '') : '',
        sessionsProperty ? (Number(p[sessionsProperty]) || 0) : 0
      ]);
    });
    after = page.paging && page.paging.next ? page.paging.next.after : '';
  } while (after);
  upsertRows(sheet, rows);
}

function upsertRows(sheet, rows) {
  const existing = sheet.getDataRange().getValues();
  const rowNumberById = {};
  for (let i = 1; i < existing.length; i++) rowNumberById[String(existing[i][0])] = i + 1;
  rows.forEach(row => {
    const rowNumber = rowNumberById[String(row[0])];
    if (rowNumber) sheet.getRange(rowNumber, 1, 1, row.length).setValues([row]);
    else sheet.appendRow(row);
  });
}

The columns in the Leads sheet: A contact ID, B email, C first name, D last name, E company, F lifecycle, G lead status, H created, I modified, J message, K sessions, L AI fit, M AI reason, N score, O routing, P disqualification.

Two limits before you run this every 15 minutes: according to the Apps Script quotas, personal Google accounts get 20,000 URL fetch calls per day and Google Workspace accounts 100,000. Each execution may also take at most 6 minutes. With 10,000 contacts that is 100 calls per run, at 96 runs per day 9,600 calls for the sync alone. From that size on, filter through the Search API by hs_lastmodifieddate and fetch only changed contacts.

Website behaviour deliberately comes from HubSpot and not from GA4: the GA4 Data API has no dimension for email addresses and the Google Analytics policies prohibit sending personally identifiable information such as email addresses to Google Analytics. If you need per-contact behaviour without HubSpot tracking, your own tracking writes it into a CRM property. Our tracking implementation covers exactly these events.

Step 3: Assess Free Text with Claude

Rules do not recognise intent. Whether “We need a tracking setup for three shops by Q4” or “I am writing my bachelor thesis and have some questions” sits behind an enquiry is obvious to a language model. For that, only the message itself goes to the Claude Messages API, no names, no email addresses. The answer comes back through structured outputs as guaranteed valid JSON, a beta header is no longer needed for this.

The guide uses claude-opus-5-5 as the model, which the models overview recommends for most workloads. The current IDs are claude-fable-5-1, claude-opus-5-5, claude-sonnet-5 and claude-haiku-4-5-20251001, older IDs with a date suffix such as claude-3-5-sonnet-20241022 no longer belong in new code. According to the pricing page, Claude Opus 5.5 costs 4 US dollars per million input tokens and 20 US dollars per million output tokens, Claude Haiku 4.5 as the cheapest option 1 and 5 US dollars. An enquiry of a few hundred words therefore costs a fraction of a cent.

function qualifyMessagesWithClaude() {
  const props = PropertiesService.getScriptProperties();
  const sheet = SpreadsheetApp.openById(props.getProperty('SHEET_ID')).getSheetByName('Leads');
  const data = sheet.getDataRange().getValues();
  for (let i = 1; i < data.length; i++) {
    const message = String(data[i][9] || '').trim();
    if (!message || data[i][11] !== '') continue;
    const result = classifyEnquiry(message);
    sheet.getRange(i + 1, 12, 1, 2).setValues([[result.fit, result.reason]]);
  }
}

function classifyEnquiry(message) {
  const payload = {
    model: 'claude-opus-5-5',
    max_tokens: 1024,
    system: 'You assess inbound enquiries to an agency for tracking, dashboards and workflow automation. Give fit as an integer from 0 to 100: 100 means a concrete project that matches the offer exactly. Student requests, job applications, sales pitches to the agency and spam get 0. Give the reason in one sentence.',
    messages: [{ role: 'user', content: message }],
    output_config: {
      format: {
        type: 'json_schema',
        schema: {
          type: 'object',
          properties: {
            fit: { type: 'integer' },
            reason: { type: 'string' }
          },
          required: ['fit', 'reason'],
          additionalProperties: false
        }
      }
    }
  };
  const body = fetchJson('https://api.anthropic.com/v1/messages', {
    method: 'post',
    contentType: 'application/json',
    headers: {
      'x-api-key': PropertiesService.getScriptProperties().getProperty('ANTHROPIC_API_KEY'),
      'anthropic-version': '2023-06-01'
    },
    payload: JSON.stringify(payload)
  });
  if (body.stop_reason !== 'end_turn') {
    return { fit: 0, reason: 'No assessment, stop_reason: ' + body.stop_reason };
  }
  const text = body.content.find(block => block.type === 'text').text;
  return JSON.parse(text);
}

The fetchJson helper from step 2 handles 429 responses here too: according to the rate limits documentation, the Claude API sends a retry-after header with the wait time in seconds when a limit is exceeded. The code also checks stop_reason, because a response with max_tokens or refusal contains no usable text.

Step 4: The Scoring Rule Set

This is where the rules live and nowhere else. When the sales team asks why a lead has 72 points, the answer is this table:

SignalPointsSource
Company given+10HubSpot company
Business email domain+15, personal domain -15HubSpot email
More than 10 sessions+20, more than 3 sessions +10Sessions property
AI fit of the enquiryup to +35 (fit value times 0.35)Claude
Lifecycle Marketing Qualified Lead+20, Lead +10HubSpot lifecyclestage

The maximum is 100 points, negative values are clamped to 0.

function calculateLeadScores() {
  const props = PropertiesService.getScriptProperties();
  const sheet = SpreadsheetApp.openById(props.getProperty('SHEET_ID')).getSheetByName('Leads');
  const data = sheet.getDataRange().getValues();
  const scores = [];
  for (let i = 1; i < data.length; i++) {
    const lead = {
      email: String(data[i][1] || ''),
      company: String(data[i][4] || ''),
      lifecycleStage: data[i][5],
      sessions: Number(data[i][10]) || 0,
      aiFit: Number(data[i][11]) || 0
    };
    let score = 0;
    if (lead.company.length > 0) score += 10;
    if (/@(gmail|googlemail|yahoo|hotmail|outlook|gmx|web|t-online)\./i.test(lead.email)) score -= 15;
    else score += 15;
    if (lead.sessions > 10) score += 20;
    else if (lead.sessions > 3) score += 10;
    score += Math.round(lead.aiFit * 0.35);
    if (lead.lifecycleStage === 'marketingqualifiedlead') score += 20;
    else if (lead.lifecycleStage === 'lead') score += 10;
    scores.push([Math.max(0, Math.min(score, 100))]);
  }
  if (scores.length) sheet.getRange(2, 14, scores.length, 1).setValues(scores);
}

Check weights instead of guessing: Export past leads with their outcome (won or lost) and recalculate the score. If won deals do not score clearly higher than lost ones on average, the weights are wrong. Step 6 automates exactly this check.

Step 5: Routing in HubSpot

You get the owner ID from the Owners API, the id field in the response is the value for hubspot_owner_id. Important for the lifecycle stage: according to the Contacts API, you can only set lifecyclestage forward via the API. A contact who is already a Sales Qualified Lead is therefore not moved back to Marketing Qualified Lead. The code checks this first. The default values are subscriber, lead, marketingqualifiedlead, salesqualifiedlead, opportunity, customer and evangelist.

const STAGE_ORDER = ['subscriber', 'lead', 'marketingqualifiedlead', 'salesqualifiedlead', 'opportunity', 'customer', 'evangelist'];

function movesForward(current, target) {
  return STAGE_ORDER.indexOf(target) > STAGE_ORDER.indexOf(current || '');
}

function routeQualifiedLeads() {
  const props = PropertiesService.getScriptProperties();
  const sheet = SpreadsheetApp.openById(props.getProperty('SHEET_ID')).getSheetByName('Leads');
  const data = sheet.getDataRange().getValues();
  const ownerIdByEmail = getOwnerIdsByEmail();
  for (let i = 1; i < data.length; i++) {
    if (data[i][14] || data[i][15]) continue;
    const contactId = data[i][0];
    const score = Number(data[i][13]);
    const update = { lead_score: score };
    let targetStage = null;
    if (score >= 80) {
      update.hubspot_owner_id = ownerIdByEmail[props.getProperty('SENIOR_REP_EMAIL')];
      targetStage = 'salesqualifiedlead';
    } else if (score >= 40) {
      update.hubspot_owner_id = ownerIdByEmail[props.getProperty('JUNIOR_REP_EMAIL')];
      targetStage = 'marketingqualifiedlead';
    }
    if (targetStage && movesForward(data[i][5], targetStage)) update.lifecyclestage = targetStage;
    hubspotRequest('/crm/v3/objects/contacts/' + contactId, 'patch', { properties: update });
    if (score >= 80) sendSlackAlert(data[i][1], score);
    sheet.getRange(i + 1, 15).setValue(score >= 40 ? 'Routed' : 'Nurture');
  }
}

function getOwnerIdsByEmail() {
  const ownerIdByEmail = {};
  let after = '';
  do {
    const page = hubspotRequest('/crm/v3/owners' + (after ? '?after=' + after : ''), 'get');
    page.results.forEach(owner => { ownerIdByEmail[owner.email] = owner.id; });
    after = page.paging && page.paging.next ? page.paging.next.after : '';
  } while (after);
  return ownerIdByEmail;
}

function sendSlackAlert(email, score) {
  const webhook = PropertiesService.getScriptProperties().getProperty('SLACK_WEBHOOK');
  const message = {
    text: 'Hot lead: ' + email + ' scored ' + score + '/100',
    blocks: [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: '*Hot lead*\nEmail: ' + email + '\nScore: ' + score + '/100\n_Assigned automatically_'
        }
      }
    ]
  };
  UrlFetchApp.fetch(webhook, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(message)
  });
}

The Slack message follows the format for incoming webhooks: POST with JSON, the text field serves as the fallback for notifications, blocks for the layout.

For many contacts at once, use the batch endpoint POST /crm/v3/objects/contacts/batch/update with up to 100 records per call instead of individual PATCH calls. That saves both the rate limit and the Apps Script quota.

Step 6: Feedback Loop with Deal Outcomes

Only closed deals show whether the weights are right. Which deal stage means “won” is not hardcoded but comes from the Pipelines API: every stage carries the fields isClosed and probability in metadata. Won stages are closed with probability 1. The Deals API returns the IDs of the associated contacts with the parameter associations=contacts.

function reviewScoringAccuracy() {
  const props = PropertiesService.getScriptProperties();
  const sheet = SpreadsheetApp.openById(props.getProperty('SHEET_ID')).getSheetByName('Leads');
  const data = sheet.getDataRange().getValues();
  const scoreByContactId = {};
  for (let i = 1; i < data.length; i++) scoreByContactId[String(data[i][0])] = Number(data[i][13]);
  const closedStages = getClosedStages();
  let correct = 0;
  let total = 0;
  let after = '';
  do {
    const page = hubspotRequest('/crm/v3/objects/deals?limit=100&properties=dealstage&associations=contacts' + (after ? '&after=' + after : ''), 'get');
    page.results.forEach(deal => {
      const won = closedStages[deal.properties.dealstage];
      if (won === undefined) return;
      const contacts = deal.associations && deal.associations.contacts ? deal.associations.contacts.results : [];
      contacts.forEach(contact => {
        const score = scoreByContactId[String(contact.id)];
        if (score === undefined) return;
        if ((score >= 60 && won) || (score < 60 && !won)) correct++;
        total++;
      });
    });
    after = page.paging && page.paging.next ? page.paging.next.after : '';
  } while (after);
  const accuracy = total ? Math.round((correct / total) * 100) : 0;
  Logger.log('Accuracy ' + accuracy + '% across ' + total + ' contacts with a closed deal');
  if (total >= 30 && accuracy < 70) sendSlackAlert('Scoring accuracy below 70%, review the weights', accuracy);
}

function getClosedStages() {
  const closedStages = {};
  const pipelines = hubspotRequest('/crm/v3/pipelines/deals', 'get');
  pipelines.results.forEach(pipeline => {
    pipeline.stages.forEach(stage => {
      if (String(stage.metadata.isClosed) === 'true') {
        closedStages[stage.id] = Number(stage.metadata.probability) === 1;
      }
    });
  });
  return closedStages;
}

The threshold of 30 contacts prevents alarms on a sample that is too small. If accuracy drops, you adjust the table in step 4, not the language model.

Step 7: Disqualification Rules

Some contacts should never reach routing at all. The rules run before scoring and write the reason into column P so it stays traceable why a contact was never assigned.

function applyDisqualificationRules() {
  const props = PropertiesService.getScriptProperties();
  const sheet = SpreadsheetApp.openById(props.getProperty('SHEET_ID')).getSheetByName('Leads');
  const data = sheet.getDataRange().getValues();
  const rules = [
    { test: lead => /^test@|@example\./i.test(lead.email), reason: 'Test address' },
    { test: lead => lead.aiFit === 0, reason: 'Enquiry does not match the offer' },
    { test: lead => lead.sessions === 0 && lead.ageInDays > 30, reason: 'Inactive for 30 days' }
  ];
  for (let i = 1; i < data.length; i++) {
    if (data[i][15]) continue;
    const lead = {
      email: String(data[i][1] || ''),
      aiFit: data[i][11] === '' ? null : Number(data[i][11]),
      sessions: Number(data[i][10]) || 0,
      ageInDays: (Date.now() - new Date(data[i][7]).getTime()) / 86400000
    };
    const hit = rules.find(rule => rule.test(lead));
    if (!hit) continue;
    sheet.getRange(i + 1, 16).setValue('Disqualified: ' + hit.reason);
    hubspotRequest('/crm/v3/objects/contacts/' + data[i][0], 'patch', { properties: { lead_score: 0 } });
  }
}

Finally the triggers. According to the Apps Script reference, everyMinutes accepts only the values 1, 5, 10, 15 and 30.

function runPipeline() {
  syncHubSpotLeads();
  qualifyMessagesWithClaude();
  applyDisqualificationRules();
  calculateLeadScores();
  routeQualifiedLeads();
}

function createTriggers() {
  ScriptApp.newTrigger('runPipeline').timeBased().everyMinutes(15).create();
  ScriptApp.newTrigger('reviewScoringAccuracy').timeBased().onWeekDay(ScriptApp.WeekDay.MONDAY).atHour(7).create();
}

As soon as enquiries go to an AI service, you are processing personal data with a processor. Four things must be in place before go-live:

  1. Data minimisation: The code above sends only the message text. Name, email, company and HubSpot ID stay in the CRM. If people mention contact details in the message itself, that is part of the processing, nothing more.
  2. Data processing agreement: Anthropic positions itself in its Data Processing Addendum as the customer’s processor and incorporates the EU Standard Contractual Clauses. According to the Commercial Terms, the DPA is part of the terms of service, which also state that Anthropic does not train on customer content from the API.
  3. Retention period: According to the Anthropic Privacy Center, API inputs and outputs are deleted within 30 days by default. A zero data retention agreement is available. Record this in your register of processing activities.
  4. Information: Your privacy policy must name the automated assessment and the provider. For contacts who enquire via a form, processing to handle the enquiry is usually covered by Art. 6(1)(b) or (f) GDPR, but that assessment is for your data protection adviser, not this guide.

The same applies to HubSpot and Google as processors: you need both in the register of processing activities and both offer standard data processing agreements.

Limits and Sources of Error

Stale data: A score is only as good as the last sync. If the quota does not allow a 15-minute cycle, filter through the Search API by modified date instead of syncing less often.

Lifecycle only forward: HubSpot lets you set lifecyclestage forward only via the API. Moving back requires clearing the value first. The code avoids this by only setting forward.

Overweighting firmographics: Company size says little about whether someone wants to buy now. That is why 35 of the 100 points sit in the content of the enquiry and 20 in behaviour. Check the weights with step 6 instead of gut feeling.

Language model as a black box: Claude returns a one-sentence reason. Store it (column M) and show it in the CRM, otherwise the team will not trust the score.

Runtime and quota: 6 minutes per execution and 20,000 URL fetch calls per day for personal Google accounts are reached quickly. From a few thousand contacts on, the process belongs in n8n or a dedicated service.

Checklist Before Go-Live

  1. Test with historical data: Run scoring on 100 past leads and look at the accuracy from step 6
  2. Monitoring: The weekly review runs and someone watches the Slack channel
  3. Override: The sales team may overwrite lead_score manually in the CRM. Routing respects column O and does not touch assigned contacts again
  4. Rules documented: The table from step 4 lives where the team can find it
  5. Parallel run: Run AI scoring alongside manual assessment for two weeks and discuss the differences
  6. Data protection: Data processing agreements, register of processing activities and privacy policy are updated

Need Professional Help?

Our AI Automation service builds this process on your data:

  • Scoring rule set: Weights derived from your historical deals and agreed with the sales team
  • CRM integration: HubSpot, Salesforce, Pipedrive or a custom CRM
  • Behavioural tracking: GA4, server-side tracking or custom events that land as properties in the CRM
  • Operations: Monitoring, weekly accuracy review and adjustment of the weights
  • Data protection: Data flows documented, contracts checked, privacy policy updated

Book a free 30-minute consultation to audit your current lead qualification process: Schedule here

Newsletter

Research for technical decisions

New reports, benchmarks and technical analyses on SaaS economics, AI engineering and owned infrastructure.

Original research Public sources No sales mail

By subscribing you receive new analyses and updates from FW Delta by email. You can withdraw your consent at any time. Further information is available in the privacy policy.