# Upwork Freelance Jobs and Budgets Scraper (`usestring/upwork-jobs`) Actor

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.

- **URL**: https://apify.com/usestring/upwork-jobs.md
- **Developed by:** [String](https://apify.com/usestring) (community)
- **Categories:** Jobs, Business
- **Features:** Limited permissions
- **Stats:** 12 total users, 7 monthly users, 90.1% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.25 / 1,000 results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

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.
- `budget` and 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.
- `skills` are 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 `experienceLevel` to separate junior work from senior.
- **Demand tracking.** Run the same keywords weekly and count posts per skill. `skills` and
  `postedAt` are 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 `jobId` and alert on what is new since the last run.

### How to use it

1. Open the Actor on Apify Store and click **Try for free**.
2. Put your keywords in **Search terms**, one per line, up to 50. `"react developer"`, `"shopify"`,
   `"technical writer"`.
3. Click **Start**. The run gets a visitor token first, then searches each keyword in parallel.
4. Open the **Dataset** tab and export as JSON, CSV or Excel, or read it through the API.
5. 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 `jobId` values 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 |

```json
{
  "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.

```json
{
  "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

```javascript
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.

### Related actors

- [Fiverr Gig Scraper](https://apify.com/usestring/fiverr-gigs)
- [Indeed Job Listings Scraper with Salaries](https://apify.com/usestring/indeed-jobs)
- [Glassdoor Jobs Scraper - Pay and Ratings](https://apify.com/usestring/glassdoor-jobs)
- [LinkedIn Public Profile Scraper (No Login)](https://apify.com/usestring/linkedin-profiles)
- [Wellfound Startup Jobs Scraper](https://apify.com/usestring/wellfound-jobs)

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.

# Actor input Schema

## `queries` (type: `array`):

Keywords to search Upwork for.

## `maxItems` (type: `integer`):

Global cap on dataset items. Runs started from an Apify free plan stop at 250 requests and 250 results; any paid plan runs the full amount.

## `concurrency` (type: `integer`):

Targets fetched in parallel.

## Actor input object example

```json
{
  "queries": [
    "react developer"
  ],
  "maxItems": 1000,
  "concurrency": 3
}
```

# Actor output Schema

## `results` (type: `string`):

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.

## `summary` (type: `string`):

Item count, failure count and every target that failed, with its error.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "queries": [
        "react developer"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("usestring/upwork-jobs").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "queries": ["react developer"] }

# Run the Actor and wait for it to finish
run = client.actor("usestring/upwork-jobs").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "queries": [
    "react developer"
  ]
}' |
apify call usestring/upwork-jobs --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,usestring/upwork-jobs"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/ftFqxVMUxusOf5qxz/builds/8ChGTJXft3nkueMAG/openapi.json
