Skip to main content
Thirdwatchthirdwatch
Business & local data

Enrich a Sales CRM with Public LinkedIn Employee Data

Pull public employee profiles for named accounts, qualify them, and upsert reviewed contacts into HubSpot or another CRM.

Jun 4, 2026 · 4 min read · 829 words
See the scraper →

A CRM enrichment job has two separate parts: find plausible contacts, then decide which records are good enough to write into the CRM. Combining those steps without review is how databases fill up with unrelated people and stale assumptions.

The LinkedIn Company Employees Scraper handles public profile discovery. It returns JSON; it does not silently write into HubSpot or Salesforce.

Run the ready-made Task: Enrich a target-account shortlist.

Pull employee records for named accounts

import os
import requests

actor = "thirdwatch~linkedin-company-employees-scraper"
token = os.environ["APIFY_TOKEN"]

response = requests.post(
    f"https://api.apify.com/v2/acts/{actor}/run-sync-get-dataset-items",
    params={"token": token},
    json={
        "queries": [
            "https://www.linkedin.com/company/stripe/",
            "https://www.linkedin.com/company/datadog/",
        ],
        "mode": "basic",
        "jobTitle": "Sales",
        "maxResults": 100,
    },
    timeout=1800,
)
response.raise_for_status()
people = response.json()

jobTitle is a search refinement. It is not an exact filter, so inspect currentTitle and headline before writing records.

Choose the fields your CRM actually needs

For a Basic row, the useful mappings are straightforward:

Actor field Typical CRM field
firstName First name
lastName Last name
currentTitle Job title
currentCompany Company, when profile-backed; Basic rows can be blank
url LinkedIn URL and deduplication key
sourceCompany Target account that produced the record
headline Research context
scrapedAt Last observed time

Do not create fake email addresses or locations when those fields are missing.

Filter before upserting

import re

TARGET_ROLES = re.compile(
    r"\b(vp|vice president|head of|director|chief|revops|sales operations)\b",
    re.I,
)

qualified = []
for person in people:
    title = person.get("currentTitle") or person.get("headline") or ""
    if TARGET_ROLES.search(title):
        qualified.append(person)

Your rules should reflect the product and account, not a universal list of "senior" titles.

Upsert by LinkedIn URL

The example below uses HubSpot's CRM API. Create a custom unique text property named linkedin_profile_url before running it. Confirm the property name and API version against your CRM account.

import os
import requests

hubspot_token = os.environ["HUBSPOT_TOKEN"]
base = "https://api.hubapi.com/crm/v3/objects/contacts"
headers = {
    "Authorization": f"Bearer {hubspot_token}",
    "Content-Type": "application/json",
}

def upsert(person):
    linkedin_url = person["url"]
    search = requests.post(
        f"{base}/search",
        headers=headers,
        json={
            "filterGroups": [{
                "filters": [{
                    "propertyName": "linkedin_profile_url",
                    "operator": "EQ",
                    "value": linkedin_url,
                }]
            }],
            "limit": 1,
        },
        timeout=30,
    )
    search.raise_for_status()
    matches = search.json().get("results", [])

    properties = {
        "firstname": person.get("firstName", ""),
        "lastname": person.get("lastName", ""),
        "jobtitle": person.get("currentTitle", ""),
        "company": person.get("currentCompany", ""),
        "linkedin_profile_url": linkedin_url,
    }

    if matches:
        result = requests.patch(
            f"{base}/{matches[0]['id']}",
            headers=headers,
            json={"properties": properties},
            timeout=30,
        )
    else:
        result = requests.post(
            base,
            headers=headers,
            json={"properties": properties},
            timeout=30,
        )
    result.raise_for_status()
    return result.json()

saved = [upsert(person) for person in qualified]

For large datasets, use your CRM's batch endpoint and rate-limit handling rather than one request per row.

Use Full mode selectively

Full mode attempts public-profile enrichment for work history, education, skills, certifications, languages, location, summary, and public follower count.

Keep sourceCompany as discovery provenance. Do not substitute it for a blank currentCompany: Basic public search can contain false positives, while currentCompany is reserved for employer data LinkedIn actually exposes.

That data is useful when it changes lead qualification. It is wasteful when a Basic headline already tells you the person is irrelevant.

{
  "queries": ["https://www.linkedin.com/company/anthropic/"],
  "mode": "full",
  "jobTitle": "Product Manager",
  "maxResults": 20
}

Check resultMode. A restricted profile that cannot be enriched is returned and charged as Basic, not Full.

Add email in a separate step

The Actor does not pull private email addresses from LinkedIn. After a person passes role and account review, use the LinkedIn Email Finder or another lawful provider.

Keep the provenance fields. Your CRM should record that a guessed or verified work email came from the enrichment step, not from LinkedIn.

Maintain the CRM with confirmed additions

For a recurring workflow, use monitorMode: "new-employees" with a stable monitorStoreName. The baseline returns all discovered profiles. Later scheduled runs return only unseen profiles confirmed in two consecutive complete snapshots.

When a monitor uses Full mode, the Actor discovers the roster cheaply first and enriches only baseline or confirmed-new rows that it will actually return. Unchanged profiles do not consume residential enrichment on every schedule.

That lets the integration upsert a small change set. It does not identify departures. Public search absence is not enough evidence to delete or close a CRM contact.

Pricing

Basic profiles cost $0.003 to $0.0015 each, Full profiles cost $0.008 to $0.005, and a completed company search costs $0.020 to $0.015 depending on tier.

On FREE, two completed company searches returning 100 Basic profiles each cost about $0.64. An unchanged monitor has company-search charges but no profile charges.

Production checklist

  • Review the first company sample for false positives.
  • Deduplicate by LinkedIn URL.
  • Keep source and observation-time fields.
  • Never overwrite a verified CRM value with a blank public field.
  • Treat title and company values as observations that may need verification.
  • Batch CRM writes and handle rate limits.
  • Keep a lawful basis for personal-data processing and honor opt-outs.

Frequently asked questions

Does the Actor write directly to my CRM?

No. It returns JSON rows through an Apify dataset. Send reviewed rows to your CRM with its API, an Apify integration, Make, Zapier, n8n, or your own code.

How should I deduplicate contacts?

Use the canonical LinkedIn profile URL or publicIdentifier. Names are not unique and change more often.

Does the dataset include email addresses?

No. Anonymous public LinkedIn profiles do not expose private emails. Run separate lawful email enrichment only after qualifying a shortlist.

Which mode should I use?

Use Basic for broad discovery and CRM contact creation. Use Full for a smaller shortlist when public work history, education, skills, or location changes lead qualification.

Related

Try it yourself

100 free credits, no credit card.

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