Upwork Jobs Scraper — Freelance Postings by Keyword avatar

Upwork Jobs Scraper — Freelance Postings by Keyword

Pricing

from $1.52 / 1,000 job posting returneds

Go to Apify Store
Upwork Jobs Scraper — Freelance Postings by Keyword

Upwork Jobs Scraper — Freelance Postings by Keyword

Upwork jobs scraper by keyword. Run a search on Upwork's freelance job board and get one row per posting: title, link, description, hourly/fixed budget, experience level, skills, posted date. No login required. Pay per posting returned; empty searches are free.

Pricing

from $1.52 / 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

7 hours ago

Last modified

Share

Upwork Jobs Lookup: Upwork Jobs Scraper by Keyword

You write job searches the way you'd type them into Upwork's search box, and this actor runs each one against Upwork's public job board and returns one clean row per posting: title, link, description, hourly or fixed-price budget, experience level, skill tags, and how recently it was posted. No login, no cookies, no Upwork account of any kind.

Upwork's search page sits behind a Cloudflare challenge for a plain request, but the page itself is server-rendered with real job data once that challenge is cleared, so there's no need for a headless browser here, only a proxy tier built for this kind of defense. See "Why this one" below.

Who it's for

If you build outbound lead lists, market intelligence tools, or agent workflows that need to know what buyers are hiring freelancers for right now, this actor turns a keyword search into a flat dataset instead of a page you'd otherwise scroll and copy by hand. Agencies scan it for competitor demand signals ("who's paying for Shopify migrations this week"), freelancers use it to build their own alert feed outside Upwork's own notification settings, and no-code builders wire it into an n8n or Make flow that posts new postings into Slack the moment they match a keyword. Pricing is pay-per-posting: a fraction of a cent for a row you actually got, and nothing for a search that finds nothing.

This actor is one of this catalogue's search-shaped job lookups, alongside stepstone-jobs-lookup (DACH region job boards) and linkedin-jobs-search-lookup (LinkedIn's guest search). All three answer "who is hiring for this role, right now" from a keyword rather than a company you already know, which is what separates them from the ATS lookups in the same family (greenhouse-jobs-lookup and friends) that answer "what is this specific company hiring for."

Why this one

  • Reaches data a plain request can't. A bare request to Upwork's search page returns an HTTP 403 Cloudflare challenge page, confirmed live. This actor routes through a proxy tier built for exactly this kind of defense; the page itself, once reached, is ordinary server-rendered HTML (Upwork's frontend is a Nuxt app) with the job data already in it, no headless browser needed.
  • Real fields, not guessed ones. Every column here was checked against a live captured page before being declared: hourly postings carry a rate range, fixed-price postings carry an "Est. budget" figure pulled from its own separate marker in the HTML (a different one than the hourly rate), and skill tags come from the same token list Upwork itself shows on the card.
  • Honest about what a guest can't see. Proposal counts, client country, and client spend history only render on the logged-out job detail page, one extra request per posting, and only for some clients (a brand-new account with no spend history shows none of it at all). Fetching that page for every row would multiply the proxy cost per posting several times over, more than this actor's own asking price, so those three fields are not sold here. See the FAQ.
  • You are never billed for the same posting twice. Every posting is deduplicated by its Upwork job ID across the pages of one search and, by default, across every search in the run.
  • Filters that don't lie about what they filter. jobType, minBudget, postedWithinDays, includeKeywords, and excludeKeywords are all applied to the postings after they arrive, because Upwork's guest search exposes no confirmed server-side parameter for job type or budget. category is the same kind of honest compromise: it's appended to your search as extra keywords rather than sold as a hard filter Upwork doesn't actually expose to a logged-out request.

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:

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 postings for that search), BAD_FORMAT (a blank line), or BLOCKED.
searchKeywordstextThe keywords actually sent to Upwork, including any category text you added.
jobCountnumberHow many postings this search returned after your filters. This is exactly what you're charged for.
totalAvailablenumberUpwork's own total-match count for the search, before this actor's page/depth limits.
truncatedbooleantrue if more postings were available than you asked for, or if paging stopped early.
jobsarrayThe full posting list. Present in every row; it's what gets expanded into separate rows in "one row per posting" mode.
jobIdtextUpwork's own numeric posting ID, stable, and what deduplication keys on.
titletextJob title.
urllinkPermanent link to the posting on upwork.com.
descriptiontextThe posting's own description, truncated to 500 characters (the search card itself is a truncated preview).
jobTypetexthourly or fixed.
budgetTexttextBudget exactly as Upwork prints it: an hourly rate range, or an "Est. budget" figure for fixed-price work.
hourlyRangetextThe hourly rate range on its own, only present on hourly postings.
experienceLeveltext"Entry Level", "Intermediate", or "Expert", as Upwork classifies the posting.
skillsarraySkill tags Upwork lists on the card.
postedAttextHow recently it was posted, in Upwork's own relative words ("Posted 3 weeks ago").
remotebooleantrue if the job title itself says "remote".
scrapedAtdate (ISO)When this actor fetched the row.

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

