Datacircle

Get LinkedIn profile data with Node.js: enrich a CSV of LinkedIn URLs in plain JavaScript

You have a CSV of LinkedIn profile URLs, from a CRM export or a list someone sent you, and you want each person's name, headline, location, current company, title, industry and headcount next to them. Below, a Node.js script of 137 lines of plain JavaScript that does it through Datacircle's API, four profiles at a time. It needs Node 18 or later and nothing from npm.

It costs $1.25 per 1,000 profiles found, through Up2Data at its own price, and nothing for a profile that can't be reached. The $5 you get at signup pays for the first 4,000.

What you need

A Datacircle API key: sign up with your work email and it's on your dashboard, with the $5 credit. Node 18 or later (node --version): its built-in fetch makes the calls. And a CSV with a linkedin_url column, in any letter case; any other columns are left alone, and a file saved by Excel reads the same:

name,notes,linkedin_url
Bill,"Met at the fair, booth 12",https://www.linkedin.com/in/williamhgates

One profile first

Up2Data takes the profile's URL in a POST. Your key goes in Authorization, and X-Data-Provider names the provider. Save this as one.mjs and run DATACIRCLE_API_KEY=... node one.mjs:

const answer = await fetch("https://api.datacircle.dev/v1/profiles/enrich", {
  method: "POST",
  headers: {
    Authorization: `Token ${process.env.DATACIRCLE_API_KEY}`,
    "X-Data-Provider": "up2data",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ url: "https://www.linkedin.com/in/williamhgates" }),
});
console.log(answer.status, await answer.json());

The answer is Up2Data's own, plus one object of Datacircle's, cut short here:

{
  "data": {
    "full_name": "Bill Gates",
    "headline": "Chair, Gates Foundation and Founder, Breakthrough Energy",
    "location": {"city": "Seattle", "region": "Washington", "country": "United States", "raw": "Seattle, Washington, United States"},
    "current_company": {"name": "Gates Foundation", "title": "Co-chair", "industry": "Non-profit Organization Management", "headcount": "1001-5000", ...},
    "positions": [...], "education": [...], "profile_picture_url": "...", ...
  },
  "meta": {"creditsUsed": 1, "billed": true, "reason": "profile_found"},
  "datacircle_meta": {"provider": "up2data", "cost_usd": 0.00125, "balance_usd": 4.99875}
}

datacircle_meta says what the call cost and what's left on your balance. Every field of data, jobs, schools and skills included: LinkedIn Profile API in the docs.

The whole file

Save this as enrich-linkedin-profiles.mjs, or download the file, and run DATACIRCLE_API_KEY=... node enrich-linkedin-profiles.mjs leads.csv enriched.csv. Four URLs are fetched at a time, each row is written as soon as it's fetched, so a stop loses nothing, and a second run skips what's already in enriched.csv. A URL that appears twice in your file is fetched once.

// Enrich a CSV of LinkedIn profile URLs through Datacircle: Up2Data at $1.25 per 1,000 profiles found, a private or deleted one free.
// Node 18 or later, nothing to npm install. https://datacircle.dev/blog/get-linkedin-profile-data-nodejs
//   DATACIRCLE_API_KEY=... node enrich-linkedin-profiles.mjs leads.csv enriched.csv                 (leads.csv has a linkedin_url column)
//   DATACIRCLE_API_KEY=... node enrich-linkedin-profiles.mjs leads.csv enriched.csv --harvestapi    (past Up2Data's daily limit, HarvestAPI)
// Run it again to go on where it stopped: the URLs already in enriched.csv are skipped.
import { appendFileSync, existsSync, readFileSync } from "node:fs";

const API = "https://api.datacircle.dev";
const KEY = process.env.DATACIRCLE_API_KEY;
const AT_ONCE = 4; // profiles fetched at the same time
const COLUMNS = ["linkedin_url", "status", "full_name", "headline", "location", "company", "title", "industry", "headcount", "cost_usd"];
const [source, target, option] = process.argv.slice(2);
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));

