Build a Job Board with the LinkedIn Jobs API (2026)

By Serpent API Team · · 13 min read

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:

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_idjob_titlecompany_namelocationjob_posted_date
4432294543Associate Data EngineerNew York Power AuthorityWhite Plains, NY2026-06-23
4425380159Data Engineer, Product Analytics (University Grad)MetaBellevue, WA2026-06-13
4388128001Data EngineerGopuffUnited States2026-06-25
4428763388Data EngineerColgate-PalmolivePiscataway, NJ2026-06-17
4428038891Data Platform EngineerChipotle Mexican GrillColumbus, OH2026-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.

A team gathered around a laptop in an office, reviewing a data feed together

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 person browsing job listings on a laptop, representing the front end of a job board

A job board's whole value is freshness. Three rules keep it healthy:

  1. 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.
  2. Upsert, do not append. The ON CONFLICT pattern above refreshes last_seen for postings still live and inserts new ones — no duplicates, ever.
  3. Expire stale postings. Hide or delete anything whose last_seen is 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

StatusMeaningWhat to do
200Success — {"success": true, "data": {…}}Read data.jobs.
400Missing keywords (or job_id for detail)Fix the request; don't retry.
404Job not found (expired posting)Expire it from your store.
429Rate limit for your bracketHonor Retry-After, then retry.
503Temporarily unavailableExponential 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

TierUnlockJobs search / detailPer call
DefaultNone$0.50 / 1,000$0.0005
GrowthOne $100 deposit (10× off)$0.05 / 1,000$0.00005
ScaleOne $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 Key

Explore: 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.