LinkedIn has no API for searching jobs. Its Job Posting API publishes jobs from approved applicant tracking systems, and LinkedIn's page says it is not accepting new partners. Our LinkedIn API searches the public job listings LinkedIn shows to logged-out visitors and returns them as JSON.
What LinkedIn's Job Posting API is for
The Job Posting API lets ATS systems, job distributors and other authorized third parties post jobs to LinkedIn for their customers. It is restricted to developers LinkedIn approves, who sign an API agreement with data restrictions, and it uses OAuth. The page documents creating, updating and closing your customers' jobs, with no call that searches other companies' jobs. A notice at the top says LinkedIn is "currently not accepting new partnerships" for it and points to Apply Connect instead.
Job data with ToolzerHub
We make ToolzerHub. Our LinkedIn API returns public LinkedIn jobs, companies, posts and ads as JSON with one API key and no LinkedIn account. These routes do the job here, each 1 credit a call:
/v1/linkedin/jobs/search: 10 jobs a call forkeywordsand alocationorgeo_id;startpages by 10/v1/linkedin/jobs/detail: one posting's applicants, criteria and full description/v1/linkedin/geo/typeahead: places and their numericgeo_idfor a name
export TOOLZERHUB_API_KEY=your_key
curl -H "x-api-key: $TOOLZERHUB_API_KEY" \
"https://api.toolzerhub.com/v1/linkedin/jobs/search?keywords=data%20engineer&location=United%20States"
The first job in data.jobs, trimmed:
{
"job_id": "4454862094",
"title": "Senior Data Engineer, DX",
"company": "Atlassian",
"location": "Salt Lake City, UT",
"posted_at": "2026-10-18",
"salary": null,
"benefits": "Actively Hiring",
"url": "https://www.linkedin.com/jobs/view/senior-data-engineer-dx-at-atlassian-4454862094"
}
salary was null on all 80 jobs from our eight test searches. Pass job_id to the detail route for the rest. For that job it returned "applicants": "Over 200 applicants", "is_active": true, criteria of Mid-Senior level, Full-time and Software Development, and the description as text and HTML. Search also takes date_posted, experience_levels, job_types, workplace and company_id filters.
Use geo_id for places. A plain location=Denver returned 28 jobs around Charlotte, North Carolina, because LinkedIn matched Denver, North Carolina. location=Washington returned Washington, DC and northern Virginia.
Save a job search to CSV
The script resolves each place to a geo_id, reads the pages of search results you ask for, then reads every posting. It writes one row per job with company, location, applicants, seniority, employment type and the first pay range in the description. It counts ranges only, so a lone figure such as a signing bonus is skipped. For each place it prints how many postings state a range and how many show over 200 or under 25 applicants. Install requests (pip install requests) and save it as linkedin_jobs.py.
import csv
import os
import re
import sys
import time
import requests
API = "https://api.toolzerhub.com/v1/linkedin"
HEADERS = {"x-api-key": os.environ.get("TOOLZERHUB_API_KEY", "")}
FIELDS = ["query", "location", "job_id", "title", "company", "job_location", "posted_at", "applicants",
"seniority", "employment_type", "industries", "pay_range", "url"]
# A pay range such as "$130,000 - $180,000", "$155K-$160K" or "$45.00 to $60.00", so a lone
# figure like a signing bonus is not counted.
AMOUNT = r"\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d{2,3}(?:\.\d+)?\s?[kK]\b|\d{2,3}\.\d{2}"
PAY = re.compile(rf"[$£€]\s?(?:{AMOUNT})\s?(?:-|–|—|to)\s?[$£€]?\s?(?:{AMOUNT})")
def get(op, params, tries=3):
# Retry rate limits, server errors and dropped connections.
for attempt in range(tries):
last = attempt == tries - 1
try:
r = requests.get(f"{API}/{op}", headers=HEADERS, params=params, timeout=120)
except requests.RequestException:
if last:
raise
time.sleep(5 * (attempt + 1))
continue
if r.status_code in (429, 500, 502, 503, 504) and not last:
time.sleep(5 * (attempt + 1))
continue
r.raise_for_status()
return r.json()["data"]
def main(query, locations, pages="3", out="linkedin_jobs.csv"):
with open(out, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
writer.writeheader()
for name in filter(None, (l.strip() for l in locations.split(";"))):
# Plain text is ambiguous ("Denver" can match Denver, North Carolina), so search by geo_id.
place = get("geo/typeahead", {"query": name})["hits"][0]
location = place["display_name"]
jobs, seen = [], set()
for page in range(int(pages)):
data = get("jobs/search", {"keywords": query, "geo_id": place["geo_id"], "start": page * 10})
for j in data.get("jobs") or []:
if j["job_id"] not in seen:
seen.add(j["job_id"])
jobs.append(j)
if len(data.get("jobs") or []) < 10:
break
with_pay, over_200, under_25 = 0, 0, 0
for j in jobs:
d = get("jobs/detail", {"job_id": j["job_id"]})
criteria = d.get("criteria") or {}
pay = PAY.findall(d.get("description_text") or "")
with_pay += bool(pay)
# LinkedIn shows "Over 200 applicants" and "Be among the first 25 applicants" instead of a count.
over_200 += (d.get("applicants") or "").startswith("Over 200")
under_25 += "first 25" in (d.get("applicants") or "")
writer.writerow({
"query": query, "location": location, "job_id": j["job_id"], "title": j["title"],
"company": j["company"], "job_location": j["location"], "posted_at": j.get("posted_at"),
"applicants": d.get("applicants"), "seniority": criteria.get("Seniority level"),
"employment_type": criteria.get("Employment type"), "industries": criteria.get("Industries"),
"pay_range": pay[0] if pay else "", "url": j["url"],
})
print(f"{location}: {len(jobs)} jobs, {with_pay} state a pay range, "
f"{over_200} with over 200 applicants, {under_25} with under 25")
if __name__ == "__main__":
if len(sys.argv) < 3:
sys.exit('usage: python linkedin_jobs.py "software engineer" "San Francisco;Austin" [pages] [out.csv]')
if not HEADERS["x-api-key"]:
sys.exit("set TOOLZERHUB_API_KEY first")
main(*sys.argv[1:])
What six cities showed
python linkedin_jobs.py "software engineer" "San Francisco;New York;Seattle;Denver;Austin;Miami" 3 made 204 calls in 245 seconds and saved 180 jobs.
| City | Jobs read | State a pay range | Median low end of the range | Over 200 applicants | Under 25 applicants | Median days since posting |
|---|---|---|---|---|---|---|
| San Francisco | 30 | 20 | $152,500 | 10 | 6 | 12.5 |
| New York | 30 | 17 | $165,000 | 16 | 3 | 10.5 |
| Seattle | 30 | 19 | $165,000 | 10 | 7 | 11.5 |
| Denver | 30 | 20 | $132,600 | 5 | 6 | 14 |
| Austin | 30 | 14 | $146,000 | 6 | 7 | 12.5 |
| Miami | 30 | 17 | $129,300 | 12 | 3 | 14 |
107 of the 180 descriptions stated a pay range, from 14 of 30 in Austin to 20 in San Francisco and Denver, and every range ran low to high. In this run 16 of 30 New York jobs showed over 200 applicants and 12 in Miami, against 5 in Denver and 6 in Austin. The labels move within hours: Austin's under-25 count was 14 in a run earlier the same day, 7 here. Exact counts ran from 25 to 198; outside that range LinkedIn shows only the label.
One employer can fill a page. Affirm's "Software Engineer I, Fullstack (Search)" came back as eight separate job IDs, one per office, so dedupe by company and title before counting openings. Six of the 30 New York jobs were in New Jersey, from Newark to Weehawken.
What it costs
A city at three pages is up to 34 credits: 1 to resolve the place, 3 searches and up to 30 postings. Search alone is 1 credit per 10 jobs. The 100 free credits a month cover two cities with every posting read, or 1,000 jobs from search alone; $19 buys 10,000 credits that never expire, and plans for higher volume cover daily job feeds. Set your key and the first search is a CSV in a few minutes.
For what LinkedIn's own APIs cost and who gets in, see LinkedIn API pricing. For a free script that reads the same job pages, see how to scrape LinkedIn with Python, and for Indeed's listings with salaries, does Indeed have an API.
We are not affiliated with LinkedIn or Microsoft.