# LinkedIn Jobs API

> Query LinkedIn job postings through one API — and why LinkedIn is a search, not a board, and what that changes about the data you get.

Source: https://hyperjobs.io/ats/linkedin

LinkedIn is not an ATS. Every other source on this site is an employer's own job
board — the [Greenhouse](/ats/greenhouse) or [Ashby](/ats/ashby) instance a
company runs for itself. LinkedIn is a job board: a posting here was aggregated by
LinkedIn, and the company didn't publish it to LinkedIn's API the way it publishes
to its own careers page. That difference isn't pedantry — it changes how the data
is collected, how fresh it is, and whether you can trust the apply link.

It's the second-largest source in this dataset at 87,462 postings. Read the live
figure from [`/v1/meta`](/docs/api-reference/meta).

## Try it

```bash
curl -G https://api.hyperjobs.io/v1/jobs \
  -H "Authorization: Bearer $HYPERJOBS_KEY" \
  --data-urlencode "source=linkedin" \
  --data-urlencode "time_frame=24h" \
  --data-urlencode "limit=2"
```

```json title="Illustrative — the shape, not a live response"
{
  "data": [
    {
      "id": "linkedin:4127883941",
      "title": "Senior Product Manager",
      "company": {
        "slug": "orbital-payments",
        "name": "Orbital Payments",
        "domain": "orbitalpayments.com",
        "website": "https://orbitalpayments.com",
        "logo": "https://media.licdn.com/dms/image/…",
        "industry": "Financial Services",
        "employee_count": 900
      },
      "location": {
        "remote": true,
        "countries": ["United States"],
        "cities": ["San Francisco"],
        "regions": null,
        "raw": ["San Francisco, CA"]
      },
      "employment_type": ["FULL_TIME"],
      "work_arrangement": "Remote",
      "seniority": "Mid-Senior level",
      "salary": {
        "min": 150000,
        "max": 350000,
        "currency": "USD",
        "unit": "YEAR",
        "annual_min": 150000,
        "annual_max": 350000,
        "summary": "Base pay range: $150,000.00/yr - $350,000.00/yr"
      },
      "skills": ["product strategy", "payments"],
      "taxonomies": ["Product"],
      "apply_url": "https://www.linkedin.com/jobs/view/4127883941",
      "url": "https://www.linkedin.com/jobs/view/4127883941",
      "source": "linkedin",
      "posted_at": "2026-07-14T08:30:00.000Z",
      "expires_at": null,
      "updated_at": "2026-07-14T09:05:22.000Z"
    }
  ],
  "total": 2,
  "limit": 2,
  "offset": 0
}
```

## How LinkedIn actually works

There's no board to fetch. LinkedIn's public guest endpoints are a **search**:

```bash
# Search — returns HTML cards, job ids in data-entity-urn
curl "https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=&geoId=103644278&f_TPR=r86400&start=0"

# Detail — HTML, the full description is in .show-more-less-html__markup
curl "https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/<id>"
```

Both are server-rendered HTML, unauthenticated, and need no browser. The job id
lives in `data-entity-urn="urn:li:jobPosting:<id>"`. The search cards carry free
per-card metadata — title, company, location, posted date — so a metadata-only
pass costs nothing beyond the search itself.

### The two hard limits

Everything about collecting LinkedIn follows from these:

| Limit | Value |
| --- | --- |
| Results per search | **~1,000** — `start` caps around 975 |
| Requests per IP before a 429 | **~20–25 rapid ones** |

The first one is the interesting one. You cannot enumerate LinkedIn. Any query
returning more than about a thousand jobs is truncated, and nothing tells you it
was truncated — you just get a thousand.

So coverage means **slicing the query space** until each slice fits under the cap,
then unioning the slices with global id dedup. The facets are the knives:
`geoId`, `f_TPR` (time posted), `f_E` (experience), `f_JT` (job type), `f_WT`
(workplace type), `f_C` (company).

`f_TPR` is the one that makes it tractable. Run `f_TPR=r86400` — posted in the
last 24 hours — and each day's new jobs per keyword × geo is comfortably under
1,000. You get complete fresh coverage, cheaply, indefinitely. That's why
LinkedIn here is refreshed as an **hourly delta of the last hour's postings**
rather than by diffing a board: there is no board to diff.

### What the detail page gives you

The guest fragment carries a structured "job criteria" list — seniority level,
employment type, job function, industries — so you get employment type without
inference. LinkedIn's own compensation block is on roughly **15% of postings**,
mostly US pay-transparency states:

```html
<section class="compensation">
  <h3 class="compensation__heading">Base pay range</h3>
  <div class="salary compensation__salary">$150,000.00/yr - $350,000.00/yr</div>
```

That's ground truth — cleaner than mining the prose, and present on some postings
whose description omits pay entirely.

## What bites you

<Warning title="`apply_url` is not the employer's form on this source">
  Everywhere else here, `apply_url` is the employer's real application form,
  because the posting came from the employer's own board. A `linkedin` posting's
  `apply_url` points wherever LinkedIn points.

  Some LinkedIn postings are "Easy Apply" — you apply on LinkedIn without leaving.
  Others redirect offsite. When a posting matters enough to verify, check `source`
  before assuming the URL is the real ATS. [Sources](/docs/reference/sources) has
  the full attribution story.
</Warning>

**The detail page gives a relative date, not a timestamp.** `3 days ago`,
`2 weeks ago`. There's a `datetime` attribute on the search card, which is why the
card metadata is worth keeping — the detail alone forces you to reconstruct an
approximate date from prose, and "3 days ago" has a day of slack in it either way.

**The default logo is a placeholder.** Logos on `static.licdn.com` are LinkedIn's
generic grey building, not the company's mark. Store it and every logo-less
company gets a fake logo instead of falling back to its domain favicon. Real logos
are on `media.licdn.com`.

**`Not Applicable` is a real seniority value.** LinkedIn emits it as a placeholder.
It means nothing and shouldn't be stored as a level.

**A thousand results looks exactly like all the results.** Worth repeating,
because it's the failure that produces a confident, wrong dataset. If your slice
returns ~980+ ids, there are more jobs than the query can return, and the slice
needs splitting.

## Or use ours

LinkedIn is the source where "just scrape it" is furthest from the truth. There's
no endpoint that returns a company's jobs. There's a search with a hard cap, a
per-IP throttle that trips in about twenty requests, and a query space you have to
slice by geography, recency, seniority and job type to get underneath the cap —
then union with global dedup and re-slice anything that comes back at the ceiling.

You get that already run: an hourly delta, structured pay where LinkedIn published
it, employment type and seniority from the criteria block, placeholder logos and
placeholder seniorities dropped, and the [company join](/docs/objects/company).

And you get it **deduplicated against the employer's own board**. This is the one
that matters most on this source: a role on both a company's Greenhouse board and
LinkedIn is [collapsed to one row](/docs/guides/deduplication). Which means
`?source=linkedin&limit=1` doesn't count "jobs on LinkedIn" — it counts jobs where
LinkedIn *won* dedup. Any per-source breakdown you publish is a breakdown of
surviving rows, and it's worth saying so out loud.

[Quickstart](/docs/quickstart) · [Pricing](/pricing)
