DEV Community

Scott Williams
Scott Williams

Posted on Edited on AI-assisted

Reading Workable, Recruitee and Teamtailor job boards from their public JSON

AI disclosure: this post was drafted with AI assistance. I ran every request and code sample below myself on November 1, 2026 and checked the output.

Greenhouse, Lever and Ashby get most of the attention when people talk about reading job boards. Plenty of companies, especially in Europe, use Workable, Recruitee or Teamtailor instead. All three publish the jobs on a company's career page as JSON, with no key, because the career page itself needs them. This post covers what each endpoint looks like, how they differ, and what to watch for before you poll them every day.

Workable

GET https://apply.workable.com/api/v1/widget/accounts/{account}?details=false
Enter fullscreen mode Exit fullscreen mode

The account name is the path segment in the career page URL: apply.workable.com/huggingface/ is account huggingface. The response is one object with name, description and jobs, and the whole board comes back in one response, with no paging.

A job, trimmed:

{
  "title": "Open-Source Machine Learning Engineer - EMEA Remote",
  "shortcode": "81B46579FE",
  "employment_type": "Full-time",
  "telecommuting": true,
  "department": "Open Source",
  "url": "https://apply.workable.com/j/81B46579FE",
  "published_on": "2026-05-29",
  "city": "Paris",
  "country": "France",
  "locations": [{"country": "France", "countryCode": "FR", "city": "Paris", "region": "Île-de-France", "hidden": false}]
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • shortcode is the stable id, and it is also the last part of url.
  • published_on and created_at are dates with no time.
  • Remote is the boolean telecommuting. There is no salary field.
  • An unknown account answers 404 with a plain-text Not Found body, not JSON.

Rate limits are the real issue. apply.workable.com sits behind Cloudflare (the response headers include server: cloudflare). From a cloud server I have been answered with HTTP 429 and Cloudflare's "error code: 1015" page. On the day I wrote this the same requests went through. Either way, space your requests out, and when you get a 429, stop hitting the host for that run instead of retrying in a loop.

Recruitee

GET https://{company}.recruitee.com/api/offers/
Enter fullscreen mode Exit fullscreen mode

The response is {"offers": [...]}, again the whole board with no paging. bunq's board had 25 offers. With descriptions included it was 731,785 bytes, so expect large responses.

An offer, trimmed:

{
  "id": 2800490,
  "slug": "data-scientist-marketing-3",
  "title": "Data Scientist Marketing",
  "company_name": "bunq",
  "location": "Amsterdam, Noord-Holland, Netherlands",
  "remote": false,
  "hybrid": true,
  "on_site": false,
  "status": "published",
  "published_at": "2026-10-28 13:21:58 UTC",
  "careers_url": "https://careers.bunq.com/o/data-scientist-marketing-3",
  "employment_type_code": "fulltime_permanent",
  "department": "Growth",
  "salary": {"min": null, "max": null, "period": null, "currency": null}
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • Work mode comes as three booleans: remote, hybrid and on_site.
  • published_at is not ISO 8601. It uses a space and a literal UTC, so parse it with a format string.
  • careers_url points at the company's own domain when it has one (careers.bunq.com, not bunq.recruitee.com).
  • The salary object exists even when every value in it is null.
  • An unknown company answers 404 with {"error":"Not Found"}.

A token is coming. Recruitee's Careers Site API documentation says the deadline for adding an X-Careers-Sites-Token header to these calls is 10 February 2027, and that calls without it will return 401 Unauthorized. The token is set per company, so a reader of other companies' boards will not have one. The same page says the XML offer feed is not covered by the token:

GET https://{company}.recruitee.com/api/feeds/offers.xml
Enter fullscreen mode Exit fullscreen mode

On November 1 that feed returned the same 25 offers for bunq, with the same fields as <offer> elements. Booleans are the text true/false, and locations are nested as <locations><location>. If you parse it, refuse documents with a DOCTYPE or entities, since you are parsing XML from servers you do not control.

Teamtailor

GET https://{company}.teamtailor.com/jobs.json
Enter fullscreen mode Exit fullscreen mode

This is a JSON Feed 1.1 document, served as application/feed+json: version, title, home_page_url, feed_url and items. Each item has id, title, url, date_published, content_html and a _jobposting object, which is a schema.org JobPosting:

{
  "id": "0817d5ec-0e95-46f8-8baf-1a566f2ecc35",
  "title": "Principal Consultant - Generalist",
  "url": "https://proxima.teamtailor.com/jobs/8630029-principal-consultant-generalist",
  "date_published": "2026-10-28T11:14:46+00:00",
  "_jobposting": {
    "@type": "JobPosting",
    "identifier": {"@type": "PropertyValue", "name": "Proxima", "value": "8630029"},
    "datePosted": "2026-10-28T11:14:46+00:00",
    "employmentType": "FULL_TIME",
    "hiringOrganization": {"@type": "Organization", "name": "Proxima"},
    "jobLocation": [{"@type": "Place", "address": {"addressLocality": "Düsseldorf", "addressCountry": "DE"}}]
  }
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • There are two ids. id is a UUID, and _jobposting.identifier.value is the number in the job URL (it matched the URL on every item I checked). Pick one and keep it.
  • Some postings carry _jobposting.baseSalary. On Teamtailor's own board, 3 of 17 did, in SEK per month.
  • There is no remote/hybrid field in the JSON. The RSS version, /jobs.rss, has a <remoteStatus> element (none, hybrid, onsite on the boards I read), so use that if you need work mode.
  • An empty board answers 200 with zero items. An unknown subdomain answers 404.

Pagination exists but is easy to miss. Without parameters, Proxima's 40 jobs and SATS's 57 came back in one response. With ?per_page=15, the response includes a next_url while more pages remain:

https://proxima.teamtailor.com/jobs.json?page=2&per_page=15
Enter fullscreen mode Exit fullscreen mode

Pages 1 to 3 held 15, 15 and 10 items. The last page had no next_url, and page 4 returned zero items. I could not find the default page size; it is at least 57. So follow next_url until it is missing, and do not assume one response is the whole board.

Teamtailor career sites can also live on a company's own domain. tibber.teamtailor.com/jobs redirects to jobs.tibber.com/jobs, and /jobs.json returned the same feed on both hosts, with feed_url set to whichever host you asked.

robots.txt and signals

All three publish a robots.txt worth reading before you poll:

  • apply.workable.com allows everything for User-agent: * and adds a Content-Signal: search=yes, ai-input=yes, ai-train=no line.
  • {company}.recruitee.com/robots.txt redirects to the company's career domain. bunq's allows /, and Tellent's (jobs.recruitee.com) disallows only /v/. For a company name that does not exist, the redirect lands on an HTML page, not a robots file.
  • *.teamtailor.com blocks one named crawler entirely and, for everyone else, disallows paths like /app/ and /jobs/internal/, not /jobs.json. The Content-Signal line varies by company. Most boards I read had search=yes, but one had search=no, ai-input=no, so check each board rather than the platform.

For comparison, SmartRecruiters' API host (api.smartrecruiters.com) disallows everything for User-agent: * and allows only LinkedInBot. That is why it is not in this post.

A minimal reader

Standard library only. It turns each board into (uid, title, url) with a key that will not change between runs:

import json
import time
import urllib.request

UA = "my-jobs-reader/1.0"  # name your script; add a contact URL or email if you have one


def get_json(url):
    req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"})
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.load(r)


def workable(account):
    d = get_json(f"https://apply.workable.com/api/v1/widget/accounts/{account}?details=false")
    for j in d["jobs"]:
        yield f"workable:{account}:{j['shortcode']}", j["title"], j["url"]


def recruitee(company):
    d = get_json(f"https://{company}.recruitee.com/api/offers/")
    for o in d["offers"]:
        yield f"recruitee:{company}:{o['id']}", o["title"], o["careers_url"]


def teamtailor(company):
    url = f"https://{company}.teamtailor.com/jobs.json?per_page=15"
    while url:
        d = get_json(url)
        for item in d["items"]:
            job_id = item["_jobposting"]["identifier"]["value"]
            yield f"teamtailor:{company}:{job_id}", item["title"], item["url"]
        url = d.get("next_url")
        time.sleep(1)


for reader, name in [(workable, "huggingface"), (recruitee, "bunq"), (teamtailor, "proxima")]:
    jobs = list(reader(name))
    print(f"{reader.__name__}:{name}: {len(jobs)} jobs, first: {jobs[0]}")
    time.sleep(5)
Enter fullscreen mode Exit fullscreen mode

Output on November 1:

workable:huggingface: 6 jobs, first: ('workable:huggingface:81B46579FE', 'Open-Source Machine Learning Engineer - EMEA Remote', 'https://apply.workable.com/j/81B46579FE')
recruitee:bunq: 25 jobs, first: ('recruitee:bunq:2800490', 'Data Scientist Marketing', 'https://careers.bunq.com/o/data-scientist-marketing-3')
teamtailor:proxima: 40 jobs, first: ('teamtailor:proxima:8630029', 'Principal Consultant - Generalist', 'https://proxima.teamtailor.com/jobs/8630029-principal-consultant-generalist')
Enter fullscreen mode Exit fullscreen mode

This is the happy path. A version you can leave running needs a few more things:

  • checks robots.txt for each host
  • turns a 429 into "skip this host today"
  • falls back to Recruitee's XML feed on a 401
  • normalises the three shapes into one schema
  • keeps only jobs it has not seen before

Field mapping at a glance

Workable Recruitee Teamtailor
Stable id shortcode id _jobposting.identifier.value
Posted published_on (date) published_at (... UTC) date_published (ISO 8601)
Work mode telecommuting remote / hybrid / on_site only in jobs.rss
Employment type Full-time fulltime_permanent FULL_TIME
Paging none none next_url
Unknown board 404 text 404 JSON 404, empty body

The version I leave running is in my repo, fecklesspleb/careers-page-jobs-feed, under the MIT license. It does everything in that list, and adds offline tests against saved responses and a GitHub Actions workflow that runs it daily and commits the new jobs to CSV.

This article was created with the help of AI. The endpoints, numbers and code were tested by me before publishing.

Top comments (0)