Build a Job Board with the LinkedIn Jobs API (2026)
A job board is one of the most durable products you can build on public data: a niche board for a single industry, an internal careers aggregator, or a hiring-intelligence feed all start from the same problem — you need a steady stream of fresh job postings in a clean, structured shape. LinkedIn is the richest public source of those postings, and this guide walks you through turning it into a working board.
We will build the whole pipeline: search the public LinkedIn jobs surface through one REST endpoint, paginate to pull depth, dedupe by job_id, store the results, and display them. The ingest code below is tested against live data, and I am going to be honest about exactly which fields you get and which you do not — because a job board built on wrong assumptions breaks in week two.
TL;DR: Call GET /api/linkedin/jobs?keywords=<role>&location=<place>&start=<offset> for a page of postings (10 per page; increment start by 10). Each posting carries job_id, job_title, company_name, location, job_posted_date and a job_url. Dedupe by job_id and store. Add GET /api/linkedin/job?job_id=… for the full job_description (text + HTML) plus structured criteria (seniority, type, function, applicant count). Jobs calls are $0.50/1K, dropping to $0.025/1K at Scale. Reads publicly available LinkedIn data only. Tested code below.
What the jobs API actually gives you (honestly)
Before you architect anything, know your raw material. Serpent exposes the public LinkedIn jobs surface — the same listings a logged-out visitor sees — through two endpoints. Here is the honest field inventory, because this is where most tutorials over-promise.
The search endpoint reliably returns, per posting:
job_id— the stable identifier (your primary key for dedupe and storage).job_title,company_name,company_linkedin_url,company_logo.locationandjob_posted_date.job_url— the link to the original posting on LinkedIn.
The job detail endpoint adds, for a single posting: the full job_description (plain text) and job_description_html, plus seniority_level, employment_type, job_function and applicants_count.
The one field to design around is structured salary. The detail endpoint returns the full job_description reliably — a real pull on July 2, 2026 returned a 5,193-character description (plus job_description_html) for a Gopuff data-engineer role — but the structured salary object (min/max/currency) came back empty on every posting I tested. That is expected: LinkedIn rarely exposes machine-readable salary on the public guest surface, and when a salary exists it is usually written inside the description text. So render the full description, and treat structured salary as a bonus that is often absent rather than a field you can depend on.
The two endpoints
Jobs search — one required parameter (keywords), plus optional location and start (pagination offset):
GET https://apiserpent.com/api/linkedin/jobs?keywords=data%20engineer&location=United%20States&start=0
Header: X-API-Key: YOUR_API_KEY
Job detail — one required parameter (job_id), for the structured criteria of a single posting:
GET https://apiserpent.com/api/linkedin/job?job_id=4432294543
Header: X-API-Key: YOUR_API_KEY
Both return {"success": true, "data": { … }}. Search wraps the postings in data.jobs[] along with the echoed keywords, location, start and count.
Real data: a live search
To keep this concrete, here is the top of a real response for keywords=data engineer&location=United States, pulled on July 2, 2026:
job_id | job_title | company_name | location | job_posted_date |
|---|---|---|---|---|
| 4432294543 | Associate Data Engineer | New York Power Authority | White Plains, NY | 2026-06-23 |
| 4425380159 | Data Engineer, Product Analytics (University Grad) | Meta | Bellevue, WA | 2026-06-13 |
| 4388128001 | Data Engineer | Gopuff | United States | 2026-06-25 |
| 4428763388 | Data Engineer | Colgate-Palmolive | Piscataway, NJ | 2026-06-17 |
| 4428038891 | Data Platform Engineer | Chipotle Mexican Grill | Columbus, OH | 2026-06-16 |
Method: live GET /api/linkedin/jobs call on 2026-07-02, first five rows copied verbatim. Each row is a real, current posting with a working job_url.
That is the shape you build a board from: clean rows, one stable ID each, ready to store.
The ingest pipeline: search, paginate, dedupe, store
A single search returns 10 postings. To build a board you want depth, so you paginate by incrementing start. The critical detail: pages can overlap or reorder between calls, so you must dedupe by job_id rather than assuming pages are disjoint. This is not theoretical — when I ingested three pages of the search above, 30 raw postings collapsed to 29 unique after dedupe, because one posting appeared on two pages.
Here is a tested ingest function that paginates, dedupes, and stores into SQLite with an upsert so re-runs refresh rather than duplicate:
import sqlite3, time, requests
API_KEY = "YOUR_API_KEY"
BASE = "https://apiserpent.com/api/linkedin/jobs"
def search_page(keywords, location, start, retries=3):
for attempt in range(retries):
r = requests.get(BASE, headers={"X-API-Key": API_KEY},
params={"keywords": keywords, "location": location, "start": start},
timeout=60)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 5))); continue
if r.status_code == 503:
time.sleep(2 ** attempt); continue
r.raise_for_status()
return r.json().get("data", {}).get("jobs", [])
return []
def ingest(keywords, location, pages=5):
"""Paginate, dedupe by job_id, return unique postings."""
seen, rows = set(), []
for p in range(pages):
jobs = search_page(keywords, location, p * 10)
if not jobs:
break # no more results
for j in jobs:
jid = j.get("job_id")
if jid and jid not in seen:
seen.add(jid)
rows.append(j)
time.sleep(0.4) # gentle pacing
return rows
def store(rows, db="jobs.db"):
con = sqlite3.connect(db)
con.execute("""CREATE TABLE IF NOT EXISTS jobs(
job_id TEXT PRIMARY KEY, title TEXT, company TEXT, location TEXT,
posted_date TEXT, url TEXT, first_seen TEXT, last_seen TEXT)""")
now = time.strftime("%Y-%m-%d")
for j in rows:
con.execute("""INSERT INTO jobs(job_id,title,company,location,posted_date,url,first_seen,last_seen)
VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(job_id) DO UPDATE SET last_seen=excluded.last_seen""",
(j["job_id"], j.get("job_title"), j.get("company_name"), j.get("location"),
j.get("job_posted_date"), j.get("job_url"), now, now))
con.commit(); con.close()
if __name__ == "__main__":
postings = ingest("data engineer", "United States", pages=5)
print(f"{len(postings)} unique postings")
store(postings)
The ON CONFLICT … DO UPDATE upsert is what makes this safe to run on a schedule: a posting you have seen before just gets its last_seen refreshed; a new one is inserted. That single line is the difference between a growing, self-cleaning board and a table full of duplicates.
Want to run this now? Grab a key in under a minute — new accounts include 10 free Google searches to explore the platform. Create a free key, paste it into the script, and watch your first postings land in SQLite.
Enriching cards with job detail
The search feed is enough for a list view. When a visitor opens a posting, call the detail endpoint to pull the full job_description plus structured criteria — seniority, employment type, function and how many people have applied:
DETAIL = "https://apiserpent.com/api/linkedin/job"
def enrich(job_id):
r = requests.get(DETAIL, headers={"X-API-Key": API_KEY},
params={"job_id": job_id}, timeout=60)
if r.status_code != 200:
return {}
d = r.json().get("data", {})
return {
"description": d.get("job_description"), # full JD text (~5 KB)
"description_html": d.get("job_description_html"),
"seniority": d.get("seniority_level"),
"employment_type": d.get("employment_type"),
"function": d.get("job_function"),
"applicants": d.get("applicants_count"),
}
# Real example (job_id 4388128001, Gopuff Data Engineer, pulled 2026-07-02):
# description -> 5,193-char full JD (omitted here for length); plus:
# {"seniority": "Mid-Senior level", "employment_type": "Full-time",
# "function": "Information Technology", "applicants": 74}
Enrich on demand — when a card is opened — rather than for every posting at ingest time. That keeps your call volume (and cost) proportional to traffic, not to the size of your index. You already have the full job_description from the detail call, so render that in the card, and point the "Apply" or "View" button at the posting's job_url as the canonical source and application link.
Displaying the board
With clean rows in SQLite, the front end is trivial. A minimal server-rendered list looks like this:
import sqlite3
def recent_jobs(db="jobs.db", limit=50):
con = sqlite3.connect(db)
con.row_factory = sqlite3.Row
rows = con.execute(
"SELECT * FROM jobs ORDER BY posted_date DESC LIMIT ?", (limit,)
).fetchall()
con.close()
return rows
def render(rows):
cards = []
for r in rows:
cards.append(
f'<a class="job-card" href="{r["url"]}" target="_blank" rel="nofollow">'
f'<h3>{r["title"]}</h3>'
f'<p>{r["company"]} — {r["location"]}</p>'
f'<time>{r["posted_date"]}</time></a>'
)
return "\n".join(cards)
Drop that into Flask, FastAPI, Next.js, or a static-site generator — the data layer does not care. The point is that your board renders from your own store, so it stays fast and available even if an ingest run is slow.
Keeping the board fresh
A job board's whole value is freshness. Three rules keep it healthy:
- Re-run searches on a schedule. Hourly for a fast-moving niche, daily for most. Put the ingest script in a cron job or a scheduled GitHub Action.
- Upsert, do not append. The
ON CONFLICTpattern above refresheslast_seenfor postings still live and inserts new ones — no duplicates, ever. - Expire stale postings. Hide or delete anything whose
last_seenis older than, say, 14 days — those postings have almost certainly been closed.
Pair this with the company employee-count endpoint and you can annotate each posting with the employer's size and growth — a small touch that makes a niche board feel premium.
Errors, rate limits and retries
| Status | Meaning | What to do |
|---|---|---|
200 | Success — {"success": true, "data": {…}} | Read data.jobs. |
400 | Missing keywords (or job_id for detail) | Fix the request; don't retry. |
404 | Job not found (expired posting) | Expire it from your store. |
429 | Rate limit for your bracket | Honor Retry-After, then retry. |
503 | Temporarily unavailable | Exponential backoff and retry. |
The ingest function already implements the right behavior for each code. Your rate limit is per-account and scales with your balance bracket, so the way to ingest faster is to keep a working balance rather than to hammer one key. Full semantics are in the API docs.
Pricing
| Tier | Unlock | Jobs search / detail | Per call |
|---|---|---|---|
| Default | None | $0.50 / 1,000 | $0.0005 |
| Growth | One $100 deposit (10× off) | $0.05 / 1,000 | $0.00005 |
| Scale | One $500 deposit (20× off) | $0.025 / 1,000 | $0.000025 |
A board that ingests 50 searches an hour is about 36,000 search calls a month — roughly $18 on Default, $1.80 on Growth, or $0.90 at Scale, plus a detail call per card view. It is pay-as-you-go with no subscription, and the same deposit unlocks the discount across every endpoint. See the full pricing page for details.
A note on compliance
Serpent's jobs endpoints read the public LinkedIn jobs surface only — the listings a logged-out visitor can see. There is no login, cookie, or credential in the flow. That keeps the integration clean, but it does not make every use automatically permitted: whether your specific board is allowed depends on LinkedIn's terms, your jurisdiction, and how you store and display the data. Linking back to the original job_url and not misrepresenting the source are sensible defaults, but treat compliance as your own decision. You remain responsible for lawful use.
Build your job board on a clean jobs feed
Search, paginate, dedupe and store public LinkedIn postings through one REST endpoint — no login, no proxies to manage. Jobs data from $0.50/1K, dropping to $0.025/1K at Scale, pay-as-you-go with no subscription.
Get Your Free API KeyExplore: LinkedIn API · Social Media APIs · Pricing
FAQ
What data does the LinkedIn Jobs API return for a job board?
The jobs search endpoint returns a list of postings, each with job_id, job_title, company_name, company_linkedin_url, company_logo, location, job_posted_date and a job_url that links to the original posting. The job detail endpoint adds the full job_description (text and HTML) plus structured criteria for a single posting: seniority_level, employment_type, job_function and applicants_count. Structured salary fields are often null, because LinkedIn rarely exposes machine-readable salary on the public guest surface.
How do I paginate LinkedIn job results?
Pass a start parameter that increments by 10 — start=0 for the first page, start=10 for the second, and so on. Each page returns up to 10 postings. Pages can overlap or reorder slightly between calls, so always dedupe by job_id as you ingest rather than assuming pages are disjoint.
How much does it cost to run a job board on the API?
Jobs search and job detail are each $0.50 per 1,000 requests on Default, dropping to $0.05 per 1,000 on Growth (one $100 deposit) and $0.025 per 1,000 on Scale (one $500 deposit). A board that ingests 50 searches every hour is about 36,000 search calls a month — roughly $18 on Default or under $1 at Scale. It is pay-as-you-go with no subscription.
Do I need a LinkedIn login to build a job board?
No. You send search keywords, an optional location, and your Serpent API key in an X-API-Key header. The jobs endpoints read the public LinkedIn jobs surface only — there is no LinkedIn login, cookie, or OAuth on your side. You remain responsible for using the data lawfully and within LinkedIn's terms.
How do I keep a job board fresh?
Re-run your searches on a schedule (hourly or daily depending on volume), dedupe new results against what you already stored using job_id as the key, and expire postings you have not seen in a set number of days. Because job_id is stable, you can safely upsert: insert new postings and refresh the last-seen timestamp on ones you have seen before.



