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
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}]
}
Notes:
-
shortcodeis the stable id, and it is also the last part ofurl. -
published_onandcreated_atare dates with no time. - Remote is the boolean
telecommuting. There is no salary field. - An unknown account answers
404with a plain-textNot Foundbody, 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/
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}
}
Notes:
- Work mode comes as three booleans:
remote,hybridandon_site. -
published_atis not ISO 8601. It uses a space and a literalUTC, so parse it with a format string. -
careers_urlpoints at the company's own domain when it has one (careers.bunq.com, notbunq.recruitee.com). - The
salaryobject exists even when every value in it is null. - An unknown company answers
404with{"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
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
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"}}]
}
}
Notes:
- There are two ids.
idis a UUID, and_jobposting.identifier.valueis 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,onsiteon the boards I read), so use that if you need work mode. - An empty board answers
200with zero items. An unknown subdomain answers404.
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
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.comallows everything forUser-agent: *and adds aContent-Signal: search=yes, ai-input=yes, ai-train=noline. -
{company}.recruitee.com/robots.txtredirects 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.comblocks one named crawler entirely and, for everyone else, disallows paths like/app/and/jobs/internal/, not/jobs.json. TheContent-Signalline varies by company. Most boards I read hadsearch=yes, but one hadsearch=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)
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')
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)