# Upwork Job Alerts: New Jobs Monitor & Filters (`nice_dev/upwork-job-alerts`) Actor

Get alerts for new Upwork jobs: schedule it every few minutes or hours and each run returns only the job posts published since the last run, filtered by keyword, budget, hourly rate, experience level, skills and category. Send them to email, Slack or Google Sheets with Apify integrations.

- **URL**: https://apify.com/nice_dev/upwork-job-alerts.md
- **Developed by:** [Nice Dev](https://apify.com/nice_dev) (community)
- **Categories:** Jobs, Lead generation, MCP servers
- **Stats:** 1 total users, 0 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.42 / 1,000 job posts

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

### 🔔 What is Upwork Job Alerts?

**Upwork Job Alerts finds the new Upwork job posts that match your search, and only those.** Give it a keyword — or paste a search URL straight from Upwork — set the filters you care about (budget, hourly rate, experience level, skills, category…) and put it on a schedule: every run returns the **job posts that no earlier run delivered**, each with its **full description**, **skills**, **budget or hourly range**, **experience level**, **project duration** and **exact publication time**. Send them to your email, Slack or Google Sheets with Apify's integrations, or read them from the API, Make, Zapier or n8n.

It reads the same job posts an anonymous visitor sees on Upwork's search pages. **No Upwork account needed.** It is set for monitoring: **Only new job posts is ON by default**, newest first.

Try it: type `python` in **Search keyword**, hit Start, and look at the Output tab. Run it again a few minutes later: only the job posts published in between come back.

### 📋 What data can you extract from Upwork?

One row per job post, **48 fields** — 23 always, 25 more with the **Client details** option:

| Category | What you get |
| --- | --- |
| 🏷️ **Job post** | title, link to the job page, Upwork's own id — `Fix Broken Python Scraper` |
| 📝 **Description and skills** | the full text as the client wrote it, line breaks kept; every skill asked for — `Python, Scrapy` |
| 💰 **Budget** | fixed price, or hourly range, or weekly retainer, in USD — `$20` / `$40-60 an hour` |
| 🎓 **Experience level** | entry level, intermediate or expert |
| ⏳ **Duration and hours** | how long the project lasts, how many hours a week — `1 to 3 months` |
| 🕒 **Dates** | when it was posted, to the millisecond — what the date filters read |
| 🔎 **Where it came from** | which of your keywords found it, the search page, when it was read |
| 👤 **Client details** (option) | money spent, hires, hours, reviews and rating, payment verified, country and city, member since, company; the job's proposals, interviews and invites — `$26,694 spent · 53 hires · 5.0 · Saudi Arabia` |

Every field, with an example, is listed in the **Output** section below.

### ✅ Why use Upwork Job Alerts?

- 🔔 **Only the new job posts.** A run returns what no earlier run of the same search delivered, so a schedule gives you a feed, not the same list again and again. A run that finds nothing new saves 0 rows and is charged only the run-start fee (plus the pages your filters kept nothing of, if you set any).
- ⏱️ **As often as you want.** Schedule it every few minutes or every few hours with Apify Schedules.
- 🎯 **Every filter Upwork itself offers**, in plain words: job type, experience level, project duration, hours per week, contract-to-hire, budget, hourly rate, category, skills, client hires, client country, client time zone.
- 🧹 **Filters Upwork does not have**, run on our side: publication date, words a job post must contain, words it must not.
- 🗂️ **Several keywords in one alert** — one search each, results deduplicated.
- 👤 **The client behind each job post**, as an option: how much they have spent, how many freelancers they hired, their rating, whether their payment is verified, and how many proposals the job already has.
- 🚨 **It tells you when it is wrong.** If Upwork changes its pages, the run stops and says which field vanished, instead of quietly charging you for empty columns.

### 🔔 How to get Upwork job alerts

1. Click **Try for free**, type your keyword in **Search keyword** (`python`, `web scraping`, `logo design`) and set the filters you want (budget, experience level, skills, category…).
2. Run it once. **The first run of a search has nothing to compare with: it returns the newest job posts, up to Max job posts**, and remembers them.
3. Save it as a **Task** (one Task per alert) and open **Schedules** in the Apify Console: create a schedule (every few minutes, every hour, every morning…) and add your Task to it.
4. Open the **Integrations** tab of the Task and pick where the new job posts go: email (Gmail), Slack, Airtable, or Zapier to reach Google Sheets and many other apps. A webhook can also call your own system when a run succeeds, and the dataset is always available from the API.

From then on every run returns only the job posts published since the previous one. The Actor itself sends nothing: the alert is the run, and Apify's integrations carry its result where you want it.

**One alert per Task.** Each search keeps its own memory of what it delivered (see Monitoring below), so a Task for `python` and a Task for `react` never hide each other's job posts. Changing the keywords of a Task starts a new alert: its first run returns the newest job posts again.

### 💰 How much does it cost to monitor Upwork jobs?

This Actor uses **pay per event** pricing: **$0.45 per 1,000 job posts** saved ($0.44 on the Bronze plan, $0.43 on Silver, $0.42 on Gold and above), plus **$0.01 per run start** (1 dollar per 100 runs). Platform usage (compute, proxy) is included in the price. Examples: 10,000 job posts ≈ $4.50; an alert scheduled 100 times a month that finds nothing new costs $1 in run starts; an hourly alert (720 runs a month) that finds 20 new job posts per run ≈ $7.20 + $6.48 = $13.68 a month, without a plan discount.

**A filtered-out job post is never charged**, and neither is one a previous run already delivered. A results page on which your filters kept nothing is charged **$0.00023** ($0.23 per 1,000 such pages; $0.20 on Gold and above): a filter that matches almost nothing stops being free to run, never expensive. Pages of job posts already delivered, or already dropped by the same filters in an earlier run, are not charged: a monitoring run without filters that finds nothing new costs only the run-start fee. To cap any run, set **Maximum cost per run** in the run options: the run stops before it would go over.

With **Include client details** ticked, each job post delivered WITH its client's details is also charged the **Client details** event: **$0.89 per 1,000 job posts with their client** ($0.88 on the Bronze plan, $0.87 on Silver, $0.86 on Gold and above), on top of the job post. A job post whose page gives no client (a private or closed job) goes out without them and is not charged for it. The option is slower — about 3 seconds per job post — and the run uses more memory, set automatically when the box is ticked. It is unticked by default.

Upwork itself never returns more than **5 050 job posts for one search**, whatever you set.

### ⚙️ Input

You can set everything in the Console form, or pass this JSON to the API. An alert on one keyword:

```json
{
    "query": "web scraping",
    "onlyNew": true,
    "maxItems": 100
}
```

An alert for experienced freelancers, hourly work paid at least $40, without the words you do not want:

```json
{
    "query": "python",
    "jobType": "hourly",
    "experienceLevel": ["Intermediate", "Expert"],
    "minHourlyRate": 40,
    "excludeKeywords": ["wordpress", "unpaid"],
    "stateKey": "python-hourly"
}
```

Watching three searches in one Task, only the new ones:

```json
{
    "searchQueries": ["web scraping", "data extraction", "crawler"],
    "stateKey": "scraping-watch",
    "maxItems": 500,
    "maxItemsPerQuery": 200
}
```

A one-off search that returns every match, not only the new ones:

```json
{
    "query": "logo design",
    "onlyNew": false,
    "maxItems": 250
}
```

| Field | Notes |
|---|---|
| `startUrls` | Upwork search URLs copied from the site. Max 100 URLs. The keyword, sort and page are read from the URL; the filters below still apply |
| `query`, `searchQueries` | Keyword, and more keywords (one search each), up to 100 per run |
| `maxItems`, `maxItemsPerQuery` | **Max job posts** (100 by default, 0 = no limit), and the cap of each search. Keep it above the number of new job posts between two runs |
| `sortBy` | `recency` (newest first, the default), `relevance` or `client_total_charge` |
| `onlyNew`, `stateKey`, `resetState` | Monitoring: on by default; the memory key (empty = one memory per search) and its reset |
| `jobType` | `hourly` or `fixed`; empty = both |
| `experienceLevel` | `Entry level`, `Intermediate`, `Expert` |
| `projectDuration` | `Less than one month`, `1 to 3 months`, `3 to 6 months`, `More than 6 months` — checked on every job post, fixed-price ones included |
| `workload` | `Less than 30 hrs/week`, `30+ hrs/week`, `As needed` — hourly job posts only, unless a fixed budget is set too |
| `contractToHire` | Only job posts flagged as leading to a long-term contract |
| `minFixedBudget`, `maxFixedBudget` | Fixed price in USD — fixed-price job posts only, unless an hourly filter is set too: then each kind matches its own filter |
| `minHourlyRate`, `maxHourlyRate` | Hourly rate in USD — hourly job posts only, unless a fixed budget is set too |
| `categories`, `subcategories` | Upwork category names, e.g. `Web, Mobile & Software Dev`; a name Upwork does not know stops the run at once and lists the valid ones |
| `skills` | Skills the job post asks for, e.g. `Python`, `Web Scraping`; several = the job post must ask for all of them |
| `clientHires` | `No hires`, `1 to 9 hires`, `10+ hires` |
| `clientLocations` | Country names as Upwork writes them, e.g. `United States` |
| `clientTimezones` | The client's time zone as Upwork names it, e.g. `Europe/Paris`, `America/New_York`; several = any of them |
| `postedAfter`, `postedBefore` | A date (`2026-09-01`, a UTC day, the whole day included) or a period before now (`7 days`, `1 month`; via the API also `2 hours`) |
| `includeKeywords`, `excludeKeywords` | Words a job post must, or must not, contain |
| `maxDescriptionLength` | Cut each description; 0 = the whole text |
| `includeClientDetails` | **Client details** option: each job post with its client (25 more columns), charged per job post that has them |
| `proxyConfiguration`, `minRequestIntervalMs`, `maxRequestRetries`, `debugLog` | Advanced |

### 📦 Output

One item per job post:

```json
{
    "id": "2101592032775799065",
    "ciphertext": "~022101592032775799065",
    "url": "https://www.upwork.com/jobs/_~022101592032775799065/",
    "title": "Fix Broken Python Scraper",
    "description": "Need a quick fix for a broken Python scraper used on an e-commerce site.",
    "skills": ["Python", "Scrapy", "Data Scraping"],
    "skillsDetailed": [{ "uid": "996364628025274386", "name": "Python", "freeText": false }],
    "jobType": "FIXED",
    "budgetType": "FIXED",
    "fixedBudget": 20,
    "hourlyBudgetMin": null,
    "hourlyBudgetMax": null,
    "weeklyRetainerBudget": null,
    "experienceLevel": "Intermediate",
    "contractorTier": "IntermediateLevel",
    "durationLabel": "1 to 3 months",
    "durationWeeks": 9,
    "hourlyEngagementType": null,
    "publishedAt": "2026-09-20T08:40:24.219Z",
    "createdAt": "2026-09-20T08:39:21.353Z",
    "clientDetailsRead": true,
    "clientPaymentVerified": true,
    "clientCountry": "Saudi Arabia",
    "clientCity": "Saihat",
    "clientTimezone": "Asia/Baghdad (UTC+03:00)",
    "clientTotalSpent": 26694.47,
    "clientHires": 53,
    "clientActiveHires": 6,
    "clientJobsWithHires": 51,
    "clientTotalHours": 100.17,
    "clientReviews": 40,
    "clientRating": 5,
    "clientMemberSince": "2022-09-02T00:00:00.000Z",
    "clientIndustry": "Finance & Accounting",
    "clientCompanySize": 1,
    "clientJobsPosted": null,
    "clientOpenJobs": null,
    "clientAvgHourlyRate": null,
    "clientIsEnterprise": false,
    "applicantsCount": 9,
    "interviewingCount": 6,
    "invitesSent": 28,
    "unansweredInvites": 22,
    "hiredCount": 0,
    "clientLastActivityAt": "2026-09-28T13:09:08.031Z",
    "searchQuery": "python",
    "searchUrl": "https://www.upwork.com/nx/search/jobs/?q=python&sort=recency&page=1",
    "scrapedAt": "2026-09-20T09:49:31.185Z"
}
```

You can download the dataset in various formats such as JSON, HTML, CSV, or Excel.

#### All 48 fields

| Fields | What you get |
| --- | --- |
| `title`, `description` | **Job post**: the text as the client wrote it, line breaks kept |
| `skills`, `skillsDetailed` | Skill names picked from Upwork's list, and the same with Upwork's own ids so you can re-filter. A skill the client typed in by hand is not in Upwork's search, so not here |
| `jobType`, `budgetType` | `FIXED`, `HOURLY` or `WEEKLY_RETAINER` |
| `fixedBudget`, `hourlyBudgetMin`, `hourlyBudgetMax`, `weeklyRetainerBudget` | **Budget**: what the client is offering, in USD |
| `experienceLevel`, `contractorTier` | `Entry level`, `Intermediate` or `Expert`, in words and in Upwork's code |
| `durationLabel`, `durationWeeks`, `hourlyEngagementType` | **Duration and hours**: how long, and how many hours a week |
| `publishedAt`, `createdAt` | ISO 8601 UTC, **to the millisecond** — you can sort an alert feed by the second. `publishedAt` = when the post went live (what the date filters read); `createdAt` = what the job page counts "Posted … ago" from, hours earlier for about 1 post in 4 |
| `url`, `id`, `ciphertext` | The public job page and Upwork's own references |
| `searchQuery`, `searchUrl`, `scrapedAt` | Which of your searches found it, and when |
| `clientDetailsRead` | **Client details** option: true when the job's page gave its client; false = the fields below are null |
| `clientTotalSpent`, `clientHires`, `clientActiveHires`, `clientJobsWithHires`, `clientTotalHours`, `clientAvgHourlyRate` | **Client's history**: money spent (USD), freelancers hired, contracts still active, job posts that ended in a hire, hours billed, average hourly rate paid |
| `clientReviews`, `clientRating`, `clientPaymentVerified` | Reviews received, average rating out of 5, payment method verified |
| `clientCountry`, `clientCity`, `clientTimezone`, `clientMemberSince` | Where the client is, and since when on Upwork |
| `clientIndustry`, `clientCompanySize`, `clientIsEnterprise`, `clientJobsPosted`, `clientOpenJobs` | The client's company and job posts |
| `applicantsCount`, `interviewingCount`, `invitesSent`, `unansweredInvites`, `hiredCount`, `clientLastActivityAt` | **This job's activity**: proposals, interviews, invites, hires, and when the client last looked at it |

### 💡 Tips

#### Monitoring: only the new job posts

**Only new job posts** is on by default. It remembers the ids it has delivered in a store that never expires (`upwork-job-alerts-seen`, up to 150,000 listings per key) and returns only the others. The memory belongs to the **Monitoring key**: left empty, each search (its keywords, or its Start URLs) has its own memory, so two Tasks never share one. Type a key of your own to keep the memory when you change the keywords or the filters of an alert, or to let several runs share it. **Reset monitoring memory** forgets everything under that key and returns every match again.

A job post is remembered once it is delivered. One your filters dropped is remembered too, for the same filters only: change a filter and it can come back. Set **Max job posts** above the number of new job posts that appear between two runs: the newest come first, and the run stops at the cap.

#### How to reduce costs

The price is per job post, so the levers are `maxItems`, `maxItemsPerQuery`, the filters (a filtered-out job post is free; a whole page of them costs $0.00023) and `onlyNew`, which is on by default: you never pay twice for the same job post. With **Sort by: Newest first** and `postedAfter`, a search stops after 5 hours of older posts.

#### How to get more results

For a one-off list, untick **Only new job posts**, set `maxItems` to `0` and use a broad keyword, or several keywords in **More search keywords**. Upwork stops every search at 5 050 job posts: to go further, split it (see below).

#### Several searches in one run

Put your keywords in **More search keywords**: each one becomes its own search. A job post found by two of them is saved once. **Max job posts per search** stops the first keyword from eating the whole budget. You can also paste several search URLs into `startUrls`: each one is a search of its own, with the same cap. For separate alerts that go to separate places, make one Task per keyword.

#### Filter by publication date

`postedAfter` and `postedBefore` take a date (`2026-09-01` is a UTC day, and the whole day is included) or a period before now (`7 days`, `2 weeks`, `1 month`; via the API also `2 hours` or a full ISO date-time). The filter reads `publishedAt`. Filtered-out job posts are never charged and do not count in `maxItems`; the run summary tells how many were filtered. With **Sort by: Newest first**, a search stops after 5 hours of older posts (Upwork ranks a few posts up to 3 hours before it publishes them), so a short window costs very little; a results page that holds only posts older than the window ends the search and is not charged. Upwork shows only the 5 050 newest posts of a search, about the last 31 hours on all of Upwork: `postedBefore` reaches older posts in a narrower search (a keyword, a category).

#### Getting more than 5 050 job posts

That is Upwork's own ceiling for one search, not ours. Split the run: the same keyword with `jobType` `hourly` then `fixed`, or one run per entry of `categories`, or by budget band.

### 🔌 Integrations and API

Schedule it with **Apify Schedules**, and carry each run's new job posts where you need them with **Apify integrations**: email (Gmail), Slack, Airtable, and Google Sheets or any other app through Zapier, Make or n8n. A webhook can start your own code when a run succeeds. Call the Actor via the Apify API or the JavaScript and Python clients, and fetch the dataset as JSON or CSV from any tool.

### 🤖 Use with AI agents (MCP)

AI agents (Claude, ChatGPT, Cursor…) can find and run this Actor through the [Apify MCP server](https://mcp.apify.com), billed to their Apify account like any run. It returns one item per Upwork job post. Actor id: `nice_dev/upwork-job-alerts`; MCP server with this Actor only: `https://mcp.apify.com/?tools=fetch-actor-details,nice_dev/upwork-job-alerts`.

Smallest input, for a cheap first call (a one-off search, so `onlyNew` is off):

```json
{
    "query": "python",
    "onlyNew": false,
    "maxItems": 10
}
```

Key output fields: `url`, `title`, `description`, `skills`, `jobType`, `fixedBudget`, `hourlyBudgetMin`, `publishedAt`. With `onlyNew` left on, an agent that calls it again with the same search gets only the job posts published since its last call.

Cost: $0.45 per 1,000 job posts plus $0.01 per run start ($0.42 per 1,000 on the Gold plan); a results page on which the filters kept nothing costs $0.00023; with `includeClientDetails`, each job post delivered with its client costs $0.89 per 1,000 more ($0.86 on Gold), see the pricing section above. Cap each call with `maxItems` and, through the API, with the run option `maxTotalChargeUsd`.

### ❓ FAQ

#### How do I get Upwork job alerts?

Make a Task with your keyword and filters, put it on an Apify Schedule, and add an integration (email, Slack, Google Sheets…) to the Task. Every run returns only the job posts no earlier run delivered. See **How to get Upwork job alerts** above.

#### Does the first run return anything?

Yes. A search with an empty memory has nothing to compare with: its first run returns the newest job posts, up to **Max job posts**, and remembers them. The runs after it return only what was published since.

#### Is it legal to scrape Upwork?

The Actor only reads what Upwork shows publicly to any anonymous visitor. It logs in to nothing. Results describe job posts written by clients and can contain personal data, which is protected by GDPR: do not store it without a legitimate reason. You are responsible for using the data in compliance with Upwork's Terms of Use and applicable law. This Actor is not affiliated with Upwork.

#### Does it need a login or a proxy?

No login. The proxy is included in the price: leave the default setting (the residential proxy is not available). A request the site turns away is retried at once on a new proxy session, up to 10 times on top of the retries (without a proxy, after a pause of 5 seconds, doubled at each retry up to 150 seconds).

**Apify free plan:** the included proxy is for paying Apify plans only. On the free plan, add your own proxy URLs in **Proxy configuration**; without them the run stops at once with a message saying so.

#### Does it return the client's spending, rating or number of proposals?

**Yes, with the Client details option.** Tick **Include client details**: each job post then comes with its client's money spent, hires, hours, reviews and rating, payment verification, country, city and company, and with the job's proposals, interviews and invites. Each job post delivered with them is charged the **Client details** event on top of the job post : $0.89 per 1,000 ($0.86 on the Gold plan). A private or closed job gives no client: it goes out without, uncharged for it.

#### Is the data safe to open in Excel or to show on a web page?

Titles and descriptions are the clients' own words, copied as they are. A text can begin with `-`, `+`, `=` or `@` (`-20% rate for a long contract`, `=SUM of my needs`): Excel and Google Sheets may read such a cell of a CSV file as a formula or as a number. The Actor leaves the text as it is, so that the JSON and the API give the real value: when you open a CSV, import these columns as text. Every URL field holds an `https://www.upwork.com/…` address built from Upwork's own reference, never a link copied from a payload. On a web page, escape every field like any text written by a stranger.

#### Known limitations

- **5 050 job posts per search**, Upwork's own ceiling (a search URL pasted past page 101 returns nothing). Split the run to go further.
- Only the keyword, the sort and the page are read from a pasted search URL: set the filters in the form, they apply to pasted URLs too.
- Client details come with the option only, about 3 seconds per job post; a private or closed job has none.
- There is no filter for the number of proposals or for payment verification, because Upwork's search offers none.
- `onlyNew` remembers job ids, not their content: a job post the client edited is not returned again.
- If more new job posts appear between two runs than Max job posts, the newest are returned and the oldest of them are not: raise Max job posts or run more often.
- Two runs sharing the same `stateKey` at the same time may both return the same new job post.

**A run that reaches its timeout** stops itself about 45 seconds before it: no new page is asked, what it read is saved and, with `onlyNew`, remembered, and the run ends *Succeeded* with "Stopped before the run's timeout". Resurrect it to go on from there, or give the next run a longer timeout (Run options).

**A run the platform stops without warning** (out of memory)

- Resurrect it: it goes on from where it stood at most a minute before the stop. What it had read since is read again, and the job posts already saved are skipped: none is delivered or charged twice, and `maxItems` still counts them.
- With `onlyNew`, the memory is saved a few seconds after the job posts are written: resurrect the stopped run and the job posts it had saved meanwhile join the memory; leave it stopped for good, and the next run may return the last few seconds of them once more.

#### Something doesn't work?

The last line of the log counts the job posts saved, filtered out and already delivered, and the requests that failed after every retry. Those requests are listed, with the reason, in the `FAILED_REQUESTS` record of the run's key-value store. A run that saved nothing and had failed requests fails, and its last message gives the cause (a search URL that does not exist says so, instead of "run it again"). A run that saved job posts can still fail: when at least as many requests failed for good as were read (page 1 read, the next pages blocked), it keeps what it saved and fails with "Most requests failed", so that a blocked run never looks like a complete one.

If Upwork changes its pages, you are told instead of paying for blank rows. A results page that counts job posts but gives none the Actor can read is an error (listed in `FAILED_REQUESTS`), never a quiet "No listings found". If the first 20 listings read all lack a column every job post fills (`title`, `description`, `publishedAt`, `createdAt`, `jobType`, `experienceLevel`, `durationLabel`, `durationWeeks`, `skills`, `ciphertext`, and `fixedBudget` or `hourlyEngagementType` on the posts of that kind), the run saves nothing more, stops and fails, and its last message names the missing field: at most those first listings are charged. A job post that `postedAfter` / `postedBefore` drops because it has no date at all counts among those 20.

### 🛟 Support

Open an issue in the **Issues** tab with a link to your run: the run log and the `FAILED_REQUESTS` record of the key-value store show exactly which URLs failed and why.

# Actor input Schema

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

Upwork search URLs copied from the site (`https://www.upwork.com/nx/search/jobs/?q=python&sort=recency`). Only the keyword, the sort and the page are read from the URL: filters ticked on Upwork's page are not, so set them below — they apply to these URLs too. When this list is not empty, **Search keyword** below is ignored; the caps and monitoring below still apply. Max 100 URLs.

## `query` (type: `string`):

Free-text search, exactly as typed on Upwork: the keywords you want alerts for. Upwork's own search syntax works: `title:(web scraping)`, `python AND NOT wordpress`, `"exact phrase"`. Empty = every open job post (about 108 000).

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

Several keywords in one run: one search per keyword. Added to **Search keyword**. A job post found by several searches is saved once. Max 100.

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

Maximum number of job posts to save in one run (after deduplication and filters). 0 = no limit. For an alert, keep it above the number of new job posts that appear between two runs: with Only new job posts on, the newest ones are returned first and the run stops at this number.

## `maxItemsPerQuery` (type: `integer`):

Cap for EACH search, so the first keyword cannot use up the whole **Max job posts** budget. 0 = no per-search cap. Upwork itself never returns more than 5 050 job posts for one search, whatever you set here.

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

Upwork's own sort order. `recency` (newest first) is the only one that makes **Only new job posts** and early stopping work — keep it for monitoring.

## `onlyNew` (type: `boolean`):

Return only the job posts never delivered by a previous run of the same search (same **Monitoring key**). The first run of a search has nothing to compare with: it returns the newest job posts, up to **Max job posts**. Untick it for a plain search that returns every match, every time. A run that finds nothing new saves 0 items and pays only the run-start fee.

## `stateKey` (type: `string`):

Name of the memory used by **Only new job posts**. Leave empty: every search (its keywords or Start URLs) then gets its own memory, so two Tasks never hide each other's job posts, and changing the keywords starts a new alert. Type a name to keep one memory when you change the keywords or the filters of an alert, or to share it between several runs. Letters, digits, `_` and `-` only.

## `resetState` (type: `boolean`):

Forget everything remembered under this **Monitoring key** (or under this search, when the key is empty) before starting, so this run returns every match again.

## `jobType` (type: `string`):

Hourly or fixed-price contracts. Empty = both.

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

Client's required level. Several can be ticked.

## `projectDuration` (type: `array`):

How long the client expects the work to last. Checked on every job post, fixed-price ones included.

## `workload` (type: `array`):

Workload the client asks for. Hourly posts only: fixed-price posts have no hours per week, so they drop out unless a fixed budget is set too.

## `contractToHire` (type: `boolean`):

Only job posts the client flagged as leading to a long-term contract.

## `minFixedBudget` (type: `integer`):

Only fixed-price job posts with a budget at or above this. Hourly posts drop out, unless an hourly rate or hours per week is set too: then you get both kinds, each matching its own filter.

## `maxFixedBudget` (type: `integer`):

Upper bound of the fixed budget. Can be used alone.

## `minHourlyRate` (type: `integer`):

Only hourly job posts whose rate range reaches this. Fixed-price posts drop out, unless a fixed budget is set too: then you get both kinds, each matching its own filter.

## `maxHourlyRate` (type: `integer`):

Upper bound of the hourly rate. Can be used alone.

## `categories` (type: `array`):

Upwork category names as shown on the site, e.g. `Web, Mobile & Software Dev`, `Design & Creative`, `Sales & Marketing`. The list of categories is read from Upwork at the start of the run, so it is never out of date. An unknown name stops the run with the list of valid ones.

## `subcategories` (type: `array`):

Upwork subcategory names, e.g. `Web Development`, `Data Extraction`. More precise than **Categories**; both can be used together.

## `skills` (type: `array`):

Skills the job post asks for, as Upwork names them, e.g. `Python`, `Web Scraping`, `Graphic Design` (case ignored). Several skills = the job post must ask for ALL of them. Each name is looked up on Upwork at the start of the run; an unknown name stops the run with the closest names Upwork knows.

## `clientHires` (type: `array`):

How many freelancers the client has hired before.

## `clientLocations` (type: `array`):

Country names as Upwork writes them, e.g. `United States`, `United Kingdom`, `Canada`. Several countries = job posts from any of them.

## `clientTimezones` (type: `array`):

The client's time zone, as Upwork names it, e.g. `Europe/Paris`, `America/New_York`, `Asia/Calcutta`. Several zones = job posts from any of them. A zone with no open job post right now is kept and said in the log.

## `postedAfter` (type: `string`):

Only job posts published on or after this date (days are UTC): `2026-09-01`, or a period before now such as `7 days`, `2 weeks`, `1 month` (API: `2 hours` and full ISO date-times work too). With **Sort by: Newest first**, the run stops after 5 hours of older posts (Upwork ranks a few posts up to 3 hours before it publishes them), so a short window costs very little.

## `postedBefore` (type: `string`):

Only job posts published on or before this date (days are UTC; the whole day is included), or older than a period such as `30 days`. Same formats as **Posted after**. Upwork shows only the 5 050 newest posts of a search (on all of Upwork, about the last 31 hours): to reach older ones, narrow the search with a keyword or a category.

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

Keep only job posts whose title, description or skills contain at least one of these words. Case and accents are ignored. Runs on our side, after Upwork's own search.

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

Drop job posts whose title, description or skills contain any of these words, e.g. `wordpress`, `unpaid`. Case and accents are ignored.

## `maxDescriptionLength` (type: `integer`):

Cut each description to this many characters (never inside an emoji). 0 = keep the full text, which is what Upwork returns.

## `includeClientDetails` (type: `boolean`):

Adds 24 client columns (clientTotalSpent, clientHires, clientRating, clientCountry, applicantsCount…) and `clientDetailsRead`. Each job post delivered WITH its client's details is charged the **Client details** event on top of the job post; a job post whose page gives no client (private or closed job) goes out without them and is not charged for it. About 3 seconds per job post.

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

Proxy, included in the price. Keep the default setting: the Actor picks the IP addresses Upwork accepts. The Apify residential proxy is refused, because it would cost you money this Actor does not need. On the Apify free plan the included proxy is not available: add your own proxy URLs, or the run stops at once with a message.

## `minRequestIntervalMs` (type: `integer`):

Milliseconds waited between two requests to Upwork. 500 ms is the measured safe pace (no refusal over 120 calls). Lowering it risks 403s; raising it only makes the run slower.

## `maxRequestRetries` (type: `integer`):

How many times a failed request is retried before it is listed as failed.

## `debugLog` (type: `boolean`):

Verbose log. Useful when opening a support ticket.

## Actor input object example

```json
{
  "startUrls": [],
  "query": "python",
  "searchQueries": [],
  "maxItems": 100,
  "maxItemsPerQuery": 0,
  "sortBy": "recency",
  "onlyNew": true,
  "resetState": false,
  "jobType": "",
  "experienceLevel": [],
  "projectDuration": [],
  "workload": [],
  "contractToHire": false,
  "categories": [],
  "subcategories": [],
  "skills": [],
  "clientHires": [],
  "clientLocations": [],
  "clientTimezones": [],
  "includeKeywords": [],
  "excludeKeywords": [],
  "maxDescriptionLength": 0,
  "includeClientDetails": false,
  "proxyConfiguration": {
    "useApifyProxy": true
  },
  "minRequestIntervalMs": 500,
  "maxRequestRetries": 5,
  "debugLog": false
}
```

# Actor output Schema

## `results` (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 = {
    "query": "python",
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("nice_dev/upwork-job-alerts").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 = {
    "query": "python",
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("nice_dev/upwork-job-alerts").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 '{
  "query": "python",
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call nice_dev/upwork-job-alerts --silent --output-dataset

```

## MCP server setup

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

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/1ZcUpEwmgyas08g5n/builds/CnCDJJxPgr6scj2OH/openapi.json
