How to Scrape SmartRecruiters Job Listings

CleanJobData Engineering

TL;DR

SmartRecruiters has a public postings API keyed by a company identifier. The list call returns summaries, 100 at a time, and a second call per posting returns the job ad, whose text is split across several sections. There's no salary field, and remote or hybrid comes from location flags plus title text.

SmartRecruiters is one of the applicant tracking systems that exposes a public JSON API behind its hosted careers pages. If you want to pull a company's listings without parsing HTML, this article covers the endpoints, the fields that matter, and the awkward parts of the data.

Finding the Company Identifier

Hosted SmartRecruiters careers pages live at https://jobs.smartrecruiters.com/<Identifier>, and the last part of that URL is the identifier you use in the API. Copy it exactly as it appears. Some companies embed the board on their own site, so if you don't see the address, look for requests to api.smartrecruiters.com or jobs.smartrecruiters.com in the network tab. As with every applicant tracking system, there's no public directory of identifiers.

The List Endpoint

curl "https://api.smartrecruiters.com/v1/companies/HireVue/postings"

No API key is needed. The response is a page of summaries:

{
  "offset": 0,
  "limit": 100,
  "totalFound": 2,
  "content": [
    {
      "id": "744000149874461",
      "name": "FP&A Manager / Senior FP&A Analyst",
      "refNumber": "REF314P",
      "releasedDate": "2026-09-16T13:44:50.650Z",
      "company": { "identifier": "HireVue", "name": "HireVue Inc" },
      "location": {
        "city": "Sandy", "region": "UT", "country": "us",
        "remote": true, "hybrid": false,
        "fullLocation": "Sandy, UT, United States"
      },
      "department": { "label": "Finance" },
      "typeOfEmployment": { "id": "permanent", "label": "Full-time" },
      "experienceLevel": { "id": "associate", "label": "Associate" },
      "ref": "https://api.smartrecruiters.com/v1/companies/HireVue/postings/744000149874461"
    }
  ]
}

The list has no job description, only summary fields. An identifier that doesn't exist doesn't return an error: you get 200 with "totalFound": 0 and an empty content array, so check totalFound rather than the status code.

Pagination

The API returns 100 postings per page by default and won't return more. Passing limit=200 still gave 100 items, so treat 100 as the maximum. Page with offset:

curl "https://api.smartrecruiters.com/v1/companies/ServiceNow/postings?limit=100&offset=100"

Compare offset + content.length against totalFound to know when to stop. A board with 646 postings took seven requests, and the last page had 46 items. Skipping this step is easy to do by accident, because small boards fit in a single page and look complete.

The Detail Endpoint

The description lives in a second call per posting:

curl "https://api.smartrecruiters.com/v1/companies/ServiceNow/postings/744000150513709"

The detail response contains everything in the summary plus the job ad and some URLs:

  • jobAd.sections is an object with named sections, each with a title and an HTML text. The ones we saw were companyDescription, jobDescription, qualifications and additionalInformation. Some postings leave sections out, and a job needs at least jobDescription to read sensibly. Build the full description by joining whichever sections exist, in that order.
  • postingUrl and applyUrl are the public job page and the apply link; on the postings we saw they were identical.
  • location adds address and postalCode to the summary's fields.
  • active tells you whether the posting is live.

Fetching Jobs (Node/TypeScript)

interface Summary {
  id: string;
  name: string;
  releasedDate: string;
  location?: {
    city?: string; region?: string; country?: string;
    remote?: boolean; hybrid?: boolean; fullLocation?: string;
  };
  typeOfEmployment?: { label?: string };
  experienceLevel?: { label?: string };
  department?: { label?: string };
}
 
const API = "https://api.smartrecruiters.com/v1/companies";
const SECTION_ORDER = ["companyDescription", "jobDescription", "qualifications", "additionalInformation"];
 
async function getJson(url: string) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`SmartRecruiters ${res.status} for ${url}`);
  return res.json();
}
 
