Remote jobs API: pull live remote jobs as JSON from 6 public job feeds (Python)
Stdlib-only Python that pulls live remote jobs as JSON from Remote OK, Remotive, WWR, Himalayas, Jobicy and Arbeitnow, deduped into one schema.

You want a list of remote jobs you can filter in code: for a job alert, a small niche board, a newsletter, or salary research. Most of the big remote job boards already publish a free remote jobs API or feed, with no key. The catch is that they all use different shapes and different rules.
This post is one stdlib-only Python script that pulls live remote jobs as JSON from six public feeds (Remote OK, Remotive, Arbeitnow, We Work Remotely, Himalayas and Jobicy), maps them to one schema, removes duplicates across boards, filters by keyword and date, and writes jobs.json + jobs.csv. Every number below comes from a live run on 2026-10-19. At the end I mention one hosted option.
Which remote job board APIs are public (and what their terms say)
I read each board's own notice (most put it inside the API response) before writing a line of code:
| Board | Endpoint | Format | What one call gave me | Terms, in short |
|---|---|---|---|---|
| Remote OK | https://remoteok.com/api |
JSON array | 99 jobs | Link back to the Remote OK job URL (followed link, no nofollow) and name Remote OK as the source. Don't use their logo. |
| Remotive | https://remotive.com/api/remote-jobs |
JSON | 12 jobs | Link back and name Remotive. Jobs are delayed 24h. Don't push them to third-party sites (Jooble, Google Jobs, LinkedIn…). They advise max ~4 calls a day. |
| Arbeitnow | https://www.arbeitnow.com/api/job-board-api?page=N |
JSON | 300 jobs/page (remote and on-site) | Free public API, "please do not abuse", link back appreciated. |
| We Work Remotely | https://weworkremotely.com/remote-jobs.rss |
RSS | 91 jobs | Anyone can use the feed; attribute the links back to WWR. |
| Himalayas | https://himalayas.app/jobs/api and /jobs/api/search |
JSON | 20 jobs/page (max) | Link back and name Himalayas; send "Apply" to their applicationLink; don't resubmit the jobs to other job boards or aggregators. Unkeyed calls share one public rate limit. |
| Jobicy | https://jobicy.com/api/v2/remote-jobs |
JSON | up to 200/page, last 7 days | Credit Jobicy with a direct link; apply buttons go to the job URL in the feed; poll no more than once an hour. |
The common rule: keep the original job URL and name the source. So the schema below always keeps url, apply_url and source, and the script caches responses so you don't call anyone 50 times while you tweak a filter.
One schema for remote jobs JSON
{
"source": "wwr",
"title": "Senior Backend Developer (Python)",
"company": "Proxify AB",
"url": "https://weworkremotely.com/remote-jobs/proxify-ab-senior-backend-developer-python-11",
"apply_url": "https://weworkremotely.com/remote-jobs/proxify-ab-senior-backend-developer-python-11",
"location": "Anywhere in the World",
"salary_min": null,
"salary_max": null,
"currency": null,
"salary_text": null,
"tags": ["Back-End Programming", "Django and Python"],
"job_type": "Full-Time",
"posted_at": "2026-10-19T09:21:57Z"
}
salary_min/salary_max are numbers when a board gives numbers; salary_text holds free text (Remotive sends strings like "$1000 per task"). posted_at is always ISO 8601 UTC.
The script
Save as remote_jobs.py. Python 3.9+, no installs:
#!/usr/bin/env python3
"""Pull live remote jobs as JSON from public job-board feeds, normalize, dedupe.
Stdlib only, no API keys. Feeds (each one's own published endpoint):
remoteok https://remoteok.com/api (JSON; element 0 is a legal notice)
remotive https://remotive.com/api/remote-jobs (JSON; 24h delay, max ~4 calls/day)
arbeitnow https://www.arbeitnow.com/api/job-board-api (JSON; mixed remote/on-site)
wwr https://weworkremotely.com/remote-jobs.rss (RSS)
himalayas https://himalayas.app/jobs/api (JSON; 20 per page, cursor)
jobicy https://jobicy.com/api/v2/remote-jobs (JSON; last 7 days)
Every board asks you to link back to the original job URL and name it as the
source, so each row keeps `url` and `source`. Responses are cached on disk
(default 6h) so re-running while you tweak filters doesn't hammer anyone.
Usage:
python3 remote_jobs.py # all feeds -> jobs.json + jobs.csv
python3 remote_jobs.py -k python -k "data engineer" --since-days 7
python3 remote_jobs.py --feeds remoteok,wwr,jobicy --out python_jobs
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import html
import json
import re
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
# Identify yourself: put your own project/contact URL here.
UA = "remote-jobs-script/1.0 (+https://example.com/contact)"
CACHE_DIR = Path(".cache_remote_jobs")
CACHE_TTL = 6 * 3600
_net = threading.local() # per-thread: did the last fetch hit the network?
FIELDS = ["source", "title", "company", "url", "apply_url", "location", "salary_min", "salary_max",
"currency", "salary_text", "tags", "job_type", "posted_at"]
def fetch(url: str, tries: int = 3) -> bytes:
"""GET with a disk cache and polite retry on 429/5xx (honours Retry-After)."""
CACHE_DIR.mkdir(exist_ok=True)
cached = CACHE_DIR / hashlib.sha1(url.encode()).hexdigest()
fresh = CACHE_TTL and cached.exists() and time.time() - cached.stat().st_mtime < CACHE_TTL
_net.hit = not fresh
if fresh:
return cached.read_bytes()
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json, application/rss+xml"})
for attempt in range(tries):
try:
with urllib.request.urlopen(req, timeout=30) as r:
body = r.read()
cached.write_bytes(body)
return body
except urllib.error.HTTPError as e:
if e.code not in (429, 500, 502, 503, 504) or attempt == tries - 1:
raise
wait = int(e.headers.get("Retry-After") or 0) or 5 * (attempt + 1)
print(f"# {url}: HTTP {e.code}, retrying in {wait}s", file=sys.stderr)
time.sleep(min(wait, 60))
raise RuntimeError("unreachable")
def get_json(url: str):
return json.loads(fetch(url))
def pause(seconds: float = 1.0) -> None:
"""Sleep between pages, but only if the last page really hit the network."""
if getattr(_net, "hit", False):
time.sleep(seconds)
def iso(value) -> str | None:
"""Epoch seconds, ISO 8601 (with or without zone) or RFC 822 -> ISO 8601 UTC."""
if value in (None, "", 0):
return None
try:
if isinstance(value, (int, float)) or str(value).isdigit():
d = datetime.fromtimestamp(int(value), tz=timezone.utc)
elif re.match(r"\d{4}-\d{2}-\d{2}", str(value)):
d = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
else:
d = parsedate_to_datetime(str(value))
if d.tzinfo is None: # Remotive sends naive timestamps; treat as UTC
d = d.replace(tzinfo=timezone.utc)
return d.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
except (ValueError, TypeError, OverflowError):
return None
def money(v) -> float | None:
try:
f = float(v)
return f if f > 0 else None # Remote OK uses 0 for "not given"
except (TypeError, ValueError):
return None
def job(source, title, company, url, **kw) -> dict:
row = dict.fromkeys(FIELDS)
row.update(kw, source=source, title=html.unescape((title or "").strip()),
company=html.unescape((company or "").strip()), url=url)
row["apply_url"] = row["apply_url"] or url # boards want applicants sent to their link
row["tags"] = [str(t).strip() for t in (row["tags"] or []) if str(t).strip()]
return row
# ---------- one small adapter per feed ----------
def remoteok(pages: int, keywords: list[str]) -> list[dict]:
data = get_json("https://remoteok.com/api")
out = []
for j in data[1:]: # data[0] = {"last_updated":..., "legal": "..."}
lo, hi = money(j.get("salary_min")), money(j.get("salary_max"))
out.append(job("remoteok", j.get("position"), j.get("company"), j.get("url"),
apply_url=j.get("apply_url"), location=j.get("location") or "Remote", salary_min=lo, salary_max=hi,
currency="USD" if lo or hi else None, tags=j.get("tags"),
posted_at=iso(j.get("epoch")) or iso(j.get("date"))))
return out
def remotive(pages: int, keywords: list[str]) -> list[dict]:
data = get_json("https://remotive.com/api/remote-jobs")
return [job("remotive", j.get("title"), j.get("company_name"), j.get("url"),
location=j.get("candidate_required_location"), salary_text=j.get("salary") or None,
tags=j.get("tags"), job_type=j.get("job_type"), posted_at=iso(j.get("publication_date")))
for j in data.get("jobs", [])]
def arbeitnow(pages: int, keywords: list[str]) -> list[dict]:
out = []
for page in range(1, pages + 1):
data = get_json(f"https://www.arbeitnow.com/api/job-board-api?page={page}")
for j in data.get("data", []):
if not j.get("remote"): # most Arbeitnow jobs are on-site in Germany
continue
out.append(job("arbeitnow", j.get("title"), j.get("company_name"), j.get("url"),
location=j.get("location"), tags=j.get("tags"),
job_type=", ".join(j.get("job_types") or []) or None,
posted_at=iso(j.get("created_at"))))
if not (data.get("links") or {}).get("next"):
break
pause()
return out
def wwr(pages: int, keywords: list[str]) -> list[dict]:
root = ET.fromstring(fetch("https://weworkremotely.com/remote-jobs.rss"))
out = []
for it in root.iter("item"):
raw = it.findtext("title") or ""
company, _, title = raw.partition(": ") # WWR titles look like "Acme: Senior Dev"
if not title:
company, title = "", raw
skills = (it.findtext("skills") or "").split(",")
out.append(job("wwr", title, company, it.findtext("link"),
location=it.findtext("region") or None,
tags=[it.findtext("category") or ""] + skills, job_type=it.findtext("type"),
posted_at=iso(it.findtext("pubDate"))))
return out
def _himalayas_rows(data: dict) -> list[dict]:
out = []
for j in data.get("jobs", []):
lo, hi = money(j.get("minSalary")), money(j.get("maxSalary"))
out.append(job("himalayas", j.get("title"), j.get("companyName"), j.get("guid"),
apply_url=j.get("applicationLink"), location=", ".join(j.get("locationRestrictions") or []) or "Worldwide",
salary_min=lo, salary_max=hi, currency=j.get("currency") if lo or hi else None,
tags=j.get("categories"), job_type=j.get("employmentType"),
posted_at=iso(j.get("pubDate"))))
return out
def himalayas(pages: int, keywords: list[str]) -> list[dict]:
"""Browse feed (cursor) without keywords; server-side search (page=) with them.
The browse feed is sorted by recent activity, so 3 pages = roughly the last hour.
Search keeps the default relevance sort: sort=recent matched descriptions, not titles
(2 of 48 titles said "python" in our test), so filter by date locally instead."""
out = []
if keywords:
for kw in keywords:
for page in range(1, pages + 1):
data = get_json(f"https://himalayas.app/jobs/api/search?q={urllib.parse.quote(kw)}&page={page}")
if not data.get("jobs"): # docs: stop on an empty page, not on totalCount
break
out += _himalayas_rows(data)
pause()
return out
cursor = None
for _ in range(pages): # 20 jobs per call is the API max
data = get_json("https://himalayas.app/jobs/api?limit=20" + (f"&cursor={cursor}" if cursor else ""))
out += _himalayas_rows(data)
cursor = data.get("nextCursor")
if not cursor:
break
pause() # unkeyed callers share one rate-limit pool
return out
def jobicy(pages: int, keywords: list[str]) -> list[dict]:
data = get_json("https://jobicy.com/api/v2/remote-jobs?count=100")
out = []
for j in data.get("jobs", []):
lo, hi = money(j.get("salaryMin")), money(j.get("salaryMax"))
out.append(job("jobicy", j.get("jobTitle"), j.get("companyName"), j.get("url"),
location=j.get("jobGeo"), salary_min=lo, salary_max=hi,
currency=j.get("salaryCurrency") if lo or hi else None,
tags=j.get("jobIndustry"), job_type=", ".join(j.get("jobType") or []) or None,
posted_at=iso(j.get("pubDate"))))
return out
FEEDS = {"remoteok": remoteok, "remotive": remotive, "arbeitnow": arbeitnow,
"wwr": wwr, "himalayas": himalayas, "jobicy": jobicy}
def norm(s: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", (s or "").lower()).strip()
def dedupe(rows: list[dict]) -> tuple[list[dict], int, int]:
"""Same company + same title = same job. Returns (rows, cross_feed_dupes, same_feed_dupes)."""
first, out, cross, same = {}, [], 0, 0
for r in rows:
key = (norm(r["company"]), norm(r["title"]))
if key in first:
if first[key] == r["source"]:
same += 1 # e.g. one req posted once per city
else:
cross += 1
continue
first[key] = r["source"]
out.append(r)
return out, cross, same
def matches(r: dict, keywords: list[str], use_tags: bool) -> bool:
if not keywords:
return True
text = r["title"] + (" " + " ".join(r["tags"]) if use_tags else "")
hay = f" {norm(text)} "
return any(f" {norm(k)} " in hay for k in keywords) # whole words: "go" != "google"
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("-k", "--keyword", action="append", default=[], help="keep jobs whose title/tags contain it (repeatable)")
ap.add_argument("--match-tags", action="store_true", help="also match keywords against tags (noisy on Remote OK)")
ap.add_argument("--feeds", default=",".join(FEEDS), help="comma list, default: all")
ap.add_argument("--pages", type=int, default=3, help="pages for paginated feeds (himalayas, arbeitnow)")
ap.add_argument("--since-days", type=int, default=0, help="drop jobs older than N days (0 = keep all)")
ap.add_argument("--out", default="jobs", help="output prefix -> PREFIX.json + PREFIX.csv")
ap.add_argument("--no-cache", action="store_true")
args = ap.parse_args()
global CACHE_TTL
if args.no_cache:
CACHE_TTL = 0
names = [n.strip() for n in args.feeds.split(",") if n.strip() in FEEDS]
t0, rows, report = time.time(), [], {}
def run(name):
t = time.time()
try:
got = FEEDS[name](args.pages, args.keyword)
return name, got, f"{len(got)} jobs in {time.time() - t:.1f}s"
except Exception as e: # one dead feed must not kill the run
return name, [], f"FAILED: {type(e).__name__}: {e}"
with ThreadPoolExecutor(max_workers=len(names) or 1) as pool:
for name, got, msg in pool.map(run, names):
report[name] = msg
rows += got
fetched = len(rows)
rows = [r for r in rows if r["title"] and r["url"]]
rows.sort(key=lambda r: r["posted_at"] or "", reverse=True) # newest copy wins the dedupe
rows, cross, same = dedupe(rows)
if args.since_days:
cutoff = (datetime.now(timezone.utc) - timedelta(days=args.since_days)).strftime("%Y-%m-%dT%H:%M:%SZ")
rows = [r for r in rows if (r["posted_at"] or "") >= cutoff]
rows = [r for r in rows if matches(r, args.keyword, args.match_tags)]
Path(f"{args.out}.json").write_text(json.dumps(rows, indent=2, ensure_ascii=False))
with open(f"{args.out}.csv", "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=FIELDS)
w.writeheader()
for r in rows:
w.writerow({**r, "tags": "; ".join(r["tags"])})
for name in names:
print(f"# {name:<10} {report[name]}", file=sys.stderr)
print(f"# fetched {fetched}, duplicates removed {cross} cross-feed + {same} same-feed, kept {len(rows)} "
f"-> {args.out}.json / {args.out}.csv in {time.time() - t0:.1f}s", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
Run it:
python3 remote_jobs.py # every feed -> jobs.json + jobs.csv
python3 remote_jobs.py -k python --since-days 7 --out python_jobs
python3 remote_jobs.py -k "data engineer" -k analyst --feeds himalayas,jobicy,wwr
Change the UA line to your own project URL first. A clear User-Agent is the polite default, and it gives the board a way to reach you instead of just blocking you.
Real numbers from a live run
2026-10-19, 11:29 UTC, default settings (3 pages for the paginated feeds), from a small Linux VM:
| Feed | Jobs kept | Time | Has salary | Posted in last 7 days |
|---|---|---|---|---|
| Remote OK | 98 | 0.6 s | 20 | 26 |
| Remotive | 11 | 0.2 s* | 7 (text) | 2 |
| Arbeitnow (remote only) | 82 | 3.8 s | 0 | 82 |
| We Work Remotely | 90 | 0.6 s | 0 | 69 |
| Himalayas | 60 | 3.5 s | 24 | 60 |
| Jobicy | 99 | 0.2 s | 55 | 99 |
| Total | 440 | 3.8 s wall | 106 | 338 |
* Remotive's 0.2 s is from the live call three minutes earlier. That run served it from the local cache, which is the point of the cache when a board asks for ~4 calls a day.
445 jobs came in. 5 were duplicates: 3 across boards and 2 inside one board. That left 440. The feeds run in parallel threads, so the wall time is the slowest feed (Arbeitnow's 3 pages with a 1 s pause between them), not the sum.
With -k python, the script kept 58 jobs whose title says Python: 55 from Himalayas search, 2 from WWR and 1 from Jobicy. Adding --since-days 7 brought it down to 15.
Gotchas I hit testing these remote job APIs
- Remote OK's first element is not a job.
data[0]is{"last_updated": ..., "legal": "API Terms of Service: ..."}. Skip it, or you get one empty "job" with the legal text in it. Also,salary_min: 0means "not given", not $0. - "Latest jobs" isn't always recent. In Remote OK's feed, 43 of 98 jobs were more than 30 days old (oldest 2026-08-12). WWR's RSS had 4 jobs over 30 days old, the oldest from 2026-01-21. If you build an alert, always pass
--since-days. - Tags are noisy, so match on titles.
remoteok.com/api?tag=pythonreturned 100 jobs, and only 4 had "python" in the title. One was "Director of IT and Workplace". Matching keywords against tags too (--match-tags) raised my Python result from 58 to 72, and none of the 14 extra jobs had Python in the title. The script matches titles by default, using whole words sogodoesn't match "Google". - Remotive's public API is small, and search didn't narrow it. It returned 12 jobs in total, delayed 24h by design.
?search=pythonstill returned 12 jobs, and none had Python in the title. It's still useful as one more source, but cache it. - Arbeitnow "remote": true doesn't mean fully remote. Across 3 pages (900 jobs), 83 had
remote: true. Of the 82 the script kept, 57 list a city instead of "Remote" (Berlin 23, Zürich 9, Paris 8). Readlocationbefore you call a job "work from anywhere". - Himalayas: 20 per page, and
sort=recentchanges what "q" means. The browse feed is ordered by recent activity, so 3 pages (60 jobs) covered only the last ~48 minutes of updates. Search (/jobs/api/search?q=python) is the better tool for keywords. But with&sort=recent, only 2 of 48 returned titles said Python (it appears to match descriptions), and page sizes came back as 20, 8, 20. I kept the default relevance sort and filter by date locally.totalCountwas5000for "python", and the docs call it an estimate, so stop on an empty page instead. - Duplicates come in two kinds. The cross-board ones were real reposts: Askable and Lemon.io on Remote OK and Remotive, Spiralyze on Remote OK and WWR. The same-board ones were the same title at the same company posted twice (GitLab on Arbeitnow, Cloudflare on Jobicy), which is often one opening per city. If you need every city, remove the same-source branch in
dedupe(). - Four date formats. Epoch seconds (Remote OK, Arbeitnow, Himalayas), ISO without a zone (Remotive), ISO with an offset (Jobicy), RFC 822 (WWR's
pubDate). Normalize once iniso()and sort on the result. - WWR puts the company in the title. RSS titles look like
"BDF EXPERTS: SAP Berater FI/CO (M/W/D)", so split on the first": ".
Using remote jobs data without breaking the terms
- Show the source and link to the job URL from the feed. Use
apply_urlfor apply buttons. - Cache. Every board above updates hourly or daily, so polling every minute just burns their goodwill (and your access).
- Don't resubmit Remotive or Himalayas jobs to other job boards or aggregators. Both say so explicitly.
- Don't gate the listings behind an email signup. Remotive calls that out as a breach.
When the free script is enough vs a hosted remote jobs API
For a personal alert or a weekly export, the script above is enough. Run it from cron and diff jobs.json.
If you'd rather not run it yourself, I maintain a hosted version on Apify: Remote Jobs API (Remote OK, Jobicy & Arbeitnow). It queries those three boards' public APIs, dedupes across them, adds descriptions as plain text and an attribution string per row, and costs $1 per 1,000 jobs saved. It doesn't include Remotive or Himalayas (both ask that their jobs not be passed on to other boards or aggregators) or WWR. If you need those, call them yourself with the script.
FAQ
Is there a free remote jobs API? Yes. All six feeds above are free and need no key. Himalayas offers a free key if you need a higher rate limit (60/min, 10,000/day).
Can I get remote jobs as JSON without scraping HTML? Yes. Five of the six return JSON directly, and WWR's RSS is easy to parse with the standard library.
Which remote job board API has salary data? In this run, Jobicy had numeric salaries on 55 of 99 jobs, Himalayas on 24 of 60 and Remote OK on 20 of 98. Remotive had free-text salaries on 7 of 11. WWR and Arbeitnow had none.
Can I call these from browser JavaScript? Not reliably. Himalayas documents that it sends no CORS headers. Run it server-side or in a build step and cache the result.
About the author
- I build data APIs for developers: job listings from 13k company career sites, bulk WHOIS/RDAP domain checks, sitemaps, PDFs and SEC filings. Python, scraping without the pain, pay-per-result.
Comments
Join the discussionNo comments yet. Be the first to comment.


