Skip to main content

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.

Updated 15 min readMarkdown
Remote jobs API: pull live remote jobs as JSON from 6 public job feeds (Python)

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

  1. 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: 0 means "not given", not $0.
  2. "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.
  3. Tags are noisy, so match on titles. remoteok.com/api?tag=python returned 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 so go doesn't match "Google".
  4. Remotive's public API is small, and search didn't narrow it. It returned 12 jobs in total, delayed 24h by design. ?search=python still returned 12 jobs, and none had Python in the title. It's still useful as one more source, but cache it.
  5. 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). Read location before you call a job "work from anywhere".
  6. Himalayas: 20 per page, and sort=recent changes 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. totalCount was 5000 for "python", and the docs call it an estimate, so stop on an empty page instead.
  7. 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().
  8. 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 in iso() and sort on the result.
  9. 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_url for 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.

No comments yet. Be the first to comment.