Datacircle

Track job changes of your contacts: a weekly LinkedIn check in Python

A customer who moves to a new company is a warm lead there, and a contact who left is a work email about to bounce. Below, a Python script of 171 lines that finds both. Run it every day: it checks each contact's LinkedIn profile once a week through Datacircle's API and writes every change it finds to job_changes.csv: a new company, a new title, a departure, a new job.

Each check costs $1.25 per 1,000 profiles found, through Up2Data at its own price, and nothing for a profile that can't be reached: 1,000 contacts checked every week is $1.25 a week. The $5 you get at signup pays for the first 4,000 checks.

What you need

A Datacircle API key: sign up with your work email and it's on your dashboard, with the $5 credit. Python 3 and requests (pip install requests). And your contacts as a CSV with a linkedin_url column, in any letter case, from your CRM or a spreadsheet; any other columns are left alone, and a file saved by Excel reads the same:

name,company,linkedin_url
Bill,Gates Foundation,https://www.linkedin.com/in/williamhgates

What a job change looks like in the answer

Up2Data takes the profile's URL in a POST and answers the profile as it is on LinkedIn today. Two of its fields say where the person works: current_company, their main job, and positions, every job they list, with ended_at null for the ones they still hold. Cut short here:

{
  "data": {
    "full_name": "Bill Gates",
    "current_company": {"name": "Gates Foundation", "linkedin_id": "8736", "title": "Co-chair", "started_at": "2000", ...},
    "positions": [
      {"company": "Gates Foundation", "linkedin_id": "8736", "title": "Co-chair", "started_at": "2000", "ended_at": null, ...},
      {"company": "Breakthrough Energy", "linkedin_id": "19141006", "title": "Founder", "started_at": "2015", "ended_at": null, ...},
      {"company": "Microsoft", "linkedin_id": "1035", "title": "Co-founder", "started_at": "1975", "ended_at": null, ...}
    ],
    ...
  },
  "datacircle_meta": {"provider": "up2data", "cost_usd": 0.00125, "balance_usd": 4.99875}
}

So the script keeps, for each contact, the company and title of the last check that found the profile, and compares. Companies are matched by their LinkedIn id (linkedin_id), and by name when a job has no company page. Many people hold several jobs at once, as above: a board seat added next to the job you knew isn't a change, leaving that job is.

The changes the script writes to job_changes.csv, and when
ChangeWhen
new companythe company of the last check is no longer among the person's current jobs; the new one is their main job now
new titlesame company, another title: a promotion, usually
left, no current jobno current job at all
new joba job after none at the last check; or, on a contact's first check, a job started in the last 90 days

Every field of the answer: LinkedIn Profile API in the docs.

The whole file

Save this as track-linkedin-job-changes.py, or download the file, and run DATACIRCLE_API_KEY=... python track-linkedin-job-changes.py contacts.csv in the folder where you want its two files. Each run checks the contacts not checked in the last 7 days, the ones never checked first, until Up2Data's daily limit. Every check is added to checks.csv as soon as it's made, so a stop loses nothing and the file is each contact's job history; every change found is added to job_changes.csv.

"""Track your contacts' job changes: each LinkedIn profile checked live once a week through Datacircle, Up2Data at $1.25 per 1,000 found.
https://datacircle.dev/blog/track-linkedin-job-changes

DATACIRCLE_API_KEY=... python track-linkedin-job-changes.py contacts.csv                (contacts.csv has a linkedin_url column)
DATACIRCLE_API_KEY=... python track-linkedin-job-changes.py contacts.csv --harvestapi   (past Up2Data's daily limit, HarvestAPI)
Run it every day: it checks the contacts not checked in the last 7 days, until Up2Data's daily limit (800 a day per account).
checks.csv keeps every check, job_changes.csv every change found.
"""
import csv
import datetime
import os
import sys
import time

import requests

