# Upwork Jobs Scraper | 30+ Filters & Duplicate-Free Runs (`tqm/upwork-jobs-scraper`) Actor

Scrape Upwork jobs with title, FULL description, budget, skills and project length. 30+ filters: budget floors across hourly AND fixed, keyword include/exclude, client country, experience level, freshness window. Monitoring mode skips jobs earlier runs returned, so you never pay twice.

- **URL**: https://apify.com/tqm/upwork-jobs-scraper.md
- **Developed by:** [TQM](https://apify.com/tqm) (community)
- **Categories:** Jobs, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 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?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

Upwork Jobs Scraper extracts freelance job postings from [Upwork](https://www.upwork.com) — title, full description, budget, skills, project length and proposal count — with **30+ filters** that let you cut a broad search down to only the postings worth your time.

It searches through Upwork's own JSON endpoint rather than rendering pages in a browser: measured, **50 postings in 30 seconds**. It also has a **monitoring mode** that remembers what previous runs returned, so a scheduled run only ever charges you for postings you have not already seen.

### ⚠️ Read this first: what this Actor does and does not return

This Actor returns everything Upwork publishes on its **public job search**: title, the full
description, skills, budget, experience level, duration, proposal count and posting time.

It does **not** return the client's lifetime spend, hire history or payment-verified status.
Upwork puts none of that on logged-out search results — it exists only on each posting's own page,
behind a Cloudflare challenge, at roughly one browser page-load per row. That is far more expensive
than a result is worth, so this Actor does not offer it at all rather than offering it as a switch
that quietly multiplies your bill.

**The one client filter that does work is `clientCountries`**, because Upwork applies it itself,
server-side, before results are returned. It narrows the run and makes it cheaper.

If client spend and hire history are what you are after, this is not the right Actor for you, and
this section is here so you find that out before you pay for a run rather than after.

### What data can I extract from Upwork?

| Field | Description |
|---|---|
| `title` | Job title |
| `url` | Link to the original Upwork posting |
| `description` | Job description |
| `skills` | Skills the client tagged the job with |
| `jobType` | `hourly` or `fixed` |
| `budgetType` | How the budget is expressed |
| `budgetMinAmount` / `budgetMaxAmount` | Budget or hourly-rate bounds |
| `budgetCurrency` | Budget currency |
| `budgetMonthlyUsd` | **Hourly and fixed budgets projected onto one monthly axis**, so they can be compared and sorted together |
| `experienceLevel` | Contractor tier the client asked for |
| `duration` / `durationWeeks` | Expected engagement length |
| `workload` | Stated hours per week |
| `postedAt` / `createdAt` / `postedAtRelative` | When the job was posted |
| `proposalsMin` | How many proposals the job already has — your competition. **Browser path only** (`jsonSearch: false`); Upwork does not expose it through the fast JSON search |
| `clientTier` | Client's Upwork tier |
| `contactEmails` | Any contact emails present in the posting text |
| `searchQuery` | Which of your queries produced this row |
| `jobId` / `scrapedAt` | Stable id and collection timestamp |

### How do I use Upwork Jobs Scraper?

1. Click **Try for free**.
2. Set `searchQueries` — e.g. `["devops", "aws infrastructure"]`. Or paste URLs you built in Upwork's own UI into `startUrls`.
3. Leave `proxyConfiguration` on residential (the default). Upwork sits behind Cloudflare and blocks datacenter traffic hard.
4. Click **Save & Start**.
5. Export as JSON, CSV, Excel or XML from the **Storage** tab.

#### Input sample

```json
{
  "searchQueries": ["devops", "kubernetes", "terraform"],
  "maxItems": 100,
  "maxPagesPerQuery": 3,
  "sortBy": "recency",
  "jobType": ["hourly"],
  "hourlyRateMin": 40,
  "includeKeywords": ["aws", "gcp", "azure"],
  "excludeKeywords": ["wordpress", "shopify"],
  "postedWithinHours": 48,
  "deduplicateAgainstPreviousRuns": true,
  "proxyConfiguration": { "useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"] }
}
```

### Output sample

```json
{
  "jobId": "~021834729471023847",
  "title": "DevOps Engineer to harden our AWS environment",
  "url": "https://www.upwork.com/jobs/~021834729471023847",
  "description": "We need an experienced DevOps engineer to review our Terraform...",
  "skills": ["Amazon Web Services", "Terraform", "Kubernetes"],
  "jobType": "hourly",
  "budgetMinAmount": 45,
  "budgetMaxAmount": 80,
  "budgetCurrency": "USD",
  "budgetMonthlyUsd": 10000,
  "experienceLevel": "Expert",
  "duration": "3 to 6 months",
  "durationWeeks": 18,
  "workload": "30+ hrs/week",
  "postedAt": "2026-09-04T06:41:00.000Z",
  "postedAtRelative": "2 hours ago",
  "proposalsMin": null,
  "clientTier": "Enterprise",
  "searchQuery": "devops",
  "scrapedAt": "2026-09-04T08:44:12.310Z"
}
```

### The filters, grouped

There are more than forty. These are the ones that change your results the most.

#### Search

`searchQueries` (several per run, deduplicated across all of them) · `startUrls` (paste a URL built in Upwork's UI, with filters this actor does not expose) · `maxItems` · `maxPagesPerQuery` (Upwork shows ~10 jobs per page) · `sortBy`.

#### Job shape

`jobType` (`hourly` / `fixed`) · `experienceLevel` · `projectLength` · `workload`.

> **`projectLength` applies to hourly jobs only.** Upwork itself applies it before returning results, and fixed-price results come back unfiltered. That is Upwork's behaviour, not ours.

#### Budget

`hourlyRateMin` / `hourlyRateMax` · `fixedBudgetMin` / `fixedBudgetMax` · `minBudgetMonthlyUsd` · `keepUnknownBudget` · `hoursPerMonth`.

> **Hourly bounds match *overlapping* ranges, not contained ones.** A `$15–40/hr` job matches a maximum of 20, because the ranges overlap. This is Upwork's matching behaviour and it surprises people. **Fixed-budget bounds are exact** — verified 2026-08-14: an upper bound of 200 returned $10, $30, $50 and $100 jobs, and a 1000–5000 window returned $1000 jobs.
>
> `minBudgetMonthlyUsd` is the filter to reach for when you want one bar across both job types: an hourly rate is projected onto a month using `hoursPerMonth`, a fixed budget onto its stated duration.

#### Keywords

`includeKeywords` (any-of by default; `requireAllIncludeKeywords` switches it to all-of) · `excludeKeywords`.

Matched on **word boundaries**, so `ops` does not match *shops*. `excludeKeywords` is the fastest way to strip the adjacent niches a broad query drags in.

#### Client country

`clientCountries` — the only client-side filter Upwork will apply for you.

Give it full country **names**: `["United States", "Germany"]`. `"US"` returns nothing. Because Upwork applies it before results come back, it makes the run cheaper as well as narrower — and an unrecognised name returns zero rows rather than being silently ignored, so a typo fails loudly.

#### Competition and freshness

`postedWithinHours` — only postings from the last N hours. Postings whose age cannot be determined are **kept**, never dropped.

`maxProposals` — drop jobs that already have more proposals than you want to compete with. **This one needs `jsonSearch: false`**: Upwork does not return proposal counts through its fast JSON endpoint, so on a default run there is nothing to compare against and the filter narrows nothing. The run tells you so in its log rather than leaving you to work it out.

#### Monitoring mode

`deduplicateAgainstPreviousRuns` remembers which jobs previous runs returned and skips them, so **a scheduled run only charges you for postings you have not seen before**. `seenExpiryDays` (default 30) controls how long a job stays in the ledger; `seenStateKey` lets two schedules share one ledger or keep separate ones.

This is the setting that makes a daily Upwork watch cheap. Turn it on for any scheduled run.

### Proxies: use residential

Upwork sits behind Cloudflare and blocks datacenter traffic hard. `proxyConfiguration` defaults to residential proxies and **should be left there** — without them, most runs return nothing. Residential proxy traffic is billed separately by Apify, so keep `maxItems` sane while you are testing.

### A run that returns nothing tells you why

With `failOnZeroResults: true` (the default), a run that finishes with an empty dataset is marked **FAILED** with a status message naming the cause. On pay-per-result pricing a zero-row "success" is you paying for nothing and unable to tell a broken actor from a quiet search.

Set it to `false` for scheduled monitoring runs where an empty result is a legitimate answer.

### How much does it cost?

Measured on the platform: **50 rows in 30 seconds at 0.033 compute units**. Costs rise with `maxItems` and `maxPagesPerQuery`, and residential proxy traffic is billed on top.

There is no expensive tier to accidentally switch on — the JSON search path is the only path, which is why the cost per row stays flat as you scale a run up.

For your own figure, run once with your real input and read the cost from the **Runs** tab.

### Who is this for?

- **Freelancers and agencies** who want the day's relevant postings filtered down to the ones with a real budget, a serious client and few proposals — instead of refreshing Upwork.
- **Lead generation** — a company posting paid work is a company with budget, right now.
- **Market researchers** tracking freelance rates, in-demand skills and budget distributions over time.
- **Recruiters** benchmarking contract rates against permanent salaries.

### FAQ

#### Do I need an Upwork account?

No. This actor reads Upwork's public job search. There is no login and no account of yours to be banned.

#### Will my searches be blocked?

Upwork is behind Cloudflare and is one of the harder sites to scrape reliably. Keep the default residential proxy configuration, and the actor rotates proxy identities when acquiring a session. If a run cannot get through, it tells you rather than returning a silent empty result.

#### Can I get the client's total spend or hire count?

Not from this Actor. Upwork publishes neither on its public search results — both live on the individual job page, behind a Cloudflare challenge that costs roughly one browser page-load per row. Charging per result while paying per page-load is a bad deal for you and for us, so the option does not exist.

You still get every signal Upwork publishes on the search itself, including `clientTier` and the full description.

#### Do I get the full job description, or a truncated one?

The full description, and the complete skill list. Some Upwork scrapers return only what fits on a search card and make you open each job to get the rest; the JSON search path this Actor uses carries both in full on every row.

#### Is it legal to scrape Upwork?

This actor collects only publicly visible job postings and extracts no private user data. Results can contain personal data — a client's name or an email inside a posting. You should not scrape or store personal data without a legitimate reason, and if you are in the EU, note that GDPR applies. If you are unsure whether your reason qualifies, consult your lawyers. See Apify's background on the [legality of web scraping](https://blog.apify.com/is-web-scraping-legal/).

#### Can I export to Excel or Google Sheets?

Yes — JSON, CSV, XML and Excel from the **Storage** tab, plus a direct Google Sheets integration.

### Integrations and API

Trigger runs and collect results via the [Apify API](https://docs.apify.com/api/v2), the JavaScript or Python client, or Apify's integrations — **Zapier, Make, Slack, Google Drive, GitHub and webhooks**. The most useful pattern: an hourly schedule with `deduplicateAgainstPreviousRuns` and `postedWithinHours`, plus a Slack webhook, gives you new matching Upwork jobs pushed to you as they appear.

### Support

Missing a filter, or seeing something wrong? Open an issue on this actor's **Issues** tab.

# Actor input Schema

## `searchQueries` (type: `array`):

What to search for on Upwork. One run can cover several queries; results are deduplicated across all of them.

## `startUrls` (type: `array`):

Upwork job-search URLs to scrape as-is. Use this to paste a URL you built in the Upwork UI with filters this actor does not expose. Combined with the search queries above.

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

Hard cap on how many job postings the run returns in total, across every query. The run stops as soon as it is reached.

## `maxPagesPerQuery` (type: `integer`):

How deep to paginate each search. Upwork shows roughly 10 jobs per page.

## `jsonSearch` (type: `boolean`):

Fetch search results through Upwork's own JSON endpoint instead of rendering search pages in a browser. Dramatically faster and cheaper - measured at 50 results in 0.5s against ~5 minutes of page rendering - and it returns the FULL job description and complete skill list, which the browser path only gets by opening each job page. Falls back to the browser automatically if the JSON endpoint is unavailable. Client data (spend, hires, payment-verified) still requires 'Open each job page for full details' either way. Leave this on unless you are debugging.

## `requireJsonSearch` (type: `boolean`):

Stops the run instead of falling back to the browser when Upwork will not issue a visitor token. The fallback still returns a full result set, so by default it is used - but it is roughly 400x more traffic per row, and a run that quietly takes it looks identical to one that did not. Turn this on if you chose this Actor for its cost profile and would rather fail than be silently upgraded to the expensive path. Check jsonFallbacks in RUN\_STATS either way.

## `sortBy` (type: `string`):

Upwork's own result ordering. 'Most recent' is the right choice for monitoring runs.

## `jobType` (type: `array`):

Applied by Upwork before results are returned, so it also makes the run cheaper.

## `experienceLevel` (type: `array`):

Contractor tier the client is asking for.

## `projectLength` (type: `array`):

Expected engagement duration as stated by the client. IMPORTANT: Upwork applies this to HOURLY jobs only - fixed-price results come back unfiltered (verified 2026-08-14: the same search with 'less than 1 month' and 'more than 6 months' returned identical fixed-price jobs). It cannot be applied afterwards either, because fixed-price listings carry no duration at all. Set Job type to Hourly if you need this enforced.

## `hourlyRateMin` (type: `integer`):

Lower bound of the hourly rate window. Upwork matches jobs whose advertised range OVERLAPS your window rather than jobs contained inside it, so a $25-100/hr job matches a minimum of 60. That is Upwork's own search behaviour, verified 2026-08-14, and it is passed through unchanged.

## `hourlyRateMax` (type: `integer`):

Upper bound of the hourly rate window. As with the minimum, Upwork matches OVERLAPPING ranges: a $15-40/hr job matches a maximum of 20, because its lower end falls inside the window.

## `fixedBudgetMin` (type: `integer`):

Only fixed-price jobs at or above this budget. Verified exact 2026-08-14, unlike the hourly rate bounds: a 1000-5000 window returned $1000, $1750 and $2500 jobs and excluded a $10 one.

## `fixedBudgetMax` (type: `integer`):

Only fixed-price jobs at or below this budget. Verified exact 2026-08-14 - an upper bound of 200 returned $10, $30, $50 and $100 jobs.

## `includeKeywords` (type: `array`):

A job is kept only if its title, description, skills or category contains at least one of these. Matched on word boundaries, so 'ops' does not match 'operations'. Leave empty to keep everything.

## `excludeKeywords` (type: `array`):

A job is dropped if any of these appears in its text. Useful for stripping out the adjacent niches a broad query drags in.

## `requireAllIncludeKeywords` (type: `boolean`):

Switch the include list from 'any of' to 'all of'.

## `minBudgetMonthlyUsd` (type: `integer`):

Compares hourly and fixed-price jobs on one axis: an hourly rate is projected to a month using the billable-hours setting below, a fixed budget is taken as one month's spend.

## `keepUnknownBudget` (type: `boolean`):

Upwork hides the budget on a fair share of postings. Turn this off to drop them rather than let them through the minimum-budget filter.

## `clientCountries` (type: `array`):

Keep only clients located in these countries. Applied by Upwork's own search before results are fetched, so it genuinely narrows the run and costs less - unlike every other client filter, which needs the detail page. Full country NAMES only - "US" returns nothing, "United States" works. An unrecognised name returns zero results rather than being ignored, so a typo fails loudly.

## `ongoingOnly` (type: `boolean`):

Keep only postings whose text reads like recurring work - 'ongoing', 'long-term', 'retainer', 'monthly', 'maintenance' and similar.

## `ongoingKeywords` (type: `array`):

Override the built-in vocabulary used by the filter above.

## `maxProposals` (type: `integer`):

Drop jobs that already have more than this many proposals. Low-competition postings are worth far more to anyone actually bidding.

## `postedWithinHours` (type: `integer`):

Drop postings older than this many hours. Leave empty for no limit. Pair with a scheduled run to get only what appeared since you last looked - sortBy: recency orders results but does not bound them. Postings whose age cannot be determined are KEPT, never dropped.

## `deduplicateAgainstPreviousRuns` (type: `boolean`):

Remember which jobs previous runs returned and skip them, so a scheduled run only ever charges you for postings you have not seen. Off by default - a one-off scrape should return everything it finds. The ledger is scoped to this search (queries + Upwork-side filters); changing maxItems or a post-fetch filter does NOT reset it. Zero results in this mode means 'nothing new', which is a valid outcome, and the run says so.

## `seenExpiryDays` (type: `integer`):

How long a job stays in the monitoring ledger. Upwork postings go stale fast, so 30 days is plenty; a longer window just makes the ledger bigger. Only used when monitoring mode is on.

## `seenStateKey` (type: `string`):

Override the automatic ledger id. Set the same value on two schedules to make them share one 'already seen' list, or different values to keep them separate. Leave empty unless you know you need it.

## `proxyConfiguration` (type: `object`):

Upwork sits behind Cloudflare and blocks datacenter traffic hard. Residential proxies are strongly recommended - without them most runs return nothing.

## `hoursPerMonth` (type: `integer`):

Used to project an hourly rate onto the monthly figure the budget filter and the budgetMonthlyUsd field use.

## `minDelaySeconds` (type: `integer`):

Politeness delay floor. Raising it lowers the block rate at the cost of run time.

## `maxDelaySeconds` (type: `integer`):

Politeness delay ceiling. Each wait is randomized between the floor and this.

## `maxRetriesPerPage` (type: `integer`):

How many times to retry a page that came back blocked, each on a fresh proxy session.

## `blockImagesAndStyles` (type: `boolean`):

Skip downloading images, fonts, media and CSS. None of it is parsed, and it is a large share of Upwork's page weight, so this makes runs meaningfully faster and cheaper. Turn it off only if a page stops rendering correctly.

## `failOnZeroResults` (type: `boolean`):

Marks the run FAILED, with a status message naming the cause, when it finishes with an empty dataset. A run that returns nothing and still reports SUCCEEDED is indistinguishable from a broken actor, so this is on by default. It does NOT apply to monitoring mode: when deduplicateAgainstPreviousRuns is on and every posting was already returned by an earlier run, that is a valid empty result and the run still succeeds. Turn this off if you want an empty result to succeed in every case. It does not change what is scraped, and it does not change what you are charged: result charges are per result, so a run that returns nothing incurs none. Every run also carries a nominal $0.00001 Actor-start charge regardless of its outcome.

## Actor input object example

```json
{
  "searchQueries": [
    "python developer",
    "shopify store setup"
  ],
  "maxItems": 100,
  "maxPagesPerQuery": 3,
  "jsonSearch": true,
  "requireJsonSearch": false,
  "sortBy": "recency",
  "includeKeywords": [
    "terraform",
    "kubernetes"
  ],
  "excludeKeywords": [
    "wordpress",
    "data entry"
  ],
  "requireAllIncludeKeywords": false,
  "keepUnknownBudget": true,
  "clientCountries": [
    "United States",
    "Germany"
  ],
  "ongoingOnly": false,
  "postedWithinHours": 24,
  "deduplicateAgainstPreviousRuns": false,
  "seenExpiryDays": 30,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  },
  "hoursPerMonth": 160,
  "minDelaySeconds": 1,
  "maxDelaySeconds": 3,
  "maxRetriesPerPage": 2,
  "blockImagesAndStyles": true,
  "failOnZeroResults": true
}
```

# Actor output Schema

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

Every matching Upwork posting this run returned - title, full description, budget, skills and project length, one row each.

# 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 = {
    "searchQueries": [
        "python developer"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("tqm/upwork-jobs-scraper").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 = { "searchQueries": ["python developer"] }

# Run the Actor and wait for it to finish
run = client.actor("tqm/upwork-jobs-scraper").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 '{
  "searchQueries": [
    "python developer"
  ]
}' |
apify call tqm/upwork-jobs-scraper --silent --output-dataset

```

## MCP server setup

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

```

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/acjMaI9QhtSwOgA5Q/builds/qMSQ7Yuohegjc7wyU/openapi.json