// A CSV file's rows as objects, keyed by its header; a quoted field can hold a comma, a quote ("") or a line break
function readCsv(file) {
  const text = readFileSync(file, "utf8").replace(/^/, ""); // Excel's byte order mark
  const rows = [[""]];
  let quoted = false;
  for (let i = 0; i < text.length; i++) {
    const row = rows[rows.length - 1];
    if (quoted && text[i] === '"' && text[i + 1] === '"') row[row.length - 1] += text[i++];
    else if (text[i] === '"') quoted = !quoted;
    else if (!quoted && text[i] === ",") row.push("");
    else if (!quoted && text[i] === "\n") rows.push([""]);
    else if (quoted || text[i] !== "\r") row[row.length - 1] += text[i];
  }
  const [header, ...lines] = rows.filter((row) => row.some((value) => value.trim()));
  const names = (header ?? []).map((name) => name.trim().toLowerCase());
  return lines.map((line) => Object.fromEntries(names.map((name, i) => [name, (line[i] ?? "").trim()])));
}

function csvValue(value) {
  const text = String(value ?? "");
  return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
}

// One call; Up2Data's own rate limit and a provider failing (502, 503, 504) aren't charged: wait, then try again twice
async function call(method, path, url, provider) {
  let answer;
  for (const wait of [0, 5, 15]) {
    await sleep(wait);
    try {
      const response = await fetch(method === "GET" ? `${API}${path}?url=${encodeURIComponent(url)}` : API + path, {
        method,
        headers: { Authorization: `Token ${KEY}`, "X-Data-Provider": provider, ...(method === "POST" && { "Content-Type": "application/json" }) },
        body: method === "POST" ? JSON.stringify({ url }) : undefined,
        signal: AbortSignal.timeout(90_000),
      });
      answer = { status: response.status, body: await response.json().catch(() => ({})) }; // a gateway's HTML error page: the status says enough
    } catch (error) {
      return { status: 0, body: { error: error.message } }; // no answer (the network, or 90 s): the next run tries again
    }
    const rateLimited = answer.status === 429 && typeof answer.body.error === "object"; // Datacircle's daily limit is a string
    if (!rateLimited && ![502, 503, 504].includes(answer.status)) break;
  }
  return answer;
}

// One URL: its row, or null when the next run should try again. A wrong key, an empty balance or the daily limit stops the run
let pastLimit = false;
async function enrich(url) {
  if (pastLimit) return harvestapi(url);
  const { status, body } = await call("POST", "/v1/profiles/enrich", url, "up2data");
  if (status === 200) {
    const profile = body.data;
    const company = profile.current_company ?? {};
    return {
      status: "found", full_name: profile.full_name, headline: profile.headline, location: profile.location?.raw, company: company.name,
      title: company.title, industry: company.industry, headcount: company.headcount, cost_usd: body.datacircle_meta.cost_usd,
    };
  }
  if (status === 422) return { status: "not found", cost_usd: 0 }; // private or deleted: free
  if (status === 400) return { status: "not a profile URL", cost_usd: 0 }; // free
  if (status === 429 && typeof body.error === "string") { // Datacircle's daily Up2Data limit: $1 a day per account, 800 profiles
    pastLimit = option === "--harvestapi";
    if (pastLimit) return harvestapi(url);
    throw new Error("Up2Data's daily limit: run this again after 00:00 UTC, or with --harvestapi to go on through HarvestAPI.");
  }
  return failed(url, status, body);
}

async function harvestapi(url) {
  const { status, body } = await call("GET", "/linkedin/profile", url, "harvestapi");
  if (status === 400) return { status: "not a profile URL", cost_usd: 0 };
  if (status !== 200) return failed(url, status, body);
  const person = body.element; // null when HarvestAPI can't find the profile, and the lookup is still billed
  if (!person) return { status: "not found (HarvestAPI)", cost_usd: body.datacircle_meta.cost_usd };
  const job = person.currentPosition?.[0] ?? {};
  return {
    status: "found (HarvestAPI)", full_name: [person.firstName, person.lastName].filter(Boolean).join(" "), headline: person.headline,
    location: person.location?.linkedinText, company: job.companyName, title: job.position, cost_usd: body.datacircle_meta.cost_usd,
  };
}

