LinkedIn Jobs Search Scraper: No Login, Only New Postings avatar

LinkedIn Jobs Search Scraper: No Login, Only New Postings

Pricing

from $1.14 / 1,000 job posting returneds

Go to Apify Store
LinkedIn Jobs Search Scraper: No Login, Only New Postings

LinkedIn Jobs Search Scraper: No Login, Only New Postings

LinkedIn jobs scraper without login or cookies: search by keyword and location, posted in the last 24 hours or week, remote and Easy Apply filters, only new postings since the last run, and hiring signals per company for sales leads. One row per posting, full description on request. Pay per posting.

Pricing

from $1.14 / 1,000 job posting returneds

Rating

0.0

(0)

Developer

Adrian Voss

Adrian Voss

Maintained by Community

Actor stats

1

Bookmarked

2

Total users

1

Monthly active users

a day ago

Last modified

Share

LinkedIn Jobs Search Lookup: LinkedIn Jobs Scraper by Keyword & Location

You write job searches the way you'd type them into LinkedIn — data engineer @ Berlin, Germany — and this actor runs each one against LinkedIn's public, logged-out job board and returns one clean row per posting: title, company, location, posted date, and a permanent link. Turn on the description option and each row also carries the full posting text, the pay range when the posting states one, seniority, employment type, job function, industry and applicant count.

No login. No cookies. No LinkedIn account or session token of any kind — see the legal note at the bottom of this page.

Who it's for

The accountable_eel catalogue sells company and hiring intelligence columns for outbound and recruiting. Each actor takes a list of identifiers — domains, company slugs, and here, job searches — and returns one flat, stably-named row per result: the shape a Clay table, an n8n workflow, or an AI agent can consume without post-processing. Pricing is pay-per-event: a fraction of a cent for a row you actually got, and nothing at all for a search that finds nothing. No seat licence, no monthly minimum, no credit system to decode.

This actor is the wide-net end of that catalogue. The ATS lookups in the same family (greenhouse-jobs-lookup, lever-jobs-lookup, ashby-jobs-lookup and friends) answer "what is this company hiring for" — you bring the company list. This one answers "who is hiring for this role, here" when you don't have the list yet.

Why this one

  • Every filter it offers is a filter that works. LinkedIn's logged-out search endpoint accepts a great many f_* parameters and quietly ignores most of them. Measured on 2026-08-24 by running each one 4–6 times and comparing the returned job IDs against an unfiltered search: date-posted, Easy Apply, location, geo ID and company ID are real; job type, experience level, remote, salary band, industry and sort order return the identical result set to no filter at all. So keywords, location and posted-date go to LinkedIn, and role/company/location/remote narrowing happens here on the postings after they arrive — and the page tells you which is which. Paste a LinkedIn search URL that carried filters LinkedIn drops, and the row's ignoredSearchFilters column names them instead of pretending they applied.
  • You are never billed for the same posting twice. LinkedIn hands out ten results at a time, reshuffles between requests, and repeats postings across neighbouring pages — two requests at offsets ten apart shared half their results on one measurement. Every posting is deduplicated by its LinkedIn job ID across the pages of a search and, by default, across every search in the run. On a per-posting price, that is a billing guarantee, not a tidiness feature.
  • The description hop is opt-in and separately priced. Opening every posting's own page is one extra visit per posting and roughly twenty times the bandwidth. It's off by default, and when it's on you're charged for it only on postings whose page actually came back.
  • Partial results survive a rate limit. If LinkedIn throttles the run halfway through a 100-posting search, you get the postings collected so far, flagged with blockedWhilePaging, instead of losing the whole search to a retry.
  • Three input shapes. keywords @ location, bare keywords with a location set once for the whole run, or a LinkedIn jobs search URL pasted straight out of your browser's address bar.

What you get

One row per job posting by default. (Turn off "One row per job posting" in the Input tab to get one row per search instead, with the whole posting list nested in jobs.) Every row carries these fields, whether or not you turned on filters or descriptions — the columns never move:

