Skip to main content
Thirdwatchthirdwatch
Business & local data

Backfill Firmographics on CRM Accounts Without a Vendor

Fill blank Industry, Employee Count, HQ Country and Founded fields across thousands of CRM accounts with LinkedIn firmographic data enrichment. No vendor seat.

Jul 28, 2026 · 8 min read · 1,794 words
See the scraper →

Thirdwatch's LinkedIn Company Enrichment actor turns a list of account domains or LinkedIn URLs into structured firmographic records — industry, size band, employee count, HQ country, founded year, company type — so you can backfill blank CRM account fields in bulk. Built for RevOps and sales-ops owners whose segmentation, territory routing and board reporting keep failing on nulls. Pay per enriched company, no seat licence, no annual contract.

Why firmographic data enrichment is a RevOps problem, not a data problem

Blank account fields do not stay contained. A null Industry breaks your segment filters, so pipeline-by-segment reporting silently under-counts. A null Employee Count breaks the routing rule that sends enterprise accounts to named reps, so they land in the self-serve queue. A null HQ Country breaks territory assignment, so quota attainment is wrong at the end of the quarter. One missing field cascades into four broken systems, and the people who notice first are the ones reading the dashboard, not the ones maintaining it.

Gartner's widely cited estimate puts the average annual cost of poor data quality at $12.9 million per organisation. Most RevOps teams do not need the number to know the shape of the problem: they have an account table with tens of thousands of rows, a handful of firmographic columns that are 40 to 70 percent empty, and a data vendor quote that requires a procurement cycle to approve.

The job to be done is narrow and mechanical. Take the account records you already own, resolve each one to a company identity, pull back the same six or seven attributes for every row, map them onto your existing picklists, and write back only where the field is currently blank. That is a scripted afternoon, not a vendor evaluation.

How does this compare to the alternatives?

Four ways to fill blank firmographic fields on an account table you already own.

Approach Pricing model Reliability Setup time Maintenance
Manual research by an SDR or intern Loaded hourly cost High per row, unusably slow past a few hundred accounts Minutes Never finishes
Full data vendor (ZoomInfo, Clearbit, Dun & Bradstreet) Annual contract plus per-seat licence High, with match rates in the vendor's terms Weeks including procurement Renewal negotiation
DIY scraper against company pages Your engineering time Breaks whenever the page markup shifts Days You own every break
Thirdwatch LinkedIn Company Enrichment Transparent pay-per-result Deterministic on LinkedIn URLs, best-effort on bare domains 5 minutes Thirdwatch tracks page changes

The vendor route is the right call when you need verified contact data, intent signals and a support SLA attached. For a one-off backfill of six company attributes across an existing account table, it is a procurement cycle to solve a scripting problem. The LinkedIn Company Enrichment actor page gives you the same attributes on a per-record basis with no seat count to negotiate.

How to backfill firmographics on CRM accounts in 4 steps

How do I find out which account fields are actually blank?

Audit before you enrich. Pull your account export and count nulls per column, because the answer usually reshapes the plan — teams routinely discover Industry is 30 percent blank while Founded Year is 90 percent blank, and only one of those is worth paying to fill.

import pandas as pd

accounts = pd.read_csv("salesforce_accounts.csv")

FIRMO_FIELDS = [
    "Industry", "NumberOfEmployees", "Employee_Band__c",
    "BillingCountry", "BillingCity", "Founded_Year__c",
    "Company_Type__c", "Website",
]

audit = pd.DataFrame({
    "blank": accounts[FIRMO_FIELDS].isna().sum(),
    "blank_pct": (accounts[FIRMO_FIELDS].isna().mean() * 100).round(1),
    "filled": accounts[FIRMO_FIELDS].notna().sum(),
}).sort_values("blank_pct", ascending=False)

print(audit)
print(f"\nAccounts missing at least one firmographic field: "
      f"{accounts[FIRMO_FIELDS].isna().any(axis=1).sum()} of {len(accounts)}")

Only enrich the rows that need it. Filtering to accounts with at least one blank field typically cuts the job by a third before you spend anything.

How do I build the input list from account domains?

Feed the actor whatever identifier your CRM already stores. The companies input takes a mixed array — LinkedIn company URLs, bare LinkedIn slugs, company names, or website domains — so you do not need a separate resolution step first.

import os, requests

TOKEN = os.environ["APIFY_TOKEN"]
ACTOR = "thirdwatch~linkedin-company-enrichment-scraper"

needs_enrichment = accounts[accounts[FIRMO_FIELDS].isna().any(axis=1)].copy()

# Prefer a stored LinkedIn URL; fall back to the website domain.
def identifier(row):
    if isinstance(row.get("LinkedIn_URL__c"), str) and row["LinkedIn_URL__c"].strip():
        return row["LinkedIn_URL__c"].strip()
    site = row.get("Website")
    if isinstance(site, str) and site.strip():
        return site.replace("https://", "").replace("http://", "").strip("/ ")
    return None

