Skip to main content
Thirdwatchthirdwatch
Business & local data

Size Your B2B Market With LinkedIn Company Firmographics

Build a bottom-up TAM from named companies: pull LinkedIn firmographics, bucket by employee band and HQ country, and show the counting method on a slide.

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

Thirdwatch's LinkedIn Company Enrichment Scraper turns a list of domains or LinkedIn URLs into firmographic rows — industry, employee size band, HQ country, founded year, company type, specialties. That is everything a bottom-up TAM needs: count real named companies, bucket them by size band and geography, apply an ACV per band, and show the arithmetic. Built for founders and heads of strategy who need a market number a board can interrogate rather than a percentage of somebody else's report.

▶ Skip the setup: Run this as a ready-to-go task on Apify → — pre-loaded with the exact configuration from this guide. No code required.

Why size a market from named companies instead of a top-down percentage

A top-down TAM is a claim. A bottom-up TAM is a list. The difference shows up the moment somebody on your board asks the only question that matters: which companies, exactly? "The global observability market is $X billion, we address 4% of it" cannot answer that. "There are 1,840 companies in this category with 200 or more employees, 61% of them headquartered in North America, and here is the file" can.

This is not an abstract preference. CB Insights' post-mortem analysis of failed startups found no market need was the single most common reason companies died, cited in 35% of cases. Most of those teams had a TAM slide. What they did not have was a countable set of accounts and evidence that the segment was large enough at the price they intended to charge.

The job-to-be-done here is narrow and mechanical. You need a real list of named companies in a category, each tagged with an employee size band and an HQ geography, so you can pivot it into segment counts, multiply by a defensible ACV per segment, and publish the method alongside the number. The same file then doubles as your territory plan, because the rows are accounts a rep can actually call.

How does this compare to the alternatives?

Four realistic routes to a market number, ranked by whether anyone can check your work:

Approach Pricing Auditability Setup time Maintenance
Analyst report + a percentage Four to five figures per report None — the base number is somebody else's model Days, plus procurement Re-buy annually
Enterprise firmographic vendor Annual contract, per seat Good, but the list leaves with the seat Weeks, plus sales cycle Vendor-managed
DIY Python scraper Engineering time Full, if it keeps working 1-2 weeks Ongoing, on your team
LinkedIn Company Enrichment Scraper Transparent pay-per-result Full — every row keeps source_query and scraped_at 15 minutes Thirdwatch tracks page changes

The analyst-report route is the one most founders default to and the one that collapses fastest under questioning, because you inherit assumptions you cannot see. The vendor route produces a good list you rent rather than own. Running the enrichment yourself keeps the raw file, the timestamps and the inclusion rules in your repository, which is what makes the number reproducible next quarter. See the LinkedIn Company Enrichment Scraper actor page for the full field list.

How to size a B2B market with company firmographics in 4 steps

The shape is: assemble a candidate universe, enrich it, apply inclusion rules, then pivot into a size-band by geography grid and price each cell.

How do I authenticate against Apify?

Create a free account at apify.com, open Settings → Integrations and copy your personal API token. Every example reads it from the environment.

export APIFY_TOKEN="apify_api_xxxxxxxxxxxxxxxx"

How do I assemble and enrich the candidate universe?

Start from any source that names companies in your category — conference exhibitor lists, software directory category pages, industry association member lists, employer names off job boards, your own pipeline. You need one identifier per company. The companies input accepts LinkedIn company URLs, bare slugs, company names or plain website domains, mixed freely in the same list.

import os, requests, pandas as pd

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

# One identifier per candidate company. LinkedIn URLs and slugs resolve
# deterministically; domains and names are resolved best-effort.
universe = pd.read_csv("candidates.csv")["identifier"].dropna().tolist()

resp = requests.post(
    f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items",
    params={"token": TOKEN},
    json={
        "companies": universe,
        "maxResults": 5000,
        "concurrency": 10,
        "proxyConfiguration": {"useApifyProxy": True},
    },
    timeout=3600,
)
firmo = pd.DataFrame(resp.json())

print(len(firmo), "resolved of", len(universe), "submitted")
print(firmo[["name", "industry", "company_size", "hq_country"]].head())

Every row carries source_query, the exact input string it came from. Left-join your candidate list back on that column and the rows with no match are your unresolved set — count them explicitly rather than letting them vanish.

How do I apply inclusion rules I can defend later?