API = "https://api.datacircle.dev"
KEY = os.environ["DATACIRCLE_API_KEY"]
EVERY_DAYS = 7  # how often each contact is checked
NEW_JOB_DAYS = 90  # on a contact's first check, a job started this recently is a change already
CHECKS = ["linkedin_url", "checked_on", "status", "full_name", "company_id", "company", "title", "started_at", "cost_usd"]
CHANGES = ["checked_on", "linkedin_url", "full_name", "change", "old_company", "old_title", "new_company", "new_title", "new_company_url", "started_at"]
MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
NO_JOB = {"company_id": "", "company": "", "title": "", "started_at": "", "company_url": ""}


def role(company_id, company, title, started_at, company_url):
    return {"company_id": company_id or "", "company": (company or "").strip(), "title": (title or "").strip(),
            "started_at": started_at or "", "company_url": company_url or ""}


def up2data_roles(profile):
    """The member's current jobs, the main one first."""
    main = profile.get("current_company") or {}
    jobs = [role(main.get("linkedin_id"), main.get("name"), main.get("title"), main.get("started_at"), main.get("url"))] if main else []
    return jobs + [role(job.get("linkedin_id"), job.get("company"), job.get("title"), job.get("started_at"), job.get("company_url"))
                   for job in profile.get("positions") or [] if not job.get("ended_at")]


def harvestapi_roles(person):
    def started(date):  # {"year": 2024, "month": "Jun"} as YYYY-MM, Up2Data's way
        date = date or {}
        return f"{date['year']}-{MONTHS.index(date['month']) + 1:02d}" if date.get("month") in MONTHS else str(date.get("year") or "")
    jobs = (person.get("currentPosition") or []) + [job for job in person.get("experience") or [] if (job.get("endDate") or {}).get("text") == "Present"]
    return [role(job.get("companyId"), job.get("companyName"), job.get("position"), started(job.get("startDate")), job.get("companyLinkedinUrl")) for job in jobs]


def call(provider, url):
    """One lookup. A provider failing (502, 503, 504) or Up2Data's own rate limit isn't charged: wait, then try twice more."""
    for wait in (0, 5, 15):
        time.sleep(wait)
        headers = {"Authorization": f"Token {KEY}", "X-Data-Provider": provider}
        try:
            if provider == "up2data":
                answer = requests.post(f"{API}/v1/profiles/enrich", headers=headers, json={"url": url}, timeout=90)
            else:
                answer = requests.get(f"{API}/linkedin/profile", headers=headers, params={"url": url}, timeout=90)
        except requests.RequestException as error:
            return 0, {"error": str(error)}  # no answer: the next run tries again
        try:
            body = answer.json()
        except ValueError:
            body = {}  # a gateway's error page: the status says enough
        rate_limited = answer.status_code == 429 and isinstance(body.get("error"), dict)  # Datacircle's daily limit is a string
        if not rate_limited and answer.status_code not in (502, 503, 504):
            break
    return answer.status_code, body


def check(url, provider):
    """One contact: (status, full name, current jobs, cost), "limit" past Up2Data's daily limit, or None to try again next run."""
    status, body = call(provider, url)
    if status == 401:
        sys.exit("401: the API key is wrong. It's on your dashboard.")
    if status == 402:
        sys.exit("402: your balance can't cover the call. Add funds on your dashboard, then run this again.")
    if status == 429 and isinstance(body.get("error"), str):
        return "limit"
    if status == 400:
        return "not a profile URL", "", [], 0  # free
    if provider == "up2data" and status == 422:
        return "not found", "", [], 0  # private or deleted: free
    if provider == "up2data" and status == 200:
        profile = body["data"]
        return "found", profile.get("full_name") or "", up2data_roles(profile), body["datacircle_meta"]["cost_usd"]
    if provider == "harvestapi" and status == 200:
        person = body.get("element")  # null when HarvestAPI can't find the profile, and the lookup is still billed
        if not person:
            return "not found (HarvestAPI)", "", [], body["datacircle_meta"]["cost_usd"]
        name = f"{person.get('firstName') or ''} {person.get('lastName') or ''}".strip()
        return "found (HarvestAPI)", name, harvestapi_roles(person), body["datacircle_meta"]["cost_usd"]
    reason = body.get("error", {}).get("message") if isinstance(body.get("error"), dict) else body.get("error", "")
    print(f"{url}: {status or 'no answer'} {reason}, not charged; the next run tries again", file=sys.stderr)
    return None


