How to Scrape LinkedIn Jobs with Python (No Login Required, 2026)

By Serpent API Team · · 13 min read

If you have ever tried to scrape LinkedIn jobs with Python the hard way — a raw requests.get() against a job search URL — you already know how that ends. You get a login wall, a near-empty HTML shell, or a 999 status code, and within a few hundred requests your IP is throttled into silence. The data is genuinely public, but the path to it is paved with friction.

This guide takes a different route. It shows you how to pull public LinkedIn job postings into clean Python data structures — search results, full job descriptions, salary ranges, applicant counts — with no LinkedIn login, no cookies, and no headless browser to babysit. You send keywords and a location, and you get JSON back. By the end you will have a complete, runnable script that searches, paginates through every page, fetches each job's full detail, dedupes, retries on errors, and writes the result to CSV and JSON.

TL;DR: Use Serpent's two LinkedIn API endpoints. GET /api/linkedin/jobs searches public postings by keyword and location and returns job cards 10 at a time (paginate with start). GET /api/linkedin/job takes a job_id and returns the full posting — description, seniority, salary, applicants, the works. Authenticate with an X-API-Key header. Both endpoints are $0.50 per 1,000 requests on Default, dropping to $0.025 at Scale. No login, no proxies to manage — the full Python script is below.

Why scraping LinkedIn jobs in Python is hard

The reason a do-it-yourself LinkedIn job scraper is so painful comes down to three walls, and you hit all of them quickly.

The authentication wall. Large chunks of LinkedIn are gated behind a session. Hit a job listing without one and you are often bounced to a sign-up interstitial. Build a scraper around a logged-in session and you are now managing credentials, two-factor prompts, and the very real risk of getting that account restricted — exactly the thing nobody wants tied to their own profile.

The rate-limit wall. Even on public pages, request volume from a single IP is policed aggressively. A naive loop that fetches a few hundred postings will start collecting 429s and the LinkedIn-specific 999 status. To get past that you would need a rotating pool of clean IPs, request pacing, and constant monitoring — a whole infrastructure project that has nothing to do with the jobs data you actually wanted.

The maintenance wall. LinkedIn's markup changes. Class names get obfuscated, the page becomes more JavaScript-rendered, selectors that worked last month return empty strings this month. A DIY scraper is never "done" — it is a thing you maintain forever. (We wrote a whole post on surviving selector drift if you want to feel that pain in detail.)

An API removes all three walls. You don't authenticate against LinkedIn, you don't manage IPs, and you don't parse HTML — you call an endpoint and read JSON. That is the whole pitch of Serpent's LinkedIn API: it accesses publicly available LinkedIn data only, never logs in with credentials, and hands you structured fields. The rest of this guide is just Python.

The two endpoints you need

Scraping LinkedIn jobs is a classic two-step pattern: search to discover postings, then detail to enrich the ones you care about. Serpent gives you one endpoint for each.

EndpointWhat it doesKey paramsReturns
GET /api/linkedin/jobsPublic job searchkeywords, location, startArray of job cards (10 per page)
GET /api/linkedin/jobSingle job detailjob_idFull posting object

The search endpoint returns lightweight cards, each with: job_id, job_url, job_title, company_name, company_linkedin_url, company_logo, location, and job_posted_date. The start parameter is your pagination cursor — it offsets into the result set, and each page holds 10 cards.

The detail endpoint takes a single job_id (straight from a search card) and returns the full posting: job_title, company_name, company_linkedin_url, location, job_posted_date, the complete job_description, seniority_level, employment_type, job_function, an industries array, applicants_count, workplace_type, a salary object (min, max, currency) when the salary is listed, and a job_poster object (name, title, profile_url) when a recruiter is attached.

Every request authenticates with the same header — X-API-Key: YOUR_API_KEY. Grab a key from your dashboard, and you are ready to write code.

Let's confirm the basics with a single page of results. Install requests (pip install requests) and run this:

A developer at a dual-monitor setup running a Python script in a terminal alongside source code
import requests

API_KEY = "sk_live_your_key"
BASE = "https://apiserpent.com/api/linkedin"

def search_page(keywords, location, start=0):
    """Return one page (up to 10 cards) of LinkedIn job search results."""
    r = requests.get(
        f"{BASE}/jobs",
        headers={"X-API-Key": API_KEY},
        params={"keywords": keywords, "location": location, "start": start},
        timeout=60,
    )
    r.raise_for_status()
    return r.json().get("jobs", [])

