Back to articles

How to Scrape Ashby Job Listings

CleanJobData Engineering

Of the major ATS platforms, Ashby's public API returns the most structured location and compensation data by default — a real address object instead of one free-text string, and a compensation array with named tiers instead of a single number pair. That doesn't mean it's problem-free; here's the full shape and where it still breaks down.

Finding the Organization Slug

An Ashby-hosted careers page lives at https://jobs.ashbyhq.com/{slug}. Visit the company's careers page (typically linked from their site's careers/jobs footer) and the slug is the path segment after ashbyhq.com/. As with any ATS, this only tells you the slug for a company whose careers page you already know — there's no public directory mapping every company to its Ashby slug, and finding those at scale across thousands of employers is a separate, much harder problem than fetching one board you already have the address for.

Fetching the Full Posting List

curl "https://api.ashbyhq.com/posting-api/job-board/acme?includeCompensation=true"

This is a plain GET request, no authentication, and returns every open posting with full detail in one response — including compensation, when includeCompensation=true is set. Omit that param and compensation fields are stripped from the response even when the posting has them configured.

The Full Response Shape

{
  "jobs": [
    {
      "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
      "title": "Senior Backend Engineer",
      "locationName": "San Francisco, CA",
      "workplaceType": "hybrid",
      "isRemote": false,
      "employmentType": "FullTime",
      "publishedAt": "2026-07-01T12:00:00.000Z",
      "jobUrl": "https://jobs.ashbyhq.com/acme/f47ac10b-58cc-4372-a567-0e02b2c3d479",
      "descriptionHtml": "<p>We're looking for...</p>",
      "address": {
        "postalAddress": {
          "addressLocality": "San Francisco",
          "addressRegion": "California",
          "addressCountry": "United States"
        }
      },
      "secondaryLocations": [
        {
          "location": "Remote - US",
          "address": { "postalAddress": { "addressCountry": "United States" } }
        }
      ],
      "compensation": {
        "compensationTierSummary": "$150K – $180K",
        "summaryComponents": [
          {
            "compensationType": "Salary",
            "interval": "1 YEAR",
            "minValue": 150000,
            "maxValue": 180000,
            "currencyCode": "USD"
          }
        ]
      }
    }
  ]
}

Field-by-field:

  • id — a UUID. Stable dedup key.
  • locationName — still free text, same inconsistency problem as every other ATS. Use it as a display fallback, not your primary location source here — address is better.
  • address.postalAddress — an actual structured object: addressLocality (city), addressRegion (state/province), addressCountry (country name, not a code). This is real structured location, not something you have to parse from a sentence — genuinely rare among ATS public APIs. The catch: addressCountry is a full country name ("United States"), not an ISO code, so you still need a name-to-code lookup table if you want ISO codes downstream.
  • secondaryLocations — an array covering multi-location and remote-eligible postings, each with its own nested address. A posting listed as based in one city but open to remote hires elsewhere will show that here — miss this array and you'll only capture the primary office location.
  • workplaceType and isRemote — two separate signals, and in practice neither is reliably populated on its own: both are commonly null/absent on real postings. Don't trust either alone — treat a posting as remote if any of isRemote === true, workplaceType === "remote" (or "hybrid" for hybrid), or the word "remote" appears in locationName holds true, and fall back to locationName text as the last resort when the structured fields are both empty.
  • employmentType — a cleaner enum than most ATS platforms (FullTime, PartTime, Contract, Intern in practice), but still worth normalizing rather than trusting blindly, since case and exact wording can vary by how each company's Ashby instance is configured.
  • compensation.summaryComponents — the real prize here: an array of structured compensation components, each with compensationType, interval (a numeric-prefixed string like "1 YEAR" or "1 HOUR", not a bare unit — match it with a substring check rather than exact equality), minValue, maxValue, and currencyCode. A posting can have more than one component (e.g. base salary plus a separate equity or bonus tier) — if you only read the first array entry, you'll sometimes grab a bonus tier instead of base salary. Filter by compensationType === "Salary" (or whatever the equivalent string is in the payload you're looking at) before treating a value as base pay.
  • compensation.compensationTierSummary — a human-readable string version of the same data ("$150K – $180K"). Useful for display, not for filtering or math — parse summaryComponents for that.
  • descriptionHtml — the full posting body as HTML, generally clean.

The Real Pain Points

Compensation components need filtering, not just reading. The richness of summaryComponents is also its trap — grabbing [0] blindly without checking compensationType and interval will occasionally hand you an hourly bonus figure and label it as an annual salary.

Country is a name, not a code, even in the structured field. addressCountry being a full name means you still need normalization logic even for the "good" location data — it's less work than parsing a full free-text string, but it's not zero work.

Not every posting has structured compensation, even with includeCompensation=true set. Disclosure still depends on the employer and, in some regions, local law — expect compensation to be absent or partially filled on a meaningful share of postings.

No webhooks, consistent with every ATS in this category — poll and diff ids to catch closures.

When This Is Enough

If you're pulling from a handful of Ashby-hosted companies, this is the best-structured API of the major ATS platforms for location and compensation — you'll write meaningfully less parsing code than you would for Greenhouse or Workday.

When It Isn't

The structure only helps within Ashby. The moment you're combining Ashby postings with Greenhouse, Lever, or Workday postings in the same product, you're back to reconciling four different response shapes — a UUID here, a sequential ID there, a compensation array here, a single salary pair there — into one schema. That reconciliation, done once across ATS platforms, is what a normalized data API like JobsDataAPI exists to handle.