LinkedIn jobs API and scraper - no cookies, no login, no account to get restricted. Search job postings by keyword, company or location for full descriptions, salary, skills, applicant counts, the ATS behind each apply link and a ghost-job score. Built for hiring-signal outbound.
Reliability. No new fields, no input changes, nothing renamed - but runs that
used to stop early now finish, and rows that carried no data are no longer
written or billed.
Runs were stopping long before the end of the results
The upstream API answers an overloaded moment with HTTP 200 and an empty body
rather than a 429. Two paths believed it.
Empty search pages ended pagination. A page with no jobs on it was read as
"no more jobs exist", so a search matching 3,204 postings could return 25 and
report success. An empty page is now re-requested on the same backoff ladder a
failed fetch gets, up to seven times, before the search accepts it as the end.
Empty job responses became rows. A job fetch that came back with nothing in
it was still written to the dataset as a record with every field null. In one
418-row run, 194 rows - 46% - were empty shells, and every one of them was
billed. A job is now written only if it has an ID, a title or a description.
Measured on the same eleven saved tasks, run twice with identical inputs:
643 rows before, 2,934 after, and blank rows from 46% to none.
Retries raised from six attempts to eight, with the backoff capped at 20
seconds so a long ladder cannot stall a run. Retry-After is still honoured
where the API sends it.
Documentation that did not match the Actor
Several statements were measured against 3,434 rows from thirteen runs and
turned out to be wrong. They are corrected rather than removed.
Pricing was stated three different ways, none of them right. The FAQ, the
disclaimer and the Store description described a pay-per-event model at
$0.010 per job. The Actor bills per result. The real figure is $0.0045, and
Apify platform usage is included.
Salary fill rate was published as ~15%. Measured: 59% overall, 62% across
2,540 US rows, 21% on a Germany/Netherlands/Ireland run. The figure understated
the most valuable field in the output by four times.
Applicant-tracking-system coverage was published as ~56%. Measured: 27%
for applicantTrackingSystem. atsProvider reaches 46% because it also reads
the apply URL, and that distinction is now stated.
Workplace type was published as ~83%. Measured: 69%.
The comparison table claimed this Actor returns hiring-manager identity.
It does not, and never has. That data is excluded deliberately: job posters
are identifiable people, and returning their names would make every user of
this Actor a data controller for a dataset they did not ask to hold.
"You will also need your own Harvest API key" was wrong. There is no
apiKey input. The key is the publisher's and comes from the environment.
Documentation that was missing
Five things the Actor already did and never said:
Paste a LinkedIn search URL. Set your filters on LinkedIn, copy the URL,
paste it in. Keywords, location, date, workplace type, employment type,
seniority and company IDs are read out of the query string. This is also how
to search a city rather than a country - the URL carries LinkedIn's geoId
and it is passed through unchanged. The feature shipped some time ago and was
documented in a form tooltip and one clause.
No cookies, no login as a section rather than a phrase. There is no
li_at field, no session, and no account of yours in the chain.
What the derived fields mean - ghostJob, atsProvider,
salary.providedByEmployer and extractedSkills are worked out from the
posting rather than copied from LinkedIn.
A worked example pairing one real input with one real row.
Run figures - 466 of 467 runs succeeded in August, none failed, none
timed out.
The FAQ went from 12 entries to 21.
1.1
Three new output fields, and a round of correctness fixes behind them.
Nothing was removed, renamed, or re-typed. Every field 1.0 returned is still
returned, under the same key, with the same type. Existing tasks, schedules,
integrations and saved runs keep working untouched; they simply see extra keys
they can ignore. The one input change is additive too: includeCompanyData
defaults to true, which is what 1.0 always did.
New fields
atsProvider / atsSource - the applicant tracking system behind the
posting. LinkedIn states it on some jobs; where it does not, the vendor is read
off the apply URL's host, which is present on every job. atsSource says which
of the two it came from, so a detected value is never mistaken for a stated one.
Coverage went from 57% to 67% of jobs. It reads the host wherever it can; where
an employer hosts the ATS on their own domain it falls back to a short list of
tracking parameters the vendor itself appends, which is how a job on
careers.cisco.com is still identified as Phenom. The path is never read. Where
neither applies it returns null rather than guessing, because a posting on an
employer's own careers domain with no vendor marker has no ATS to report, and
saying so is more useful than inventing one. applicantTrackingSystem is
untouched.
extractedSkills - the skills a job asks for, read out of the description
text. LinkedIn's own skill tags are only shown to signed-in users, so the
existing skills field is empty on every job this Actor returns and always has
been. These are inferred from the text, which is why they live in a separate
field rather than filling that one. Dictionary-based, around 200 canonical
skills, because a wrong skill is worse than a missing one. 89% of jobs get at
least one, averaging 7.7. Guards against words that are both products and
ordinary English: "able to react to change" is not React, "payment rails" is not
Rails, "your normal workday" is not Workday.
ghostJob - a score, a risk band, and the list of reasons behind them, for
how likely a posting is to be advertised without really being filled. Built from
one payload with no memory between runs, so it flags postings worth a second
look rather than passing a verdict; the strongest signal of all - the same role
reposted every few weeks - needs cross-run state and is not here. Every
contributing signal is returned alongside the number, so the score can be
argued with instead of trusted blindly.
Alongside the age, applicant-volume and company signals, it reads what the
posting says about itself: that it is advertised on behalf of another company,
that the role leads to placement with an unnamed partner, that it requires a
training bond, or that it is a talent pool rather than an opening. This matters
because the industry field is not enough on its own - a train-and-deploy firm is
often filed under "IT Services and IT Consulting", not staffing. These patterns
are deliberately narrow, because a signal attaches a score to a named company on
the strength of its own words: a posting warning candidates about "agencies
working on behalf of Anthropic" is a real job saying the opposite, and is not
flagged.
Fixes
Skills were being lost at every bullet boundary. LinkedIn's plain-text
description is the HTML with its tags deleted and nothing put in their place,
so two list items run together into one word and "Embedded Operating
SystemsLinux Driver Architecture" hides Linux inside a word. Matching now runs
against the HTML with block boundaries restored, which is where most
requirements live. description.text is unchanged; the restored text is used
for matching only and is never emitted.
Amounts written without thousands separators were being truncated. The
pattern that reads a figure could match 210 out of 210000 and stop, so a
range like $147000 - $210000 came back empty, and in the worst case
$8000 - $9000 was returned inverted as 900 to 8000. Numbers with commas were
never affected, which is why it took a posting written without them to show
it. The figure pattern is now defined once, with a guard that refuses a
partial match.
An empty salary object is now absent rather than present. Where the API
returned a salary with nothing in it but providedByEmployer: false, the
record carried a salary object whose every figure was null. salary is now
null in that case, as it already was elsewhere, and the description is read
instead - which the stray object had been silently preventing.
Salaries the parser could not see. A range with the currency after both
numbers (224,000 USD - 356,500 USD) matched nothing, and a rate with no
currency at all (Pay around 50/hr) was skipped entirely. Both are now read.
The second returns currency: null rather than assuming USD, because the
posting did not say. A bare figure needs a pay word beside it to count, so
40 hours/week is not mistaken for a wage.
Salary is now chosen, not taken. The parser used to return the first
figure in the description that parsed. On a Ramp internship that meant
$10,000 per year, lifted from "Fertility HRA (up to $10,000 per year)". It
now collects every candidate with its position, scores each by the words
around it, and returns nothing when the best one still looks more like a
benefit than like pay.
European salary formats.€35.000 parsed as 35, then tripped the
"under 200 means hourly" heuristic and came back as an hourly rate. Thousands
and decimal separators are now decided by position, so 35.000 is 35000,
88.59 is 88.59, and 1.234,56 is 1234.56. Where our re-parse lands 100x or
more above the figure the API supplied, ours wins.
applicantCountBucket on jobs with no number. A posting showing only
"Be among the first 25 applicants" returned a null bucket as well as a null
count, even though the payload said which bucket it was. Count and bucket are
now resolved together, reading the message before giving up.
Duplicate URLs in details mode. De-duplication compared URL text, so the
same job submitted in two URL forms was fetched twice and billed twice. It now
compares job IDs.
Network errors were never retried. Connection resets and timeouts carry no
HTTP status, and the retry check read a missing status as "permanent". They
are now retried like 429s and 5xxs.
Error rows could fail an entire run. The dataset schema required fields
that a friendly error row does not carry, so writing one was rejected. That
path had never worked since the schema was added; it does now.
Charge warnings. The Actor is priced per result, but the code still called
charge() for events that no longer exist, producing hundreds of warnings a
run. Removed.
Seniority values now use LinkedIn's own labels ("Mid-Senior level",
"Entry level") instead of a private vocabulary.
Results stream as they are found
Rows now appear in the dataset as each job finishes, instead of all at once at
the end. A long run is readable while it is still going, and a run that is
stopped early keeps everything it had already collected.
Costs
Company data is fetched without the paid withCompany parameter, which returns
the same data. If a response ever comes back without a company, the Actor
detects it and pays for the parameter from then on. No change to what you are
charged; this is upstream cost, and it is why the price did not move.
1.0
Initial release. Two modes - search LinkedIn jobs with filters, or enrich a list
of job URLs - returning the full job page for every result: description, salary,
applicant count, parsed location, and the hiring company's profile.