function failed(url, status, body) {
  if (status === 401) throw new Error("401: the API key is wrong. It's on your dashboard.");
  if (status === 402) throw new Error("402: your balance can't cover the call. Add funds on your dashboard, then run this again.");
  const reason = body.error?.message ?? body.error ?? "";
  console.error(`${url}: ${status ? `${status} ${reason}, not charged` : `no answer (${reason})`}, the next run tries again`);
  return null;
}

if (!KEY || !source || !target) {
  console.error("DATACIRCLE_API_KEY=... node enrich-linkedin-profiles.mjs leads.csv enriched.csv [--harvestapi]");
  process.exit(1);
}
const leads = readCsv(source);
if (leads.length && !("linkedin_url" in leads[0])) {
  console.error(`${source} has no linkedin_url column in its first row`);
  process.exit(1);
}
const urls = [...new Set(leads.map((lead) => lead.linkedin_url).filter(Boolean))];
const done = new Set(existsSync(target) ? readCsv(target).map((row) => row.linkedin_url) : []);
const todo = urls.filter((url) => !done.has(url));
if (!existsSync(target)) appendFileSync(target, COLUMNS.join(",") + "\n");
let spent = 0;
let stop = null;
await Promise.all(Array.from({ length: AT_ONCE }, async () => {
  while (todo.length && !stop) {
    const url = todo.shift();
    try {
      const row = await enrich(url);
      if (!row) continue;
      appendFileSync(target, COLUMNS.map((column) => csvValue(column === "linkedin_url" ? url : row[column])).join(",") + "\n"); // a stop keeps it
      done.add(url);
      spent += row.cost_usd;
    } catch (error) {
      stop = error.message; // no new URL starts; the ones on their way are written
    }
  }
}));
console.log(`${urls.filter((url) => done.has(url)).length} of ${urls.length} URLs in ${target}, $${spent.toFixed(5)} spent this run`);
if (stop) {
  console.error(stop);
  process.exitCode = 1;
}

What each answer does to the run:

What the script writes or does for each answer of the API
AnswerWritten asCost
200found, with the profile's columns$0.00125
422: private or deletednot foundfree
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; with --harvestapi, the URL and the rest of the file go to HarvestAPIfree
401: wrong key; 402: balance too lowthe run stops; the URLs already on their way are writtenfree

The two 429s are told apart by their body: Up2Data's rate limit answers an error object, with a type and a message; Datacircle's daily limit answers an error string.

Past the daily limit: HarvestAPI

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. Add --harvestapi and the run goes on through HarvestAPI from the URL that met the limit: same key, $3.70 per 1,000 profiles found. HarvestAPI's call is a GET with the URL as a query parameter, and its answer puts the profile in element:

const answer = await fetch(
  `https://api.datacircle.dev/linkedin/profile?url=${encodeURIComponent(url)}`,
  { headers: { Authorization: `Token ${process.env.DATACIRCLE_API_KEY}`, "X-Data-Provider": "harvestapi" } },
);
const { element } = await answer.json(); // null when HarvestAPI can't find the profile (billed $0.0023)
const job = element?.currentPosition?.[0] ?? {};
console.log(element?.firstName, element?.lastName, element?.headline, job.companyName, job.position);

It answers a 200 even when it finds nothing: then element is null, the row is written as not found (HarvestAPI), and the lookup still costs $0.0023. Without the flag, the run stops and says so; the next run after 00:00 UTC goes on through Up2Data. Both providers side by side: LinkedIn Profile API.

What npm has

The LinkedIn packages on npm get a profile three ways: a browser signed in to a LinkedIn account, LinkedIn's public page read without one, or LinkedIn's own API. From the npm registry and each package's README, on October 10, 2026:

