Salary Data Extraction from Job Listings
TL;DR
Salary comes back as salary_min/salary_max (numeric strings, not JSON numbers — cast before doing math), salary_currency, and the original salary_text. There's no pay-period field, so a small number like 45 could be an hourly rate that slipped through — sanity-check magnitude, especially on CONTRACT postings, rather than assuming everything is annualized. Filter with the single salary=min,max param, and never aggregate salary_min/salary_max across mixed salary_currency values without converting first.
Compensation data appears in many forms across job listings. CleanJobData normalizes the parts it can structure while preserving the original salary text for display. Everything below reads fields off the GET /jobs response (see the Jobs API reference for the full endpoint) — for why salary normalization is harder than it looks and how it affects analytics, see Why Salary Normalization Matters for Job Data.
What You Get
The API exposes salary through four fields:
salary_min— lower bound when available, as a numeric string (e.g."140000") — the underlying column is a 64-bit integer, which comes back over the wire as a string rather than a JSON number, soNumber(job.salary_min)before doing math with it.salary_max— upper bound, same string-typed caveat assalary_min.salary_currency— currency code when availablesalary_text— original employer salary text
Example:
{
"title": "Senior Product Designer",
"salary_min": "140000",
"salary_max": "170000",
"salary_currency": "USD",
"salary_text": "$140k-$170k"
}If the source does not provide structured compensation, numeric fields may be null. Always handle missing salary gracefully in your UI.
Filtering by Salary
The main way to filter is a single salary query parameter that does double duty. Pass min,max for a bounded range, or just min for a lower-bound-only filter:
# Range
curl "https://api.cleanjobdata.com/jobs?salary=120000,180000&title=engineer" \
-H "Authorization: Bearer $CLEANJOBDATA_API_KEY"
# Lower bound only — omit max
curl "https://api.cleanjobdata.com/jobs?salary=100000&remote=true" \
-H "Authorization: Bearer $CLEANJOBDATA_API_KEY"The applied filter is echoed in meta.filters_applied as { "key": "salary", "min": 100000, "max": null, "display_label": "Salary: 100,000+" } — check that instead of re-parsing your own query string if you need to confirm what was actually applied. A legacy min_salary (or minSalary) param is also still accepted as a fallback when salary isn't present, but it applies a lower-bound-only filter and shows up in filters_applied under the key min_salary, not salary — prefer salary for new integrations.
The filter is designed to work with incomplete ranges on the listing side too. A job with salary_min set but no salary_max (the employer didn't disclose a ceiling) can still match a salary=100000,180000 query — don't assume a hit means both bounds exist on the returned job.
The Missing Piece: Pay Period Isn't a Field
salary_min and salary_max are bare numbers — there's no salary_period field telling you whether they're annual, hourly, or monthly. In practice the vast majority of listings are annualized figures, but a contract or hourly-rate posting can report small numbers ("salary_min": 45, "salary_max": 65) that are meaningless without knowing the period. The only place the period is actually stated is inside salary_text (e.g. "$45-$65/hr"), which isn't structured.
Two practical mitigations:
- Sanity-check the magnitude before displaying or aggregating. A
salary_minunder roughly 1,000 is almost certainly a non-annual figure that slipped through; don't plot it on the same axis as$140,000without at least flagging it. - If you're building analytics that mix listings across
employment_type(the full set of values:FULL_TIME,PART_TIME,CONTRACT,INTERN,TEMPORARY,FREELANCE,APPRENTICESHIP,VOLUNTEER,PER_DIEM,OTHER), be more suspicious ofCONTRACTpostings specifically — hourly/day rates are far more common there than forFULL_TIMEroles. Cross-referencingemployment_typewith the rawsalary_textis the only reliable way to catch these before they skew an average.
Don't try to "fix" this by dividing every sub-1000 number by an assumed hourly-to-annual multiplier — you'll silently corrupt the real hourly postings that are correctly small.
Displaying Salary
Use salary_text when you want to show the employer's original wording. Use salary_min and salary_max when you want consistent formatting.
function formatSalary(job: Job) {
if (!job.salary_min && !job.salary_max && job.salary_text) {
return job.salary_text;
}
if (!job.salary_min && !job.salary_max) return null;
const currency = job.salary_currency || "USD";
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency,
maximumFractionDigits: 0,
});
const min = job.salary_min ? formatter.format(Number(job.salary_min)) : null;
const max = job.salary_max ? formatter.format(Number(job.salary_max)) : null;
return [min, max].filter(Boolean).join(" - ");
}Aggregating Across Currencies
salary_currency is a real, per-listing field — it is not normalized to USD. A "senior engineer average salary" query that sums salary_min/salary_max across a mixed set of USD, GBP, and EUR listings without checking salary_currency first will produce a number that means nothing. If you're building cross-market benchmarking, either filter to a single currency (e.g. combine with city_id/country_id so the market is naturally single-currency) or convert explicitly before aggregating — don't average raw numbers across currencies.
Common Pitfalls
- Treating missing
salary_min/salary_maxas zero instead of "undisclosed." - Assuming all salary figures are annualized (see the pay-period gotcha above).
- Averaging across
salary_currencyvalues without converting. - Parsing
salary_textin the frontend for values you could get structured — reserve text parsing for display fallback only. - Excluding a listing from results just because
salary_max(orsalary_min) alone is missing.