def compare(last, jobs, today):
    """(the change or None, the job to remember). `last` is the last check that found the profile."""
    main = jobs[0] if jobs else NO_JOB
    if last is None:  # first check
        since = (today - datetime.timedelta(days=NEW_JOB_DAYS)).strftime("%Y-%m")
        return ("new job" if len(main["started_at"]) == 7 and main["started_at"] >= since else None), main
    if not last["company_id"] and not last["company"]:  # no current job last time
        return ("new job" if jobs else None), main
    def same(job):
        return job["company_id"] == last["company_id"] if job["company_id"] and last["company_id"] else job["company"].lower() == last["company"].lower()
    kept = next((job for job in jobs if same(job)), None)
    if kept is None:
        return ("new company" if jobs else "left, no current job"), main
    return ("new title" if kept["title"] != last["title"] else None), kept


def open_csv(name, columns):
    file = open(name, "a", newline="", encoding="utf-8")
    writer = csv.DictWriter(file, columns)
    if file.tell() == 0:
        writer.writeheader()
    return file, writer


def main(source, harvestapi):
    today = datetime.datetime.now(datetime.timezone.utc).date()  # Up2Data's daily limit resets at 00:00 UTC
    with open(source, newline="", encoding="utf-8-sig") as file:  # utf-8-sig: Excel's byte order mark
        contacts = [{name.strip().lower(): (value or "").strip() for name, value in row.items() if name is not None} for row in csv.DictReader(file)]
    if contacts and "linkedin_url" not in contacts[0]:
        sys.exit(f"{source} has no linkedin_url column")
    urls = list(dict.fromkeys(contact["linkedin_url"] for contact in contacts if contact["linkedin_url"]))
    last_check, last_found = {}, {}
    if os.path.exists("checks.csv"):
        with open("checks.csv", newline="", encoding="utf-8") as file:
            for row in csv.DictReader(file):
                last_check[row["linkedin_url"]] = row
                if row["status"].startswith("found"):
                    last_found[row["linkedin_url"]] = row
    due = [url for url in urls if url not in last_check or (today - datetime.date.fromisoformat(last_check[url]["checked_on"])).days >= EVERY_DAYS]
    due.sort(key=lambda url: last_check[url]["checked_on"] if url in last_check else "")  # never checked first, then the oldest
    checks_file, checks = open_csv("checks.csv", CHECKS)
    changes_file, changes = open_csv("job_changes.csv", CHANGES)
    provider, checked, changed, spent = "up2data", 0, 0, 0.0
    for url in due:
        result = check(url, provider)
        if result == "limit" and harvestapi:
            provider = "harvestapi"
            result = check(url, provider)
        if result == "limit":
            print("Up2Data's daily limit: the next run, after 00:00 UTC, goes on (or add --harvestapi to go on now through HarvestAPI).")
            break
        if result is None:
            continue
        status, name, jobs, cost = result
        job, change = NO_JOB, None
        if status.startswith("found"):
            last = last_found.get(url)
            change, job = compare(last, jobs, today)
            if change:
                old = last or NO_JOB
                changes.writerow({"checked_on": today, "linkedin_url": url, "full_name": name, "change": change, "old_company": old["company"],
                                  "old_title": old["title"], "new_company": job["company"], "new_title": job["title"],
                                  "new_company_url": job["company_url"], "started_at": job["started_at"]})
                changes_file.flush()
                changed += 1
        checks.writerow({"linkedin_url": url, "checked_on": today, "status": status, "full_name": name, **{column: job[column] for column in CHECKS[4:8]},
                         "cost_usd": cost})
        checks_file.flush()  # a stop keeps every check written
        checked += 1
        spent += cost
    print(f"{checked} of {len(due)} due contacts checked ({len(urls)} in all), {changed} job changes in job_changes.csv, ${spent:.5f} spent")


