# Upwork Jobs Scraper - Budget, Skills, Proposals, Client Country (`lanternlab318/upwork-jobs`) Actor

Search Upwork's public job board by keyword: full description, fixed budget or hourly range, experience level, skills, posted time, proposals, client country and payment verification. No login or cookies. Deduplicated across queries; incomplete rows are marked, never shown as empty.

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

## Pricing

$0.99 / 1,000 jobs

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## Upwork Jobs Scraper

Search Upwork's public job board by keyword and get each job back with its **full description, fixed budget or hourly range, experience level, skills, posted time, proposals count**, and the client's **country, payment verification, total spend and rating** — public statistics only, never a client or company name. No Upwork login, no cookies of yours.

**Price:** $0.99 per 1,000 jobs. Partial job rows, search query status rows and the run summary are free, and so is a query that finds nothing.

### Quick example

Input:

```json
{
  "searchQueries": ["react native", "shopify"],
  "maxJobs": 50,
  "sortBy": "recency"
}
```

One job row (`recordType: "job"`, shortened):

```json
{
  "recordType": "job",
  "jobId": "2107267731025843411",
  "title": "Next.js Website Review & Bug Audit",
  "description": "Summary\n\nWe're looking for an experienced Next.js developer to review an existing website ...",
  "jobType": "fixed",
  "budget": 100,
  "hourlyRateMin": null,
  "hourlyRateMax": null,
  "currency": "USD",
  "experienceLevel": "Expert",
  "skills": ["React", "JavaScript", "Web Development"],
  "category": "Web Development",
  "projectLength": "Less than 1 month",
  "proposalsText": "20 to 50",
  "proposalsCount": 30,
  "clientCountry": "United States",
  "clientPaymentVerified": true,
  "clientTotalSpent": 1017.5,
  "clientRating": 5,
  "clientReviewCount": 6,
  "url": "https://www.upwork.com/jobs/~022107267731025843411",
  "searchQuery": "react native",
  "publishedAt": "2026-10-06T00:32:35.522Z",
  "partial": false,
  "collectedAt": "2026-10-23T21:50:20.034Z"
}
```

### Limits

- **How many jobs a query reaches is up to Upwork's public search.** It lists open jobs only, and the count it shows a visitor who is not signed in varies: on 2026-10-24 "python" listed 113 jobs in one run and 1,828 in another. This Actor does not sign in, so a query ends when Upwork's list ends, even below `maxJobs`. The `query_status` row gives the count Upwork listed (`jobsAvailable`).
- **Private listings: search fields only.** Some listed jobs (about 1 in 10 in our tests) are private listings whose page opens only for signed-in users. They are still written, free, with what the search results show (title, description, type, budget or rate where shown, experience level, skills, posted time) and `partial: true`, `partialReason: "private_listing"`. Their client country, payment verification and proposals are `null`.
- About 2 seconds per job: every job's own page is read for the client and proposals fields, one at a time.
- Public data only. Upwork does not show a visitor the client's name or company, and this Actor never collects them; the client's city is also left out.
- Runs a real browser on Apify Proxy (datacenter), because Upwork refuses plain HTTP clients and requests without a proxy. When Upwork refuses a request, the Actor waits, then retries from a fresh IP.
- No scheduling or monitoring built in; use Apify schedules.

### What it does

- Searches each keyword the way Upwork's own job search does, newest first or by best match, 50 jobs per results page.
- Reads every new job's page for the fields the results page leaves out: client country, payment verification, total spend, rating, proposals, interviewing and invites, category and workload.
- Deduplicates across the whole run: a job found by two queries is written and charged once, for the first query that found it.
- Keeps Upwork's job ID (`jobId`), its publish time (`publishedAt`) and our collection time (`collectedAt`) as separate fields.
- Says what happened to every query. A query with no job, a query that was cut short, and a job whose page could not be read are different records and are never confused: anything incomplete is marked `partial: true` with the reason.
- Caps itself: `maxUpstreamRequests` is a hard ceiling on page requests to Upwork for the whole run, retries included, derived from your input when you leave it empty, so a run is never unbounded.

### Input

