LinkedInAnswer

LinkedIn jobs API: search public job postings as JSON

LinkedIn's only jobs API posts jobs for approved partners and takes no new ones. Our API searches public LinkedIn jobs and reads postings, 1 credit a call.

6 min read

Short answer

  • LinkedIn's Job Posting API lets approved ATS partners post jobs to LinkedIn. It has no call to search other companies' jobs, and LinkedIn says it is not accepting new partners.
  • Search results leave salary empty, but most job descriptions state a pay range: 107 of 180 software engineer postings we read in six US cities.
  • Our LinkedIn API searches public jobs, 10 a call, and reads a posting's applicants, criteria and description, each at 1 credit.
On this page

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 for keywords and a location or geo_id; start pages by 10
  • /v1/linkedin/jobs/detail: one posting's applicants, criteria and full description
  • /v1/linkedin/geo/typeahead: places and their numeric geo_id for a name
bash
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:

json
{
  "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.

python
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.

Get this data from your own code

The LinkedIn API returns it as JSON with one x-api-key header. 100 free credits monthly.