LinkedIn Job Scraper for extracting job titles, companies, locations, salaries, seniority and full job descriptions from LinkedIn.com. This repo has a free LinkedIn job scraping script you can run right now, and a LinkedIn job data API that returns one posting as 17 parsed JSON fields.
Last updated: 2026-07-20. Working against LinkedIn.com as of July 2026.
pip install requests
export CHOCODATA_API_KEY="your_key" # free: 1,000 requests, one-time, no card
python linkedin_job_scraper_api_codes/job.py 4318517031Those three lines return this, live from LinkedIn.com (8 of the 17 fields on the posting, verbatim; the full object is below):
{
"job_id": "4318517031",
"title": "Software Engineer",
"company": "Moab",
"location": "New York, NY",
"salary": "$150,000.00/yr - $350,000.00/yr",
"seniority": "Not Applicable",
"employment_type": "Full-time",
"posted_label": "9 months ago"
}...multiplied across a list of job ids, with 17 fields each:
That is the whole point of this repo. The rest of this page is the free script, the measured evidence behind it, and the full API reference.
- Free LinkedIn Job Scraper
- Avoid getting blocked when scraping LinkedIn jobs
- LinkedIn Job Scraper API reference
- Build a salary dataset from a list of job ids
- Measured latency
- License
LinkedIn serves each public posting through a guest fragment that returns plain HTML, so you can extract a job posting without a headless browser, a login, or JavaScript rendering. No key, no cost:
python free_scraper/linkedin_job_free_scraper.py 4318517031 4318514364 4363726700Source: free_scraper/linkedin_job_free_scraper.py. It calls /jobs-guest/jobs/api/jobPosting/{job_id}, parses the rendered card, and emits job_id, title, url, company, company_url, location, applicants, posted_label, seniority, employment_type, job_function, industries, description.
After running the command, your terminal should look something like this:
It works. On 2026-07-20, from one residential IP, it parsed a top card on 10 of 10 postings requested sequentially 3.5 seconds apart.
So why does the rest of this page exist? Because "it works" and "it scales" are different claims, and the gap between them is the whole product. The next section measures that gap.
The wall is not where people expect it. LinkedIn did not serve a CAPTCHA, a bot check, or a 403. It served every posting we asked for, one at a time, and started refusing the moment we asked in parallel. Every row below is committed in benchmarks/measurements.json, and benchmarks/measure.py reproduces the whole table on your own IP.
| What we measured (2026-07-20, one residential IP) | Value |
|---|---|
| 10 postings, sequential, 3.5s apart | 10 of 10 parsed a top card |
| 16 requests at 8 concurrent | 7 OK, 9 x HTTP 429, in 1.7s |
| 40 requests at 12 concurrent | 10 OK, 30 x HTTP 429, in 1.1s |
| Guest fragment size, per posting | 15,745 to 33,275 characters |
| Same posting through the API | 6,018 bytes of parsed JSON |
salary chip in the guest fragment |
absent on 10 of 10 postings checked |
30 rejections out of 40 requests, from one IP, in 1.1 seconds. That is the whole problem in miniature, and it is the most important thing to understand about scraping LinkedIn jobs at scale:
| What bites you | Why | What it costs you |
|---|---|---|
| Concurrency, not access | The guest fragment answered 10 of 10 sequential requests. Fan out to 8 workers and 9 of 16 came back 429. |
Your scraper works perfectly on your laptop and falls over the day you parallelise it. The failure arrives in production, not in dev. |
| The ceiling moves | The same 40-requests-at-12-workers test returned 9 rejections on a cooler IP and 30 forty-nine minutes later, after the IP had spent more of its budget. Both runs are in measurements.json. |
You cannot pick a concurrency number once and leave it. Whatever you tune to on Monday is wrong by Thursday. |
| You get markup, not data | The posting documented below arrived as 29,948 characters of guest markup; the parsed record is 6,018 bytes. Fragment size is not stable, and ranged 15,745 to 33,275 characters across the ten postings in that run. | You write and maintain a parser per surface, forever, and redo it every time LinkedIn reshapes the card. |
salary is not on this surface |
The guest fragment carried no salary chip on any of the 10 postings checked. The compensation is in the description prose, in whatever format the employer typed it. | Extracting pay means writing and maintaining a money parser over free text, per posting, in every format an employer might use. |
| Postings expire | A job id stops resolving when the posting closes, and LinkedIn returns a 404 shell rather than an error you can read. | Your pipeline has to tell "this role closed" from "our parser broke", or a market that went quiet looks identical to an outage. |
| The markup moves | The guest card class names change without notice. A selector-based parser returns None and keeps running. |
Ongoing maintenance, plus alerting smart enough to distinguish "no data" from "broken". |
One thing worth reading rather than paraphrasing: LinkedIn's robots.txt disallows this. The User-agent: * block, in its entirety, is two lines: User-agent: * followed by Disallow: /. Everything. The file carries 76 crawler blocks in total, and 40 of them, Googlebot included, are disallowed from /jobs-guest/ specifically, which is the exact path the free script above calls. The file also states that "the use of robots or other automated means to access LinkedIn without the express permission of LinkedIn is strictly prohibited", pointing at their user agreement and publishing a whitelisting address to request permission (www.linkedin.com/robots.txt, retrieved 2026-07-20).
So the free script is disallowed, and so is the API below. It is your call to make, and worth making with the actual text in front of you rather than discovering it later.
The managed option, and the one this repo is built around. The Chocodata LinkedIn Job Scraper API returns one posting as 17 parsed JSON fields, including the normalised salary the guest surface does not carry, with a ~99% success rate and no proxy management. Free for the first 1,000 requests.
Below is the LinkedIn Job Scraper API reference to get you started: authentication, the global parameter, error handling, concurrency, and the endpoint.
curl "https://api.chocodata.com/api/v1/linkedin/job?api_key=YOUR_KEY&job_id=4318517031"import requests
r = requests.get(
"https://api.chocodata.com/api/v1/linkedin/job",
params={"api_key": "YOUR_KEY", "job_id": "4318517031"},
timeout=90,
)
job = r.json()
print(job["title"], "at", job["company"], "|", job["salary"])
# Software Engineer at Moab | $150,000.00/yr - $350,000.00/yrAfter running the command, your terminal should look something like this:
Get a key at chocodata.com (1,000 requests, one-time, no card).
Pass api_key as a query parameter on every request. That is the whole auth model. No OAuth, no LinkedIn login, no session cookie: you never hand us or LinkedIn an account.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
api_key |
string | yes | - | Your Chocodata API key. The only parameter every endpoint shares. |
Each request costs 5 credits (= 1 request). Responses are billed only on success (2xx).
Real captured error responses, committed in linkedin_job_scraper_api_data/errors.json. Nothing below is billed: you are only charged on a 2xx.
| Status | error code |
Meaning | Billed | What to do |
|---|---|---|---|---|
400 |
invalid_params |
Neither job_id nor url was supplied, or one is the wrong type. |
no | Fix the query string. |
401 |
INVALID_API_KEY |
Key missing, unrecognised, or revoked. | no | Check api_key. Get one at chocodata.com. |
402 |
INSUFFICIENT_CREDITS |
Balance exhausted. | no | Top up or upgrade at chocodata.com. |
404 |
item_not_found |
The posting closed, or the id never existed. retryable: false. |
no | Fix the id. Retrying will not help. |
429 |
RATE_LIMITED |
Over your plan's concurrency. | no | Back off and retry; see Rate limits. |
502 |
target_unreachable |
LinkedIn refused this request. retryable: true. |
no | Retry after a few seconds. |
Two response shapes exist: auth and billing errors nest under error.code (uppercase), while scrape-layer errors are flat with a lowercase error string.
The scripts in this repo map every documented status onto an actionable message, so a typo'd key does not hand you a stack trace:
A bad key, verbatim:
curl "https://api.chocodata.com/api/v1/linkedin/job?api_key=totally_invalid_key_123&job_id=4318517031"{"error":{"code":"INVALID_API_KEY","message":"Api key not recognised."}}A missing required param, verbatim. It names the exact requirement:
{"error":"invalid_params","issues":[{"code":"custom","message":"job_id or url is required","path":[]}]}A closed posting returns 404 item_not_found with retryable: false, and the message names the cause and confirms the call was free: The target returned 404 for this request - the item or identifier does not exist. Check the id or URL. You were not charged. Treat that status as "this role came off the board", not as an error to retry, and record it: a 404 is the only signal LinkedIn gives you that a posting closed.
There is no per-minute request cap. The limit is concurrency: how many requests you may have in flight at once. This is the ceiling the free script hits at 8 workers and never gets past.
| Plan | Concurrent requests |
|---|---|
| Free | 10 |
| Vibe | 30 |
| Pro | 50 |
| Custom | 100 to 500+ |
Exceed it and you get 429, not a queue. The endpoint is a synchronous GET: there is no webhook, callback, or async job to poll. A request can take up to ~4s when LinkedIn forces a re-attempt (see Measured latency), which is why the examples use timeout=90.
Sizing: at Pro (50 concurrent) and the measured 2.4s median, one worker pool sustains roughly 50 / 2.4 = 21 requests/second, so 100,000 postings is about 80 minutes of saturated pulling. Fan out with a thread pool:
from concurrent.futures import ThreadPoolExecutor
import requests
def one(job_id):
r = requests.get("https://api.chocodata.com/api/v1/linkedin/job",
params={"api_key": KEY, "job_id": job_id}, timeout=90)
return job_id, (r.json() if r.ok else None)
job_ids = ["4318517031", "4318514364", "4363726700", "4378357766"]
with ThreadPoolExecutor(max_workers=10) as pool: # <= your plan's concurrency
for job_id, job in pool.map(one, job_ids):
print(job_id, job["title"] if job else "unavailable")One public LinkedIn job posting by id: title, company and company URL, location, applicant count, posted label, salary, seniority, employment type, job function, industries, and the description as both text and HTML.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
job_id |
string (digits) | one of job_id/url |
- | LinkedIn job id, e.g. 4318517031. |
url |
string (URL) | one of job_id/url |
- | Full LinkedIn job URL. The numeric id is parsed out of it; the bare, slug (/jobs/view/software-engineer-at-moab-4318517031) and tracking-parameter forms all resolved to the same posting when we tested them. |
curl "https://api.chocodata.com/api/v1/linkedin/job?api_key=YOUR_KEY&job_id=4318517031"Real response, captured 2026-07-20. description truncated at 230 of 2,483 chars and description_html at 150 of 2,792, both marked with ...; all 17 fields are present and verbatim (full sample):
{
"id": "4318517031",
"job_id": "4318517031",
"title": "Software Engineer",
"url": "https://www.linkedin.com/jobs/view/4318517031",
"company": "Moab",
"company_url": "https://www.linkedin.com/company/trymoab",
"location": "New York, NY",
"applicants": "Over 200 applicants",
"posted_label": "9 months ago",
"salary": "$150,000.00/yr - $350,000.00/yr",
"seniority": "Not Applicable",
"employment_type": "Full-time",
"job_function": "Engineering and Information Technology",
"industries": "Technology, Information and Internet",
"description": "About The Company Moab is a seed stage company founded by early employees of Ramp and Uber. We're funded by Elad Gil and Ironspring Ventures. Moab is building heavy equipment rental and dealer management software with the aim to a...",
"description_html": "<strong>About The Company<br><br></strong>Moab is a seed stage company founded by early employees of Ramp and Uber. We're funded by Elad Gil and Irons...",
"company_logo": "https://media.licdn.com/dms/image/v2/D4E0BAQFgsDnzq1uEdw/company-logo_100_100/B4EZxn6ZN3HMAQ-/0/1771269887252/trymoab_logo?e=2147483647&v=beta&t=o9BCzySWsNwImz752QyT0T9TyEse5XDTPE6myG7uzfk"
}description is the field that makes this worth automating: 2,483 characters here, and the only place the actual requirements live. You get it as text and as description_html, so you can keep the employer's formatting or throw it away. seniority and employment_type are the two fields people filter on.
Running it:
Four things to know before you build on this:
salaryis a normalised annual range, derived from the posting. Employers write compensation in whatever shape they like, and this posting states "The annual salary for this role is $150,000 to $350,000" in its description prose; the field returns$150,000.00/yr - $350,000.00/yr. It is a reading of the posting, not a quotation of it, so keepdescriptionif you need the employer's exact wording. It was populated on 6 of the 10 postings injobs_sampled.jsonand isnullwhen the posting states no pay.idduplicatesjob_id. Every payload carries both. Pick one; do not count them as two fields of information.seniorityis the employer's own selection and is frequentlyNot Applicable. It wasNot Applicableon 9 of the 10 postings sampled. Treat it as a weak filter, not a reliable grade.applicantsis a bucketed string, not a number. It comes back asOver 200 applicantsorBe among the first 25 applicants, so parse it as a label. LinkedIn stops counting precisely past 200.- Job ids expire.
4318517031was live and 9 months old when this page was captured on 2026-07-20, which is why it is the example here, but every posting closes eventually. When this one does, the curl above starts returning404 item_not_foundwhile the committed sample stays exactly as captured. Swap in a live id from any/jobs/view/URL to reproduce the call.
Where a job_id comes from: it is the trailing number in a /jobs/view/ URL (/jobs/view/software-engineer-at-moab-4318517031), and the job search endpoint returns it for a keyword and location, which is documented in the LinkedIn Scraper. One call here returns one posting, so enriching a search costs one request per job on top of the search itself. Size any crawl on that.
Runnable: linkedin_job_scraper_api_codes/job.py
Turning a pile of job ids into a comparable pay dataset is the main reason people pull single postings, so that use case is in the repo end to end rather than as a snippet. jobs_to_dataset.py resolves every id concurrently, writes a flat CSV, and reports the salary band it found:
python linkedin_job_scraper_api_codes/jobs_to_dataset.py 4318517031 4318514364 4363726700 4378357766 4406548495That is a real run: five postings resolved at 5 concurrent, four of which published pay, giving a band of $135,000 to $350,000 across the set. Feed it a file instead when the list gets long:
python linkedin_job_scraper_api_codes/jobs_to_dataset.py --file job_ids.txt --out jobs.csv --workers 8Closed postings are recorded with status gone rather than dropped, so a re-run tells you which roles came off the board instead of silently shrinking your dataset. Keep --workers at or below your plan's concurrency.
Do this arithmetic before you start: one request per posting, so a 1,000-row dataset is 1,000 requests and consumes the entire one-time free allowance in a single run. Re-running the same list weekly to watch for closures costs the same again each time.
Real end-to-end wall clock, measured from a laptop against the live API on 2026-07-20 by benchmarks/measure.py. This includes the upstream fetch and the parse:
| Endpoint | Median | Range | n |
|---|---|---|---|
/linkedin/job |
2.4s | 1.2 to 4.0s | 8 |
Read the range, not just the median. The 4.0s is the interesting number: that is a request that ran into a refusal upstream and was re-attempted until real data came back. Absorbing that, silently, is a good part of what you are paying for. Small sample, one machine, one day: run benchmarks/measure.py and use your own numbers.
MIT. See LICENSE.






