Skip to main content
Thirdwatchthirdwatch
Jobs & recruitment

Audit Live Job Postings Against Your ATS Requisition Data

Run a job posting audit across Workday, Greenhouse, Lever, Ashby and four more ATS boards to catch stale reqs, duplicate roles and mis-tagged departments.

Jul 28, 2026 · 8 min read · 1,828 words
See the scraper →

TL;DR: The Multi-ATS Jobs Scraper pulls every posting that is publicly live on your company's career boards across eight applicant tracking systems and returns them in one flat schema. Recruiting-ops teams join that pull against their requisition export on requisition_id, or on the platform + company_slug + job_id triple, to find reqs that closed internally but stayed public, duplicates across acquired-company boards, and department or location values that no longer match the system of record.

Why audit live job postings against your requisition data?

Your public career-board surface drifts away from your requisition record faster than anyone notices, and only candidates and auditors see the gap. A req gets filled, the recruiter marks it closed in the ATS, and the posting stays live on a board nobody owns any more. An acquisition brings three business units onto Greenhouse, Lever and a Workday tenant, and the parent company's careers page now points at all three with three different department taxonomies. A location field says "London" on one board and "London, United Kingdom (Hybrid)" on another, so headcount reporting by region silently disagrees with what applicants read.

The compliance side has hardened. The EU pay transparency directive, Directive (EU) 2023/970, required member states to transpose rules by 7 June 2026 that oblige employers to give applicants pay information before the interview, in many cases in the vacancy notice itself. That turns a career board from a marketing asset into a record you have to be able to evidence. If you cannot produce a dated list of what was publicly posted and what pay range it carried, you cannot answer the question.

A job posting audit makes that answerable. Read every board you own, normalise it, and diff it against the ATS export.

How does this compare to the alternatives?

The audit is a reconciliation problem, so the hard part is normalising eight different board formats into one comparable table, not fetching pages. Any of the three routes below can fetch. Only one of them hands you a single schema.

Method Commercial model Reliability Setup time Ongoing maintenance
DIY per-ATS scripts Engineering time plus hosting You own eight pagination models and eight response shapes Days per platform Breaks whenever any one ATS changes
Generic scraping API Usage or subscription Returns HTML; parsing and normalisation stay yours Hours Selectors are your problem on every board
ATS vendor reporting Per-seat, per-platform Authoritative, but only for that one platform Procurement cycle No cross-platform view at all
Thirdwatch Actor Transparent pay-per-result Eight systems, one flat schema, stable dedup key Minutes Board-format changes handled upstream

Vendor reporting is the right tool when you are on one ATS and staying there. The moment your estate spans an acquisition, a legacy tenant or a business unit that never migrated, per-platform reporting cannot produce the single table the audit needs. The Multi-ATS Jobs Scraper exists for that estate.

How to run a job posting audit in 4 steps

Which boards does my company actually publish to?

Start by writing down every board your organisation and its acquisitions publish to, including the ones nobody owns. This inventory is usually the first finding of the audit. Check the careers page footer, any redirect on careers.yourcompany.com, and the boards the acquired entities used before integration.

The actor accepts a full board URL or a platform:slug shorthand. Both forms go in boardUrls:

{
  "boardUrls": [
    "https://job-boards.greenhouse.io/acmecorp",
    "https://jobs.lever.co/acme-labs",
    "https://jobs.ashbyhq.com/acme-analytics",
    "workday:acmecorp/wd5/AcmeExternalCareerSite",
    "smartrecruiters:AcmeServices"
  ],
  "includeDescription": false,
  "maxResults": 2000,
  "maxResultsPerBoard": 500
}

Set includeDescription to false for the audit itself. You are reconciling identifiers, titles, departments and dates, not reading job copy, and skipping descriptions makes a large listing-only run substantially faster.

How do I pull every live posting across all of them?

Run the actor once with no keyword or location filter so the pull is a complete census of your public surface. Any filter you add is a filter on your own audit, and a stale posting you filtered out is a stale posting you will not find.

import os
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

run = client.actor("thirdwatch/multi-ats-jobs-scraper").call(run_input={
    "boardUrls": [
        "https://job-boards.greenhouse.io/acmecorp",
        "https://jobs.lever.co/acme-labs",
        "workday:acmecorp/wd5/AcmeExternalCareerSite",
    ],
    "searchText": "",
    "locationFilter": "",
    "remoteOnly": False,
    "includeDescription": False,
    "maxResults": 2000,
    "maxResultsPerBoard": 500,
})