export async function listPostings(company: string): Promise<Summary[]> {
  const all: Summary[] = [];
  let offset = 0;
  while (true) {
    const page = await getJson(`${API}/${company}/postings?limit=100&offset=${offset}`);
    all.push(...page.content);
    offset += page.content.length;
    if (page.content.length === 0 || offset >= page.totalFound) break;
  }
  return all;
}
 
export async function fetchSmartRecruitersJobs(company: string) {
  const summaries = await listPostings(company);
  const jobs = [];
  const BATCH = 10;
 
  for (let i = 0; i < summaries.length; i += BATCH) {
    const batch = summaries.slice(i, i + BATCH);
    const settled = await Promise.allSettled(
      batch.map((s) => getJson(`${API}/${company}/postings/${s.id}`)),
    );
    settled.forEach((r, idx) => {
      const s = batch[idx];
      const d = r.status === "fulfilled" ? r.value : null; // still keep the job if detail fails
      const sections = d?.jobAd?.sections ?? {};
      const html = SECTION_ORDER
        .filter((k) => sections[k]?.text)
        .map((k) => `<h3>${sections[k].title ?? k}</h3>\n${sections[k].text}`)
        .join("\n");
      jobs.push({
        id: s.id,
        title: s.name,
        published: s.releasedDate,
        location: s.location?.fullLocation ?? [s.location?.city, s.location?.region, s.location?.country].filter(Boolean).join(", "),
        remote: Boolean(s.location?.remote),
        hybrid: Boolean(s.location?.hybrid),
        employmentType: s.typeOfEmployment?.label,
        experienceLevel: s.experienceLevel?.label,
        department: s.department?.label,
        url: d?.postingUrl ?? `https://jobs.smartrecruiters.com/${company}/${s.id}`,
        html,
      });
    });
  }
  return jobs;
}

Fetching details in small batches keeps the request rate polite. On a board with hundreds of postings the detail calls are the slow part.

Fields Worth Knowing

  • releasedDate is an ISO timestamp, the best "published" date.
  • typeOfEmployment and experienceLevel are objects with an id and a readable label, for example permanent / "Full-time" and associate / "Associate".
  • location.country is a lowercase ISO code such as us. fullLocation is a ready-made string like "San Diego, California, United States", so prefer it when it's present.
  • department, function and industry are labeled objects that you can use for filtering.
  • customField is an array of company-specific labels (for example "Exempt / Non Exempt"). It varies by employer.
  • creator holds the recruiter's name, so decide whether you want to store it at all.

Gotchas

Remote and hybrid are two booleans. location.remote and location.hybrid are real fields, but employers don't always set them. Also check the title for "remote" or "hybrid".

There's no salary field. The detail responses we looked at had no compensation field. If a company discloses pay, it's inside the ad text.

Sections vary. Some postings lack a qualifications or additional-information section, and companyDescription is boilerplate that repeats on every job. Decide whether you want it in your description.

Edits are easy to miss. The list doesn't tell you when a posting was last changed. If you skip detail calls for IDs you already stored, you won't see edits to those jobs.

Old postings can stay open. Compare releasedDate with today's date before you treat a job as fresh.

No webhooks and no published limits we can point to. It's pull-only. Keep detail concurrency modest, retry on 429 and 5xx, and compare posting IDs between runs to detect closures.

When This Is Enough

For one company's jobs, the list-plus-detail pattern is simple and free. Add pagination and you can cover even very large boards.

When It Isn't

Across many companies and platforms you end up writing and maintaining a different collector for each. See Greenhouse and Lever for how different the shapes are, and how we normalized four ATS platforms for the general approach.

CleanJobData reads SmartRecruiters boards alongside other platforms and returns one schema, with locations resolved to cities, states and countries, a remote type, an employment type and an experience level. There's no salary to extract from SmartRecruiters itself, so that field can be empty. Try it in the Playground or see the API Documentation.