needs_enrichment["input_key"] = needs_enrichment.apply(identifier, axis=1)
targets = needs_enrichment["input_key"].dropna().unique().tolist()

print(f"{len(targets)} unique identifiers queued")

Deduplicate before you submit. Multi-division account tables frequently carry the same parent domain on a dozen rows, and each unique identifier only needs to be enriched once.

How do I run the enrichment in batches?

Submit the identifiers in chunks and collect one record per company. Each company page produces exactly one output row, so your result count is predictable before the run starts.

BATCH = 500
enriched = []

for i in range(0, len(targets), BATCH):
    chunk = targets[i:i + BATCH]
    resp = requests.post(
        f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items",
        params={"token": TOKEN},
        json={
            "companies": chunk,
            "maxResults": len(chunk),
            "concurrency": 10,
            "proxyConfiguration": {"useApifyProxy": True},
        },
        timeout=3600,
    )
    resp.raise_for_status()
    enriched.extend(resp.json())
    print(f"{len(enriched)} / {len(targets)} companies enriched")

firmo = pd.DataFrame(enriched)
firmo.to_csv("firmographics_raw.csv", index=False)

Keep concurrency at the default of 10 for a first run. Raise it once you have seen a clean batch complete; lower it if the run log shows repeated retries.

How do I map company_size onto my employee-band picklist?

This is the step that makes the backfill usable. Raw enrichment output is not CRM-shaped: company_size arrives as a display string like 1001-5000 employees, and your Employee Band picklist has its own labels. Map explicitly rather than writing raw strings into a restricted picklist, which will silently reject values.

Actor field CRM account field Transform
industry Industry Map through your own industry picklist; fall back to Other
company_size Employee_Band__c Band lookup below
employee_count_min / employee_count_max NumberOfEmployees Midpoint, or use employees_on_linkedin when present
hq_country BillingCountry ISO-normalise, then feed territory rules
hq_city / hq_region / hq_postal_code BillingCity / BillingState / BillingPostalCode Direct
founded_year Founded_Year__c Integer
company_type Company_Type__c Direct
website / website_domain Website Fill only when blank
locations_count Office_Count__c Integer, useful as a multi-site flag
BAND_MAP = {
    "1-10 employees":        "Micro",
    "11-50 employees":       "Small",
    "51-200 employees":      "Small",
    "201-500 employees":     "Mid-Market",
    "501-1000 employees":    "Mid-Market",
    "1001-5000 employees":   "Enterprise",
    "5001-10000 employees":  "Enterprise",
    "10001+ employees":      "Strategic",
}

def to_band(row):
    band = BAND_MAP.get((row.get("company_size") or "").strip())
    if band:
        return band
    hi = row.get("employee_count_max") or row.get("employees_on_linkedin")
    if not hi:
        return None
    return ("Micro" if hi <= 10 else "Small" if hi <= 200
            else "Mid-Market" if hi <= 1000
            else "Enterprise" if hi <= 10000 else "Strategic")

firmo["employee_band"] = firmo.apply(to_band, axis=1)
firmo["headcount"] = firmo["employees_on_linkedin"].fillna(
    (firmo["employee_count_min"].fillna(0) + firmo["employee_count_max"].fillna(0)) / 2
).round().astype("Int64")

How do I write it back without clobbering rep-entered data?

Fill blanks only. Join on source_query, which echoes the exact input string each row came from, then update a field only where the CRM value is currently null and stage the conflicts for review.

from simple_salesforce import Salesforce

sf = Salesforce(username=os.environ["SF_USER"],
                password=os.environ["SF_PASS"],
                security_token=os.environ["SF_TOKEN"])

merged = needs_enrichment.merge(
    firmo, left_on="input_key", right_on="source_query", how="inner")

MAPPING = {
    "Industry": "industry",
    "NumberOfEmployees": "headcount",
    "Employee_Band__c": "employee_band",
    "BillingCountry": "hq_country",
    "BillingCity": "hq_city",
    "Founded_Year__c": "founded_year",
    "Company_Type__c": "company_type",
}

filled, conflicts = 0, []
for _, row in merged.iterrows():
    payload = {}
    for crm_field, src in MAPPING.items():
        new = row.get(src)
        if pd.isna(new) or new in (None, ""):
            continue
        if pd.isna(row.get(crm_field)):
            payload[crm_field] = new
        elif str(row[crm_field]).strip() != str(new).strip():
            conflicts.append((row["Id"], crm_field, row[crm_field], new))
    if payload:
        payload["Firmo_Enriched_At__c"] = row["scraped_at"]
        payload["Firmo_Source__c"] = "LinkedIn Company Enrichment"
        sf.Account.update(row["Id"], payload)
        filled += 1

