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.readandcrm.objects.contacts.writescopes - 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)
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_TOKEN = os.environ["HUBSPOT_TOKEN"]
HEADERS = {"Authorization": f"Bearer {HUBSPOT_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>&1For 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.
Cost
- Hosting: Free via cron on any server, or use GitHub Actions (2,000 free minutes/month)
- No per-execution cost beyond hosting
Need help implementing this?
We build and optimize automation systems for mid-market businesses. Let's discuss the right approach for your team.