Keep HubSpot company records current
A weekly Python job that enriches every HubSpot company with a domain: employee count, size band, industry, headquarters and its LinkedIn page.
What it does#
Once a week, the script reads every company in your HubSpot account, looks up the ones with a domain, and writes back what it finds. Companies it enriched in the last seven days are skipped, so a weekly run only pays for what's due.
| HubSpot property | From HappyFetch |
|---|---|
Number of Employees (numberofemployees) |
employeeCount |
City, Country/Region (city, country) |
headquarter.city, headquarter.country |
LinkedIn Company Page (linkedin_company_page) |
linkedInUrl |
Year Founded (founded_year) |
foundedOn.year |
Industry (HappyFetch) (happyfetch_industry) |
industry |
Size band (HappyFetch) (happyfetch_size_band) |
employeeCountRange |
Enriched by HappyFetch (happyfetch_enriched_at) |
when the script last updated the company |
HubSpot's own Industry property only accepts values from its fixed list, so the industry goes into a text property of its own. The script creates the three HappyFetch properties on its first run. It never blanks out a value: a field we don't know leaves HubSpot's value as it is.
What you need#
- A HubSpot private app token in
HUBSPOT_TOKEN, with the scopescrm.objects.companies.read,crm.objects.companies.writeandcrm.schemas.companies.write. Create one under Settings > Integrations > Private Apps. - An API key in
HAPPYFETCH_API_KEY. New accounts get 200 credits. - Python 3.10 or newer, and
pip install requests.
The script#
"""Keep HubSpot company records current with HappyFetch: size, industry, headquarters, LinkedIn page.
Usage: python hubspot_enrich.py
Needs HUBSPOT_TOKEN, a private app token with the scopes crm.objects.companies.read,
crm.objects.companies.write and crm.schemas.companies.write, and HAPPYFETCH_API_KEY.
Run it weekly: companies it enriched in the last FRESH_DAYS days are skipped.
"""
import datetime
import os
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
import requests
HAPPYFETCH = "https://api.happyfetch.net"
HUBSPOT = "https://api.hubapi.com"
HF = {"Authorization": f"Bearer {os.environ['HAPPYFETCH_API_KEY']}"}
HS = {"Authorization": f"Bearer {os.environ['HUBSPOT_TOKEN']}"}
FRESH_DAYS = 7
# HubSpot's own industry property only takes values from its fixed list, so the industry and the size
# band go into text properties of their own, created on the first run.
OWN_PROPERTIES = [
{"name": "happyfetch_industry", "label": "Industry (HappyFetch)", "type": "string", "fieldType": "text"},
{"name": "happyfetch_size_band", "label": "Size band (HappyFetch)", "type": "string", "fieldType": "text"},
{"name": "happyfetch_enriched_at", "label": "Enriched by HappyFetch", "type": "datetime", "fieldType": "date"},
]
def ensure_properties() -> None:
for prop in OWN_PROPERTIES:
res = requests.post(f"{HUBSPOT}/crm/v3/properties/companies", headers=HS,
json={**prop, "groupName": "companyinformation"}, timeout=30)
if res.status_code not in (201, 409): # 409: it exists from an earlier run
res.raise_for_status()
def companies():
"""Every company in HubSpot, 100 at a time, with its domain and when we last enriched it."""
params = {"limit": 100, "properties": "domain,happyfetch_enriched_at"}
while True:
res = requests.get(f"{HUBSPOT}/crm/v3/objects/companies", headers=HS, params=params, timeout=30)
res.raise_for_status()
page = res.json()
yield from page["results"]
after = page.get("paging", {}).get("next", {}).get("after")
if not after:
return
params["after"] = after
def due(company: dict, now: datetime.datetime) -> bool:
props = company["properties"]
if not props.get("domain"):
return False
last = props.get("happyfetch_enriched_at")
return not last or now - datetime.datetime.fromisoformat(last.replace("Z", "+00:00")) > datetime.timedelta(days=FRESH_DAYS)
def lookup(domain: str):
key = str(uuid.uuid4()) # one key per domain, reused on every retry: a retry never pays twice
for attempt in range(6):
try:
res = requests.post(f"{HAPPYFETCH}/v1/domain", headers={**HF, "Idempotency-Key": key},
json={"domain": domain}, timeout=90)
answer = res.json()
except (requests.RequestException, ValueError):
time.sleep(2**attempt) # a timeout or a dropped connection
continue
if res.ok:
return answer["data"], None
error = answer["error"]
if not error["retryable"]:
return None, error["code"]
time.sleep(error.get("retry_after_seconds") or 2**attempt)
return None, "gave_up"
def properties(company: dict, stamp: str) -> dict:
hq = company["headquarter"] or {}
size = company["employeeCountRange"] or {}
founded = (company["foundedOn"] or {}).get("year")
values = {
"numberofemployees": company["employeeCount"],
"city": hq.get("city"),
"country": hq.get("country"),
"linkedin_company_page": company["linkedInUrl"],
"founded_year": str(founded) if founded else None,
"happyfetch_industry": company["industry"],
"happyfetch_size_band": f"{size['start']}-{size['end'] or ''}" if size else None,
"happyfetch_enriched_at": stamp,
}
return {k: v for k, v in values.items() if v is not None} # never blank out what HubSpot already has
def main() -> None:
ensure_properties()
now = datetime.datetime.now(datetime.timezone.utc)
stamp = now.isoformat(timespec="seconds").replace("+00:00", "Z")
todo = [c for c in companies() if due(c, now)]
print(f"{len(todo):,} companies to enrich")
updates, found, missing, invalid, failed = [], 0, 0, 0, 0
with ThreadPoolExecutor(max_workers=8) as pool:
for company, (data, error) in zip(todo, pool.map(lambda c: lookup(c["properties"]["domain"]), todo)):
if data:
updates.append({"id": company["id"], "properties": properties(data, stamp)})
found += 1
elif error == "not_found": # no company at that domain: don't ask again this week
updates.append({"id": company["id"], "properties": {"happyfetch_enriched_at": stamp}})
missing += 1
elif error == "invalid_domain": # not a website: never charged, worth fixing in HubSpot
invalid += 1
else:
failed += 1 # left as it was, so the next run tries again
for i in range(0, len(updates), 100): # HubSpot takes 100 updates per call
res = requests.post(f"{HUBSPOT}/crm/v3/objects/companies/batch/update", headers=HS,
json={"inputs": updates[i:i + 100]}, timeout=60)
res.raise_for_status()
print(f"Updated {found:,} companies. {missing:,} domains have no company, {invalid:,} aren't websites, "
f"and {failed:,} failed and wait for the next run.")
if __name__ == "__main__":
main()Run it with python hubspot_enrich.py, then schedule it weekly: a cron job, a GitHub Actions schedule or
any scheduler you already use.
How it handles failures#
- Retries never pay twice. Each lookup has its own idempotency key, reused on every retry.
- A domain with no company is an answer: the script stamps the company so it isn't asked again that week. It costs 4 credits, and nothing when the answer comes from our cache.
- A domain that isn't a website is never charged. The script counts it so you can fix it in HubSpot.
- A call we couldn't complete is refunded in the same request and left alone, so the next run tries again.
What it costs#
A weekly run looks up each company once a week. Most companies change slowly, so set FRESH_DAYS to 30
for a monthly refresh at a quarter of the cost.
| Lookups a month | Smallest pack (Starter) | Biggest pack (Business) |
|---|---|---|
| 1,000 lookups by website domain | $2.00 | $1.00 |
| 10,000 lookups by website domain | $20.00 | $10.00 |
| 100,000 lookups by website domain | $200.00 | $100.00 |
Every lookup fetched fresh, at 4 credits each. Lookups our cache answers cost half. A confirmed not-found costs the same, and nothing from our cache. Starter: $10 for 20,000 credits. Business: $500 for 2,000,000 credits. Credits never expire.
Try it on your own companies
200 credits free when you verify your email. No card needed, no subscription.