print(f"Filled {filled} accounts; {len(conflicts)} conflicts staged for review")

Writing Firmo_Enriched_At__c and Firmo_Source__c alongside the values is what makes the second run cheap — next month you enrich only accounts where the timestamp is null or stale.

Sample output

One record per resolved company. Two redacted rows:

[
  {
    "name": "HashiCorp",
    "slug": "hashicorp",
    "linkedin_url": "https://www.linkedin.com/company/hashicorp",
    "industry": "Software Development",
    "company_size": "1001-5000 employees",
    "employee_count_min": 1001,
    "employee_count_max": 5000,
    "employees_on_linkedin": 2184,
    "follower_count": 186420,
    "founded_year": 2012,
    "company_type": "Public Company",
    "specialties": ["Infrastructure as Code", "Secrets Management", "Cloud Automation"],
    "website": "https://www.hashicorp.com",
    "website_domain": "hashicorp.com",
    "headquarters": "San Francisco, California, US",
    "hq_city": "San Francisco",
    "hq_region": "California",
    "hq_country": "US",
    "locations": ["San Francisco, California, US", "London, England, GB"],
    "locations_count": 2,
    "scraped_at": "2026-07-28T09:14:02Z",
    "source_query": "hashicorp.com"
  },
  {
    "name": "Example Logistics BV",
    "industry": "Truck Transportation",
    "company_size": "51-200 employees",
    "employee_count_min": 51,
    "employee_count_max": 200,
    "employees_on_linkedin": 88,
    "founded_year": null,
    "company_type": "Privately Held",
    "hq_city": "Rotterdam",
    "hq_country": "NL",
    "locations_count": 1,
    "source_query": "https://www.linkedin.com/company/example-logistics"
  }
]

source_query is the join key back to your CRM row — it is the literal string you submitted, not a normalised version, so the merge is exact. company_size is the display band and employee_count_min / employee_count_max are that band parsed into integers; employees_on_linkedin is a separate, finer-grained signal and is usually the better value for a numeric headcount field. hq_country drives territory rules directly. Note the second row: founded_year is null because that company never filled it in on their page. That is normal and your write-back logic should expect it.

Common pitfalls

Four failure modes show up in real backfills. Domain resolution is imperfect — a bare website domain resolves to the right company roughly three times in four, and ambiguous or heavily rebranded domains miss. Pass LinkedIn URLs or slugs wherever your CRM already stores them, and spot-check domain-only matches by comparing the returned website_domain against the domain you submitted before writing anything back. Self-reported fields are optionalfounded_year, specialties and employees_on_linkedin are absent when the company never filled them in, so never make a downstream rule depend on a field being non-null. Size bands are self-declared and coarse — a company straddling a boundary may sit one band away from its real headcount, which matters if your enterprise routing threshold sits exactly on a band edge; use employees_on_linkedin as the tiebreaker. Picklist rejection — writing a raw industry string into a restricted Salesforce picklist fails silently on some API paths; map through your own vocabulary and route unmatched values to a review queue rather than to Other.

Thirdwatch's actor handles proxy rotation and page-structure changes so your pipeline sees a stable field set, and every record carries scraped_at and source_query for provenance.

Related use cases

Frequently asked questions

What is firmographic data enrichment?

Firmographic data enrichment fills company-level attributes on an account record: industry, employee band, headcount, HQ city and country, founded year, company type and website. It operates on organisations, not people, and it is what segmentation, territory routing and pipeline reporting depend on.

Which CRM fields can this actually fill?

Industry, Employee Count, Employee Band, Headquarters City, State, Postal Code and Country, Founded Year, Company Type, Website, Number of Offices and Description. Every one of these maps to a standard Salesforce or HubSpot account field, so no custom object work is required.

Do I need LinkedIn company URLs, or will domains work?

Both work. LinkedIn URLs and slugs resolve deterministically and should be your first choice. Bare website domains resolve correctly roughly three times in four, so treat domain-only rows as best-effort and review low-confidence matches before writing them back.

How often should I re-run a firmographic backfill?

Run a full backfill once, then a monthly delta on accounts created or edited since the last run. Employee bands and HQ addresses move slowly, so a full refresh once or twice a year is enough for most reporting and routing use cases.

Will this overwrite data my reps entered manually?

Only if you let it. The recommended pattern is fill-if-blank: write an enriched value only where the CRM field is currently null, and stage everything else in shadow fields for review. That keeps rep-entered ground truth intact while still killing the nulls.

Is enriching account records with public company data compliant?

Company-level firmographics are business information about an organisation, not personal data about an individual, so the GDPR and CCPA questions that dog contact enrichment mostly do not apply. Record the source and timestamp per field anyway so any audit can trace where a value came from.

Related

Try it yourself

100 free credits, no credit card.

About 30 real searches. Add the MCP to Claude or Cursor in two minutes.