LinkedIn Jobs API in Python: Remote Jobs, Date Filters and Pagination
Owen's career product does not render a jobs board. It mails one attachment every Monday, and the filename is a contract: remote-jobs.csv. The keyword is software engineer. Workplace type is remote. Geography is the United States, but only as a geocode. The posting window is the past week. Each job id appears once. Product did not ask for descriptions, skills, employer headcount, or a live dashboard β the alert worker diffs this week's file against last week's and stops. Mix on-site rows into a remote filter, send a city label where a geocode belongs, or let page two reprint page one after a recent-sort reshuffle, and the digest is wrong before anyone reads a title.
The LinkedIn Data API is an independent, read-only HTTP surface. It is not an official LinkedIn product and not a partner integration. A first-page sample once returned 25 jobs; that observation does not validate every filter, continuation page, or freshness window. Opening this page or copying the script does not send a data request. Only a request you actually run does.
Sign in, copy an authorized platform key from the dashboard, and keep it in OPENDATA_API_KEY on the server. Snippets that show YOUR_API_KEY are placeholders. The key travels in the query string, so those URLs do not belong in tickets, screenshots, or browser JavaScript. No LinkedIn account, cookie, or session is involved.
The alert file a career tool actually ships
The artifact is remote-jobs.csv. Shared on every row: the geocode you actually sent, date_posted, and observed_at_utc. Listing columns: string id, title, url, listed_at, location, company name. Two operations produce that file. Everything else in the project stays off the attachment.
search-location turns a readable place into candidate location / geocode pairs. search-jobs requires keyword, then the filters this CSV cares about: geocode, remote, date_posted, sort_by, page. get-job-detail hydrates one vacancy after you already have an id β description, workplace types, work_remote_allowed, salary metadata. include_skills=true can increase consumption. That is a different export. Company profile, company jobs, and job-count belong to an employer-shaped feed, not this keyword digest. Leave them out of the file.
easy_apply never submits an application. Search rows may include is_easy_apply and is_promote; those are listing flags, not hiring outcomes. A returned cost is response metadata, not a published credit quote β check current platform pricing if you need consumption numbers.
Resolve a geocode, then filter remote
Job search does not accept United States as geography. It accepts geocode. Call search-location with keyword=United States, inspect the matches, and send the code you meant. The documented job example uses 103644278 for the United States. Do not take the first ambiguous city because the label looked close, and do not type the display name into search-jobs. People search names the same kind of code geocode_location; jobs do not.
Remote is not a boolean. Documented values are onsite, remote, and hybrid. A geography filter still matters for remote roles: remote does not mean eligible from every country. Posting windows are anytime, past_month, past_week, and past_24_hours. This digest uses past_week. Do not reuse post-search values such as past_24h, and do not copy start / count pagination from another jobs API β this search is page-based. sort_by=recent is the usual companion when the file is an alert, not a relevance ranking.
Transport is the first layer: HTTP 4xx / 5xx means the call did not run (auth, quota, or network). The gateway envelope is {code, message, data}. Gateway code 200 means the platform accepted the request. It does not mean listings came back. Inside data, the operation has its own success. An observed profile failure had HTTP 200 with success: false, data: null, and status_code: 403. HTTP 202 also occurred with business failures. Do not treat null as a verified empty market, and do not retry access failures as if the page index were wrong.
Paginate, dedupe, stop
Start at page=1. Advance only while the payload says has_more is true, the page added at least one new id, and you are under a page cap. Deduplicate with str(job["id"]). Listings drift: a job that sat on page one can reappear on page two after sort_by=recent reshuffles. Reported total is not a promise that every result is retrievable.
Stop on has_more false, an empty page, a duplicate-only page, a missing continuation signal, or your request budget. Never invent a pagination_token β other operations in this API use cursors; job search does not. Persist seen ids across runs if this CSV is a digest rather than a one-shot; an in-memory set dies when the process exits. A failed run can leave a partial file; do not label it complete.
Keep ids as strings. Preserve listed_at as returned and record your own UTC observation time separately. Missing salary on a later detail call is unknown, not zero β and this file does not call detail.
Mail remote-jobs.csv on Monday
Install requests, export OPENDATA_API_KEY on the server, and run this as a job. It resolves a United States geocode, pages search-jobs with remote=remote and date_posted=past_week, dedupes on job id, and writes remote-jobs.csv. Missing fields stay missing. These filters are documented inputs, not a guaranteed non-empty result.
import csv
import os
import time
from datetime import datetime, timezone
import requests
BASE = "https://api.opendata-api.com/api/project/linkedin-scraper"
API_KEY = os.environ["OPENDATA_API_KEY"]
KEYWORD = "software engineer"
LOCATION_KEYWORD = "United States"
DATE_POSTED = "past_week"
REMOTE = "remote"
MAX_PAGES = 5
OUT = "remote-jobs.csv"
def read(operation, **params):
for attempt in range(3):
try:
response = requests.get(
f"{BASE}/{operation}",
params={"key": API_KEY, **params},
timeout=30,
)
except requests.RequestException:
if attempt < 2:
time.sleep(2 ** (attempt + 1))
continue
raise RuntimeError("Network failure; request URL withheld") from None
if response.status_code in (429, 500, 502, 503, 504) and attempt < 2:
time.sleep(2 ** (attempt + 1))
continue
if not 200 <= response.status_code < 300:
raise RuntimeError(f"HTTP failure: {response.status_code}")
try:
body = response.json()
except ValueError:
raise RuntimeError("Expected JSON") from None
if not isinstance(body, dict) or body.get("code") != 200:
raise RuntimeError("Gateway business failure")
payload = body.get("data")
if not isinstance(payload, dict) or payload.get("success") is not True:
raise RuntimeError("Operation failed; review redacted status")
return payload
def resolve_geocode(keyword):
payload = read("search-location", keyword=keyword)
rows = payload.get("data")
if not isinstance(rows, list) or not rows:
raise RuntimeError("No location matches")
chosen = None
for row in rows:
if not isinstance(row, dict):
continue
if str(row.get("location", "")).lower() == keyword.lower():
chosen = row
break
if chosen is None:
chosen = rows[0] # ranked fallback; store the geocode you actually sent
geocode = chosen.get("geocode")
if not geocode:
raise RuntimeError("Match missing geocode")
return str(geocode)
def collect(geocode):
seen = set()
for page in range(1, MAX_PAGES + 1):
payload = read(
"search-jobs",
keyword=KEYWORD,
geocode=geocode,
remote=REMOTE,
date_posted=DATE_POSTED,
sort_by="recent",
page=page,
)
jobs = payload.get("data")
if not isinstance(jobs, list):
raise RuntimeError("Expected job array")
added = 0
observed = datetime.now(timezone.utc).isoformat()
for job in jobs:
if not isinstance(job, dict) or not job.get("id"):
raise RuntimeError("Missing job identifier")
job_id = str(job["id"])
if job_id in seen:
continue
seen.add(job_id)
added += 1
company = job.get("company")
yield {
"id": job_id,
"title": job.get("title"),
"url": job.get("url"),
"listed_at": job.get("listed_at"),
"location": job.get("location"),
"company": company.get("name") if isinstance(company, dict) else None,
"geocode": geocode,
"date_posted": DATE_POSTED,
"observed_at_utc": observed,
}
if not added or payload.get("has_more") is not True:
break
time.sleep(1) # client spacing, not a published limit
def cell(value):
text = "" if value is None else str(value)
# Prevent returned text from becoming a spreadsheet formula.
return "'" + text if text.lstrip().startswith(("=", "+", "-", "@")) else text
if __name__ == "__main__":
geocode = resolve_geocode(LOCATION_KEYWORD)
fields = [
"id", "title", "url", "listed_at", "location",
"company", "geocode", "date_posted", "observed_at_utc",
]
rows = list(collect(geocode))
if not rows:
raise SystemExit("No job rows to write")
with open(OUT, "w", newline="", encoding="utf-8") as output:
writer = csv.DictWriter(output, fieldnames=fields)
writer.writeheader()
for row in rows:
writer.writerow({key: cell(value) for key, value in row.items()})
That file is the whole deliverable: one remote-jobs CSV with a resolved geocode, a posting window, and job-id dedupe.