LinkedIn profile packages on npm, how each gets a profile and when it was last published
PackageHow it gets a profileLast published
linkedin-profile-scraperPuppeteer, logged in with your LinkedIn account's li_at session cookieMay 29, 2023
scrapedinPuppeteer, logged in with your LinkedIn email and password, or cookiesMay 17, 2022
linkedin-scraperLinkedIn's public profile page, no login; Node 20+. Its README: the page "varies by profile", and it returns whatever is thereJuly 27, 2026
linkedin-api-clientLinkedIn's official client for its own APIs, in beta: a 3-legged OAuth token, the signed-in member's own profileApril 24, 2023

The first two drive a browser as you, on your LinkedIn account. LinkedIn's own API gives another member's profile only to developers it approves: LinkedIn API alternative quotes its docs. The script above is one API call per URL: no LinkedIn account, no browser, and every request live.

How we tested it

We ran the file above as it is, its host aside, on Node 18.20.8 and Node 26.10.0, against a stand-in of the API answering as its reference does. The CSV was saved as Excel saves it, with a byte order mark, Windows line ends and a header typed LinkedIn_URL, a note holding a comma, quotes and a line break, a URL twice and a row with none: 13 URLs. The first run wrote 5 rows and stopped at the daily limit: 5 of 13 URLs in enriched.csv, $0.00375 spent this run. The URL behind Up2Data's rate limit came back on its second try, the one behind two 503s on its third, and the one that never answered was left for the next run. With --harvestapi, the second run wrote the 7 rows left but that one: 12 of 13 URLs in enriched.csv, $0.02205 spent this run, one profile through Up2Data, five through HarvestAPI and one HarvestAPI lookup that found nothing. 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.

What it costs

From the pricing, per profile, taken from your balance with nothing added:

What a LinkedIn profile costs through Datacircle, per provider
Up2DataHarvestAPI
Profile found$0.00125$0.0037
Per 1,000 found$1.25$3.70
Profile not foundfree$0.0023
Daily limit800 profiles per accountnone

So a file of 1,000 URLs run in one day with --harvestapi, every profile found, costs $1.00 for the first 800 through Up2Data and $0.74 for the other 200 through HarvestAPI: $1.74. Run without it, on two days, they all go through Up2Data: $1.25. Each row's cost_usd is what that call cost, and the last line adds up the run. Live. Each request goes to the provider and gets the profile as it is today.

The same call from Python: get LinkedIn profile data with Python; every week, for a new company or title: track job changes of your contacts. Without code, in an n8n workflow: LinkedIn profiles in n8n; in a Google Sheet: LinkedIn profiles in Google Sheets. Inside Claude, ChatGPT or Cursor: LinkedIn MCP server. Coming from another vendor: Proxycurl alternative, and every vendor's price per 1,000 in LinkedIn profile API pricing compared.

Questions

How do I get LinkedIn profile data in Node.js?

Send the profile's URL to a profile API with fetch and read the JSON it answers. With Datacircle: POST {"url": "<the profile's LinkedIn URL>"} to https://api.datacircle.dev/v1/profiles/enrich with your key in the Authorization header and X-Data-Provider: up2data. It costs $0.00125 a profile found, nothing when the profile can't be reached. Node 18 or later has fetch built in, so there's nothing to install.

Does it need a LinkedIn account, Puppeteer or a proxy?

No. You send a URL to an API and get JSON back: no LinkedIn login or li_at cookie, no browser to drive, no proxy. You need a Datacircle API key and Node 18 or later.

Can I call the API from a web page?

Call it from your server, a script or a serverless function, not from code that runs in your visitors' browsers: the key goes in the Authorization header, and anyone who opens the page could read it there.

How much does it cost to enrich 1,000 LinkedIn profiles?

$1.25 through Up2Data, for the profiles it finds; a private or deleted profile is free. Through HarvestAPI, $3.70 per 1,000 found and $0.0023 for each lookup that finds nothing. The $5 signup credit pays for 4,000 profiles through Up2Data.

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