cards = search_page("python developer", "United States")
for c in cards:
    print(c["job_id"], "—", c["job_title"], "@", c["company_name"])

That prints the first 10 public postings for "python developer" in the United States — job IDs, titles, and companies — without a single line of HTML parsing. The location string is a plain place name ("United States", "London", "Remote"), and keywords behaves like the LinkedIn jobs search box.

Paginate every page with start

One page is 10 jobs. To get all of them you walk the start cursor: 0, then 10, then 20, and so on, until a page comes back empty. Two rules keep this honest:

import time

def search_all(keywords, location, max_pages=20, pause=1.0):
    """Walk the start cursor until results run out. Dedupe by job_id."""
    seen, results = set(), []
    for page in range(max_pages):
        start = page * 10
        cards = search_page(keywords, location, start=start)
        if not cards:
            break  # empty page = end of results

        new = 0
        for card in cards:
            jid = card.get("job_id")
            if jid and jid not in seen:
                seen.add(jid)
                results.append(card)
                new += 1

        print(f"page {page + 1}: +{new} new ({len(results)} total)")
        if new == 0:
            break  # all duplicates = nothing left to find
        time.sleep(pause)  # be polite between pages
    return results

This is the same disciplined pagination loop you would write for any cursor-based API — bounded, deduped, and polite. The pause between pages is good manners, not a workaround; it keeps your own job tidy and predictable.

Fetch the full job detail

Search cards are enough to list jobs, but the interesting data — description text, salary band, seniority, how many people have applied — lives on the detail endpoint. Feed it a job_id from any card:

def job_detail(job_id):
    """Return the full posting for one job_id."""
    r = requests.get(
        f"{BASE}/job",
        headers={"X-API-Key": API_KEY},
        params={"job_id": job_id},
        timeout=60,
    )
    r.raise_for_status()
    data = r.json()
    return data.get("job", data)  # tolerate either shape

detail = job_detail(cards[0]["job_id"])
print(detail["job_title"], "—", detail.get("seniority_level"))
print("Applicants:", detail.get("applicants_count"))
print(detail.get("salary"))

Now you have the whole posting. Merge a search card with its detail object and you get one flat record per job, ready to drop into a database, a spreadsheet, or a model's context window.

Add retry, backoff, and politeness

Any networked job needs to survive a hiccup. The single most useful upgrade is a thin wrapper that retries on transient errors with exponential backoff, so one blip doesn't kill a long run. This is ordinary client-side resilience — the kind you would add to any API integration.

def get_json(path, params, max_retries=4):
    """GET with exponential backoff on 429 / 5xx and network errors."""
    for attempt in range(max_retries):
        try:
            r = requests.get(
                f"{BASE}/{path}",
                headers={"X-API-Key": API_KEY},
                params=params,
                timeout=60,
            )
            if r.status_code == 429 or r.status_code >= 500:
                wait = 2 ** attempt          # 1s, 2s, 4s, 8s
                print(f"  {r.status_code} — backing off {wait}s")
                time.sleep(wait)
                continue
            r.raise_for_status()
            return r.json()
        except requests.RequestException as e:
            wait = 2 ** attempt
            print(f"  network error ({e}) — retrying in {wait}s")
            time.sleep(wait)
    raise RuntimeError(f"gave up after {max_retries} tries: {path} {params}")

A 429 means you are asking for data faster than your plan's rate bracket allows — the fix is simply to slow down, which the backoff does automatically. Keep batches reasonable, sleep a beat between calls, and a few thousand postings collect themselves without drama.

The complete script (search to CSV)

Here is everything assembled into one runnable program. It searches, paginates across all pages, dedupes, fetches each job's full detail through the resilient wrapper, then writes both a rich linkedin_jobs.json and a flat linkedin_jobs.csv.

import csv
import json
import time
import requests

API_KEY = "sk_live_your_key"
BASE = "https://apiserpent.com/api/linkedin"


def get_json(path, params, max_retries=4):
    """GET with exponential backoff on 429 / 5xx and network errors."""
    for attempt in range(max_retries):
        try:
            r = requests.get(
                f"{BASE}/{path}",
                headers={"X-API-Key": API_KEY},
                params=params,
                timeout=60,
            )
            if r.status_code == 429 or r.status_code >= 500:
                time.sleep(2 ** attempt)
                continue
            r.raise_for_status()
            return r.json()
        except requests.RequestException:
            time.sleep(2 ** attempt)
    raise RuntimeError(f"gave up: {path} {params}")