live = list(client.dataset(run["defaultDatasetId"]).iterate_items())
print(f"{len(live)} live postings across {len({r['board_url'] for r in live})} boards")

Log the run ID and the row count. A run that returns dramatically fewer rows than last week is either a real hiring freeze or a board that stopped answering, and you want to know which before you act on the diff.

How do I reconcile the pull against my requisition export?

Join on requisition_id where the board publishes one, and fall back to the platform + company_slug + job_id triple everywhere else. That triple is the actor's own dedup key, so it is stable across runs and safe to store as a surrogate identifier in your warehouse.

import csv

# Requisition export from your ATS: req_id, status, department, owner
with open("requisitions.csv") as fh:
    reqs = {row["req_id"]: row for row in csv.DictReader(fh)}

def key(row):
    return row.get("requisition_id") or f"{row['platform']}:{row['company_slug']}:{row['job_id']}"

live_by_key = {key(row): row for row in live}

# 1. Public but closed internally — the classic stale posting
zombies = [
    row for k, row in live_by_key.items()
    if k in reqs and reqs[k]["status"].lower() in {"closed", "filled", "cancelled", "on_hold"}
]

# 2. Public with no matching requisition at all
orphans = [row for k, row in live_by_key.items() if k not in reqs]

print(f"{len(zombies)} postings live against a closed req")
print(f"{len(orphans)} postings with no requisition record")

Orphans are usually postings on a board that was never brought into the integrated ATS after an acquisition. That is a governance finding, not a data bug.

How do I find stale dates and mis-tagged departments?

Treat posted_at, updated_at and department as the three fields where drift shows up first. A posting whose updated_at has not moved in months, on a req your team believes is actively hiring, is either abandoned or filled. A department value that does not appear in your finance taxonomy means headcount reporting will not roll up.

from collections import Counter
from datetime import datetime, timedelta, timezone

cutoff = datetime.now(timezone.utc) - timedelta(days=120)

def parsed(value):
    return datetime.fromisoformat(value.replace("Z", "+00:00")) if value else None

stale = [
    r for r in live
    if (parsed(r.get("updated_at")) or parsed(r.get("posted_at")) or datetime.now(timezone.utc)) < cutoff
]

approved = {"Engineering", "Sales", "Marketing", "Finance", "People"}
mis_tagged = [r for r in live if r.get("department") and r["department"] not in approved]

print(f"{len(stale)} postings untouched for 120+ days")
print(Counter(r["department"] for r in mis_tagged).most_common(10))
print(Counter((r["platform"], r["title"], r["location"]) for r in live).most_common(5))

That last line is your duplicate check. The same title and location appearing on two different platform values almost always means one acquired board is still publishing a role the parent already posts.

How do I make this a repeatable weekly control?

Save the tested input as an Apify Task and schedule it, then write every successful run to a dated partition rather than overwriting the last one. The audit's value is historical: proving what was public on a given date is a different question from what is public now.

snapshot = {
    "run_id": run["id"],
    "collected_at": datetime.now(timezone.utc).isoformat(),
    "rows": len(live),
    "boards": sorted({r["board_url"] for r in live}),
}

Alert on the delta, not the level. New orphan boards, a posting crossing the staleness threshold, and any department value outside the approved set are decisions. A row count that moved by three is not.

What does the audit output look like?

Every posting from every ATS returns with the same keys, so a Greenhouse row and a Workday row sit in one table without post-processing. Two representative rows:

[
  {
    "platform": "ashby",
    "company": "acme-analytics",
    "company_slug": "acme-analytics",
    "job_id": "34413f8d-26bf-4bbc-8ade-eb309a0e2245",
    "title": "Security Engineer, Cloud",
    "department": "Engineering",
    "team": "Backend",
    "employment_type": "FullTime",
    "workplace_type": "hybrid",
    "is_remote": true,
    "location": "New York, NY (HQ), Remote (US)",
    "city": "New York City",
    "region": "NY",
    "country": "USA",
    "country_code": "",
    "posted_at": "2026-04-07T17:12:35.753000Z",
    "updated_at": "",
    "salary_min": 211400,
    "salary_max": 290600,
    "salary_currency": "USD",
    "salary_period": "year",
    "requisition_id": "",
    "job_url": "https://jobs.ashbyhq.com/acme-analytics/34413f8d-26bf-4bbc-8ade-eb309a0e2245",
    "apply_url": "https://jobs.ashbyhq.com/acme-analytics/34413f8d-26bf-4bbc-8ade-eb309a0e2245/application",
    "description": "",
    "board_url": "https://jobs.ashbyhq.com/acme-analytics",
    "source_query": "ashby:acme-analytics",
    "scraped_at": "2026-07-28T09:14:02.118431Z"
  },
  {
    "platform": "greenhouse",
    "company": "AcmeCorp",
    "company_slug": "acmecorp",
    "job_id": "6104883",
    "title": "Payroll Operations Analyst",
    "department": "Finance",
    "team": "",
    "employment_type": "",
    "workplace_type": "onsite",
    "is_remote": false,
    "location": "London, United Kingdom",
    "city": "",
    "region": "",
    "country": "United Kingdom",
    "country_code": "GB",
    "posted_at": "2026-01-19T11:02:00Z",
    "updated_at": "2026-01-19T11:02:00Z",
    "salary_min": null,
    "salary_max": null,
    "salary_currency": "",
    "salary_period": "",
    "requisition_id": "REQ-2026-0431",
    "job_url": "https://job-boards.greenhouse.io/acmecorp/jobs/6104883",
    "apply_url": "https://job-boards.greenhouse.io/acmecorp/jobs/6104883#app",
    "description": "",
    "board_url": "https://job-boards.greenhouse.io/acmecorp",
    "source_query": "https://job-boards.greenhouse.io/acmecorp",
    "scraped_at": "2026-07-28T09:14:04.402188Z"
  }
]

The second row is a finding on sight: requisition_id REQ-2026-0431 is joinable, posted_at and updated_at have not moved since January, and there is no published pay range on a UK-located role. The first row has no requisition_id, so it falls back to the ashby:acme-analytics:34413f8d… triple. source_query tells you which input entry produced each row, which matters when you are chasing down which board owner to email.

Common pitfalls in a job posting audit

Field coverage varies by ATS far more than most people expect, and an empty field is not evidence of a missing value. Across a mixed eight-board test run, department was populated on 88% of rows, posted_at on 100%, city on 48%, and salary on 0% because none of those employers published pay ranges. Ashby and Lever boards carry compensation most often; Workday and BambooHR essentially never do. Blank requisition_id is common — that is why the fallback key exists.

Three more traps. Very large Workday boards report a display-capped total, commonly 2000, rather than the true count, so a huge Workday tenant can return fewer postings than it actually has open — split it by keyword or accept the ceiling. A wrong SmartRecruiters slug returns a normal, successful, empty response, so a typo looks identical to a company with no open roles; the run log flags the ambiguity but cannot resolve it. Some BambooHR boards are access-restricted and are logged and skipped rather than failing the run.

Zero results is also a clean, successful outcome. Never let an empty run overwrite your last good snapshot. The actor handles pagination limits, retries and the platform + board + job_id dedup so the same board listed twice never double-counts; deciding what a gap means stays with you.

Related use cases

The same pull supports several adjacent recruiting-ops and analytics workflows. Continue with:

Frequently asked questions

What is a job posting audit?

A job posting audit compares what is publicly live on your career boards against your internal requisition record. It surfaces postings that stayed public after the req closed, duplicates across acquired-company boards, and department or location values that contradict your system of record.

Can this reconcile boards on different ATS platforms?

Yes. The actor reads Workday, Greenhouse, Lever, SmartRecruiters, Workable, Ashby, Recruitee and BambooHR and returns all of them in one flat schema, so postings from three acquired companies on three platforms land in a single comparable table.

Which field should I join on?

Join on requisition_id when the board publishes it, and fall back to the platform plus company_slug plus job_id triple otherwise. That triple is the actor's own dedup key and is stable across runs, so it works as a surrogate identifier.

How often should the audit run?

Weekly is enough for most recruiting-ops teams, with a daily run during a hiring freeze or an acquisition integration. Store every run as a dated snapshot so you can prove what was public on a given date rather than only what is public now.

Does it see internal-only or draft postings?

No. The actor reads boards exactly as a candidate would, without logging in. That is the point of the audit: it shows your public surface. Anything internal-only stays invisible, and a posting missing from the pull is itself a finding worth checking.

Related

Try it yourself

100 free credits, no credit card.

About 30 real searches. Add the MCP to Claude or Cursor in two minutes.