Upwork Freelance Jobs and Budgets Scraper
Pricing
from $2.25 / 1,000 results
Upwork Freelance Jobs and Budgets Scraper
Scrape Upwork job posts by keyword, newest first, through the same search API the site itself calls. Each row has the title, the full post description, a fixed budget or hourly range as numbers, the currency, the experience level the client asked for, Upwork's skill tags, posted time and job URL.
This Actor collects freelance job posts from Upwork search. Hand it keywords and it returns the newest matching posts: the description a client wrote, whether the work is fixed-price or hourly, the money as numbers, the experience level asked for, Upwork's own skill tags and when the post went live.
What does the Upwork Jobs Scraper do?
Upwork's search page is a Nuxt bundle whose state blob is a minified function with its values hoisted into positional arguments, so nothing on the page reads back as JSON. The Actor skips the page and calls the GraphQL search the app itself calls, which answers with typed JSON: budgets as numbers, skills as labels, an explicit job type and a real publish timestamp.
That call needs Upwork's visitor token, which the search page issues as a Set-Cookie header and
never puts in the page body. Each run mints one token up front and reuses it for every keyword. No
Upwork account, no login, no Freelancer Plus subscription.
- Results are sorted by recency, so a scheduled run gives you the new posts rather than a shuffled page of old ones.
budgetand the hourly bounds are gated on the job type Upwork assigned, so a fixed-price post never shows an invented hourly rate.- Money arrives from Upwork as decimal strings and is parsed to numbers before it reaches the dataset.
skillsare Upwork's own ontology labels, which makes them safe to group on.- The query selection stays inside what an anonymous visitor may read. Asking for a scope the visitor token lacks aborts the entire response rather than nulling one field, so client history is deliberately left out.
What data does it extract?
| Field | Type | Description |
|---|---|---|
jobId | string | Upwork's job ID. Stable, so use it as the de-duplication key |
title | string | The post's title |
description | string | The description text as Upwork's search returns it |
jobType | string | "FIXED" or "HOURLY" |
budget | number | Fixed-price total. Populated only when jobType is "FIXED" |
hourlyMin | number | Bottom of the hourly range, only when jobType is "HOURLY" |
hourlyMax | number | Top of the hourly range, only when jobType is "HOURLY" |
currency | string | ISO code of the fixed-price budget, e.g. "USD" |
experienceLevel | string | The tier the client asked for, e.g. "IntermediateLevel", "ExpertLevel" |
skills | string[] | Upwork's skill labels, e.g. ["Data Scraping", "Python"] |
postedAt | string | ISO 8601 timestamp of publication |
jobUrl | string | Direct link to the post |
clientCountry | string | Always null. See the note below |
clientTotalSpent | number | Always null. See the note below |
query | string | The keyword that produced the row |
sourceUrl | string | The Upwork search URL for that keyword |
collectedAt | string | ISO 8601 timestamp of the run |
clientCountry and clientTotalSpent are part of the row shape but are never filled. Client
identity and spend sit behind a scope the anonymous visitor token does not hold, and reading them
would take one extra fetch per job. They stay as explicit nulls rather than being quietly dropped
from the schema.
Why scrape Upwork?
- Rate benchmarking. What clients actually offer for a skill, split by fixed-price and hourly,
with
experienceLevelto separate junior work from senior. - Demand tracking. Run the same keywords weekly and count posts per skill.
skillsandpostedAtare enough to plot a trend line for a stack. - Lead lists for an agency or freelancer. Newest posts first, filtered on budget floor, with the apply link attached.
- Sizing a service market. Sum budgets by skill over a month to see what a niche is worth before you enter it.
- Feeding a job-alert product. Join runs on
jobIdand alert on what is new since the last run.
How to use it
- Open the Actor on Apify Store and click Try for free.
- Put your keywords in Search terms, one per line, up to 50.
"react developer","shopify","technical writer". - Click Start. The run gets a visitor token first, then searches each keyword in parallel.
- Open the Dataset tab and export as JSON, CSV or Excel, or read it through the API.
- To track a market rather than sample it, save the input as a task and schedule it hourly or
daily. Recency sort means each run's new
jobIdvalues are the new posts.
There is no location or budget filter. queries is the only filter Upwork's visitor search takes,
so narrow with the keyword and filter the returned rows on budget, hourlyMin, experienceLevel
or skills.
Input
| Field | Type | Default | Description |
|---|---|---|---|
queries | string[] | required | Keywords to search for, 1 to 50. Duplicates collapse to one fetch |
maxItems | integer | 1000 | Cap on dataset rows, 1 to 50000. Free plans stop earlier, see below |
concurrency | integer | 3 | Keywords fetched in parallel, 1 to 5 |
{"queries": ["react developer", "shopify", "technical writer"],"maxItems": 500,"concurrency": 3}
Concurrency is capped at 5 for this Actor, lower than the 10 most of our others allow.
Output
One row per job post. Illustrative values, not a captured run.
{"jobId": "1826104573920145408","title": "React developer to rebuild a customer dashboard","description": "We have an existing dashboard in Vue that we want rewritten in React...","jobType": "HOURLY","budget": null,"hourlyMin": 25,"hourlyMax": 45,"currency": null,"experienceLevel": "IntermediateLevel","skills": ["React", "TypeScript", "REST API"],"postedAt": "2026-08-20T07:41:12.000Z","jobUrl": "https://www.upwork.com/jobs/~021826104573920145408","clientCountry": null,"clientTotalSpent": null,"query": "react developer","sourceUrl": "https://www.upwork.com/nx/search/jobs/?q=react+developer&sort=recency","collectedAt": "2026-08-20T09:31:02.118Z"}
A fixed-price post is the mirror image: budget and currency set, both hourly bounds null.
Reliability
The rows are Upwork's own JSON response, field for field. Nothing is parsed out of rendered markup and no model sits in the path, so a number is the number Upwork holds.
A keyword whose search cannot be read is recorded in the run's SUMMARY under failures, together
with Upwork's own explanation when it gave one. Upwork answers a rejected call with HTTP 200 and a
message rather than an error status, so that message is carried into the failure record instead of
being lost. A run in which every keyword failed exits with an error.
Limitations
One request per keyword, asking for Upwork's 50 newest matches, which is the most the endpoint returns for a visitor. There is no pagination input, so a run samples the top of the market rather than crawling it; the measured average is around 37 posts per keyword. Ten keywords come back with roughly 370 posts.
Client country, client total spend, hire rate, proposal and applicant counts, and freelancer profiles are all out of scope. So is filtering by location or budget at the source.
How much does it cost?
Pay per event: you are charged per result row written to the dataset, plus Apify's platform usage for the run. The current per-result price is on this Actor's Store page under Pricing.
Minting the visitor token at the start of a run writes no rows and costs you nothing, and neither does a keyword that fails.
Runs started from an Apify free plan stop at 250 requests and 250 results and report that in the
run's status message. Any paid plan runs the full input and whatever maxItems you set. The limit
exists because this Actor fetches through our own anti-bot infrastructure, which Apify does not
cover for free-plan runs. It binds on requests as well as rows so that a long input list cannot
spend those fetches on rows the run will never return.
Using it with the Apify API
import { ApifyClient } from 'apify-client';const client = new ApifyClient({ token: 'YOUR_APIFY_TOKEN' });const run = await client.actor('usestring/upwork-jobs').call({queries: ['react developer', 'shopify'],maxItems: 500});const { items } = await client.dataset(run.defaultDatasetId).listItems();const hourly = items.filter((job) => job.jobType === 'HOURLY' && job.hourlyMax);const topOfRange = hourly.map((job) => job.hourlyMax).sort((a, b) => a - b);console.log(`${hourly.length} hourly posts, median top rate`, topOfRange[Math.floor(topOfRange.length / 2)]);
The Python client and the REST API take the same input. The run's SUMMARY record, with the item
count and any failed keywords, is in the default key-value store.
Is it legal to scrape Upwork?
The Actor searches Upwork as an anonymous visitor, using the same public endpoint the site's own search page uses and the visitor token that page hands out to anyone who loads it. No account is created, no credentials are supplied and nothing behind a login is read.
Job posts are advertisements clients published so freelancers can find and bid on them. The output holds no freelancer profiles, no client identities and no contact details.
Public data collection is broadly lawful in the US and the EU; how you use the data is governed by your own jurisdiction and agreements. This is not legal advice.
Related actors
- Fiverr Gig Scraper
- Indeed Job Listings Scraper with Salaries
- Glassdoor Jobs Scraper - Pay and Ratings
- LinkedIn Public Profile Scraper (No Login)
- Wellfound Startup Jobs Scraper
Fiverr is the useful pairing: Upwork prices the demand side, Fiverr the supply side, for the same skill keyword.
FAQ
Do I need an Upwork account or a paid plan? No. The run searches as an anonymous visitor with the token Upwork's own search page issues.
How many posts does one keyword return? One call per keyword asking for Upwork's 50 newest matches; the measured average is about 37. There is no way to page deeper as a visitor, so use narrower keywords to widen coverage.
Are these really the newest posts?
Yes. Every search is sorted by recency, and postedAt carries the publish timestamp if you want to
filter further or measure how quickly a market moves.
Do I get the client's country, spend or hire rate?
No. Those live behind a scope the anonymous visitor token does not hold. The fields exist in the row
shape and are always null, which is honest about what the run could see.
Can I filter by budget or client location?
Not at the source. queries is the only input filter, so filter the returned rows on budget,
hourlyMin, experienceLevel or skills after the run.
What happens if a keyword fails?
It goes into failures in the run's SUMMARY with Upwork's own message where there is one, and the
other keywords still return their rows. If every keyword fails, the run errors out.
Feedback
Found a post that came back wrong, or need a field that is not in the table? Open an issue from this Actor's Store page with the keyword you searched and roughly when you ran it.