I run a visa agency in Bali, and the other half of my week goes into Apify Actors. One of them reads Indeed's remote listings — the postings the employer actually flagged Remote, not the ones with "remote" in the title. It gets used for the boring, useful thing: a daily feed of fresh remote roles in a category.
A daily feed sounds like a solved problem. Pull the top 25 per keyword every morning, keep the job IDs you have not seen, email the rest. I had that running against my own Actor as a health check, so I had two weeks of identical runs sitting in storage. When I finally went through them, the "unseen IDs" rule turned out to be measuring something other than new jobs.
The runs
Twenty-four runs, 22 October to 4 November, byte-identical input every time:
{ "keywords": ["customer support", "software engineer"], "country": "us", "maxJobsPerQuery": 25, "datePosted": "any", "sortBy": "relevance", "includeContacts": true } Fifty rows in 23 of the 24 runs — 25 per keyword — and 48 once, when Indeed returned only 23 engineers. Median wall time 6.1 seconds (3.6 to 16.0) for 50 jobs with full descriptions, because Indeed's search endpoint hands over the whole posting in the search response; there is no per-job page fetch to pay for.
1,198 job rows in total. 373 distinct job IDs.
That ratio is the whole article. Twenty-four pulls of "the top 25" produced 373 different jobs, not 25 jobs seen 24 times.
The top 25 is not a top 25
Here is the overlap between consecutive software engineer pulls, counting shared job IDs out of 25:
| Pair | Gap | Shared |
|---|---|---|
| 31 Oct 20:11 → 20:32 | 21 min | 25 / 25 |
| 1 Nov 20:10 → 20:32 | 22 min | 25 / 25 |
| 2 Nov 20:10 → 20:13 | 3 min | 25 / 25 |
| 3 Nov 20:12 → 20:18 | 6.5 min | 0 / 25 |
| 4 Nov 20:12 → 20:14 | 2 min | 22 / 25 |
Same keyword, same filter, same country, minutes apart. Sometimes the identical page; once, 0 of 25 in six and a half minutes. Across all 23 consecutive software engineer pairs the overlap ranged from 0 to 25, with eight pairs at 6 or fewer.
Across the whole fortnight, counting how many of the 24 runs each job appeared in:
| Appeared in | Jobs |
|---|---|
| exactly 1 run | 165 |
| 2–3 runs | 104 |
| 4–11 runs | 93 |
| 12 or more | 11 |
One job survived 22 of 24 runs (a Software Engineer role at Trillium Engineering, posted 26 August). Forty-four percent of everything I ever saw showed up once and never again.
This is not jobs appearing and expiring. expired was false on all 373 rows, and the two near-disjoint runs are not split by freshness in any clean way — the 20:12 set had a median posting age of 98 days, the 20:18 set 40 days, and both contained postings from the same week. What varies is which slice of a much larger result set Indeed's relevance ranking decides to put on page one for a given request. The filter is deterministic; the ordering is not.
So: do not treat position in a relevance-sorted job search as a stable key for anything. Not "the market shrank", not "this role was pulled", not "we are now ranking above competitor X". Twenty-five is a sample, drawn fresh each time.
Deduplicating by ID does not give you new jobs
Take one run per day, keep a running set of IDs, count the ones you have never seen — the classic feed rule. Then check how many of those were actually posted in the previous 24 hours:
| Day | New IDs | Posted in last 24 h |
|---|---|---|
| 23 Oct | 26 | 3 |
| 24 Oct | 23 | 2 |
| 26 Oct | 14 | 1 |
| 27 Oct | 9 | 3 |
| 28 Oct | 6 | 3 |
| 29 Oct | 40 | 4 |
| 30 Oct | 24 | 1 |
| 31 Oct | 16 | 1 |
| 1 Nov | 18 | 2 |
| 2 Nov | 4 | 0 |
| 3 Nov | 32 | 6 |
| 4 Nov | 12 | 5 |
| Total | 224 | 31 |
Eighty-six percent of what a dedupe-by-ID feed would have called "new remote jobs" were postings that already existed and simply had not surfaced yet. If you send that to subscribers daily, the complaint arrives in about a week: I applied to this one already.
The fix is on the input side, not in your dedupe logic. Ask Indeed for freshness instead of inferring it:
{"keywords": ["customer support"], "country": "us", "datePosted": "day", "maxJobsPerQuery": 100} and then still filter on postedAt yourself, because datePosted is Indeed's idea of recent, not yours. Keep the ID set as a safety net against duplicates, never as the definition of "new".
While you are at it, ignore listedAt. It is Indeed's own indexing timestamp and it does not behave: the gap between postedAt and listedAt was exactly zero on 238 of 373 rows, ran up to 2,400 days on the oldest posting, and went negative on three — one row claims it was posted on 2026‑02‑12 and listed on 2025‑03‑19. postedAt is the date to trust.
"Remote" does not mean "location-free"
Every one of the 373 rows carried isRemote: true, workplaceType: "remote", and the string Remote among Indeed's own attributes. The Remote filter is honest. But location:
What location contains | Jobs |
|---|---|
a city, often with a ZIP — Boston, MA, Salt Lake City, UT, New Haven, CT 06510 | 196 |
the literal string Remote | 159 |
a state or area name — Minnesota, Puerto Rico | 18 |
A remote job with a ZIP code is not a contradiction and it is not a bug in the data. It is the employer saying remote, but hire near this office or remote, but in this state for tax reasons. More than half the results were that kind of job. A real row:
{ "id": "6f72db136ac34058", "title": "Software Engineer II, Engineering", "company": "Ellevation Education", "location": "Boston, MA", "region": "MA", "countryCode": "US", "isRemote": true, "workplaceType": "remote", "employmentType": "fulltime", "salaryMin": 125000, "salaryMax": 145000, "salaryCurrency": "USD", "salaryPeriod": "year", "salaryEstimated": false, "postedAt": "2026-10-20T05:00:00.000Z", "expired": false } If your product promises work from anywhere, filter to location == "Remote" and accept that you just threw away 57% of the results. If it promises remote roles for people in Ohio, do the opposite. Either way the decision has to be explicit — isRemote will not make it for you.
Salary: two currencies of time in one column
204 of 373 rows carried a salary. The periods:
salaryPeriod | Rows |
|---|---|
year | 110 |
hour | 91 |
week | 2 |
month | 1 |
Average the midpoints of salaryMin/salaryMax without looking at salaryPeriod and you get $73,309. Annualise first — hourly × 2,080, weekly × 52, monthly × 12 — and the same rows give $101,956, median $95,000. The naive number is not merely wrong, it is wrong in the direction that makes remote work look underpaid, because hourly postings are concentrated in customer support.
MULT = {"year": 1, "hour": 2080, "week": 52, "month": 12, "day": 260} def annual(row): lo, hi, per = row.get("salaryMin"), row.get("salaryMax"), row.get("salaryPeriod") if lo is None or per not in MULT: return None return (lo + (hi or lo)) / 2 * MULT[per] One more thing in that column: 18 of the 204 values are not round numbers. 99591.140625, 85849.921875, 24.040000915527344. Those are 32-bit floats somewhere upstream of me, round-tripped into JSON. Round them before display — and do not read 99591.14 as a precisely negotiated figure.
A field I expected to matter and did not: salaryEstimated was false on all 204 rows. Indeed's own estimate bands did not appear once in this sample, so every salary here came from the employer. Do not build logic that depends on the estimate being there.
Age, and what datePosted: "any" really means
Median posting age in the sample: 30 days. But 86 of 373 were older than 90 days, 25 were older than a year, and the oldest — a Customer Experience Team Member at LuckyGunner.com — was posted on 9 October 2019, seven years ago, still not flagged expired, still landing on page one of a relevance search. "Relevance" has no opinion about age unless you give it one.
Calling it
curl -X POST "https://api.apify.com/v2/acts/lergassy~remote-jobs-scraper/run-sync-get-dataset-items?token=YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"keywords":["customer support"],"country":"us","datePosted":"day","maxJobsPerQuery":25}' Fifty rows with full descriptions cost $0.04 on the Free plan ($0.0008 per job, cheaper on paid plans) and come back in about six seconds, so the sync endpoint is fine for feed-sized pulls. The Actor is Remote Jobs Scraper; if you also want on-site and hybrid roles or company career sites, the same engine without the forced Remote filter is Jobs API.
When this is the wrong tool
If you need the complete set of remote jobs for a keyword, no top-N search will give it to you, and my 373-from-24-pulls number is the evidence. Widen maxJobsPerQuery and split the query by state or job type rather than pulling the same page repeatedly — repetition buys you a random new slice, which is a terrible way to pay for coverage.
If you need a market-size figure — "how many remote support roles exist in Canada" — this is the wrong instrument entirely. Search result counts on Indeed are not a census, and a 25-row sample redrawn every call is not a trend line.
Where it does earn its keep is the per-posting layer: full description, apply link, employer website and size, salary as numbers with an explicit period, and the recruiter's email when the posting contains one — 35 of my 373 rows had an email, 9 had a phone — in one request per 25 jobs, with no login and no browser. Just do not ask page one to tell you what changed since yesterday. Ask postedAt.