Upwork API v1
Public Upwork job postings as JSON or Markdown, from https://api.fetchlayer.dev/upwork. Budgets arrive parsed rather than as display strings, and every response tells you how much of what you got is the full record.
Read enrichedJobs before you read anything else
Every posting comes back with the listing-level fields Upwork puts on its results page — title, body, budget, skills, duration, workload, client location, client spend. Those are always there.
The fuller record behind a posting — the exact posting time, the exact proposal count, the client’s hire rate and average paid rate — is not always available, and how much of it is varies by hour. Nothing is invented to fill the gap, and nothing is hidden either.
| What you see | What it means | What to do |
|---|---|---|
| enrichedJobs === jobs.length | Every posting is the full record | Use every field |
| enrichedJobs < jobs.length | The rest are listing-level only | Filter on each row’s enriched |
| enrichedJobs === 0 | None of them is | Retry — it often returns more |
A listing-level posting is still a 200 and still a real result. What it is not is a posting with no proposals: proposals is null and proposalsRange still says "50+". Reading a null as a zero is the one mistake that silently corrupts an analysis, which is why enriched is on every row rather than only in the total.
Measured on 17 September 2026: {"query":"python developer","limit":5} returned 200 in 25.8s with 5 postings, enrichedJobs: 0, and the note saying so. A second search in the same window behaved identically. That is what an honest degraded window looks like.
Available endpoints
Give a query or a search URL — one of the two
Every field on /search-jobs is optional on its own, but the request carries a cross-field rule. An empty body is a 400 in about 80ms: “Provide either a query or a searchUrl.” That is deliberate — an unfiltered sweep of every posting on Upwork is not a request anyone means to make.
searchUrl is usually the faster route: build the search on Upwork with its own filter UI, paste the address, and the filters come across as-is. /job-detail has the same shape — “Provide either a jobId or a jobUrl.”
Budgets are numbers
budget comes back as {type, amount, min, max, currency, raw}, not as the string Upwork displays:
- Fixed price —
{type:"fixed", amount:210, currency:"USD", raw:"Est. budget:$210.00"} - Hourly with a published range —
{type:"hourly", min:15, max:100, currency:"USD", raw:"Hourly: $15.00 - $100.00"} - Hourly with no published rate —
{type:"hourly", amount:null, min:null, max:null, raw:"Hourly"}
A null min means Upwork published no rate, not that the rate is zero. raw is always the exact string Upwork showed, so any parse can be checked by eye.
Dates, and when they are approximate
A results page publishes the age of a posting as a phrase — “Posted 1 hour ago” — which is kept verbatim in relativeDate. postedAt is an ISO timestamp derived from it, and postedAtIsApproximate is true when that is where it came from. Check the flag before using postedAt as a precise ordering key.
Paging and billing
One credit per results page fetched, stated on every response as pagesFetched. A page costs the same whether it returns 3 postings or 50, and a posting that came back without its fuller record costs nothing extra. Up to 500 postings across up to 20 pages in one request.
/resolve-url fetches nothing at all and is free, reporting pagesFetched: 0.
One page of results measured 24–28 seconds against production. Asking for several pages in one request takes proportionally longer — page through rather than batching if you are building something interactive.
Errors
- 400 — the request is wrong, and the message names the problem. Also what you get for an empty body on either reading route, and for an unknown field: the body is a strict object, so a typo is rejected rather than accepted and ignored.
- 401 — missing or invalid key.
- 503 — on
/job-detail, the fuller record was not available: “The upstream source is temporarily refusing automated requests. Please retry.” Retry it, or fall back to/search-jobs, which keeps answering 200 with listing-level fields in the same window.