Price

  • Job posting returned: $2 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 $2 per 1,000 postings returned, not per search, plus a $0.00005 actor start fee per run. A search that returns 12 postings costs twelve, a search that returns none costs nothing, and a blank line costs nothing. The competing incumbent on the Apify Store charges $3.50 per 1,000 and advertises "bypass CAPTCHAs" as its headline feature; this actor does the same job for roughly 43% less.

Because you pay per posting, "Most postings to return per search" is your budget control. Leave it at 30 and a ten-search run costs at most 300 postings' worth.

How to use

  1. In the Apify Console. Open the actor page and click Start — the queries 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~upwork-jobs-lookup/run-sync-get-dataset-items?token=<YOUR_TOKEN>" \
    -X POST \
    -H "Content-Type: application/json" \
    -d '{"queries":["python developer"]}'
  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:

python developer
wordpress plugin
data entry

🔍 Search settings:

InputWhat it does
categoryOptional extra keywords appended to every search line (e.g. "Web Development"). Not a hard filter, see "Why this one" for why.
maxJobsPerQueryMost postings to return per search. Default 30, capped at 50 (5 pages) by default.

🎯 Narrow the results, applied here, to the postings after they arrive, since Upwork's guest search exposes no confirmed server-side parameter for most of these. They combine with AND across fields and OR within a field:

InputWhat it does
jobTypeKeep only hourly or only fixed-price postings.
minBudgetCompares against the low end of the hourly range, or the fixed-price estimate. A posting whose budget text can't be parsed is kept, not dropped.
postedWithinDaysKeep only postings within this many days of now, based on Upwork's own relative timestamp.
includeKeywordsKeep only postings whose title or description contains one of these.
excludeKeywordsDrop postings whose title or description contains one of these. Applied after the include list.
skipDuplicateJobsOn by default. Each posting is returned, and billed, once per run even if two searches overlap.

Input

{
"queries": [
"python developer"
]
}

One search per line, Upwork job search keywords, e.g. "python developer" or "wordpress plugin". Accepted formats: python developer, wordpress plugin, data entry.

Sample output

queryfoundstatussearchKeywordsjobCounttotalAvailabletruncatedjobsjobIdtitleurldescriptionjobTypebudgetTexthourlyRangeexperienceLevelskillspostedAtremoteisNewfirstSeenAtscrapedAt
python developertrueOKpython developer1072true<all postings found (full list)>2089438228379442832Python Developer for AI Prototype (LLM + State Comparison, Short Project)https://www.upwork.com/jobs/span-class-highlight-Python-span-span-class-highlight-Developer-span-for-Prototype-LLM-State-Comparison-Short-Project_~022089438228379442832/?referrer_url_path=/nx/search/jobs/I’m looking for a developer to help build a lightweight AI prototype using OpenAI or Anthropic APIs. This is NOT a full product build. This is a focused prototype to test a specific idea. Project Goal: Build a simple Python -based system that: Runs the same LLM task multiple times. Captures outputs and any intermediate state (memory/logs). Compares differences between runs. Classifies differences into simple categories: Stable Boundary Violation What This Means Think: •Run the same prompt 5–10 tfixedEst. budget: $100.00Intermediate["Python"]Posted 3 weeks agofalse<is this posting new?>2026-09-09T00:04:18.524Z

A real row, captured live 2026-09-08 against the search "python":

jobIdtitlejobTypebudgetTextexperienceLevelpostedAt
2090087423123075346Python DevhourlyHourly: $3.00 - $3.00Entry LevelPosted 3 weeks ago

A search that returns no postings comes back as a single found: false row, and is never charged.

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 Upwork returns is checked against the job IDs your previous run already delivered, and anything you have seen is dropped before you are billed. On a marketplace where the first hour matters, that is the difference between a feed and an alert. A run with nothing new returns no rows and costs only the run fee, and each run logs a count like 3 new of 50 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 Upwork has posted since.

To turn that into an alert:

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

Memory lives in a named key-value store, upwork-jobs-lookup-delta, one record per query, holding the last 5,000 postings per watchlist. Leave the checkbox off but fill in Watchlist name and every posting comes back stamped with isNew and firstSeenAt.

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~upwork-jobs-lookup/run-sync-get-dataset-items?token=<YOUR_TOKEN>" \
-X POST \
-H "Content-Type: application/json" \
-d '{"queries":["python developer"]}'

