Tutorial

How to Get Upwork Job Alerts That Actually Work

Upwork's built-in job alerts have a reputation for arriving late or missing matches entirely. Here's how to build your own — searching by keyword or a saved search URL, deduplicated and on a schedule.

Written by Alex P.

  • Upwork alerts
  • job monitoring
  • freelance
  • Upwork API
  • job search automation

Upwork’s own job alerts have a well-known reputation problem: freelancers report alerts arriving well after a posting went up, or a saved search that just doesn’t notify them for jobs that plainly match it. By the time an email lands, a posting with a dozen connects already spent on it isn’t really new anymore.

The fix isn’t complicated: search Upwork on a tight schedule yourself, remember what you’ve already seen, and alert on the rest. This guide builds that pipeline — including the two things that make it more than a toy: reusing a search you’ve already built in Upwork’s own UI, and handling the fact that some fields aren’t always there.


The architecture

Cron (every few minutes) → search-jobs, sort: newest → diff by job id → filter → alert

sort: newest matters here the same way it does everywhere else you’re polling for change: relevance is Upwork’s own ranking and can reshuffle between polls, which produces false “new job” signals. newest gives you a stable order where anything genuinely new sorts to the top.


Reuse a search you already built

If you’ve already tuned a search in Upwork’s own interface — filters, budget range, experience level and all — you don’t need to rebuild it in code. Pass the page’s own URL and the backend reads the filters off it:

const res = await fetch('https://api.fetchlayer.dev/upwork/search-jobs', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer ss-your-api-key',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    searchUrl: 'https://www.upwork.com/nx/search/jobs/?q=python%20developer',
    sort: 'newest',
    limit: 25,
  }),
});

const { jobs, totalJobs, enrichedJobs } = await res.json();

A request needs either query or searchUrl — not both, and not neither. If you’re building the search from scratch instead, query plus the individual filter fields (experienceLevel, jobType, paymentVerified, location, and so on) works the same way.


The enrichment gap you have to design around

Not every posting comes back with its full detail — exact posting time, proposal count, client hire history. What’s available varies hour to hour, and search-jobs is honest about it rather than silently thinning the response: it still answers 200, but enrichedJobs will be lower than jobs.length (0 when nothing expanded), every under-filled job is marked enriched: false, and the response’s notes array says so.

Design your monitor to work off the listing-level fields — title, url, budget, skills, experienceLevel, clientRating, clientPaymentVerified — as the source of truth for the alert itself, and treat the fuller record as a bonus:

function summarize(job) {
  const base = `${job.title} — ${formatBudget(job.budget)}`;
  return job.enriched
    ? `${base} — ${job.proposals ?? '?'} proposals, client ${job.clientHireRatePercent}% hire rate`
    : base; // still a complete, alertable job — just without the extra detail
}

If you specifically need the expanded record for a posting you already found interesting, job-detail returns only that fuller record — and when it’s unavailable, it answers 503 with a retry hint rather than a thin 200. Call it opportunistically on postings you’ve already decided matter, with a retry, not as a required step for every result.


Parsing budget correctly

budget comes back structured, not as raw text: { type, amount, min, max, currency, raw }. A fixed-price posting fills amount; an hourly posting with a published range fills min and max; an hourly posting with no published range fills neither and keeps raw: "Hourly". Handle all three:

function formatBudget(budget) {
  if (!budget) return 'budget not listed';
  if (budget.type === 'fixed') return `$${budget.amount} fixed`;
  if (budget.min && budget.max) return `$${budget.min}-$${budget.max}/hr`;
  return budget.raw; // hourly with no published range
}

raw is always the exact string Upwork showed, so if a parsed value ever looks wrong, check it against raw before assuming the request is broken.


A working monitor

import { readFileSync, writeFileSync, existsSync } from 'node:fs';

const API_KEY = process.env.FETCHLAYER_API_KEY;
const SEARCH_URL = process.env.UPWORK_SEARCH_URL;
const SEEN_FILE = './seen-jobs.json';
const MIN_CLIENT_RATING = 4.0;

function loadSeen() {
  if (!existsSync(SEEN_FILE)) return new Set();
  return new Set(JSON.parse(readFileSync(SEEN_FILE, 'utf-8')));
}

function saveSeen(seen) {
  writeFileSync(SEEN_FILE, JSON.stringify([...seen].slice(-2000)));
}

async function searchJobs() {
  const res = await fetch('https://api.fetchlayer.dev/upwork/search-jobs', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ searchUrl: SEARCH_URL, sort: 'newest', limit: 25 }),
  });
  if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
  const { jobs } = await res.json();
  return jobs;
}

function isWorthAlerting(job) {
  // clientRating is only present when Upwork has shown one; a new client
  // with none yet shouldn't be filtered out for lacking a rating.
  return job.clientRating === undefined || job.clientRating >= MIN_CLIENT_RATING;
}

async function run() {
  const seen = loadSeen();
  const isFirstRun = seen.size === 0;

  const jobs = await searchJobs();
  const fresh = jobs.filter((j) => !seen.has(j.uid ?? j.id));
  jobs.forEach((j) => seen.add(j.uid ?? j.id));
  saveSeen(seen);

  if (isFirstRun) {
    console.log(`Seeded ${jobs.length} existing postings.`);
    return;
  }

  const worthAlerting = fresh.filter(isWorthAlerting);
  if (worthAlerting.length) await alert(worthAlerting);
  console.log(`${fresh.length} new posting(s), ${worthAlerting.length} alerted.`);
}

run().catch(console.error);

The cold-start guard matters as much here as anywhere else: without it, the first run treats every job on page one as new and fires alerts for postings that have been up for days.


Alerting

async function alert(jobs) {
  await fetch(process.env.SLACK_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: `${jobs.length} new Upwork posting(s) matching your search`,
      blocks: jobs.map((j) => ({
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `*<${j.url}|${j.title}>*\n${formatBudget(j.budget)}${j.clientPaymentVerified ? ' · payment verified' : ''}`,
        },
      })),
    }),
  });
}

Running it every few minutes

*/5 * * * * cd /path/to/project && FETCHLAYER_API_KEY=ss-... UPWORK_SEARCH_URL="https://www.upwork.com/nx/search/jobs/?q=python" node monitor.mjs >> monitor.log 2>&1

Every page fetched is billed as one credit, and a single page of 3-5 postings can take upward of 20 seconds against Upwork’s own site — so a 5-minute interval on one page is a sane default; don’t set the interval shorter than the request itself can reliably finish.


Watching multiple searches

Most freelancers track more than one niche. Run each search independently rather than merging them into one giant query — it keeps your seen set meaningful per search and makes it trivial to pause one without affecting the others:

const SEARCHES = [
  { name: 'python', searchUrl: 'https://www.upwork.com/nx/search/jobs/?q=python' },
  { name: 'react', searchUrl: 'https://www.upwork.com/nx/search/jobs/?q=react%20developer' },
];

for (const search of SEARCHES) {
  const jobs = await searchJobs(search.searchUrl);
  // ...diff and alert per search, with a separate seen-file per `search.name`
}

Practical notes

  • paymentVerified: true filters to clients whose payment method Upwork has verified — a cheap first-pass quality filter before you spend time reading a posting in full.
  • The request body is strict. An unrecognized field is a 400, not a field that’s silently ignored — double-check filter names against the API reference rather than guessing at Upwork’s own UI parameter names.
  • page, pages, and perPage are separate knobs. page picks a starting page, pages walks several in one request, and perPage sets how many postings per page — each is capped, and each additional page fetched is a separate credit.

Next Steps