Practical guide
Remote Jobs API: Pull Listings from Jobicy and We Work Remotely (2026)
Last reviewed: October 24, 2026
Pull remote listings from official JSON and RSS feeds, keep their source links and save only newly discovered roles for your daily workflow. Start with Jobicy and We Work Remotely, then normalize and compare their rows.
Which remote job listings APIs are free?
Start with the publisher's supported interface. These two sources worked without keys in live checks on October 24, 2026. A free job listings API still has source conditions and a bounded inventory.
| Board | Official interface | Published conditions |
|---|---|---|
| Jobicy | https://jobicy.com/api/v2/remote-jobs (tested); current docs recommend https://jobicy.com/api/jobs. | Free public access. Credit Jobicy and keep its listing URL for Apply actions. Start at most one automated synchronization pass per hour; cursor pages may follow sequentially. |
| We Work Remotely | https://weworkremotely.com/remote-jobs.rss; category feeds are also listed. | Public RSS reuse with links attributed to WWR. The live channel has ttl=60. No numeric request quota was found on its feed page or general terms. |
Checked October 24, 2026: Jobicy: current API documentation and fair use, Jobicy: official API repository, fields and polling rules, Jobicy: live v2 response with count and tag, We Work Remotely: public RSS feeds and attribution, We Work Remotely: live RSS feed and cache TTL and We Work Remotely: terms and conditions. RSS TTL describes cache minutes, not a contractual rate limit (RSS Advisory Board: RSS 2.0 specification). Use hourly or slower WWR refreshes as a conservative default.
Two other popular boards need separate review. Himalayas offers keyless access and optional free keys for higher limits; keyed accounts get 60 requests per minute and 10,000 per day. Its API terms allow display on your own sites or apps with source credit and unchanged applicationLink, but forbid onward distribution to third-party boards or aggregators. Remotive requires source credit and a link back, prohibits submission to third-party job sites, and forbids requiring signup or email collection to reveal listings. Its public jobs are delayed 24 hours. These conditions were checked October 24, 2026; neither board is used in the examples below.
Jobicy API v2: curl with count and tag
This request returned HTTP 200, 20 jobs and matching applied filters on October 24, 2026. Treat the v2 URL as a tested interface; the current documentation instead advertises /api/jobs. No deprecation date for v2 was established. Live response; current documentation.
# Run in Bash with curl and jq installed.
set -euo pipefail
curl --fail --silent --show-error --max-time 30 \
'https://jobicy.com/api/v2/remote-jobs?count=20&tag=python' \
> jobicy.json.tmp
jq -e '.jobs | type == "array"' jobicy.json.tmp > /dev/null
mv jobicy.json.tmp jobicy.json
The docs accept count from 1 to 200; tag searches job content. Listings cover seven publication days with a three-hour delay. A page is not the whole feed. For broader coverage, follow nextCursor with unchanged filters until it is null; restart without a cursor on the next scheduled pass. Jobicy: current API documentation and fair use.
This small example intentionally watches Python matches from one page. If hasMore is true, it is incomplete. For a complete filtered pass, combine the jobs arrays from all cursor pages into one validated jobicy.json before processing. Preserve the prior good file if any page fails. Check the returned fields whenever the publisher changes its API.
Fetch and parse the We Work Remotely RSS feed in Python
Run this Python 3 script once per collection pass. It uses the standard library, bounds the download, checks for an RSS channel and replaces the saved file only after parsing. A timeout, HTTP error or malformed XML stops the script. Python: urllib.request and Python: ElementTree XML API document the underlying functions.
# Save as fetch_wwr.py; run: python3 fetch_wwr.py
import json
from pathlib import Path
from urllib.request import Request, urlopen
import xml.etree.ElementTree as ET
request = Request("https://weworkremotely.com/remote-jobs.rss",
headers={"User-Agent": "RemoteFeedReader/1.0"})
with urlopen(request, timeout=30) as response:
body = response.read(8 * 1024 * 1024 + 1)
if len(body) > 8 * 1024 * 1024:
raise ValueError("Feed exceeds size limit")
root = ET.fromstring(body)
if root.tag != "rss" or root.find("channel") is None:
raise ValueError("Expected RSS channel")
fields = ("guid", "title", "link", "pubDate", "category", "region", "country", "state", "type")
rows = [{key: item.findtext(key) for key in fields}
for item in root.findall("./channel/item")]
target = Path("wwr.json")
target.with_suffix(".tmp").write_text(json.dumps(rows), encoding="utf-8")
target.with_suffix(".tmp").replace(target)
print(f"Saved {len(rows)} WWR items")
The all-jobs feed returned HTTP 200 and 95 items in our October 24, 2026 check. That is an observation, not a guaranteed feed size. Use the official feed page for category-specific URLs, such as https://weworkremotely.com/categories/remote-programming-jobs.rss. Fetch either the combined feed or the categories you need; avoid duplicating both requests.
In the observed feed, titles used a company prefix followed by a colon. Extra fields included region, country, state and type. The next script treats that title split as a heuristic and retains the source link for review. Do not render feed descriptions as trusted HTML.
Normalize fields and dedupe across remote job boards
Use a stable internal schema and keep the publisher's original values alongside any derived fields. The mapping below is based on the Jobicy field reference and the observed WWR RSS, checked October 24, 2026.
| Your field | Jobicy | WWR RSS |
|---|---|---|
id | Prefix the numeric id with jobicy: | Prefix guid, falling back to link, with weworkremotely: |
title, company | jobTitle, companyName | Split the observed Company: Title pattern; review ambiguous names. |
remoteRegion | jobGeo | region; retain country and state separately when supplied. |
postedAt | pubDate | pubDate; parse into UTC, preserving unknowns as null. |
sourceUrl, applyUrl, attribution | Keep public url and visible Jobicy credit. | Keep link and visible We Work Remotely credit. |
This starter drops descriptions and salary rather than guessing. If you add salary, retain minimum, maximum, currency and pay interval as separate nullable fields. Do not equate an hourly amount with annual pay. Conflicting region fields need review; remote does not automatically mean worldwide eligibility.
# Save as normalize_remote.py; run: python3 normalize_remote.py
import json
import unicodedata
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from html import unescape
from pathlib import Path
def utc(value):
if not value:
return None
try:
stamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
try:
stamp = parsedate_to_datetime(value)
except (ValueError, TypeError, IndexError):
return None
return stamp.astimezone(timezone.utc).isoformat() if stamp.tzinfo else None
def folded(value):
return " ".join(unicodedata.normalize("NFKC", unescape(value or "")).casefold().split())
jobicy = json.loads(Path("jobicy.json").read_text(encoding="utf-8"))
wwr = json.loads(Path("wwr.json").read_text(encoding="utf-8"))
if not isinstance(jobicy.get("jobs"), list) or not isinstance(wwr, list):
raise ValueError("Expected both source arrays")
rows = []
for job in jobicy["jobs"]:
rows.append({"id": "jobicy:" + str(job["id"]), "source": "Jobicy",
"title": unescape(job["jobTitle"]), "company": unescape(job["companyName"]),
"remoteRegion": job.get("jobGeo"), "category": job.get("jobIndustry") or [],
"postedAt": utc(job.get("pubDate")), "sourceUrl": job["url"],
"applyUrl": job["url"], "attribution": "Source: Jobicy"})
for job in wwr:
company, separator, title = unescape(job.get("title") or "").partition(": ")
rows.append({"id": "weworkremotely:" + (job.get("guid") or job["link"]),
"source": "We Work Remotely", "title": title if separator else company,
"company": company if separator else None,
"remoteRegion": job.get("region"),
"locationRestrictions": [job[k] for k in ("country", "state") if job.get(k)],
"category": [job["category"]] if job.get("category") else [],
"postedAt": utc(job.get("pubDate")), "sourceUrl": job["link"],
"applyUrl": job["link"], "attribution": "Source: We Work Remotely"})
unique = {}
for row in rows:
if not row["title"] or not row["sourceUrl"]:
raise ValueError("Missing title or source URL")
key = (folded(row["company"]), folded(row["title"])) if row["company"] else (row["id"],)
unique.setdefault(key, row) # Jobicy wins; keep its own links and credit.
Path("jobs.json").write_text(json.dumps(list(unique.values()), indent=2), encoding="utf-8")
Employer/title matching catches the same role posted on both boards when labels agree. It can merge separate requisitions or locations and miss spelling variants. For a requisition inventory, prefer a verified shared employer requisition identifier, or keep candidate matches for review. Avoid stripping punctuation aggressively: C++ and C are different labels. This example keeps the first matching row, with its own source credit; if you combine details from both, retain both provenance links.
Get only new postings each day
Refresh both source files successfully, run normalize_remote.py, then run the script below once per day. It saves a first-run baseline and later writes previously unseen employer/title combinations to new-remote.json. Reusing that key across days suppresses a role that moves from one board to the other. Keep the state file between runs.
# Save as new_remote.py; run: python3 new_remote.py
import json
import unicodedata
from datetime import datetime, timezone
from pathlib import Path
rows = json.loads(Path("jobs.json").read_text(encoding="utf-8"))
if not isinstance(rows, list):
raise ValueError("Expected normalized rows")
state = Path("seen-remote.json")
first_run = not state.exists()
seen = set(json.loads(state.read_text(encoding="utf-8"))) if not first_run else set()
def key(row):
def fold(value):
return " ".join(unicodedata.normalize("NFKC", value).casefold().split())
return json.dumps([fold(row["company"]), fold(row["title"])]) if row.get("company") else row["id"]
now = datetime.now(timezone.utc).isoformat()
new = []
for row in rows:
identity = key(row)
if identity not in seen and not first_run:
new.append({**row, "firstSeenAt": now})
seen.add(identity)
# Save a durable batch before advancing the seen set.
Path("new-remote.json").write_text(json.dumps(new, indent=2), encoding="utf-8")
state.with_suffix(".tmp").write_text(json.dumps(sorted(seen)), encoding="utf-8")
state.with_suffix(".tmp").replace(state)
print(f"Saved {len(new)} new roles; first run establishes a baseline")
Use a daily scheduler in a fixed timezone. Keep one state file per filter scope and prevent overlapping runs. Archive or deliver each new batch before the next run overwrites it. A production alert pipeline should track delivery acknowledgements separately so a failed delivery can be retried. Resetting state deliberately establishes a fresh baseline.
These are newly discovered roles, which can include older postings. A strict “published in the previous 24 hours” digest needs an additional UTC publication-date filter. Keep an overlapping collection window and a persistent seen set so delayed arrivals are not lost. Same-employer/title reposts and edits are suppressed by this example. An item missing from a recent feed does not prove it closed, and downtime longer than the feed window can leave gaps.
Poll politely, cache and show attribution per row
- Share one cache per source across users and filters. For Jobicy, start no more than one synchronization pass per hour and fetch its cursor pages sequentially (Jobicy: official API repository, fields and polling rules). For WWR, respect the observed 60-minute TTL; daily polling is enough for a daily digest.
- Store fetch time and successful payload together. Where the server supplies ETag or Last-Modified, send If-None-Match or If-Modified-Since on refresh. A 304 reuses the cached body. Conditional requests still count as requests (RFC 9110: conditional requests and Retry-After).
- Honor Retry-After and back off on temporary errors. Stop on access denial. Preserve your last good snapshot, label it stale and skip the diff after a failed fetch. Never turn an error page into an empty inventory.
- Use a truthful User-Agent and review current Jobicy robots.txt, WWR robots.txt and source terms before recurring collection.
- Display a visible source name and original posting link on every row, including exported dashboards. Keep Apply buttons on the supplied board URL for these free feeds. Do not replace Jobicy links with guessed employer URLs.
The snippets above are single-pass examples. Add the cache, scheduler and backoff around them before running an unattended collector. A source cap or a truncated page limits coverage even when the code exits successfully.
The easy option: a remote jobs scraper with monitor mode
Remote Jobs Scraper API collects Jobicy and We Work Remotely into one normalized dataset, dedupes by employer/title and keeps original board links and attribution. Its README documents keyword, category and remote-region filters, posted salary when supplied, and JSON, CSV or Excel export. Descriptions are off by default; optional excerpts are redacted and limited to 3,000 characters.
{
"sources": ["jobicy", "weworkremotely"],
"keywords": ["python"],
"maxResults": 100,
"monitorMode": true,
"monitorKey": "daily-python-roles",
"includeDescription": false
}
In the Actor's monitor mode, the first run emits current matches up to the cap. Later runs emit unseen employer/title combinations with firstSeenAt. Reuse monitorKey and filters, and preserve the named remote-jobs-aggregator-state store. Changing filters starts a fresh scope. This first-run behavior differs from the manual baseline script above.
Schedule sequential hourly or daily runs and inspect RUN_SUMMARY for cache age, skipped sources and coverage limits. The shared source cache refreshes at most hourly. Results are a recent window, and caps or filters can reduce output. The Actor produces job rows; configure alert delivery through your own integration. It supports only these two sources. Planned README pricing is $1.00 per 1,000 jobs; confirm the final Store price and platform fees.
Frequently asked questions
Is there a free remote jobs API without a key?
The tested Jobicy public endpoint and We Work Remotely RSS feed worked without keys on October 24, 2026. Keep their attribution and original posting links. Free access still has source conditions.
Does We Work Remotely have an RSS feed?
Yes. The all-jobs feed is https://weworkremotely.com/remote-jobs.rss, and its feed page lists category feeds. WWR asks users to attribute links back to We Work Remotely.
How often can I poll these feeds?
Jobicy allows a new automated synchronization pass at most hourly, with sequential cursor pages within that pass. The observed WWR RSS TTL is 60 minutes; no numeric quota was found on its feed page or terms. Use hourly or slower refreshes.
How do I avoid duplicate daily job alerts?
Persist a seen set across runs. Use source-prefixed IDs for individual postings and a reviewed employer/title match for cross-board roles. That match can merge separate requisitions and suppress same-title reposts.
Can these feeds return every open remote job?
No. These examples collect a recent source window. Page caps, filters, delayed publication and feed turnover can omit jobs. A listing disappearing from a discovery feed is not proof of closure.
Sources
Official documentation and live feeds checked October 24, 2026. Feed fields and response sizes are observations on that date; policies and interfaces can change.
- Jobicy: current API documentation and fair use (checked October 24, 2026)
- Jobicy: official API repository, fields and polling rules (checked October 24, 2026)
- Jobicy: live v2 response with count and tag (checked October 24, 2026)
- We Work Remotely: public RSS feeds and attribution (checked October 24, 2026)
- We Work Remotely: live RSS feed and cache TTL (checked October 24, 2026)
- We Work Remotely: terms and conditions (checked October 24, 2026)
- Himalayas: API access and optional keys (checked October 24, 2026)
- Himalayas: terms for API users, clause 113 (checked October 24, 2026)
- Remotive: public API terms (checked October 24, 2026)
- RSS Advisory Board: RSS 2.0 specification (checked October 24, 2026)
- Python: urllib.request (checked October 24, 2026)
- Python: ElementTree XML API (checked October 24, 2026)
- RFC 9110: conditional requests and Retry-After (checked October 24, 2026)
- Jobicy: robots.txt (checked October 24, 2026)
- We Work Remotely: robots.txt (checked October 24, 2026)
Tool claims are limited to the supplied Actor README: Remote Jobs Scraper API documentation.