A feed of new job postings straight from company career sites: search a built-in directory of 692 boards or your own list, filter by time range, title, location, salary and visa sponsorship, and get only new jobs on each scheduled run. Expired-job tracking. $2 per 1,000 jobs.
Unreleased: one free status row when every board fails or is blocked (GAP17a)
Behaviour change (exit code 1 -> 0): when every entry fails or is blocked (network or HTTP errors, no board found, robots.txt or rate-limited hosts, or private addresses on the Apify platform), the run no longer FAILS (exit 1) with no dataset row. It now SUCCEEDS (exit 0) and writes ONE free status row: recordType: "status", status: "error", statusRow: true, uid: "status:allBoardsFailed", reason: "allBoardsFailed", message, demoMode, boardsRequested, boardsFailed, boardsBlocked, boardsNetworkError, boardsNotFound, boardsSkipped, failedBoards (first 50: input, status, error) and scrapedAt. This mirrors it-hiring-signals' allBoardsFailed row, which also exits 0.
The message (also the terminal status message, cut to 1000 characters) reads Not charged: every board failed or was blocked (<n> of <total> entries: <counts>). No jobs were returned and nothing was charged. Check the board URLs or slugs, or try again later. Reasons: <first three "input: error" pairs>, plus the existing notes on free "warning" and "unresolved" records when those were written. The counts use blocked (private, loopback or link-local address), network or HTTP error, not found (no board) and skipped (robots.txt or rate-limited host).
Charging: the row is pushed with no charged event, so nothing is charged (as before, when the run failed). Free unresolved and rate-limit warning records are still written as before. RUN_SUMMARY is unchanged. When at least one board is read, nothing changes.
README: one new Field notes bullet ("Every board failed or was blocked"). Files: CHANGELOG.md, README.md, my_actor/main.py (all_failed_status_row, main), tests/test_gap14_ratelimit.py (the all-boards-rate-limited test now expects the status row and a succeeded run), tests/test_gap17a_all_failed_status_row.py (new).
Unreleased: private-address guard (GAP17)
On the Apify platform (APIFY_IS_AT_HOME set), every request now checks the host first: if any address it resolves to is private, loopback, link-local, reserved, multicast, unspecified or otherwise not global (also IPv4-mapped IPv6), the request is not sent. This covers careers-page URLs, every redirect hop (an httpx request hook on the client), robots.txt and the ATS API calls. Local runs are unchanged.
A blocked careers page gets a free unresolved dataset row (and a RUN_SUMMARY entry) with not fetched: private, loopback and link-local addresses are not allowed. Nothing is charged for it. A site whose robots.txt redirects to a private address is skipped as "robots.txt unreadable". A host whose DNS lookup fails reads DNS lookup failed for <host> (same handling as before).
No change to prices, events, input, output fields or .actor/ files. Files: CHANGELOG.md, README.md (one FAQ entry), my_actor/sources.py, my_actor/main.py, tests/test_ssrf_guard_1102t.py.
Unreleased: input keys the Actor never reads no longer turn the demo into a real run (GAP16b)
Bug fix (demo detection): an input key that is not in the input schema and is not one of the legacy keys the code still reads (maxTotalJobs, postedWithinDays, keywords, excludeKeywords, locations, remoteOnly) is now ignored when deciding between the demo and a real run. Before, any such key (for example the Apify template's {"helloWorld": 123}) made a real 100-job run ($0.20); now {"helloWorld": 1} is the same 4-job demo as {} ($0.008), and an unknown key next to the prefill (maxItems 4) runs the same demo as the prefill alone. These keys were never read, so they change nothing else.
Unchanged: any value set for a schema key or a legacy key is still a real run under the GAP11b/GAP14 rule (for example {"helloWorld": 1, "maxTotalJobs": 5} gives 5 jobs, and {"maxTotalJobs": 5} is still a real run).
New log line (info): Ignoring input keys that are not in the input schema: <keys>, written once per run, only when such keys are present.
Files: CHANGELOG.md, my_actor/main.py (SCHEMA_KEYS, LEGACY_KEYS, KNOWN_KEYS, unknown_keys, is_demo_input, main), tests/test_gap16b_unknown_keys.py (new; checks that SCHEMA_KEYS matches .actor/input_schema.json and that every legacy key is read by the code).
Unreleased: explicit maxItems 100 is a real run; rate-limited hosts are skipped (GAP14)
Bug fix (demo detection):{"maxItems": 100} and {"timeRange": "7d", "maxDirectoryCompanies": 100} were treated as the empty-input demo and stopped at 4 jobs. The input schema no longer has a default for maxItems, maxDirectoryCompanies and timeRange, so a value you type can be told apart from an empty field; the code applies the same values when they are left empty (100, 100, 7d), so {} behaves as before. The demo now means: nothing set, or only the prefill / exampleRunInput (maxItems 4) and the remaining schema defaults. Any other maxItems (100 included) is a real run. A switch set explicitly to its schema default (for example hasSalary: false) still counts as not set, which does not change any result.
Rate limits (Workable 429 / Cloudflare error 1015): a host that answers HTTP 429 is now skipped for the rest of the run (per-host circuit breaker) instead of being retried up to four times with up to 30 s waits, board after board (about 2 minutes per Workable board). Workable hosts and any Cloudflare 1015 answer trip on the first 429; other hosts get one retry when Retry-After is 10 s or less. Every later board on that host is skipped without a request (board status skipped). One free record per skipped host is pushed: recordType: "status", status: "warning", host, reason, skippedBoards, and a message starting "Not charged". RUN_SUMMARY gets hostsSkipped and the status message names the hosts. If every board failed the run still fails as before (GAP7), after writing the warning record.
robots.txt answered with HTTP 429 is now DISALLOW (the host is skipped with the same free warning record), no longer "no robots.txt, allow all". 404 and 410 still mean no robots.txt, and so do 401/403 (RFC 9309): api.ashbyhq.com answers its robots.txt with 401 (checked 2026-10-31), so treating every non-200/404 answer as disallow would skip all 487 Ashby boards. 5xx and unreadable answers were already skipped (GAP12).
Docs: README FAQ board count corrected to 692 (147 Greenhouse, 487 Ashby, 58 Lever; it said 489 Ashby), the empty-input and rate-limit behaviour described, and a note that workArrangement usually repeats the normalised workplaceType (it is inferred from title/location when the ATS gives none, so both are kept) and that Lever often publishes team without department (both fields kept as the ATS sends them; the department filter matches either). Output fields unchanged.
pricing.json (not shipped): the stale "maxItems default 1,000 / $2.00" notes now say 100 jobs / $0.20, and the board count is 692 (the 694 figure is kept as the dated 2026-10-28 measurement). A test derives the count from directory.json and checks README, schema and pricing.json.
Unreleased: empty-input demo capped at 4 jobs (GAP11b)
Behaviour change: an empty input {} now stops at 4 jobs (4 x $0.002 = $0.008, under $0.01) instead of 100. Before, a cloud {} run with a $0.05 maximum cost stopped at the cost cap (25 jobs) instead of a job cap, and a local {} run gave 100 jobs ($0.20). The demo reads the same boards as before (the 100 largest directory boards, jobs from the last 7 days), logs a warning, and RUN_SUMMARY has demoMode: true.
Demo means the input sets nothing beyond the schema defaults ({}, or the defaults the platform fills in; empty values count as not set). Any other input is a real run: an explicit maxItems (other than the default 100) or any search filter keeps the normal default of 100 jobs. A run with only the defaults and maxItems 100 typed in is also treated as the demo, because it cannot be told apart from {}.
The input prefill is now only maxItems 4 (was 100), so the prefilled Console form runs the same 4-job demo as {}. The timeRange prefill (24h) and the titleSearch prefill (engineer*, "data scientist") were removed so the form matches the demo; the timeRange default (7d) is unchanged.
New exampleRunInput in actor.json with the demo values ({"maxItems": 4}).
Unreleased: a board listed twice is read and charged once; clean location strings (GAP11)
BREAKING (fewer charged records): the same board listed more than once in companies used to be read and charged once per entry (for example airbnb plus https://boards.greenhouse.io/airbnb gave every job twice and two charges per job). Entries are now matched on a normalized board key before anything is read: a slug or URL that differs only in letter case (Greenhouse, Ashby and the host-name vendors Recruitee and Teamtailor), a trailing slash, a query string or vendor: prefix names one board. Lever keeps letter case (its board names are case-sensitive). The board is read once, by the first entry; later entries are skipped with a log line, nothing is read or charged for them, and they are listed in RUN_SUMMARY as duplicateEntries (input, sameBoardAs).
A Greenhouse, Lever or Ashby job ID read through two different entries is output and charged once (those IDs are unique across boards). On other vendors an ID is only unique per board, so jobs from different boards are never merged by ID. A job already output in this run is dropped before the push, so it is never charged; RUN_SUMMARY gets duplicateJobsDropped and the board duplicateJobs, only when that happened.
BREAKING (output values):location, every locations entry and country are trimmed and runs of whitespace (tabs, line breaks, non-breaking spaces) collapsed to one space, for every ATS; a space before a comma is dropped (Austin , TX becomes Austin, TX). United States is now United States, and locations no longer lists a value twice because of whitespace. An empty location becomes null.
The location filters (locationSearch, locations) now match against the cleaned values, so United States in a job matches "united states".
Unchanged: uid, the onlyNewJobs state and inputs that list each board once.
Unreleased: release fixes (input validation, docs, consolidated field list)
BREAKING (additive output fields), full list of fields added since the last release:
With dedupeBy: companyTitle: locations (union), duplicateCount, mergedUids on merged records (GAP3).
RUN_SUMMARY, only while the matching option is on: agencyJobsExcluded, duplicatesDropped, salaryJobsExcluded.
visaSponsorship, yearsExperienceMin/Max and status already existed and are unchanged.
Input validation: negative numbers, a fractional count, minAnnualSalaryUsd above maxAnnualSalaryUsd, an unknown salaryCurrency code and unknown option values (for example dedupeBy, timeRange) now stop the run at the start with one status message listing every problem (Actor.fail, no traceback). Nothing is fetched or charged. 0 and empty keep their existing meaning (off / no limit), and {} plus every previously valid input behave as before.
Behaviour change: an API or scheduled input with a negative limit (for example maxItems: -1) used to run and now fails at the start with a status message. Nothing to read (no companies and no matching directory boards), more than 2000 companies, or an unsupported atsFilter value now also ends with a clean failed-run status message instead of a traceback; the run still fails as before.
Docs: README and sample output show the new fields; the README states that USD conversion uses fixed approximate rates set in October 2026 (RATES_AS_OF), that isAgency, visaSponsorship and yearsExperienceMin/Max are heuristics, and that the visa and years filters are off by default (this Actor already reads every description, so they add no requests here).
Unreleased: salary in US dollars and salary filters (hasSalary, minAnnualSalaryUsd, maxAnnualSalaryUsd, includeJobsWithoutSalary)
BREAKING (additive output fields): every job record now has salaryAnnualUsdMin / salaryAnnualUsdMax: the annual range converted to whole US dollars with a fixed rate table bundled with the Actor (my_actor/fx.py, RATES_AS_OF). The rates are rough reference values, not live or official rates; no rate is fetched at run time. Null when there is no salary, no pay period, or the currency is not in the table (very-high-inflation currencies are left out on purpose). Consumers that reject unknown fields need updating; no existing field changes.
New inputs, all off by default: hasSalary keeps only jobs that state a salary; minAnnualSalaryUsd / maxAnnualSalaryUsd keep jobs whose annual USD range reaches the minimum and starts at or below the maximum; includeJobsWithoutSalary also keeps, under a USD bound, jobs whose salary is missing or cannot be converted. Dropped jobs are never pushed or charged. RUN_SUMMARY gets salaryJobsExcluded and each board salaryExcluded, only while a salary filter is on. The existing minAnnualSalary / salaryCurrency filters (posting currency) are unchanged and can be combined.
With the filters off, which jobs are output, the state store and the cost of {} are unchanged.
Unreleased: duplicate removal (dedupeBy, opt-in)
New input dedupeBy (default none). job drops a job whose company, title and first location (the dedupeKey recipe) were already output in this run; the first one read wins. companyTitle merges the jobs of one company with the same title into one record: locations is the union of their locations, plus the new fields duplicateCount (jobs merged in) and mergedUids. Merged records are output when their board has been read; a later board of the run with the same company and title is dropped.
Duplicates are dropped or merged before the dataset push, so they are never charged; the only charged push is one per output record. RUN_SUMMARY gets duplicatesDropped and each board duplicates, only while the option is on.
BREAKING only when opted in (additive output fields): with companyTitle, records carry duplicateCount and mergedUids and locations can list more than one job's locations. With none (the default) output, state and the cost of {} are unchanged.
With onlyNewJobs, dropped and merged jobs are remembered as output. With includeExpired, a merged record is reported expired only when none of its members is listed.
Limits: only duplicates within one run are handled (a duplicate of a job output by an earlier run is output again unless onlyNewJobs is on). In companyTitle mode maxJobsPerCompany counts merged records.
Unreleased: recruiting-agency flag and filter (isAgency, excludeAgencies)
BREAKING (additive output field): every job record now has isAgency (true / false). It is true when the company name looks like a staffing or recruitment agency (generic words such as Staffing, Recruitment, Executive Search, Personalberatung, Zeitarbeit, or a short list of known agencies such as Randstad, Adecco, Hays, Robert Half, TEKsystems) or the description says the job is filled for a client ("on behalf of our client", "our client, a …", "für unseren Kunden … suchen wir", "im Auftrag unseres Mandanten", "pour le compte de notre client", "voor onze opdrachtgever"). Consumers that reject unknown fields need updating; nothing else in the record changes.
New input excludeAgencies (default false) leaves those jobs out. They are dropped before the dataset push, so they are never charged. RUN_SUMMARY gets agencyJobsExcluded and each board agencyExcluded, only while the option is on.
It is a heuristic (module my_actor/agency.py): an in-house job at an agency's own office is also flagged, and an agency with a neutral name and no client wording is not.
With excludeAgencies off, which jobs are output, the state store and the cost of {} are unchanged.
Unreleased (2026-10-29)
Default maxItems 100 (about $0.20 and under 10 seconds for {}), so every {} run stays under $0.25.
Tooling (not part of the Actor build): tools/refresh_directory.py re-validates the bundled directory (dropping closed boards, reporting empty and unreachable ones) and can add boards from the ATS vendors' published customer lists, writing a diff report. .github/workflows/refresh-directory.yml is a monthly example that opens a PR.
Greenhouse workplaceType / employmentType (and the *Raw fields) are read from the company's custom job-board fields (Workplace Type, Employment Type, Time Type and similar names). Before, they were always null on Greenhouse. Boards that publish no such field (e.g. GitLab) still give null. workArrangement: remote filters now also match Greenhouse jobs whose workplace field says Remote.
0.1 (2026-10-29, not published)
First version of Career-Site Jobs Feed, built from the ats-jobs-scraper engine (copied; that Actor is unchanged).
Feed search: timeRange, title / location / description / company search and exclusion with OR / AND / prefix / phrase syntax, atsFilter, work arrangement, employment type, seniority, minimum annual salary, visa sponsorship and maximum years of experience.
With no companies given, the built-in directory (about 690 Greenhouse, Ashby and Lever boards) is searched; company-name and ATS filters are applied to it before any request. Directory boards that have closed appear in RUN_SUMMARY with status gone, not as dataset records.
Enrichment: visaSponsorship with evidence, yearsExperienceMin/Max, keySkills.
status field; free expired records for jobs that left a fully read board (includeExpired).
maxItems (old maxTotalJobs still accepted), plus hidden aliases keywords, excludeKeywords, locations, remoteOnly, postedWithinDays.
Supported ATSs: Greenhouse, Lever, Ashby, Workable, Recruitee, Teamtailor. Workday, SmartRecruiters and Personio inputs are refused without a request.