if __name__ == "__main__":
    main(sys.argv[1], "--harvestapi" in sys.argv[2:])

What each answer does to the run:

What the script writes or does for each answer of the API
AnswerWritten asCost
200found, with the main job; compared with the last check$0.00125
422: private or deletednot found; the last job found stays the one compared next timefree
400: not a profile URL (a company page, say)not a profile URLfree
429 from Up2Data's own rate limit; 502, 503, 504tried again after 5 s, then 15 s; still failing, printed and left for the next runfree
429: Datacircle's daily Up2Data limitthe run stops, and the next day's run goes on; with --harvestapi, the contact and the rest due go to HarvestAPIfree
401: wrong key; 402: balance too lowthe run stops; every check made before it is writtenfree

Run it every day

Once a day is enough, at any hour: Up2Data's daily limit starts again at 00:00 UTC. On a Mac or Linux, crontab -e and one line, here at 7:00 every morning:

0 7 * * * cd /path/to/folder && DATACIRCLE_API_KEY=... python3 track-linkedin-job-changes.py contacts.csv >> run.log 2>&1

Add a contact to contacts.csv and the next run checks it; remove one and it's no longer checked. To check every 30 days instead of every 7, change EVERY_DAYS at the top of the file.

What to do with job_changes.csv is yours. GitLab's public handbook says what it does with each job change its tool finds: a new lead with the new company, and the old contact marked "No Longer at Company" and disqualified. The columns are there for both: the old company and title, the new ones, the new company's LinkedIn page and when the job started.

What it costs

Every check is a live call, at the provider's price per profile found, taken from your balance with nothing added: $0.00125 through Up2Data, $0.0037 through HarvestAPI, and $0.0023 for a HarvestAPI lookup that finds nothing. Up2Data takes $1 a day per account (800 profiles), with a shared daily limit for all customers, then answers 429 until 00:00 UTC; HarvestAPI has no daily limit. So, every profile found:

What tracking your contacts' job changes costs through Datacircle, every profile found
ContactsEach checkedThroughCost
1,000every 7 daysUp2Data$1.25 a week
5,600every 7 days (800 a day)Up2Data$7.00 a week
15,000about every 19 days (800 a day)Up2Data$7.00 a week
15,000every 7 days, with --harvestapi800 through Up2Data, 14,200 through HarvestAPI$53.54 a week

Past 5,600 contacts, the script without the flag never checks more than Up2Data's 800 a day: each contact is simply checked less often, the oldest check first. With --harvestapi, every contact due is checked that day, those past the limit through HarvestAPI. A contact checked through one provider one week and the other the next is compared all the same: both answer the company's LinkedIn id. Live. Each request goes to the provider and gets the profile as it is today.

What job change tools charge

The tools sold for this track your contacts and act in your CRM. From their own pricing pages, on October 10, 2026:

What job change tracking tools charge, from their pricing pages, October 10, 2026
PriceContacts tracked
Champifyfrom $2,000 a month (Core), $3,000 (Pro), $6,000 (Enterprise)15,000, 40,000 and 150,000
UserGems$40,000 a year (Core), $75,000 (Advanced), $150,000 (Elite), plus $3,000 to $10,000 to set upsold in credits: 4,000,000 on Core
This script, through Datacircle$1.25 per 1,000 checks through Up2Data, $3.70 through HarvestAPIyour file

Champify's Core tracks 15,000 contacts from $2,000 a month; its page doesn't say how often each is checked. The script checks the same 15,000 about every 19 days for $7.00 a week, or every week for $53.54 with --harvestapi. Sales Navigator alerts you to job changes in the app, from $119.99 a month per license, with no export to a CSV: LinkedIn Sales Navigator alternative.

