I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them reads company job boards on Greenhouse: you hand it a list of company slugs, it hands back one row per open job. No login, no browser — Greenhouse publishes every board as JSON at boards-api.greenhouse.io/v1/boards/<slug>/jobs?content=true, and one request returns the whole board with every description in it.

The field people actually buy it for is salaryMin / salaryMax. So I pulled nine boards — 3,421 open jobs, measured 8 November 2026 — and checked what that field really is. The short version: the coverage is a legal artefact, one large company publishes nothing at all, and my own parser was wrong on every European row it had ever produced.

Greenhouse has no salary field

This is the first thing to internalise. The Greenhouse board API gives you title, location.name, departments, absolute_url, first_published, metadata and content. There is no pay object. When a company complies with a pay-transparency law, Greenhouse's editor drops a block into the description HTML:

<div class="title">Annual Salary:</div> <div class="pay-range"> <span>$222,800</span><span class="divider">—</span><span>$290,000 USD</span> </div> 

That block — and only that block — is what my Actor reads. Never a loose number in the body text. I will come back to why that restriction earns its keep.

Coverage is a map of US pay-transparency law

Of 3,421 jobs, 1,858 carry a pay-range block. Split by whether the location names a US state, a US city or "United States":

Board Jobs With a range US Non-US
databricks 922 477 313/331 164/591
stripe 766 0 0/257 0/509
anthropic 675 587 531/534 56/141
coinbase 283 260 179/184 81/99
gitlab 248 109 98/115 11/133
airbnb 205 170 101/101 69/104
robinhood 150 135 122/125 13/25
figma 145 94 94/94 0/51
discord 27 26 21/22 5/5
total 3,421 1,858 1,459/1,763 (83%) 399/1,658 (24%)

Figma is the cleanest illustration in the set: 94 of 94 US postings publish a range, 0 of 51 everywhere else. Nobody at Figma decided to be transparent — Colorado, California, New York and Washington decided for them. If you are building a salary dataset out of Greenhouse, you are building a dataset of US postings with a non-US tail that skews toward whoever voluntarily opted in.

A planning number worth stealing: budget on roughly half your rows having a salary, and about four in five if you filter to the US.

Stripe publishes zero ranges, and the 19 numbers it does publish are not jobs

Stripe is 766 open jobs and not one pay-range block. Searching the raw description text for $NNN,NNN finds 19 postings with a number in them. Eighteen of those nineteen look like this:

Salary: $121,600.00 - $182,400.00 /yr. This salary range represents the base salary range for the role... 40 hrs/week 100 % Telecommuting permitted Multiple Positions 

"40 hrs/week", "% Telecommuting permitted", "Multiple Positions" — those are US immigration notices (PERM / H-1B postings), not pay transparency. Eight are in Seattle, four in South San Francisco. If my parser scraped loose currency amounts, Stripe would contribute exactly 19 salaries to your dataset and all 18 of them would be visa paperwork. That is the whole argument for reading one CSS class and nothing else: a null is a fact, a wrong number is a bug you will never notice.

The bug: €114.000 is not 114

Here is the part I did not enjoy finding. My number parser was one line:

const n = Number(String(t).replace(/[^0-9.]/g, '')); 

Strip everything that is not a digit or a dot. That is correct for $222,800 and wrong for Airbnb's European rows, which use the dot as a thousands separator:

Germany Annual Pay Range €114.000 – €142.000 EUR → min 114, max 142 Brazil Monthly Pay Range R$30.666,67 – R$38.333,33 → min 30.66667 

Across my 1,857 parsed rows that is 56 EUR rows and 8 BRL rows — 64 rows, which is 100% of every EUR and BRL row in the sample. USD, GBP and CAD were fine the whole time, which is exactly why it survived: my own health checks run US-heavy boards.

The fix decides the separator per value instead of assuming one. If a string has both . and ,, the last one is the decimal point; if it has only commas, 1,234,567 is thousands and 1,5 is decimal; if it has only dots, 114.000 matching \d{1,3}(\.\d{3})+ is thousands.

function money(t) { let s = String(t).replace(/[^0-9.,]/g, ''); if (!s) return null; const dot = s.lastIndexOf('.'); const com = s.lastIndexOf(','); if (dot >= 0 && com >= 0) s = dot > com ? s.replace(/,/g, '') : s.replace(/\./g, '').replace(',', '.'); else if (com >= 0) s = /^\d{1,3}(,\d{3})+$/.test(s) ? s.replace(/,/g, '') : s.replace(',', '.'); else if (dot >= 0 && /^\d{1,3}(\.\d{3})+$/.test(s)) s = s.replace(/\./g, ''); const n = Number(s); return Number.isFinite(n) && n > 0 ? n : null; } 

