How We Normalize Job Data From 30+ ATS Platforms

Updated
CleanJobData Engineering

TL;DR

CleanJobData reads jobs from 33 applicant tracking systems, from Greenhouse and Lever to Workday, iCIMS and SuccessFactors, and turns them into one job object. Each platform has its own adapter that maps native fields into a common shape. Every job then goes through structural checks, location resolution against a geo database, seniority mapping and salary parsing, and is tracked by employer and source job ID so closed postings are marked inactive with an expired_at date. Some fields are always present; others are null when the source didn't provide them.

On this page

CleanJobData collects jobs from 33 applicant tracking systems (ATS), the software employers use to post and manage openings, and serves every one of them as the same JSON object. A job from Greenhouse, Workday or Personio has the same field names, the same date format and the same structured location. This article explains how that works at a high level, which fields you can rely on, and which can be null.

The Problem

Every ATS exposes jobs differently. Some return clean JSON from a public endpoint, some publish an XML feed, and some only render HTML career pages. Even among the JSON ones, nothing lines up:

  • IDs: sequential integers on Greenhouse, UUIDs on Lever and Ashby, short codes on Workable.
  • Dates: ISO strings on most platforms, Unix milliseconds on Lever, Unix seconds elsewhere, and date-only values on some.
  • Location: one free-text string (SF, Remote US, London / New York) on most, a structured address on Ashby, a list of offices on others.
  • Salary: a structured range on Lever and Ashby, a free-text field on BambooHR and PageUp, buried in the description on many, absent on Greenhouse and SmartRecruiters.
  • Description: one HTML field, or split across several sections, or only available from a second request per job.
  • Seniority: almost never a field. It's implied by titles like Staff Engineer, Principal or Lead Developer.

If each source stays in its native format, every client has to write and maintain source-specific code. The point of the API is to do that once.

The 33 Platforms, Grouped by How They Expose Jobs

How jobs are exposedPlatforms
Public JSON endpoint (GET)Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee, Teamtailor, BambooHR, Pinpoint, PageUp, Rippling, Eightfold, Oracle Recruiting Cloud, Zoho Recruit, ADP Workforce Now
Search API called with POSTWorkday, SAP SuccessFactors, UKG Pro Recruiting, Dayforce, Cornerstone OnDemand, Paycom, BrassRing, Gem
XML feedPersonio, JazzHR
Career-site HTML, embedded JSON or JobPosting JSON-LDiCIMS, Taleo, Jobvite, Avature, Breezy HR, Freshteam, Comeet, Paylocity

For the endpoint, pagination and salary behavior of each one, see Which ATS Have a Public Job Postings API?. The per-platform walkthroughs for Greenhouse, Lever, Ashby, Workday and others go deeper on individual quirks.

The Common Job Shape

Every job, whatever its source, is stored and served in this shape:

{
  "id": 123,
  "title": "Senior Frontend Engineer",
  "location": "San Francisco, CA",
  "locations": [
    {
      "kind": "city_state_country",
      "is_primary": true,
      "city_id": 123,
      "city_name": "San Francisco",
      "state_id": 456,
      "state_code": "CA",
      "country_id": 233,
      "country_code": "US",
      "lat": 37.7749,
      "lng": -122.4194,
      "timezone": "America/Los_Angeles"
    }
  ],
  "application_url": "https://example.com/careers/senior-frontend-engineer",
  "published": "2026-07-01T12:00:00.000Z",
  "has_remote": false,
  "remote_type": null,
  "is_active": true,
  "expired_at": null,
  "language": "en",
  "employment_type": "FULL_TIME",
  "salary_min": 150000,
  "salary_max": 180000,
  "salary_currency": "USD",
  "salary_text": "$150k-$180k",
  "experience_level": "SE",
  "experience_levels": ["SE"],
  "employer_id": "a1B2c3",
  "company": {
    "name": "Example Co",
    "description": "Example Co builds tools for...",
    "logo": "https://example.com/logo.png",
    "website_url": "https://example.com",
    "employee_count": "51-200",
    "industry": "Software",
    "specialties": []
  }
}

The fields returned depend on field selection, but the shape stays the same. The default list response includes summary fields; add extra_fields=description to include the description, or replace the default set with something like fields=title,location,salary_min,salary_max,company.

What's Always Present and What Can Be Null

Always present: title, application_url, published (ISO timestamp), has_remote (boolean), locations (array, may be empty) and experience_levels (array, may be empty).

Null when the source didn't provide it: location (a display string, not structured), description, employment_type, salary_min, salary_max, salary_currency, salary_text, company, and experience_level (a single best guess derived from experience_levels).

A missing source field becomes null, never a missing key or a different type. That's what lets you write one parser for every source.

How the Pipeline Works

1. Per-Source Adapters