def search_all(keywords, location, max_pages=20, pause=1.0):
    """Paginate the jobs search, deduping by job_id."""
    seen, results = set(), []
    for page in range(max_pages):
        data = get_json("jobs", {
            "keywords": keywords,
            "location": location,
            "start": page * 10,
        })
        cards = data.get("jobs", [])
        if not cards:
            break

        new = 0
        for card in cards:
            jid = card.get("job_id")
            if jid and jid not in seen:
                seen.add(jid)
                results.append(card)
                new += 1
        print(f"page {page + 1}: +{new} ({len(results)} total)")
        if new == 0:
            break
        time.sleep(pause)
    return results


def enrich(cards, pause=0.5):
    """Fetch full detail for each card and merge into one record."""
    full = []
    for i, card in enumerate(cards, 1):
        data = get_json("job", {"job_id": card["job_id"]})
        detail = data.get("job", data)
        full.append({**card, **detail})
        print(f"  [{i}/{len(cards)}] {detail.get('job_title', '?')}"
              f" @ {detail.get('company_name', '?')}")
        time.sleep(pause)  # be polite
    return full


def to_csv(rows, path="linkedin_jobs.csv"):
    cols = ["job_id", "job_title", "company_name", "location",
            "job_posted_date", "seniority_level", "employment_type",
            "workplace_type", "job_function", "applicants_count", "job_url"]
    with open(path, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=cols, extrasaction="ignore")
        w.writeheader()
        for row in rows:
            w.writerow(row)


if __name__ == "__main__":
    cards = search_all("python developer", "United States")
    print(f"\n{len(cards)} unique cards found. Pulling detail...\n")

    jobs = enrich(cards)

    with open("linkedin_jobs.json", "w", encoding="utf-8") as f:
        json.dump(jobs, f, indent=2, ensure_ascii=False)
    to_csv(jobs)

    print(f"\nDone — wrote {len(jobs)} jobs to "
          f"linkedin_jobs.json + linkedin_jobs.csv")

Change the two arguments on the last block — keywords and location — and you have a reusable LinkedIn jobs scraper for any role in any market. Swap "python developer" for "data engineer", "United States" for "Berlin", and rerun.

What the data looks like

A single search-result card from /api/linkedin/jobs looks like this:

{
  "success": true,
  "count": 10,
  "jobs": [
    {
      "job_id": "3901234567",
      "job_url": "https://www.linkedin.com/jobs/view/3901234567",
      "job_title": "Senior Python Developer",
      "company_name": "Stripe",
      "company_linkedin_url": "https://www.linkedin.com/company/stripe",
      "company_logo": "https://media.licdn.com/dms/image/.../stripe.png",
      "location": "San Francisco, CA",
      "job_posted_date": "2026-06-27"
    }
  ]
}

And the matching /api/linkedin/job detail response, fetched with that job_id, fills in everything else:

{
  "success": true,
  "job": {
    "job_title": "Senior Python Developer",
    "company_name": "Stripe",
    "company_linkedin_url": "https://www.linkedin.com/company/stripe",
    "location": "San Francisco, CA",
    "job_posted_date": "2026-06-27",
    "job_description": "We are looking for a Senior Python Developer to design and scale our payments platform...",
    "seniority_level": "Mid-Senior level",
    "employment_type": "Full-time",
    "job_function": "Engineering and Information Technology",
    "industries": ["Software Development", "Financial Services"],
    "applicants_count": 87,
    "workplace_type": "Hybrid",
    "salary": { "min": 160000, "max": 220000, "currency": "USD" },
    "job_poster": {
      "name": "Jane Recruiter",
      "title": "Engineering Recruiter",
      "profile_url": "https://www.linkedin.com/in/jane-recruiter"
    }
  }
}

A quick word on honesty: not every posting carries every field. salary only appears when the employer published a band, and job_poster only appears when a named recruiter is attached. Your code should treat those as optional (the .get() calls above already do), not assume they are always present.

Use case: a hiring-intelligence feed

Once you can pull structured job data on demand, a whole category of products opens up. The most common build is a hiring-intelligence feed — a job you run on a schedule that turns raw postings into a signal.

A group of professionals studying results together on screen, acting on a hiring-intelligence feed

