Score HubSpot leads based on firmographic and technographic fit using code

medium complexityCost: $0

Prerequisites

Prerequisites
  • Python 3.9+ or Node.js 18+
  • HubSpot private app token with crm.objects.contacts.read and crm.objects.contacts.write scopes
  • A custom HubSpot contact property for the fit score (e.g., icp_fit_score, number type, 0-100)
  • A cron scheduler (crontab, GitHub Actions, or a serverless function)

Why code?

A custom script gives you full control over the scoring model — complex weighting, conditional logic, multi-source enrichment data, and batch re-scoring are all straightforward. You own the code, there are no per-execution costs, and you can version-control the scoring model alongside your codebase.

The trade-off is maintenance. You write and host the script yourself. For teams with a developer on staff, this is often the most cost-effective option. For non-technical teams, n8n or Zapier is simpler.

How it works

  • Python script defines a weighted scoring model with configurable criteria
  • HubSpot CRM Search API fetches contacts with enriched properties (employee count, industry, job title, source)
  • Scoring function evaluates each contact against ICP criteria and outputs a 0-100 score
  • HubSpot PATCH API writes the score to the icp_fit_score custom property
  • Cron or GitHub Actions runs the script on a schedule

Step 1: Define your scoring model

Create a config that maps your ICP criteria to point values. This is the core of the recipe -- everything else is just plumbing.

SCORING_MODEL = {
    "company_size": {
        "ranges": [
            {"min": 200, "max": 2000, "points": 30},   # sweet spot
            {"min": 50,  "max": 199,  "points": 20},   # good fit
            {"min": 2000, "max": 10000, "points": 15},  # enterprise
        ],
        "default": 5,  # any known value
        "unknown": 0,
    },
    "industry": {
        "ideal": {"saas": 25, "technology": 25, "software": 25, "computer software": 25},
        "good": {"financial services": 15, "consulting": 15, "marketing": 15},
        "default": 5,
        "unknown": 0,
    },
    "seniority": {
        "c_suite": {"keywords": ["ceo", "cto", "cfo", "coo", "cmo", "cro", "chief"], "points": 30},
        "vp": {"keywords": ["vp", "vice president", "head of"], "points": 25},
        "director": {"keywords": ["director"], "points": 20},
        "manager": {"keywords": ["manager", "lead"], "points": 10},
    },
    "source": {
        "organic_search": 15,
        "direct_traffic": 12,
        "referrals": 10,
        "paid_search": 8,
        "social_media": 5,
        "email_marketing": 5,
    },
}

Step 2: Build the scoring function

def score_contact(contact):
    props = contact.get("properties", {})
    score = 0
 
    # Company size
    employees = int(props.get("numberofemployees") or 0)
    size_scored = False
    for r in SCORING_MODEL["company_size"]["ranges"]:
        if r["min"] <= employees <= r["max"]:
            score += r["points"]
            size_scored = True
            break
    if not size_scored and employees > 0:
        score += SCORING_MODEL["company_size"]["default"]
 
    # Industry
    industry = (props.get("industry") or "").lower()
    if industry in SCORING_MODEL["industry"]["ideal"]:
        score += SCORING_MODEL["industry"]["ideal"][industry]
    elif industry in SCORING_MODEL["industry"]["good"]:
        score += SCORING_MODEL["industry"]["good"][industry]
    elif industry:
        score += SCORING_MODEL["industry"]["default"]
 
    # Seniority
    title = (props.get("jobtitle") or "").lower()
    for level in SCORING_MODEL["seniority"].values():
        if any(kw in title for kw in level["keywords"]):
            score += level["points"]
            break
 
    # Lead source
    source = (props.get("hs_analytics_source") or "").lower()
    score += SCORING_MODEL["source"].get(source, 0)
 
    return min(score, 100)

Step 3: Fetch contacts and write scores

import requests
import os
 
HUBSPOT_ACCESS_TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_ACCESS_TOKEN}", "Content-Type": "application/json"}
 
def fetch_unscored_contacts():
    """Fetch contacts that don't have a fit score yet, or were recently updated."""
    all_contacts = []
    after = None
 
    while True:
        body = {
            "filterGroups": [{"filters": [
                {"propertyName": "icp_fit_score", "operator": "NOT_HAS_PROPERTY"}
            ]}],
            "properties": ["jobtitle", "company", "numberofemployees",
                           "industry", "hs_analytics_source"],
            "limit": 100,
        }
        if after:
            body["after"] = after
 
        resp = requests.post(
            "https://api.hubapi.com/crm/v3/objects/contacts/search",
            headers=HEADERS, json=body
        )
        resp.raise_for_status()
        data = resp.json()
        all_contacts.extend(data.get("results", []))
 
        after = data.get("paging", {}).get("next", {}).get("after")
        if not after:
            break
 
    return all_contacts
 
def update_score(contact_id, score):
    resp = requests.patch(
        f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}",
        headers=HEADERS,
        json={"properties": {"icp_fit_score": str(score)}}
    )
    resp.raise_for_status()
 
if __name__ == "__main__":
    contacts = fetch_unscored_contacts()
    print(f"Scoring {len(contacts)} contacts...")
 
    for contact in contacts:
        score = score_contact(contact)
        update_score(contact["id"], score)
        name = f"{contact['properties'].get('firstname', '')} {contact['properties'].get('lastname', '')}".strip()
        print(f"  {name}: {score}/100")
 
    print("Done.")
HubSpot API rate limits

HubSpot allows 100 requests per 10 seconds for private apps. The batch update endpoint (/crm/v3/objects/contacts/batch/update) lets you update up to 100 contacts per request -- use it if you're scoring more than a few hundred contacts at a time.

Step 4: Schedule the scoring run

Run the script on a schedule to catch new and updated contacts:

# Score unscored contacts every hour
0 * * * * cd /path/to/project && python score_leads.py >> /var/log/lead-scoring.log 2>&1

For a batch re-score of all contacts (e.g., when you change your scoring model), modify the filter to remove the NOT_HAS_PROPERTY condition and process everyone.

Troubleshooting

Common questions

How do I handle HubSpot's API rate limits for large contact lists?

HubSpot allows 150 requests per 10 seconds for private apps. The script updates contacts one by one — for 150+ contacts, add time.sleep(0.1) between updates or use the batch update endpoint (POST /crm/v3/objects/contacts/batch/update) which handles 100 contacts per request.

Should I score on a cron schedule or use webhooks?

Cron is simpler and sufficient for most teams — score nightly or hourly. Use HubSpot webhooks (via a webhook subscription) if you need real-time scoring when a contact is created or enriched.

How do I version-control scoring model changes?

Keep the scoring weights in a config dict at the top of the script. Commit changes to git with a description of what changed and why. Re-run with --all to apply new weights to existing contacts.

Cost

  • Hosting: Free via cron on any server, or use GitHub Actions (2,000 free minutes/month)
  • No per-execution cost beyond hosting

Looking to scale your AI operations?

We build and optimize automation systems for mid-market businesses. Let's discuss the right approach for your team.