n8n. Add an HTTP Request node: Method POST, URL https://api.apify.com/v2/acts/accountable_eel~upwork-jobs-lookup/run-sync-get-dataset-items?token=<YOUR_TOKEN>, Body Content Type JSON, JSON Body {"queries":["python developer"]} (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~upwork-jobs-lookup/run-sync-get-dataset-items?token=<YOUR_TOKEN>, Body {"queries":["{{search}}"]}, mapping the row's search into the queries array.

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

Tips

  • Run a small "Most postings to return per search" value (10 to 20) on your first try with a new keyword to sanity-check the match quality before spending on a bigger pull.
  • Combine includeKeywords and excludeKeywords to cut noise from an ambiguous keyword. A search for "python" alone returns tutoring gigs, data entry work mislabeled with the word, and real development roles all mixed together; excluding "tutor" and "lesson" trims a lot of it.
  • minBudget reads the low end of an hourly range, so a $3 to $50 hourly posting passes a minBudget of 10 even though the top of its range is well above your floor. Treat it as "could pay at least this much", not "will pay at least this much".
  • A BLOCKED status means Upwork's challenge caught this run. Lowering "Max concurrency" and trying again shortly usually clears it, since UNBLOCKER sessions rotate.
  • Set up a scheduled Task with postedWithinDays set to match your run cadence (e.g. 1 for a daily run) so each run only bills for postings you haven't already seen in a prior run's window.

vs. alternatives

What it costsWhat you getTrade-off
This actor (upwork-jobs-lookup)$0.002 per posting returned, $0.00005 actor start, nothing for a search with no matchesOne row per posting: title, link, description, hourly/fixed budget, experience level, skills, posted date, filtered by job type, budget, and keywordsNo proposal count, client country, or client spend history, those only exist on a per-posting detail page and fetching one per row would cost more than this actor charges. Use Upwork's own site if you need that for a specific posting.
neatrat/upwork-job-scraper$3.50 per 1,000 postingsThe current Store leader for Upwork search, similar posting fields, advertises CAPTCHA bypass as its headline featureSame job, roughly 75% more expensive per posting than this actor.
Upwork's own searchFreeThe same data, one search at a time, in a browserIt's the same source. This actor exists for the batch: many searches in, one flat table out, deduplicated and filtered, callable from a script or a schedule instead of a browser tab.
Doing it yourselfYour time, plus a Cloudflare-grade proxy budget, HTML that changes without notice, and building your own dedup and filter logicThe same dataThe proxy wiring, the field mapping for both pricing shapes (hourly vs. fixed), and the retry/dedup handling are the maintenance burden this actor absorbs.

Prices for third-party tools are their published list prices as of September 2026 and are not tracked here, check the vendor before relying on the comparison.

FAQ

Why is a row empty, or why does found say false? Either the input line is blank (status: BAD_FORMAT), Upwork's challenge blocked the request (status: BLOCKED), or the search genuinely returned no postings (status: NOT_FOUND). Check the message column for the specific reason. None of these are billed.

Am I charged for a miss? No. Billing only fires for postings actually returned in jobs. A blocked search, a blank line, or a search with zero matches all produce a row and none of them cost anything.

Why don't you include proposal count, client country, or client spend? Those fields only render on a posting's own detail page, one extra request per posting through the same paid proxy this actor already uses, and even there they're inconsistent (a brand-new client account with no spend history shows none of it). Fetching a second page per row would multiply the proxy cost several times over per 1,000 postings, more than this actor's own asking price, so those fields are not sourced or faked here.

Is this a live search or a stored database? Live. Every run searches Upwork's current listings, there's no cached snapshot behind it. A posting that's filled or removed since the last run simply won't appear again.

Does this handle rate limits or blocking? Each request goes through Apify's UNBLOCKER proxy group. A run that gets BLOCKED mid-search still returns whatever postings it already collected before the block, and a search that starts blocked returns a BLOCKED row so you know to retry rather than assume there were no postings.

Do I need to configure proxies? No. UNBLOCKER is set as the default proxy group for this actor, since a plain request or the default datacenter proxy gets a Cloudflare challenge page instead of results.

Is this GDPR-relevant? The postings are public job listings. No personal data about the client or freelancers on either side is collected; client identity fields (country, spend, proposal count) are the exact fields this actor deliberately doesn't source, see the question above.

Can I schedule this to re-check the same keywords on a cadence? Yes, set up an Apify Task with a schedule (hourly, daily, weekly) and point it at this actor with a saved input. Pair it with postedWithinDays so each run only bills for postings freshly posted since your last check.

Can an AI agent call this directly? Yes. It's registered on the Apify MCP server, an agent in Claude, Cursor, or another MCP client can find and run it by name ("Upwork Jobs Scraper | Apify"), or you can call the REST endpoint shown above from any script or workflow tool.