The pattern is simple. Run search_all() nightly for a set of tracked queries — your competitors' names, the roles you recruit for, the skills you sell into. Store each result keyed by job_id. Diff today's set against yesterday's, and the new IDs are net-new openings. From there:

To go from "list of jobs" to "intelligence", pair this with the company side. The same LinkedIn API returns full firmographics — employee count, industry, founded year — which we cover in scraping LinkedIn company data with an API. Join jobs to companies on company_linkedin_url and your feed knows not just who is hiring but how big they are and how fast they are growing. If you are migrating an existing pipeline, our guide to LinkedIn API alternatives shows a thin-adapter pattern that keeps you portable.

Because everything is JSON over HTTP, this slots into whatever you already run — a cron job, a serverless function, an Airflow DAG, or an SERP-data pipeline you already maintain.

What it costs

The LinkedIn endpoints are pay-as-you-go — no subscription, no monthly minimum. Both Jobs Search and Job Detail are priced identically, and a single deposit moves your whole account to a cheaper tier permanently.

TierHow to unlockJobs SearchJob Detail
DefaultJust sign up$0.50 / 1,000$0.50 / 1,000
GrowthSingle $100 deposit (10× off)$0.05 / 1,000$0.05 / 1,000
ScaleSingle $500 deposit (20× off)$0.025 / 1,000$0.025 / 1,000

Each search page and each detail call is one request, so a page of 10 cards plus all 10 details is 11 requests. Pulling 1,000 fully-enriched jobs is roughly 1,100 requests — about $0.55 on Default, or under three cents at Scale. New accounts include 10 free Google searches to evaluate the platform; the LinkedIn endpoints themselves are pay-as-you-go from the first call. See the full breakdown on the pricing page and the field-by-field reference in the docs.

Scrape LinkedIn jobs without the headaches

Serpent's LinkedIn API turns public job postings into clean JSON — search, paginate, and pull full job details with one API key. No login, no proxy pool, no HTML parsing. Pay-as-you-go from $0.50/1K, dropping to $0.025/1K at Scale, and 10 free Google searches to evaluate the platform.

Get Your Free API Key

Explore: LinkedIn API · Social Media API · Pricing · Docs

FAQ

Can I scrape LinkedIn jobs with Python without logging in?

Yes. LinkedIn's job search and individual job pages are publicly available — you do not need a LinkedIn account, cookies, or OAuth to read them. The approach in this guide uses Serpent's LinkedIn API: you send keywords, a location, and a pagination cursor with your own API key, and you get back clean JSON. Serpent accesses publicly available data only and never logs in with credentials. You are responsible for ensuring your own use of the data complies with applicable laws and LinkedIn's terms.

How do I paginate through all LinkedIn job results in Python?

The Jobs Search endpoint returns 10 job cards per page and accepts a start parameter that offsets into the result set. Request start=0 for the first page, start=10 for the second, start=20 for the third, and so on. Keep incrementing start by 10 in a loop, collect the cards, and stop when a page comes back empty or returns only job_ids you have already seen. Always dedupe by job_id, because adjacent pages can overlap.

What fields does the LinkedIn job detail endpoint return?

The Job Detail endpoint takes a job_id and returns the full posting: job_title, company_name, company_linkedin_url, location, job_posted_date, the complete job_description, seniority_level, employment_type, job_function, an industries array, applicants_count, workplace_type, a salary object with min, max and currency when the salary is listed, and a job_poster object with the recruiter's name, title and profile URL when present. The search cards give you enough to list jobs; the detail call gives you everything you need to enrich them.

How much does it cost to scrape LinkedIn jobs with this API?

Jobs Search and Job Detail are each $0.50 per 1,000 requests on the Default tier. A single $100 deposit unlocks the Growth tier at 10× off ($0.05 per 1,000), and a single $500 deposit unlocks the Scale tier at 20× off ($0.025 per 1,000). New accounts include 10 free Google searches to evaluate the platform; the LinkedIn endpoints are pay-as-you-go with no subscription. Each search page and each detail call counts as one request, so pulling a page of 10 cards plus their details is 11 requests.

Is it legal to scrape LinkedIn jobs?

This guide only touches publicly available job postings, accessed with no login and no credentials. Public-data collection has been the subject of significant litigation, and the rules differ by jurisdiction and by how you use the data. We do not make a blanket claim that scraping LinkedIn is legal. You are responsible for ensuring your use complies with applicable laws, privacy regulations, and LinkedIn's terms of service. When in doubt, consult a lawyer.