Each ATS has its own adapter instead of one generic scraper, because each has its own contract. The adapter fetches the jobs the same way that platform's careers page does, and maps native concepts onto the common shape:

  • first_published (Greenhouse), createdAt (Lever), publishedAt (Ashby) and releasedDate (SmartRecruiters) all become published.
  • Lever's description, lists and additional are joined into one description; platforms that only give a summary in the list get a detail fetch for the full text.
  • Structured addresses, office lists and secondary locations become entries in locations, alongside the free-text location for display.
  • Compensation objects and salary fields become salary_min, salary_max, salary_currency and salary_text.
  • Workplace fields and location text become has_remote and remote_type.

Sources with no public jobs API are read from the career-site HTML and each job page's schema.org JobPosting markup, then go through the same mapping.

2. Structural Enforcement

Before anything is written to the database, every job is checked against a strict structure. Strings are trimmed, titles and URLs are validated, dates are coerced to timestamps, country codes are normalized, and arrays such as experience_levels are always arrays. API stability starts at ingestion: a bad or missing value becomes null rather than breaking the response shape for everyone downstream.

3. Location Resolution

Raw location text like SF, San Francisco, CA, Remote - US or London / New York is resolved against a geo database into a locations array with real city_id, state_id and country_id values. Structured provider data, such as Ashby's address object, is used where it exists, and parsed text fills the gaps. Repeated rows are removed, and a parent location is dropped when a more specific child covers it. The location normalization guide explains how to read the result.

That array is what makes a filter like this reliable instead of a string match:

curl "https://api.cleanjobdata.com/jobs?city_id=123&remote=true" \
  -H "Authorization: Bearer $CLEANJOBDATA_API_KEY"

4. Seniority Mapping

Seniority is inferred from the title and stored as canonical tokens: EN (entry), MI (mid), SE (senior) and EX (executive). experience_levels can hold more than one when a title fits several buckets, and experience_level is the single best guess. See the experience level mapping guide.

5. Salary Parsing

Salary is kept in two forms. Numeric salary_min and salary_max with a salary_currency code power filters like salary=120000,180000, and the employer's original wording is kept in salary_text for display. When a source has a structured range, it's used directly: the base-salary component is picked over bonus or equity, and a stated hourly, weekly or monthly interval is converted to a yearly figure. Otherwise the text is parsed, and if nothing reliable comes out, the numeric fields stay null. Currency is never converted, and there's no separate pay-period field. The salary data extraction guide covers coverage and edge cases.

6. Identity, Duplicates and Closures

Each job is identified by its employer and the source's own job ID, and its apply URL is unique across the dataset, so re-reading a board updates existing jobs instead of creating copies. When a job disappears from its board, it's marked is_active: false with an expired_at timestamp rather than being deleted, so clients that sync can remove it on their side. Cross-source duplicates need more than an ID match; deduplicating job listings across sources covers the approaches.

Querying the Result

Developers get one endpoint with consistent fields and predictable filters:

curl "https://api.cleanjobdata.com/jobs?title=frontend%20engineer&experience_level=SE&remote=true&salary=120000,180000&limit=20" \
  -H "Authorization: Bearer $CLEANJOBDATA_API_KEY"

Useful filters include:

  • title or search
  • city_id, state_id, country_id, or location=US
  • remote=true, remote_only=true or has_remote=true
  • experience_level=EN,MI,SE,EX
  • salary=120000,180000
  • published_after=2026-06-01 or max_age=7d
  • company_website_url=company.com
  • sort_by=published or sort_by=relevance

The response includes data, cursor pagination and meta.filters_applied, so your UI can show exactly which filters were applied. Filter server-side with these parameters rather than re-deriving values like seniority from title text yourself.

What You Don't Have to Build

Because the normalization happens once, before the data reaches you, you don't need to:

  • parse 33 ATS-specific response formats
  • scrape employer career sites
  • guess salary values from text
  • fuzzy-match NYC, New York and New York, NY
  • write title heuristics for seniority
  • track which jobs have closed

Start with the Jobs API reference or try queries in the playground.

FAQ

Which ATS platforms does CleanJobData normalize?

33 of them, including Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Workday, iCIMS, Taleo, SAP SuccessFactors, Oracle Recruiting Cloud, UKG, Dayforce, Personio, Teamtailor, Recruitee and BambooHR. The table above lists all of them.

Does every job have the same fields?

Yes. Every job has the same keys. Fields such as title, application_url, published and locations are always present; fields such as salary, description and company are null when the source didn't provide them.

How are closed jobs handled?

When a job disappears from its source board, it's marked is_active: false and given an expired_at timestamp. It isn't deleted, so you can sync closures into your own database.

Is salary converted to a yearly figure?

Where the source states a pay interval, salary_min and salary_max are converted to yearly figures, so most values are annual. There's no pay-period field, so a small number can still be an hourly rate; the employer's original wording is always kept in salary_text. Currency isn't converted.