Upwork has an official API, but its keys go to accounts with $25,000 in lifetime earnings or spend, for personal and internal use only. To get job postings without a key, use our Upwork API: one search call returns 50 jobs as JSON, and the script below saved 300 of them to CSV in 6 calls.
What Upwork's API is for
Upwork's GraphQL API has job search queries, such as publicMarketplaceJobPostingsSearch, and allows 300 requests per minute per IP address. A key request needs an identity-verified account with a verified payment method, at least $25,000 in lifetime earnings, spend or both, and for freelancers and agencies a Job Success Score of 90% or more. You also confirm you'll stay within 40,000 requests a day. Upwork emails a decision in about a week and gives no sandbox or test accounts. It says: "Commercial use isn't supported." If you qualify and need it for personal or internal use, ask Upwork for the key.
Upwork jobs with ToolzerHub
We make ToolzerHub. Our Upwork API reads what a signed-out visitor sees on Upwork, so it has no key request and nothing from your own account, contracts or messages. It has two routes, each a GET with the x-api-key header at 1 credit a call:
/v1/upwork/search?query=...returns one page of 50 jobs, up to page 101, withtotaland Upwork's ownfacetscounts. Optional filters:sort(newest,relevance,client_spendorclient_rating),job_type,experience_levelandpage./v1/upwork/job?job_id_or_url=...takes ajob_idor a job link and returns the full posting: category, required skills, applicants and interviews, and the client's country, payment verification and spend history.
export TOOLZERHUB_API_KEY=your_key
curl -G -H "x-api-key: $TOOLZERHUB_API_KEY" \
--data-urlencode "query=python scraping" \
"https://api.toolzerhub.com/v1/upwork/search"
The second job in data.results, with its description cut:
{
"job_id": "~022118416160812586681",
"title": "Python AI Agent to Generate Application specific Web Scraping rules.",
"url": "https://www.upwork.com/jobs/~022118416160812586681",
"description": "We are looking for an experienced Python AI Agent Developer to build an ...",
"skills": ["Python", "Data Scraping", "Web Crawling", "Agentic Workflows", "AI Agent Development"],
"job_type": "hourly",
"experience_level": "intermediate",
"budget": { "fixed_price": null, "hourly_min": 20, "hourly_max": 50, "currency": "USD" },
"workload": "part_time",
"duration": "1 to 3 months",
"duration_weeks": 9,
"posted_at": "2026-11-05T18:52:52Z"
}
A fixed-price job has fixed_price and no hourly range. Some hourly jobs have neither, so check for null before you average.
Save Upwork jobs to CSV
The script pages through one search, keeps each job once and writes one row per job with its type, experience level, budget, duration, skills and posting time. Save it as upwork_jobs.py and install requests (pip install requests).
import csv
import os
import sys
import requests
API = "https://api.toolzerhub.com/v1/upwork/search"
HEADERS = {"x-api-key": os.environ["TOOLZERHUB_API_KEY"]}
def search(query, pages=3, sort="newest", job_type="any"):
seen = set()
for page in range(1, pages + 1):
params = {"query": query, "page": page, "sort": sort, "job_type": job_type}
response = requests.get(API, headers=HEADERS, params=params, timeout=60)
response.raise_for_status()
body = response.json()["data"]
for job in body["results"]:
if job["job_id"] not in seen:
seen.add(job["job_id"])
yield job
if not body["has_more"]:
break
def main():
query = sys.argv[1]
pages = int(sys.argv[2]) if len(sys.argv) > 2 else 3
fields = ["job_id", "title", "job_type", "experience_level", "fixed_price",
"hourly_min", "hourly_max", "duration", "skills", "posted_at", "url"]
with open("upwork_jobs.csv", "w", newline="", encoding="utf-8") as out:
writer = csv.DictWriter(out, fieldnames=fields)
writer.writeheader()
count = 0
for job in search(query, pages):
budget = job["budget"]
writer.writerow({
"job_id": job["job_id"],
"title": job["title"],
"job_type": job["job_type"],
"experience_level": job["experience_level"],
"fixed_price": budget["fixed_price"],
"hourly_min": budget["hourly_min"],
"hourly_max": budget["hourly_max"],
"duration": job["duration"],
"skills": "; ".join(job["skills"]),
"posted_at": job["posted_at"],
"url": job["url"],
})
count += 1
print(f"saved {count} jobs to upwork_jobs.csv")
if __name__ == "__main__":
main()
python upwork_jobs.py "python scraping" 6 saved 300 jobs in 6 calls, and python upwork_jobs.py "shopify developer" 3 saved 150 in 3 calls.
What the saved jobs showed
Some hourly jobs come back with no hourly range. Both hourly_min and hourly_max were null on 66 of the 153 hourly jobs for "python scraping" and 43 of the 99 for "shopify developer". We can't tell from this whether the client left it blank or a signed-out visitor just isn't shown it, so don't read null as a rate of zero. Every fixed-price job in both searches came back with a price.
| Search | No hourly range | Median fixed | Newest 150 span |
|---|---|---|---|
| python scraping, 300 jobs | 66 of 153 | $100 | 18 days |
| shopify developer, 150 of 1,665 | 43 of 99 | $250 | 2 days |
84 of the 147 fixed-price jobs for "python scraping" paid $100 or less, and 20 paid $1,000 or more. How far back a pull reaches depends on the keyword: the newest 150 jobs covered 2 days for "shopify developer" and 18 for "python scraping", and all 300 of the latter went back 140 days.
sort=relevance changes the page. All 50 jobs on the first relevance page for "python scraping" had "scrap" or "crawl" in the title, against 19 of 50 on the first newest page, and only 8 jobs appeared on both.
What it costs
The 300-job export took 6 credits, and a full posting is 1 more credit each. The 100 free credits a month cover 16 exports like it; $19 buys 10,000 credits that never expire, and plans for higher volume cover more searches. Set your key and the first search is a CSV in under a minute.
For other job boards, see Internshala internships and Naukri job data.
We are not affiliated with Upwork.