FieldType / formatDescription
querytextThe search line you passed in, unchanged.
foundbooleantrue if the search returned at least one posting. false rows are never charged.
statustextOK, NOT_FOUND (no public postings for that search), BAD_FORMAT (the line wasn't a search or a LinkedIn URL), or BLOCKED.
searchKeywordstextThe keywords actually sent to LinkedIn.
searchLocationtextThe location actually sent to LinkedIn.
jobCountnumberHow many postings this search returned after your filters — this is exactly what you're charged for.
truncatedbooleantrue if more postings were available than you asked for, or if paging stopped early.
ignoredSearchFiltersarrayFilters present on a pasted LinkedIn URL that the public endpoint does not honour. Empty for a normal search line.
jobsarrayThe full posting list. Present in every row; it's what gets expanded into separate rows in "one row per posting" mode.
jobIdtextLinkedIn's own numeric posting ID — stable, and what deduplication keys on.
titletextJob title.
companytextHiring company as LinkedIn names it.
companyUrllinkThe company's LinkedIn page, with tracking parameters stripped.
companyLogoUrlimageCompany logo image URL.
locationtextLocation as LinkedIn prints it on the posting (e.g. "Fort Wayne, IN", "United States").
remotebooleantrue if the location or title reads as remote.
postedAtdate (ISO)The posting date, read from LinkedIn's own datetime attribute — a real calendar date, not a guess back-derived from "4 days ago".
postedAgotextThe same thing in LinkedIn's words ("4 days ago", "20 hours ago").
jobUrllinkPermanent public link to the posting, with this run's tracking parameters removed.
activelyHiringbooleantrue if LinkedIn shows the "Actively Hiring" badge.
earlyApplicantbooleantrue if LinkedIn says you'd be among the first applicants.
benefitsTexttextThe benefits teaser LinkedIn shows on some cards ("Medical insurance +6 benefits"). Most postings don't have one.
descriptionFetchedbooleantrue if the posting's own page was opened for the fields below.
descriptiontextThe full posting text, as plain text.
salaryTexttextThe pay range, when the posting states one in its description.
seniorityLeveltexte.g. "Associate", "Mid-Senior level".
employmentTypetexte.g. "Full-time", "Contract".
jobFunctiontexte.g. "Engineering and Information Technology".
industriestexte.g. "Software Development".
applicantsTexttexte.g. "Over 200 applicants", "Be among the first 25 applicants". Shown on roughly half of postings.
scrapedAtdate (ISO)When this actor fetched the row.

A search that returns no public postings comes back as a single found: false row with a status/message explaining why, and is never charged. So does a line that isn't a usable search.

Two honest limits, both measured rather than assumed:

  • There is no applyUrl. The Apply button on a logged-out posting opens a LinkedIn sign-up dialog, not an offsite application link — so no such field is offered rather than filled with the sign-up URL. jobUrl is the permanent public link.
  • salaryText comes out of the description. There is no structured compensation field on a logged-out posting, so pay is read from the posting text and is null unless you turn descriptions on and the posting actually states a range. A perk written like pay (a learning budget, a signing bonus) is deliberately not reported as salary.

Run it on a schedule

Turn on deltaMode: true (alongside your searches) and this actor returns only postings that are new since the last run — nothing you've already seen. Name your watchlist with deltaName if you're running the same searches on more than one schedule and want to keep their memory apart; leave it blank and one gets derived automatically. Schedule it in Apify (Schedules > Create) or trigger it from n8n/Make; the delta state lives in a named key-value store, so every schedule run bills only new rows. See "Monitoring: only new results" below for how the memory works.

Pricing

  • Job posting returned: $1.5 per 1,000 job postings
  • Full description added: $1 per 1,000 job postings

Plus a $0.00005 start fee per run. Each event above is billed independently, only when it actually returns data — misses (found:false) are never charged.

You're charged per posting returned, not per search — a search that returns 4 postings costs four, a search that returns none costs nothing, and a BAD_FORMAT line costs nothing. The description option adds a second, separate charge on top, and only on the postings whose page actually loaded.

Because you pay per posting, "Most postings to return per search" is your budget control: leave it at 100 and a five-search run costs at most 500 postings' worth. LinkedIn stops serving results at roughly 1,000 per search regardless, so that is the real ceiling per line.

How to use

  1. In the Apify Console. Open the actor page and click Start — the searches field is already pre-filled with a working example. Results land in the run's dataset as soon as each item is found.
  2. Via the API. Call it directly with a POST request — no Console needed once you have an API token:
    curl "https://api.apify.com/v2/acts/accountable_eel~linkedin-jobs-search-lookup/run-sync-get-dataset-items?token=<YOUR_TOKEN>" \
    -X POST \
    -H "Content-Type: application/json" \
    -d '{"searches":["marketing manager @ United States"]}'
  3. On a schedule. Save this actor as an Apify Task with the input you want, then add a Schedule (hourly, daily, weekly) so it runs on its own — no server of your own required.

Paste one search per line. All three of these work, and you can mix them in one run:

data engineer @ Berlin, Germany
marketing manager
https://www.linkedin.com/jobs/search?keywords=data%20engineer&location=Berlin%2C%20Germany

A line with no @ location uses the location you set once in 🔍 Search settings. A pasted LinkedIn URL brings its own keywords, location, geo ID, date-posted, Easy Apply and company filters with it; anything else it carries is dropped and named in ignoredSearchFilters.

🔍 Search settings — these are sent to LinkedIn, so they narrow the search before results are returned, which makes runs cheaper as well as more relevant:

InputWhat it does
defaultLocationLocation for any line that doesn't name one. Write it as LinkedIn does — "Berlin, Germany", "United States".
maxJobsPerQueryMost postings to return per search. Default 100, capped at what LinkedIn will serve (about 1,000).
postedWithinPast 24 hours / week / month.
easyApplyOnlyOnly roles you can apply to without leaving LinkedIn.
fetchDescriptionOpen each posting for its full text, pay range, seniority, employment type, function, industry and applicant count.

🎯 Narrow the results — these are applied here, to the postings after they arrive, because LinkedIn's logged-out search ignores the equivalent parameters. They combine with AND across fields and OR within a field:

InputWhat it does
titleKeywordsKeep only titles containing one of these — ["engineer","designer"].
excludeTitleKeywordsDrop titles containing one of these — ["intern","senior"]. Applied after the include list.
companiesKeep only these companies — partial names match.
excludeCompaniesDrop these companies — useful for filtering out staffing agencies you already know.
locationsNarrow a wide search to particular cities or regions.
remoteOnlyKeep only roles whose location or title reads as remote.
skipDuplicateJobsOn by default. Each posting is returned, and billed, once per run even if two searches overlap.

If LinkedIn throttles a run, lower Max concurrency (in ⚙️ Advanced) to 1 and keep the Residential proxy setting on. Rate limits are retried with backoff automatically, and a run that gets limited mid-search returns what it collected rather than failing.

A bulk search example — 26 role/location pairs across common roles and hiring hubs, the kind of list you'd run on a weekly schedule:

{
"searches": [
"software engineer @ Berlin, Germany",
"software engineer @ London, United Kingdom",
"software engineer @ New York, United States",
"product manager @ San Francisco, United States",
"product manager @ Amsterdam, Netherlands",
"data engineer @ Paris, France",
"data scientist @ Toronto, Canada",
"marketing manager @ Chicago, United States",
"marketing manager @ Dublin, Ireland",
"sales development representative @ Austin, United States",
"account executive @ Boston, United States",
"customer success manager @ Madrid, Spain",
"devops engineer @ Warsaw, Poland",
"backend engineer @ Stockholm, Sweden",
"frontend engineer @ Barcelona, Spain",
"full stack developer @ Lisbon, Portugal",
"site reliability engineer @ Zurich, Switzerland",
"recruiter @ Denver, United States",
"operations manager @ Seattle, United States",
"financial analyst @ Singapore",
"business development manager @ Sydney, Australia",
"UX designer @ Copenhagen, Denmark",
"content marketing manager @ Vancouver, Canada",
"solutions engineer @ Munich, Germany",
"HR business partner @ Milan, Italy",
"enterprise account executive @ Miami, United States"
],
"maxJobsPerQuery": 50
}

At $1.50 per 1,000 postings returned (this actor's headline rate — see "Pricing" above), this list's worst case is 26 searches × 50 postings = 1,300 postings, under $2 for the whole run — and a quiet week across all 26 searches costs less, because a search that finds nothing is never billed.

Input

{
"searches": [
"marketing manager @ United States"
]
}

One search per line. Write it as "keywords @ location", or just the keywords and set a location below, or paste a LinkedIn jobs search URL straight from your browser. Accepted formats: data engineer @ Berlin, Germany, marketing manager, https://www.linkedin.com/jobs/search?keywords=data%20engineer&location=Berlin%2C%20Germany.

Output

queryfoundstatussearchKeywordssearchLocationjobCounttruncatedignoredSearchFiltershiringSignalsjobsjobIdtitlecompanycompanyUrlcompanyLogoUrllocationremotepostedAtpostedAgojobUrlactivelyHiringearlyApplicantbenefitsTextdescriptionFetcheddescriptionsalaryTextseniorityLevelemploymentTypejobFunctionindustriesapplicantsTextisNewfirstSeenAtscrapedAt
marketing manager @ United StatestrueOKmarketing managerUnited States100true<all postings found (full list)>4460494450Marketing - Brand Marketing ManagerGOODAI GLOBAL USA INC.https://www.linkedin.com/company/goodaiglobalushttps://media.licdn.com/dms/image/v2/D560BAQF32eHj1r0-PQ/company-logo_100_100/B56Z77zmlZGUAI-/0/1782341069374/hansung_usa_logoIrvine, CAfalse2026-09-032 weeks agohttps://www.linkedin.com/jobs/view/marketing-brand-marketing-manager-at-goodai-global-usa-inc-4460494450falsefalsefalse2026-09-20T06:10:12.757Z

Use it from Clay, n8n, Make, or an AI agent

This actor runs synchronously over plain HTTP — call it directly from a script, a workflow tool, or an AI agent, no Apify Console needed once you have an API token.

curl "https://api.apify.com/v2/acts/accountable_eel~linkedin-jobs-search-lookup/run-sync-get-dataset-items?token=<YOUR_TOKEN>" \
-X POST \
-H "Content-Type: application/json" \
-d '{"searches":["marketing manager @ United States"]}'

n8n. Add an HTTP Request node: Method POST, URL https://api.apify.com/v2/acts/accountable_eel~linkedin-jobs-search-lookup/run-sync-get-dataset-items?token=<YOUR_TOKEN>, Body Content Type JSON, JSON Body {"searches":["marketing manager @ United States"]} (swap in an expression from an earlier node for a real value).

Clay. Add an "HTTP API" column: Method POST, URL https://api.apify.com/v2/acts/accountable_eel~linkedin-jobs-search-lookup/run-sync-get-dataset-items?token=<YOUR_TOKEN>, Body {"searches":["{{search}}"]}, mapping the row's search into the searches array.

MCP. In Claude, Cursor, or any MCP client with the Apify MCP server, ask for "LinkedIn Jobs Scraper Without Login | Apify" — the agent will find and run this actor.

Monitoring: only new results

Turn on Only return results that are new since the last run and this actor becomes a job monitor. Every posting LinkedIn returns is checked against the job IDs your previous run already delivered, and anything you have seen is dropped before you are billed. A run where nothing was posted returns no rows and costs only the run fee. Each run logs a count like 3 new of 100 fetched.

The first run has nothing to compare against, so it returns everything it finds and remembers it. From the second run on you get only what LinkedIn added since.

To turn that into an alert:

  1. Save your search as a task with the checkbox on.
  2. Add a Schedule to that task, hourly or daily.
  3. Add a webhook on Run succeeded, pointing at Slack, n8n, Make or Zapier.

The memory lives in a named key-value store, linkedin-jobs-search-lookup-delta, one record per search line, holding the last 5,000 postings per watchlist. The same memory is what powers Add hiring signals, below. Two schedules with different filters keep separate memories. Leave the checkbox off but fill in Watchlist name and every posting comes back stamped with isNew and firstSeenAt, so you can filter them yourself in Sheets or n8n.

Hiring signals for sales and investors

A job search tells you who is hiring right now. What a sales team or an investor actually wants is the derivative: which companies moved since last week, and in which direction. Turn on Add hiring signals alongside the monitoring checkbox and the search row gains a hiringSignals list, one entry per company the search found at least two postings for.

{
"query": "enterprise account executive @ United States",
"jobCount": 6,
"hiringSignals": [
{
"company": "Acme Robotics",
"openRoles": 14,
"signal": "gtm-push",
"signalReason": "4 of the 5 new roles (80%) are sales or marketing, which usually means a go-to-market push rather than general growth.",
"newRolesSinceLastRun": 5,
"closedRolesSinceLastRun": 1,
"growthPct": 40,
"roleClusters": { "engineering": 2, "sales": 9, "marketing": 2, "ops": 1, "finance": 0, "other": 0 },
"newRoleClusters": { "engineering": 1, "sales": 3, "marketing": 1, "ops": 0, "finance": 0, "other": 0 },
"topDepartments": [],
"seniorityMix": { "junior": 0, "mid": 9, "senior": 3, "lead": 2 },
"remoteShare": 0.29,
"comparedToRunAt": "2026-09-03T06:00:00.000Z"
}
]
}
FieldTypeWhat it tells you
companystringThe employer name as LinkedIn shows it.
openRolesintegerPostings this search returned for them. Read the note below before treating it as headcount.
signalstringOne of baseline, expanding, gtm-push, contracting, steady. The one field to sort a watchlist on.
signalReasonstringOne sentence naming the numbers behind the verdict.
newRolesSinceLastRunintegerPostings that were not there at your previous run.
closedRolesSinceLastRunintegerPostings that were there and are gone. Filled or withdrawn, LinkedIn does not say which.
growthPctnumber | nullPercent change in openRoles since the previous run. null on the baseline run.
roleClustersobjectEvery posting bucketed into engineering, sales, marketing, ops, finance, other.
newRoleClustersobjectThe same six buckets over only the newly opened postings. This is what gtm-push is read from.
topDepartmentsarray{ department, count } from jobFunction, so empty unless Fetch full description is on. A guest search card carries no department.
seniorityMixobjectPostings by junior, mid, senior, lead, read from the title.
remoteSharenumber | nullShare of postings flagged remote, 0 to 1.
comparedToRunAtISO datetime | nullThe run this entry was measured against.

How the verdict is decided, in this order:

  1. baseline if this is the first run of the watchlist. There is nothing to compare against yet, and calling every company "expanding" on day one would put a false spike at the front of your time series.
  2. expanding if growth is at least 20% or at least 5 postings opened, and the company is no smaller than it was.
  3. gtm-push if sales plus marketing account for 40% or more of the newly opened postings.
  4. contracting if more postings closed than opened.
  5. steady otherwise.

The schedule recipe

  1. Write the search you want to track, for example account executive @ United States.
  2. Put your 5 to 15 competitors or target accounts in Only these companies, so the run measures them and not the whole market.
  3. Turn on Only return results that are new since the last run, and Add hiring signals.
  4. Turn One row per job posting off. Signals sit on the search row, so grouped mode gives you one clean row per run instead of the same list repeated on every posting.
  5. Set Max job postings per search high enough that truncated comes back false, and then leave it alone. A moving cap makes every company look like it grew. See the sampling caveat below.
  6. Save it as a task, add a Schedule, and pick weekly.
  7. Add a webhook on Run succeeded to push the dataset into Slack, Sheets, n8n or your CRM. The Hiring signals dataset view is already trimmed for this.

Your first scheduled run is the baseline. Read from run two on.

{
"searches": ["account executive @ United States"],
"companies": ["Ramp", "Brex", "Mercury", "Navan", "Airbase"],
"maxJobsPerQuery": 100,
"deltaMode": true,
"signals": true,
"deltaName": "competitors-weekly",
"expandRows": false
}

What openRoles here does and does not mean

openRoles is postings this search returned for that company, not the size of their careers page. A search for account executive @ United States capped at 100 measures their US sales hiring, which is exactly the number you want for a GTM watchlist, and is not the number a careers-page scraper would give you. Two consequences:

  • Keep maxJobsPerQuery steady across runs. Raising it makes every company look like it grew.
  • Changing the search or its filters changes what is being measured. The watchlist name is derived from your filters for that reason, so a filter edit re-baselines cleanly instead of silently comparing two different questions. If you pinned your own Watchlist name, edit the search and the name together.

For whole-board numbers on a named company, use ATS Jobs Unified Lookup in hiring-signal mode instead, which reads each company's own ATS.

The sampling caveat, which you should read before you act on one run

LinkedIn's logged-out job search does not serve a stable result set. The same offset requested twice comes back with an overlapping but different list, as the endpoint alternates between two differently ranked populations. That is documented at the top of src/target.js and it is why this actor deduplicates by job ID in the first place.

It has a direct consequence for signals: a single run's newRolesSinceLastRun, closedRolesSinceLastRun and growthPct include sampling noise, not only real hiring movement. Two verification runs two minutes apart on 2026-09-10, with nothing on earth having changed in between, reported Salesforce at +23.1%, expanding and Cisco at -60%, contracting. Both were the endpoint reshuffling, not hiring.

What to do about it:

  • Read the trend, not the run. The watchlist memory is cumulative, so the set of postings it knows about saturates over the first few runs and the noise falls away. A company that reads expanding four weeks running is expanding. One that reads expanding once is a coin flip.
  • Set maxJobsPerQuery high enough that your search is exhausted, and keep it fixed. truncated: false on the row means the actor reached the end of what LinkedIn would serve. That reduces the noise but does not remove it, because the population itself moves.
  • Treat roleClusters, seniorityMix and remoteShare as the sturdy fields. They are proportions of a sample, so they are far more stable than the counts, and they answer "what kind of hiring is this" without depending on run-over-run identity at all.
  • Ignore companies with only a handful of postings. Entries below two postings are already left out; below about five, a swing of one or two postings dominates the percentage.
  • If you need per-company numbers you can act on individually, read the company's own job board instead. ATS Jobs Unified Lookup in hiring-signal mode returns the whole board from the company's own ATS API, where a posting that is gone is genuinely gone.

vs. TheirStack and PredictLeads

TheirStack and PredictLeads both sell hiring signals as a data product. TheirStack lists at roughly $0.005 to $0.03 per company; PredictLeads is sold as an annual contract, around $24,000 a year at the tiers most teams land on. Both are genuinely broader: they cover many job boards and ATS platforms at once, they keep years of back history, and they resolve a company from its domain rather than from how its name is spelled on a job card. If you need whole-market coverage with history, buy one of them.

What this gives you instead is the same weekly question answered off LinkedIn's own public job search, at $1.50 per 1,000 postings and nothing else. A weekly watchlist that returns 100 postings a run is about $8 a year, with no contract and no minimum. The trade-offs are worth naming: history starts the day you start running it, closedRolesSinceLastRun cannot tell a filled role from a withdrawn one, companies are matched on their display name rather than on a resolved entity, and the numbers describe one search rather than a whole company.

This actor reads public data only. It requests the same two logged-out URLs your browser requests when you open a LinkedIn job search or a job posting while signed out — the ones LinkedIn serves to search engines and to visitors without an account.

It does not log in, does not accept or store a LinkedIn account, password, session cookie or li_at token, and has no input field in which you could give it one. It reads no private profiles, no connection graphs and no personal data beyond what a hiring company chose to publish in its own job advertisement: the role, the company, the location and the posting text. It does not collect names, email addresses or contact details of individuals.

You are responsible for how you use the results. Public-data collection of this kind has been held lawful in the United States (hiQ Labs v. LinkedIn), but that is not the whole picture: LinkedIn's User Agreement prohibits automated collection regardless, and if you are in or handling data from the EU/UK, the GDPR applies to job-posting data that identifies a person (a named hiring manager in the posting text, for instance) even though it is public. Check your own obligations before putting this on a schedule, and don't use it to build profiles of individuals.

  • ATS Jobs Unified Lookup — cross-check hiring signals for a named company against its own ATS, across Greenhouse, Lever, Ashby and more.
  • LinkedIn Profile Lookup — look up who posted a role, or the hiring manager named in a posting, from their LinkedIn profile URL.
  • Email Pattern Finder + Verifier — once you have a company from a posting, work out its email address pattern to reach the hiring team.

If this saved you a scrape, a rating on the Store page helps other buyers find it.