Write the rules as code, not as a filtered spreadsheet view. The three fields that do most of the work are industry (LinkedIn's own label), specialties (what the company says it does) and company_type (which removes non-purchasers such as non-profits or government bodies when your product is commercial). Keep a column recording why each company was excluded.

SEGMENT_TERMS = ("observability", "monitoring", "apm", "logging", "telemetry")
INDUSTRIES = {"Software Development", "IT Services and IT Consulting"}

def in_segment(row):
    specialties = " ".join(row.get("specialties") or []).lower()
    blob = f"{specialties} {row.get('tagline') or ''} {row.get('description') or ''}".lower()
    if row.get("company_type") in {"Nonprofit", "Government Agency"}:
        return "excluded: non-commercial"
    if row.get("industry") not in INDUSTRIES:
        return "excluded: industry"
    if not any(t in blob for t in SEGMENT_TERMS):
        return "excluded: no category signal"
    return "included"

firmo["inclusion"] = firmo.apply(in_segment, axis=1)
print(firmo.inclusion.value_counts())

segment = firmo[firmo.inclusion == "included"].copy()

Publishing this function next to the TAM number is what converts an assertion into a method. When somebody disagrees with the boundary, they argue with a line of code and you re-run it.

How do I bucket the segment by employee band and HQ geography?

Each row carries company_size as LinkedIn displays it plus employee_count_min and employee_count_max, the same band parsed into numbers. Pivot on the band against hq_country and you have the grid your model sits on.

BANDS = [
    ("1-10", 1, 10), ("11-50", 11, 50), ("51-200", 51, 200),
    ("201-500", 201, 500), ("501-1000", 501, 1000),
    ("1001-5000", 1001, 5000), ("5001-10000", 5001, 10000),
    ("10001+", 10001, 100000),
]

def band_of(row):
    lo = row.get("employee_count_min")
    if lo is None or pd.isna(lo):
        return "unknown"
    for label, floor, ceil in BANDS:
        if floor <= lo <= ceil:
            return label
    return "unknown"

segment["band"] = segment.apply(band_of, axis=1)
segment["region"] = segment.hq_country.fillna("unknown")

grid = pd.pivot_table(
    segment, index="band", columns="region",
    values="name", aggfunc="count", fill_value=0,
).reindex([b[0] for b in BANDS] + ["unknown"])

print(grid.to_string())
print("\nUnknown band:", (segment.band == "unknown").sum(), "companies")

Print the unknown count on the slide. Companies that never filled in a size band on LinkedIn are a real part of your universe, and a model that quietly drops them overstates its own coverage.

How do I turn segment counts into a TAM range?

Price each band rather than the whole market, and carry the band edges through so you produce a range instead of false precision. A band like 1001-5000 employees spans a factor of five; pretending it is a point estimate is where bottom-up models lose their credibility advantage.

# Annual contract value per employee band, from your own closed-won data.
ACV = {
    "51-200": 12_000, "201-500": 34_000, "501-1000": 60_000,
    "1001-5000": 140_000, "5001-10000": 310_000, "10001+": 640_000,
}
SEAT_RATE = 0.35  # share of headcount you expect to licence

rows = []
for band, floor, ceil in BANDS:
    n = int((segment.band == band).sum())
    if not n or band not in ACV:
        continue
    rows.append({
        "band": band, "companies": n,
        "tam_low": n * ACV[band] * SEAT_RATE * (floor / ceil),
        "tam_mid": n * ACV[band] * SEAT_RATE,
        "tam_high": n * ACV[band] * SEAT_RATE * 1.4,
    })

model = pd.DataFrame(rows)
print(model.to_string(index=False))
print(f"\nTAM range: ${model.tam_low.sum()/1e6:.1f}M - ${model.tam_high.sum()/1e6:.1f}M")

How do I audit the model for LinkedIn coverage bias?

This is the step most bottom-up decks skip, and the one a sharp investor will find. employees_on_linkedin counts members who list the company as their current employer — not headcount. In markets where LinkedIn is the default professional network that ratio runs high; in Japan, Korea, China and parts of continental Europe it runs materially lower, so a segment weighted toward those geographies looks smaller than it is. Compare the two signals per country before you present anything.

segment["band_mid"] = (segment.employee_count_min + segment.employee_count_max) / 2
audit = (
    segment[segment.employees_on_linkedin.notna() & segment.band_mid.notna()]
    .assign(coverage=lambda d: d.employees_on_linkedin / d.band_mid)
    .groupby("region")
    .agg(companies=("name", "count"),
         median_coverage=("coverage", "median"),
         missing_size=("band_mid", lambda s: s.isna().sum()))
    .sort_values("companies", ascending=False)
)
print(audit.head(15).to_string())

A country whose median coverage sits far below your best-covered market is a flag on the data, not on the market. Either state the caveat next to that region's count or reweight it, but do not let a low-penetration geography silently shrink your TAM.

Sample output

Two enriched records, lightly redacted. Every field below is written by the Actor; the band, region and coverage columns above are derived from them.

[
  {
    "source_query": "hashicorp.com",
    "name": "HashiCorp",
    "slug": "hashicorp",
    "company_id": "3175282",
    "linkedin_url": "https://www.linkedin.com/company/hashicorp",
    "tagline": "Infrastructure and security automation for multi-cloud",
    "industry": "Software Development",
    "company_size": "1001-5000 employees",
    "employee_count_min": 1001,
    "employee_count_max": 5000,
    "employees_on_linkedin": 2184,
    "follower_count": 214300,
    "founded_year": 2012,
    "company_type": "Public Company",
    "specialties": ["infrastructure as code", "secrets management", "service networking"],
    "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_count": 4,
    "scraped_at": "2026-07-27T19:12:44Z"
  },
  {
    "source_query": "REDACTED-eu-vendor.de",
    "name": "[REDACTED]",
    "slug": "redacted-eu-vendor",
    "linkedin_url": "https://www.linkedin.com/company/redacted-eu-vendor",
    "industry": "IT Services and IT Consulting",
    "company_size": "201-500 employees",
    "employee_count_min": 201,
    "employee_count_max": 500,
    "employees_on_linkedin": 96,
    "follower_count": 4120,
    "founded_year": null,
    "company_type": "Privately Held",
    "specialties": ["observability", "managed monitoring"],
    "website_domain": "redacted-eu-vendor.de",
    "headquarters": "Munich, Bavaria, DE",
    "hq_country": "DE",
    "locations_count": 1,
    "scraped_at": "2026-07-27T19:12:51Z"
  }
]

The second row is the bias case in one record. Its declared band midpoint is roughly 350 people, but only 96 employees surface on LinkedIn — a coverage ratio under 0.3 against the first row's 0.9. Segment on company_size, not on employees_on_linkedin, and that company still lands in the correct bucket. founded_year is null because the company never filled it in, which is a gap to report rather than impute.

Common pitfalls

Five things distort a firmographic market model, and most of them are properties of LinkedIn rather than of the tooling. Domain resolution is imperfect — feeding bare website domains lands on the right company roughly three times in four, and heavily-branded or ambiguous domains miss. Pass LinkedIn URLs or slugs wherever you have them, since those resolve deterministically, and always reconcile source_query against your input list so the unresolved set is counted rather than lost. Company-name inputs are fuzzier still: a common name can resolve to a larger company that shares it. Size bands are self-declared and coarse, updated whenever the company remembers to; treat them as ordinal buckets and carry the band edges through to a range. Optional fields are genuinely absent, not zero — founded_year, specialties and employees_on_linkedin are missing on pages where the company never filled them in, so count nulls explicitly instead of imputing them into a segment. Only public company pages are in scope; there is no private or member-gated data here, and companies with no LinkedIn presence at all are invisible to this method and belong in a separate manual bucket. Thirdwatch's Actor handles the proxy rotation and retry behaviour, keeps source_query and scraped_at on every row so a run is reproducible, and returns a partial result with a warning rather than failing silently when a page will not resolve.

Related use cases

Frequently asked questions

What makes a bottom-up TAM more defensible than a top-down one?

A bottom-up TAM is a list you can open. Every dollar traces to a named company, an employee band and an HQ country, so a skeptical board member can challenge a row rather than the whole number. Top-down percentages of an analyst figure cannot be audited that way.

Where do I get the candidate company list to enrich?

Conference exhibitor lists, G2 or Capterra category pages, industry association directories, job-board employer names and your own CRM all work. You only need a domain or a LinkedIn URL per company. The enrichment step supplies industry, size band, HQ and founded year.

Is employees_on_linkedin the same as headcount?

No. It counts members who list that company as their current employer, so it under-reports in geographies and industries where LinkedIn adoption is lower. Use the declared company_size band as the primary segmentation key and treat employees_on_linkedin as a cross-check.

How coarse are LinkedIn's employee size bands?

Coarse enough to matter. Bands like 1001-5000 employees span a factor of five, so a mid-band assumption can move a segment total substantially. Model the low and high edges of every band and present the resulting range instead of one false-precision figure.

How many companies can I enrich in one run?

Up to 10,000 records per run, one record per resolved company page. Most bottom-up models need a few hundred to a few thousand named accounts in the segment, which finishes quickly and can be re-run each quarter to refresh the same list.

Do I need a LinkedIn account or a data-vendor contract?

Neither. The Actor reads public company pages, so there is no login, cookie or seat licence involved. You need an Apify API token to start a run, and the output is a plain dataset you can export to CSV and drop straight into a spreadsheet model.

Related

Try it yourself

100 free credits, no credit card.

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