If you've ever searched for remote jobs from outside the US, you know the problem. The listing says Remote. You read the whole description, and in the last paragraph: \"Candidates must reside in one of the following states.\"
If you're a job seeker, our earlier post on finding remote jobs you can actually apply to from your country is the short version: one script, one filter, done.
This post is for the people building the filter: a remote job board, a newsletter or a job-matching tool. For them, "is it remote?" is the easy question. The useful question is "remote for whom?" Below are the signals to look at, a small Python classifier you can extend, and the false positives that will catch you out. If you need the raw postings first, this post shows how to pull them from Greenhouse, Lever, Ashby and Workday.
Three questions, not one
For each posting you want to know:
- Remote type: fully remote, remote-optional (remote possible but offices listed), or hybrid.
- Scope: worldwide, a region (EMEA, LATAM, "Americas"), specific countries, or unspecified.
- Time zone: some roles say "UTC-3 to UTC+1" or "must work PST hours", which matters as much as the country.
Where the signals are
In order of reliability:
- The ATS flag. Greenhouse, Lever, Ashby and others let employers mark a workplace type (remote, hybrid, on-site). When it's set, it's the best signal, but many postings leave it empty.
- The location field.
Remote - US,Anywhere in EMEA,Brazil (Remote),Hybrid - Berlin, or a list mixing offices and "Remote". - The title.
Senior Engineer (Remote),Work From Home. - The description. "3 days a week in our office", "must be located in", "working hours overlap with CET". It's the richest source and the noisiest.
A small classifier
Here's a heuristic version that handles the location field, which is where most of the eligibility information lives:
import re REGIONS = {"emea": "EMEA", "europe": "Europe", "latam": "LATAM", "apac": "APAC", "americas": "Americas", "north america": "North America"} COUNTRIES = {"us": "US", "usa": "US", "united states": "US", "canada": "CA", "uk": "GB", "united kingdom": "GB", "germany": "DE", "brazil": "BR"} FALSE_POSITIVES = re.compile(r"remote (sensing|pilot|patient|monitoring|support technician)", re.I) def classify(location, title=""): text = f"{title} | {location}".lower() if FALSE_POSITIVES.search(text): return {"remoteType": None} if "hybrid" in text: rtype = "HYBRID" elif re.search(r"\b(remote|anywhere|work from home|wfh)\b", text): rtype = "FULLY_REMOTE" else: return {"remoteType": None} regions = sorted({v for k, v in REGIONS.items() if re.search(rf"\b{k}\b", text)}) countries = sorted({v for k, v in COUNTRIES.items() if re.search(rf"\b{k}\b", text)}) if re.search(r"\b(anywhere|worldwide|global)\b", text) and not (countries or regions): scope = "WORLDWIDE" else: scope = "COUNTRY" if countries else "REGION" if regions else "UNSPECIFIED" return {"remoteType": rtype, "scope": scope, "countries": countries, "regions": regions} print(classify("Remote - US")) # {'remoteType': 'FULLY_REMOTE', 'scope': 'COUNTRY', 'countries': ['US'], 'regions': []} print(classify("Anywhere in EMEA")) # {'remoteType': 'FULLY_REMOTE', 'scope': 'REGION', 'countries': [], 'regions': ['EMEA']} print(classify("Houston, TX", title="Remote Sensing Analyst")) # {'remoteType': None} It's a starting point. Things it doesn't handle yet, and you'll hit all of them:
- "Remote" + office cities in one list.
["Remote", "New York, NY", "London"]usually means remote is possible but not the default (remote-optional). - One role, twelve postings. "Remote - Ohio", "Remote - Texas", … for the same job. Merge them and keep all locations.
- Corporate "Home Office". In some postings "Home Office" is the headquarters address, not working from home.
- "Temporarily remote" or "remote until relocation". Not really remote.
- Bare "Remote". No scope at all. Treat it as unspecified rather than worldwide; many US employers mean "in the US".
- Time zones. "PST hours", "UTC-3 to UTC+1", "±2 hours of CET" all need parsing into a UTC offset range if you want to filter by where someone lives.
Doing this at scale
Disclosure: we (SteadyScrape) built the tool below and get paid when it's used.
Remote Jobs API & Scraper: Filter by Country (steady-scrape/remote-jobs-api) is an Apify Actor that runs this kind of classification, with the guards above, over an index of 1.3 million+ open jobs from 37,000+ company career sites, refreshed daily from the employers' public job board APIs. Each remote job gets:
- a
remoteType(FULLY_REMOTE,REMOTE_OPTIONAL,HYBRID), - a
remoteConfidence(high when the ATS flag and the text agree) and theremoteEvidencebehind it, - a
remoteScope(WORLDWIDE,REGION,COUNTRY,UNSPECIFIED) witheligibleCountriesandeligibleRegions, - any stated time zones as a UTC offset range (
utcOffsetMin,utcOffsetMax).
Duplicate postings of the same role are merged, with every location kept in mergedPostings.
For a board or newsletter, the filter is the audience you serve. Here's a feed of senior data roles for readers in Europe who want at most two hours' difference from Lisbon:
import os from apify_client import ApifyClient client = ApifyClient(os.environ["APIFY_TOKEN"]) run = client.actor("steady-scrape/remote-jobs-api").call(run_input={ "keywords": ["data", "analytics"], "regions": ["Europe"], "workerTimezone": "Europe/Lisbon", "maxTimezoneDiffHours": 2, "seniority": ["SENIOR", "LEAD"], "postedWithinDays": 30, "maxResults": 40, }) for job in client.dataset(run["defaultDatasetId"]).iterate_items(): print(job["remoteConfidence"], job["remoteScope"], job["remoteEvidence"], job["title"], "|", job["company"]) What came back
We ran exactly that input on November 9, 2026, with the run's maximum charge capped at $0.02, so it stopped after 15 jobs. The run's OUTPUT record shows what the classifier and the dedupe did:
- 103 postings matched, and 79 were unique roles. The other 24 were the same role posted in several locations, which is the "one role, twelve postings" problem from the list above.
- All 15 returned jobs were
FULLY_REMOTE. 9 were high confidence and 6 medium. - 11 had a
COUNTRYscope and 4 aREGIONscope. For example, one senior product analyst role was open in 10 European countries (PT, IE, IT, RO, FR, NL, DE, CH, GB, ES), and three roles were limited to Spain (ES). - The evidence is readable. Each job lists the signals it was classified on. Five of the 15 had two, such as
["ats:REMOTE", "location:Remote - Europe"], where the ATS flag and the location text agree. The rest had the ATS flag alone, with the eligible countries taken from the location list.
The search itself took about 4 seconds.
Set onlyNewSinceLastRun: true and schedule it daily, and you have the feed for a remote job newsletter or a board. Add includeDescriptions: true to fetch full descriptions for Greenhouse, Lever, Ashby and Workday jobs. That also refines the classification ("3 days a week in our office", "must be located in…") and skips jobs that have closed. If your board shows pay or tech stacks, enrichJobs: true adds an annualized salary range, skills, seniority and a short summary, with recruiter names and contact details stripped.
Honest limits:
- The index only covers the supported ATS platforms.
- Eligibility is parsed from what the employer wrote, so check
remoteConfidenceandremoteEvidence. - Bare "Remote" postings come back as
UNSPECIFIED. These are excluded from country, region and time-zone filters unless you setincludeUnspecifiedEligibility: true.
What it costs
On Apify's free plan it's $0.0015 per remote job returned ($1.50 per 1,000), plus Apify's $0.00005 start fee per run. On paid Apify plans it's $0.0013 to $0.001 per job. Jobs that turn out to be closed during the description fetch are skipped and not charged.
Enrichment is an optional add-on: +$0.004 per enriched job on the free plan ($0.0036 to $0.003 on paid plans), so an enriched job is $0.0055 in total.
Examples at free-plan prices: a newsletter that runs daily and gets about 100 new jobs a day pays 30 × 100 × $0.0015 = $4.50 a month. The 40-job run above, uncapped, costs $0.06.
If you've found a "remote" phrasing that breaks classifiers, post it in the comments. That's how the guard list grows.