How We Normalized Hiring Data from Multiple ATS Platforms into One API
Hiring data is messy before it ever reaches your application. Every ATS has its own field names, date formats, location conventions, and compensation patterns. The goal behind JobsDataAPI was simple: make every job look like the same object, no matter whether it came from Ashby, Lever, Greenhouse, Workable, or SmartRecruiters.
The Problem
A raw job posting can contain anything from a clean JSON object to a paragraph of HTML. The hardest fields are the ones your users depend on most:
- location strings such as
SF,Remote US, orLondon / New York - compensation written as
$120k-$160k,150000 - 180000, or hidden in the description - seniority implied by titles such as
Staff Engineer,Principal, orLead Developer - employment context split across departments, job types, and posting metadata
- company information spread across board metadata, employer profiles, and scraped pages
If each source stays in its native format, every client needs source-specific code. That is exactly what the API is meant to prevent.
The Common Job Shape
The backend normalizes each posting into a consistent schema before it is stored:
{
"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"
}
],
"salary_min": 150000,
"salary_max": 180000,
"salary_currency": "USD",
"salary_text": "$150k-$180k",
"experience_level": "SE",
"experience_levels": ["SE"],
"has_remote": false,
"employment_type": "full_time",
"company": {
"name": "Example Co",
"website_url": "https://example.com",
"specialties": []
}
}
The exact fields returned depend on field selection, but the shape is intentionally stable. The default list response includes summary fields, and clients can request description with extra_fields=description or replace the default set with fields=title,location,salary_min,salary_max,company.
How the Pipeline Works
1. Source-Specific Fetching
Each ATS adapter knows how to fetch and interpret its own API. Greenhouse uses the public boards API, Lever uses the JSON postings endpoint, Workable uses its widget API, Ashby uses its posting APIs, and SmartRecruiters uses its posting API with optional detail fetches.
The adapter converts source concepts into the common internal shape. For example:
first_publishedorreleasedDatebecomespublishedlocationsor address fields becomelocations- compensation fields become
salary_min,salary_max,salary_currency, andsalary_text - department or job type hints help infer employment context
2. Structural Enforcement
The backend enforces a strict job structure before writing to Postgres. That step trims strings, removes invalid values, normalizes country codes, converts salary text into numbers where possible, and ensures arrays such as experience_levels are always arrays.
This is important because API stability starts at ingestion. A missing source field should become null, not a broken response shape.
3. Location Resolution
The location normalizer combines structured provider data with parsed location strings. It deduplicates repeated rows, prunes redundant parent locations, and resolves city, state, and country IDs.
The final locations array is what allows reliable filtering by city_id, state_id, country_id, or ISO country code.
4. Experience Mapping
Seniority is inferred from the title and stored as canonical tokens:
ENfor entry-level rolesMIfor mid-level rolesSEfor senior-level rolesEXfor executive-level roles
The API exposes both experience_level and experience_levels, so a title can match multiple seniority buckets when appropriate.
5. Salary Extraction
Salary data is preserved in several forms. Structured values become numeric salary_min and salary_max, the currency is stored as an ISO code when available, and the original text is preserved in salary_text.
That design lets applications display the original compensation text while still powering filters such as salary=120000,180000.
The Result for API Consumers
Developers get one endpoint with consistent fields and predictable filters:
curl "$CLEANJOBDATA_API_BASE_URL/jobs?title=engineer&experience_level=SE&remote=true&location=US&sort_by=relevance" \
-H "Authorization: Bearer $CLEANJOBDATA_API_KEY"
The backend handles the messy parts: source adapters, schema enforcement, location resolution, salary parsing, seniority mapping, and cursor pagination. The client can focus on product experience instead of maintaining a parser for every ATS.