Back to articles

How to Scrape Workable Job Listings

CleanJobData Engineering

Workable's public API is a "widget" endpoint — built for embedding a careers page, not designed as a developer API, which shows in a few of its quirks. It's still fully usable for scraping, and it has one structural advantage over Greenhouse worth knowing about: a real array of location objects for multi-location postings, not just one text string.

Finding the Account Slug

A Workable-hosted careers page lives at https://apply.workable.com/{slug}/ (often mapped to a custom domain like careers.acme.com via a CNAME, with the underlying slug hidden unless you check the page's network requests). Visit the company's careers page and watch for a call to apply.workable.com/api/v1/widget/accounts/{slug} — that request's URL has the slug you need. 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 Workable 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://apply.workable.com/api/v1/widget/accounts/acme?details=true"

details=true is what gets you full job descriptions in the same bulk response — omit it and you get a lighter listing without descriptions, similar to Greenhouse's default vs. content=true split.

The Full Response Shape

{
  "name": "Acme Inc",
  "description": "Acme builds tools for...",
  "jobs": [
    {
      "shortcode": "AB12CD34",
      "title": "Senior Backend Engineer",
      "city": "San Francisco",
      "state": "California",
      "country": "United States",
      "telecommuting": false,
      "employment_type": "Full-time",
      "experience": "Senior Level",
      "published_on": "2026-07-01",
      "created_at": "2026-07-01",
      "application_url": "https://apply.workable.com/acme/j/AB12CD34/",
      "description": "<p>We're looking for...</p>",
      "locations": [
        { "city": "San Francisco", "region": "California", "country": "United States", "countryCode": "US" },
        { "city": null, "region": null, "country": "United States", "countryCode": "US" }
      ]
    }
  ]
}

Field-by-field:

  • shortcode — an alphanumeric ID, stable across requests. Safe dedup key.
  • city / state / country — the "primary" location, already split into separate fields rather than one free-text string, which is genuinely more convenient than Greenhouse's single location.name. Note that the ISO countryCode only shows up nested inside each locations[] entry, not alongside these top-level fields — you'll need to read it from there.
  • locations — an array covering every location the posting is open to, including remote-only entries (note the second example entry above: city: null with only a country set, representing "remote within this country"). Read only the top-level city/state/country fields and you'll silently drop every location past the first.
  • telecommuting — a genuine boolean remote flag, not a text guess. More reliable than inferring from a location string, though still worth cross-checking against locations for postings that are remote-eligible in some regions but not others.
  • employment_type and experience — both freeform-ish strings set by whoever configured the posting ("Full-time", "Senior Level") rather than a fixed enum guaranteed to be consistent across every company's Workable instance — expect to normalize casing and wording before relying on them for filtering.
  • published_on — a plain date ("2026-07-01"), not a full timestamp with time-of-day. Fine for day-level freshness filtering, not for anything needing hour precision.
  • No salary fields anywhere. Same gap as Greenhouse — nothing structured, and nothing reliably in the description either, since Workable doesn't have a dedicated compensation section in its posting template the way Ashby or Lever do.
  • No real updated_at-equivalent field. A created_at field is present alongside published_on, but it tracks creation, not last edit — there's no separate "last edited" timestamp to distinguish a freshly posted job from an old one that just got a typo fix, unlike Greenhouse's updated_at/first_published pair.

The Real Pain Points

This is a widget API, not a developer API, and it shows. There's no official public documentation for it — the shape above is reverse-engineered from what the endpoint actually returns, the same way any embed script's backing API tends to work. Expect it to be less stable across Workable's own product changes than a platform that documents and versions a real public API.

Location is better structured, but still needs a name-to-code lookup for anything beyond country. countryCode is a genuine ISO code, but there's no equivalent code for state/region — you'll still need your own state-name normalization if you want to filter or group by state reliably.

No salary, no way around it. Unlike Lever or Ashby, there's no structured compensation field and no consistent place in the description to extract it from either — plan for salary data from Workable-sourced postings to be sparse to nonexistent.

No webhooks, same as every ATS covered here — poll and diff shortcodes to detect closures.

When This Is Enough

For a handful of Workable-hosted companies, this API is solid, especially if location matters more to your use case than salary — the structured locations array and countryCode field save real parsing work that Greenhouse simply doesn't offer.

When It Isn't

The moment you're combining Workable postings with other ATS platforms, the "widget API" nature becomes a real cost: it's less documented and more likely to shift shape without warning than Greenhouse's or Lever's public APIs, on top of the usual cross-platform reconciliation problem of combining four or five different response shapes into one schema. A normalized data API like JobsDataAPI absorbs that instability so it doesn't become your production incident.