| Field | Default | Meaning |
| --- | --- | --- |
| `searchQueries` | — | Keywords, one per line, in any language. At least one, at most 100. Repeats (ignoring case) run once. |
| `maxJobs` | `50` | Upper bound on jobs per query, from the top of the results. Max 5,000. |
| `sortBy` | `recency` | `recency` (newest first) or `relevance` (Upwork's best match). |
| `maxUpstreamRequests` | derived | Hard ceiling on page requests to Upwork for the whole run, retries included. |
| `proxyConfiguration` | Apify Proxy (datacenter) | The proxy the browser goes through. |

### Output

One row per job, `recordType: "job"`. Charged when `partial` is `false`:

| Field | Meaning |
| --- | --- |
| `jobId`, `ciphertext` | Upwork's job ID (the dedupe key) and the ID in the job's URL. |
| `title`, `description` | As posted; the description is the full text. |
| `jobType` | `fixed` or `hourly`. |
| `budget` | Fixed-price budget; `null` on hourly jobs and when the client hid it. |
| `hourlyRateMin`, `hourlyRateMax` | Hourly range; `null` on fixed-price jobs and when the client set none. |
| `currency` | `USD`. |
| `experienceLevel` | `Entry level`, `Intermediate` or `Expert`. |
| `skills` | The skills the job lists. |
| `category`, `categoryGroup`, `projectLength`, `workload` | As the job's page shows them. |
| `proposalsText` | The proposals range the job's page shows (`Less than 5`, `5 to 10`, `10 to 15`, `15 to 20`, `20 to 50`, `50+`). |
| `proposalsCount`, `interviewing`, `invitesSent` | The counts behind the job's "Activity on this job" panel. |
| `clientCountry`, `clientPaymentVerified` | The client's country and whether their payment method is verified. |
| `clientTotalSpent`, `clientRating`, `clientReviewCount`, `clientMemberSince` | The client's public history; `clientRating` is `null` until the client has a review. |
| `url` | The job's Upwork page. |
| `searchQuery` | The query that found the job first in this run. |
| `publishedAt` | When Upwork published the job. |
| `partial`, `partialReason` | `true` with a reason when only the search-result fields could be read: `private_listing`, `blocked`, `upstream_error`, `upstream_request_cap`. Partial rows are free. |
| `collectedAt` | Our own collection time. |

One row per search query, `recordType: "query_status"`, free: `outcome` is `found`, `empty` (no public job matches) or `partial`, with `jobsAvailable` (the count Upwork listed), `jobsReturned`, `partialJobs`, `privateJobs`, `duplicatesSkipped`, `jobsGone` (jobs closed between the search and their page), `partialReason` (`blocked`, `upstream_error`, `upstream_request_cap`, `charge_limit_reached`, `not_collected`) and `statusText`.

The last row is `recordType: "RUN_SUMMARY"` — what the queries came to, jobs collected and charged, partial jobs by reason, repeated jobs written once, requests sent, refusals and IP changes, and why collection stopped. The same object is also in the run's key-value store under `RUN_SUMMARY`.

# Actor input Schema

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

Keywords to search Upwork jobs for, one per line - the same words you would type into Upwork's job search, in any language. Repeated queries (ignoring case) run once. A query that matches no public job gets a status row that says so and is not charged.

## `maxJobs` (type: `integer`):

Upper bound on the jobs collected for each search query, from the top of the results. Upwork shows 50 per page. Upwork's public search decides how many jobs a visitor who is not signed in can page through, so a query can end before this number - see the README.

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

Which jobs come first, and so which ones the limit keeps.

## `maxUpstreamRequests` (type: `integer`):

Hard ceiling on the page requests this run sends to Upwork, retries included. At the ceiling collection stops - what was not reached is written with partialReason "upstream_request_cap" and the run then ends normally. Leave empty and the Actor works a ceiling out from your input (one request per results page and per job, plus room for retries), so a run is never unbounded.

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

The proxy the browser goes through. The default, Apify Proxy (datacenter), is what this Actor is measured on; Upwork refuses requests that come without a proxy.

## Actor input object example

```json
{
  "searchQueries": [
    "python",
    "web scraping"
  ],
  "maxJobs": 50,
  "sortBy": "recency",
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

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

No description

## `runSummary` (type: `string`):

No description

# 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",
        "web scraping"
    ],
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("lanternlab318/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 = {
    "searchQueries": [
        "python",
        "web scraping",
    ],
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("lanternlab318/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 '{
  "searchQueries": [
    "python",
    "web scraping"
  ],
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call lanternlab318/upwork-jobs --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,lanternlab318/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/3Mcb7eGDc53KiigPo/builds/nWKNKYdibXN4gmRad/openapi.json
