Quick answer
Levels.fyi's own compensation reports separate base, stock, and bonus per submission — but most "total comp" scrapers flatten that split back down to a single number before it reaches your dataset. The Levels.fyi Compensation Scraper keeps it intact: base_salary, stock_annual_value, bonus_annual_value, and total_compensation ship as four distinct fields, per individual submission, per company and job family. Pricing is $0.02 per Actor start plus $1.80 per 1,000 results — $1.82 for your first 1,000 rows.
Total comp is not one number 💰
Ask most compensation tools for "total comp at Google" and you get back one dollar figure. That figure hides the interesting part: two engineers at the same level with the same total comp can have very different risk profiles if one is base-heavy and the other is stock-heavy — and that split is exactly what disappears when everything collapses into a single scalar.
This Actor runs in two modes, and the field shape tells you which one produced a given row. In company_job_family mode (the default), every row is one individual submission with base_salary, stock_annual_value, bonus_annual_value, and total_compensation all populated as separate floats, alongside level, track, location, and an ISO offer_date. Nothing gets pre-summed on your behalf.
In company_overview mode, you trade granularity for breadth: one lightweight row per title, total_compensation as the median, and base_salary left null — the overview endpoint doesn't expose the underlying split, so we don't fabricate one. A null base_salary isn't a scrape gap; it's the mode telling you what data actually exists at that level of aggregation.
The submission count that isn't your sample size 🔍
Levels.fyi's job-family pages ship two different numbers that look interchangeable and aren't. level_submission_count is the bucket's total submission count. level_submission_count_last_12_months is the trailing-12-month count for the same bucket, and it only ships in company_job_family mode.
Both exist on the row because the page itself renders a bounded preview of individual submissions, not the full underlying set the counts describe. Comparing the two count fields is how you gauge how much of that total you're actually seeing versus how much is summarized-but-not-shown. Treat any single submission row as one data point in a self-reported population, not as "the" compensation figure for that level.
Two entrypoints, one Dockerfile CMD, and a green run that did nothing 🐛
Here's the one we're not proud of, and the one worth telling on ourselves. Build 0.0.1 of this Actor ran on Apify's cloud, came back SUCCEEDED, exit code 0, finished in 8 seconds — with zero application log lines and zero dataset rows. Not a scrape failure. The code never ran.
The Dockerfile CMD was python -m src.main, which executes src/main.py as __main__. Our asyncio.run(main()) runner block, though, lived only in src/__main__.py — the module that python -m src (no .main) executes. The container imported src/main.py, defined every function inside it correctly, and exited cleanly having done nothing at all.
The uncomfortable part: this passed everything we check before shipping. 42 unit tests, green. Ruff, green. Pyright, green. Our input-prefill verifier, green. And a real local apify run returned 5 live rows — because apify run invokes python -m src, hitting the other entrypoint. Every local signal we trust said ship it. Only the cloud container, running the actual Dockerfile CMD, disagreed.
We fixed it by moving the runner block into src/main.py, with a comment explaining why it has to live there, and re-ran it in the cloud: build 0.0.2, SUCCEEDED, "Done — 5 row(s) from 1 pair(s)," real Google L3 rows with the base/total split intact (base 140000 / total 142500, New York NY). We've also added a static pre-publish gate that checks the module named in a Dockerfile's CMD actually contains a __main__ guard, so this failure mode can't ship silently again on any Actor in the fleet. If you're shipping your own containerized scrapers: test the entrypoint the container actually runs, not the one that's convenient in dev.
What we handle for you 🛡️
- 🌐 We route every request through Apify Proxy, residential preferred and pinned to the US, with a fallback pool, so results aren't tied to one exit IP or one country's pricing quirks.
- 🔁 We retry with exponential backoff on
408 / 429 / 503, honoringRetry-After, up to 5 attempts per page. - 🎯 We keep the granularities separate — component-split individual submissions or lightweight company-wide aggregates, never silently merged.
- 🧊 We keep the dataset clean — Pydantic-validated rows (
extra="forbid"), deduped by submission ID, ISO-8601 timestamps, JSON / CSV / Excel export straight from the Apify Console. - 🔍 We ship submission-count context on every row, so a single data point is never presented as more representative than it is.
- 💰 You pay only for results that land in your dataset — the small run-start fee covers warmup, not padding.
Full output schema 📦
Same 20-field shape across both modes; fields that don't apply to a given mode ship as null rather than being omitted or guessed at.
| Field | Type | Notes |
|---|---|---|
granularity |
string |
individual_submission or aggregate_title — tells you which mode produced the row |
company |
string | Company display name |
company_slug |
string | Input companies entry (Levels.fyi URL slug) |
job_family |
string | null | Job family display name |
job_family_slug |
string | null | Job family URL slug |
title |
string | null | Job title |
level |
string \ | null |
track |
string \ | null |
location |
string | null | Location string. Null in overview mode |
years_of_experience |
float | null | Years of experience |
years_at_company |
float | null | Years at the company |
base_salary |
float | null | Base salary. Null in overview mode — no split available |
stock_annual_value |
float | null | Average annual stock grant value |
bonus_annual_value |
float | null | Average annual bonus value |
total_compensation |
float \ | null |
currency_code |
string | ISO currency code |
offer_date |
string | null | ISO-8601 offer date. Null in overview mode |
level_submission_count |
int | null | The level/title bucket's total submission count |
level_submission_count_last_12_months |
int \ | null |
source_url |
string | The fetched page URL this row was parsed from |
scraped_at |
string | ISO-8601 UTC row creation timestamp |
Who this is for
Comp benchmarking — pull per-level base, stock, bonus, and total comp for a shortlist of companies into a spreadsheet or BI tool, components separate instead of working backward from a blended average.
Offer negotiation research — compare a specific offer's base/stock/bonus split against Levels.fyi's published bands for the same company and job family.
Recruiting and TA analytics — feed component-separated rows into a sourcing or comp-modeling pipeline where the base-vs-equity mix matters.
Internal pay-band research — track a peer company's published levels and comp over time; company_overview for broad title coverage, company_job_family for submission-level detail.
A note on the underlying data: every row reflects what Levels.fyi's own self-reported submissions say at scrape time. This Actor doesn't verify or adjust those figures — it parses and types them faithfully, base/stock/bonus kept apart, with submission-count fields so you can judge sample size yourself.
Frequently asked questions
What's the actual difference between company_job_family and company_overview mode?
company_job_family returns one row per individual submission with the full base/stock/bonus/total split and requires you to specify job_families. company_overview returns one lightweight row per title company-wide, with total_compensation as a median and base_salary left null because that split isn't exposed at that aggregation level.
Why is base_salary null on some rows?
Only in company_overview mode. The overview endpoint doesn't expose a base/stock/bonus split, so the field ships null instead of an estimate.
What does 1,000 rows cost? What about 5,000?
1,000 rows: $0.02 (run start) + 1,000 × $0.0018 = $1.82. 5,000 rows: $0.02 + 5,000 × $0.0018 = $9.02.
Can I trust the compensation numbers themselves?
That depends on Levels.fyi's underlying self-reported data, which this Actor doesn't verify or adjust — we parse and type exactly what the page reports, base/stock/bonus kept separate, with submission-count fields on every row so you can weigh the sample size yourself.
Does the Actor require a company slug or can I search by company name?
companies takes Levels.fyi's own URL slugs (google, meta, apple), not arbitrary display names — check the company's Levels.fyi URL if you're unsure of the slug.
Try it
Live on the Apify Store: Levels.fyi Compensation Scraper.
Point it at one or more companies, pick your granularity, and get back typed rows with base, stock, and bonus kept apart — not a blended number pretending to be the whole story. Pay-per-event, no subscription, $5 free credit to try it with no card required.
Built by Devil Scrapes — we build scrapers for the targets that fight back.
Top comments (0)