Replayed over the same 1,857 rows: 64 numbers changed, 0 USD/GBP/CAD rows changed. Then the same input on the old build and the new one — one role Airbnb posts in six cities, which is a free before/after because the London row is in pounds:

 old build 0.1.26 new build 0.1.28 Berlin, Germany 104 130 EUR 104000 130000 EUR Dublin, Ireland 102 128 EUR 102000 128000 EUR Barcelona, Spain 85 105 EUR 85000 105000 EUR Milan, Italy 85 105 EUR 85000 105000 EUR Paris, France 96 120 EUR 96000 120000 EUR London, UK 94000 118000 GBP 94000 118000 GBP 

Shipped as build 0.1.28 of Greenhouse Jobs Scraper.

Currency and period were lying too, more quietly

Two smaller things fell out of the same measurement.

30 rows had salaryCurrency: null because the symbol was not in my list: ₱798,300 PHP, 503,500 د.إ AED. Matched by symbol now, not only by ISO code.

714 of 1,857 rows had salaryPeriod: null — 38%. Not because companies were vague, but because my regex wanted the word "annual", "year", "hour" or "salary" in the label, and the common labels have none: Zone 2 Pay Range (Databricks, 358 rows), Local Pay Range (212), Pay Range (Airbnb US, 103). All annual. Rather than hard-code labels that change next quarter, the period now falls back to year when the number is four digits or more: nulls go from 714 to 30, and those 30 deserve to be null — Internship $46 – $46 USD, Local Pay Range $170.40 – $255.60 USD, hourly rates with nothing in the posting saying so.

Coinbase's board contradicts itself, in the same city, in the same week

While checking PHP rows I hit this pair. Same job title, same office, both labelled by Coinbase as an annual range:

Customer Support Specialist II Manila ₱798,300 – ₱798,300 PHP "Annual base salary range" Customer Support Specialist ll Manila ₱364.61 – ₱383.80 PHP "Annual base salary range" 

798,300 ÷ 2,080 working hours = 383.80. The second posting is an hourly rate wearing an annual label, typed by a human into the Greenhouse editor. No parser can save you from that, and mine does not try: it reports what the block says. If you are averaging salaries by country, throw out anything implausible for its currency before you aggregate.

The same title also shows why row count is not job count. "Customer Support Specialist ll" appears 29 times on Coinbase's board, all in Manila, all created between 29 October and 3 November, with slightly different description text and 29 different job IDs — one requisition per seat. Across the nine boards, Databricks has 922 jobs under 683 distinct titles; 347 of its rows sit in a duplicate-title group. You are billed per row, so if you are counting "open roles at this company", deduplicate on title plus location before you report a number.

One more trap: the location is free text holding several places

654 distinct location strings across 3,421 jobs, and the top ones are not cities: San Francisco, CA | New York City, NY (169 jobs), Remote - USA (164), United States (124), San Francisco, CA • New York, NY • United States (67). Three separators — |, •, ; — and 233 strings pack several places into one. Greenhouse has no remote field either: "remote" is a word in that string or it is nothing, so a remote job advertised as "Austin, TX" escapes any remote filter. My locations filter is a substring match for exactly this reason; an equality join here quietly drops a third of your matches.

Calling it

An empty input returns a demo (newest 20 jobs each from gitlab, discord, airbnb) — the cheapest way to see the row shape. A real call:

curl -X POST "https://api.apify.com/v2/acts/lergassy~greenhouse-jobs-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"companies":["anthropic","https://job-boards.greenhouse.io/gitlab","airbnb"], "titleKeywords":["engineer"],"workplace":"remote","postedWithin":"month"}' 

A board of 800 roles is one HTTP request, so runs take seconds. Billing is $1.60 per 1,000 job rows; rows your filters removed are not charged, and error rows (bad slug, board that would not answer) are free.

When this is the wrong tool

  • You want to search the whole job market by keyword. There is no directory of Greenhouse boards; you supply the companies. Guessing slugs from company names is a bad time — on Lever I tried 199 guesses and 180 came back 404, and Greenhouse is no kinder. Open the careers page, click a job, read the domain: greenhouse.io, lever.co, ashbyhq.com each tell you the tool and the slug.
  • You need a reliable "posted today". first_published is the requisition's creation date, and on some boards it is archaeology. Keep your own history of job IDs and diff it — board APIs return a stable ID set.
  • You need salary for a non-US market. 24% coverage is not a dataset, it is an anecdote.

The measurement script in this post is nine curl calls against a public API and about forty lines of JavaScript. It found a thousand-fold error in production code I wrote in October and had been shipping ever since. If you maintain a scraper with a number in its output, go and replay your parser over a few thousand real rows today — not the ten you wrote tests against.