What those tools do that the script doesn't:

  • Work inside your CRM: Champify's Salesforce package maps the fields, matches the new company to your accounts, routes the lead and removes duplicates (its FAQ: no native HubSpot integration); UserGems syncs with Salesforce and HubSpot, and at GitLab it creates the new lead itself.
  • Alert in Slack or by email (Champify), and watch the accounts you pick for new executive hires.
  • UserGems: much more than job changes: lists, scoring, campaigns, a writing agent, a research agent and a Chrome extension.

The script gives you the change, from the profile as it is today, in a CSV; the rest is yours to wire.

How we tested it

We ran the file above as it is, its host aside, on Python 3.9.6 and 3.12.14, against a stand-in of the API answering as its reference does, with people we made up and their jobs changed between runs. The contacts file was saved as Excel saves it, with a byte order mark, Windows line ends and a header typed LinkedIn_URL, a name holding a comma, a URL twice, a row with none, a private profile and a company page: 12 contacts. Each run's week was made by moving the dates in checks.csv back 7 days.

  • Week 1, with a daily limit of 8 found profiles: the first run checked 10 and stopped at the limit; a second run that day checked none; the next day's checked the last 2, one of them after Up2Data's own rate limit, on its second try. One change: a job started two months before, on its first check.
  • Week 2, with a limit of 3 and --harvestapi: 3 checks through Up2Data, 9 through HarvestAPI, all 12 checked, $0.03055 spent, exactly 3 × $0.00125 + 6 × $0.0037 + 2 × $0.0023 (the company page, a 400, is free). Written: a new company, a new title and a departure. No change written, as it should be, for a board seat added next to a job, a company with no LinkedIn page, or the switch of provider.
  • Week 3: the person who left has a new job, the board member left their first job (a new company), and a contact whose provider answered 502 three times was left for the next run, which checked it.

Both Pythons wrote the same two files. A wrong key and an empty balance each stopped the run with nothing written. The same file, unchanged, against api.datacircle.dev with a wrong key: 401: the API key is wrong, nothing written. We didn't run it on real profiles here: the test proves the script's logic and its calls, against the answers the API documents.

The same call for a whole CSV, once: get LinkedIn profile data with Python, or with Node.js; in a Google Sheet: LinkedIn profiles in Google Sheets. Both providers side by side: LinkedIn Profile API. The same weekly check written back into HubSpot: enrich HubSpot contacts with LinkedIn.

Questions

How do I track when my contacts change jobs?

Check each contact's LinkedIn profile on a schedule and compare its current company and title with the last check. With Datacircle: POST {"url": "<the profile's LinkedIn URL>"} to https://api.datacircle.dev/v1/profiles/enrich with your key and X-Data-Provider: up2data, and read data.current_company and the jobs in data.positions whose ended_at is null. The script on this page does it every day, each contact once a week, and writes every change to a CSV.

How much does it cost to check 1,000 contacts every week?

$1.25 a week through Up2Data, for the profiles it finds; a private or deleted profile is free. Up2Data takes at most 800 a day per account, so up to 5,600 contacts can each be checked every week, for $7.00 a week. The $5 signup credit pays for the first 4,000 checks.

What counts as a job change?

Four changes: a new company (the company of the last check is no longer among the person's current jobs), a new title at the same company, no current job at all, and a new job after none. On a contact's first check, a job started in the last 90 days counts as a new job. A board seat or a side project added next to the job you knew is not a change; leaving that job is.

Does it need a LinkedIn account or Sales Navigator?

No. You send each profile's URL to an API and get JSON back: no LinkedIn login, no browser extension, no saved leads. You need a Datacircle API key, Python 3 and its requests library.

Is each request live, or cached?

Live. Each request goes to the provider and gets the profile as it is today.

Is there a daily limit?

Up2Data takes $1 a day per account (800 profiles), with a shared daily limit for all customers, then answers 429 until 00:00 UTC; HarvestAPI has no daily limit.

Sign up at datacircle.dev with your work email: a $5 credit, that's 4,000 LinkedIn profiles at $1.25 per 1,000. Free: 10M+ U.S. B2B leads, as a flat file. Download it at datacircle.dev.

Sign up