Normalization

Experience Level Mapping for Job Listings

CleanJobData Engineering

TL;DR

Every job exposes experience_level (a single best-guess) and experience_levels (the full array of matches) inferred from the title — or an explicit source hint on the rare ATS that sends one — never from the description, and matching runs across multiple languages, not just English. A meaningful share of listings have no seniority signal at all and come back experience_level: null; treat that as its own state, not as entry-level. Filter with comma-separated values (experience_level=SE,EX) rather than merging two separate queries client-side.

Job titles vary widely between companies. One company's Senior Engineer might be another company's Staff Engineer, Lead Developer, or Principal Engineer. As part of normalizing hiring data from multiple ATS platforms, CleanJobData maps these titles into four canonical seniority tokens — EN, MI, SE, EX — so you can filter by career stage without keyword-matching titles yourself. The part most integrations get wrong isn't the mapping itself, it's what to do with the listings that don't map to anything.

Canonical Tokens

  • EN — Entry level
  • MI — Mid level
  • SE — Senior level
  • EX — Executive level

Every job exposes two related fields:

  • experience_level — the primary inferred level, or null if nothing matched
  • experience_levels — an array of every level token the title matched (can be empty, one, or more than one)
{
  "title": "Staff Software Engineer",
  "experience_level": "SE",
  "experience_levels": ["SE"]
}
curl "https://api.cleanjobdata.com/jobs?experience_level=SE,EX&title=engineer" \
  -H "Authorization: Bearer $CLEANJOBDATA_API_KEY"

experience_level accepts comma-separated values, so SE,EX gets you "senior and above" in one request rather than two.

It's Inferred from the Title (or a Source Hint), Never the Description

The normalizer checks two inputs, in order: an explicit seniority value from the source ATS if one was provided in the raw posting data, then the job title as a fallback. In practice, most ATS platforms don't send an explicit seniority field at all — Greenhouse, Lever, Ashby, and Workday postings carry no such hint, so for the large majority of listings the title is the only real signal. A handful of sources (Workable is one) do pass through their own seniority field when the poster filled it in, and that takes priority over the title when it matches. Either way, the job description is never consulted — a posting titled Software Engineer that says "8+ years of experience required" in the body text still won't get SE unless the title itself (or a source-provided hint) carries a seniority signal (Senior, Staff, Lead, Principal, etc.).

The matching is also multilingual — the same seniority/level detection runs against German, French, Spanish, Italian, Portuguese, Russian, Chinese, and Japanese terms alongside English ones (e.g. Geschäftsführer matches EX, 资深 matches SE), not just English keywords. This matters if you're filtering postings from non-English career sites — seniority mapping isn't limited to English-language titles.

This matters for two reasons:

  1. Coverage isn't 100%. Plenty of real-world titles carry no seniority signal at all — Software Engineer, Product Manager, Data Analyst — and those get experience_level: null and experience_levels: []. Don't treat null as a fifth category ("unspecified = entry"); it's a genuinely distinct case from EN, and folding it into entry-level silently misrepresents your data. If you're building a seniority filter UI, decide explicitly whether "not specified" listings should be included by default or surfaced as their own bucket — don't just drop them.
  2. A title can match more than one token. experience_levels is an array because ambiguous or compound titles can trigger multiple signals — e.g. a title like Senior Manager, Early Career Programs could plausibly match more than one level depending on which keywords the title contains. experience_level gives you the first/primary match for simple display; use the full experience_levels array if you're deciding whether a listing belongs in more than one seniority-scoped feed.

Filtering vs. Displaying

For server-side filtering, always use experience_level (or experience_levels semantics via comma-separated experience_level values) — never re-derive seniority from title client-side with your own keyword list. Your keyword list will drift out of sync with the API's mapping and produce results that disagree with what experience_level says about the same job.

curl "https://api.cleanjobdata.com/jobs?title=frontend&experience_level=SE&remote=true" \
  -H "Authorization: Bearer $CLEANJOBDATA_API_KEY"

For display — e.g. a seniority badge on a job card — read experience_level directly rather than pattern-matching the title yourself:

const LEVEL_LABELS: Record<string, string> = {
  EN: "Entry",
  MI: "Mid",
  SE: "Senior",
  EX: "Executive",
};
 
function seniorityBadge(job: Job) {
  return job.experience_level ? LEVEL_LABELS[job.experience_level] : null;
}

Returning null from a badge component when experience_level is null is the correct behavior — don't default it to "Mid" or hide the fact that the level is unknown.

Building a "Senior and Above" Feed

Since experience_level accepts multiple comma-separated tokens in one request, a "senior+" feed doesn't need two queries merged client-side:

curl "https://api.cleanjobdata.com/jobs?title=engineer&experience_level=SE,EX&max_age=7d" \
  -H "Authorization: Bearer $CLEANJOBDATA_API_KEY"

If your product wants to include the "not specified" bucket alongside a chosen level (e.g. because you'd rather over-include than miss a relevant listing), you can't express experience_level=SE,null — the parameter only takes the four tokens. Run the null-inclusive case as a second consideration in your own logic (e.g. a badge of "Level not specified" alongside your SE,EX results) rather than trying to get the API to return both in one filtered call.

Summary

  • The four tokens (EN, MI, SE, EX) are inferred from the job title (or an explicit source-provided hint, when the ATS sends one), at normalization time — never from description text, and matching runs across multiple languages, not just English.
  • A meaningful share of listings have no seniority signal in the title and will have experience_level: null / experience_levels: []. Treat that as its own state, not as entry-level.
  • experience_level accepts comma-separated values — use SE,EX for "senior and above" in a single request instead of merging two queries.
  • Filter and display from the API's fields; don't re-derive seniority from title with your own keyword heuristics, since it will drift from what experience_level reports for the same job.