<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: UDANINN</title>
    <description>The latest articles on DEV Community by UDANINN (@udaninn).</description>
    <link>https://dev.to/udaninn</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4071235%2Fe9dc9d88-07c2-4d58-b318-da40f6ff01a4.png</url>
      <title>DEV Community: UDANINN</title>
      <link>https://dev.to/udaninn</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/udaninn"/>
    <language>en</language>
    <item>
      <title>Six ATS platforms publish their job boards as open JSON. Here are the endpoints.</title>
      <dc:creator>UDANINN</dc:creator>
      <pubDate>Mon, 10 Aug 2026 12:12:40 +0000</pubDate>
      <link>https://dev.to/udaninn/six-ats-platforms-publish-their-job-boards-as-open-json-here-are-the-endpoints-2d3k</link>
      <guid>https://dev.to/udaninn/six-ats-platforms-publish-their-job-boards-as-open-json-here-are-the-endpoints-2d3k</guid>
      <description>&lt;p&gt;Most "job scraping" tutorials teach you to render a careers page in a headless&lt;br&gt;
browser and pick apart the HTML. For the majority of company career pages, that&lt;br&gt;
is unnecessary. The page you are trying to scrape is itself a client-side app&lt;br&gt;
calling a public JSON API — and you can call that API directly.&lt;/p&gt;

&lt;p&gt;No key. No proxy. No anti-bot. Just JSON.&lt;/p&gt;

&lt;p&gt;Below are the six applicant tracking systems that cover most of the startup and&lt;br&gt;
mid-market hiring world, the exact endpoint each one exposes, and the traps I&lt;br&gt;
hit building against all six.&lt;/p&gt;

&lt;p&gt;Every endpoint here was run live while writing this, in August 2026. The job&lt;br&gt;
counts are from that run and will have drifted by the time you read it — they&lt;br&gt;
are there to show the shape of the response, not as figures to check against.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the APIs are open in the first place
&lt;/h2&gt;

&lt;p&gt;When a company buys Greenhouse or Lever, they get a hosted job board, and they&lt;br&gt;
usually want the openings to appear on their own marketing site under their own&lt;br&gt;
design. So the ATS ships a public read API for exactly that purpose.&lt;/p&gt;

&lt;p&gt;That is the important bit: this is not an oversight to be exploited. The&lt;br&gt;
endpoint exists to be read by third parties. It is documented, versioned, and&lt;br&gt;
stable — the same properties that make it pleasant to build on.&lt;/p&gt;

&lt;p&gt;The practical consequence is that reading job data is one of the few remaining&lt;br&gt;
corners of the web where you do not need a proxy budget.&lt;/p&gt;
&lt;h2&gt;
  
  
  The six endpoints
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Greenhouse
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://boards-api.greenhouse.io/v1/boards/{token}/jobs
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The token is the slug in &lt;code&gt;job-boards.greenhouse.io/{token}&lt;/code&gt;. &lt;code&gt;stripe&lt;/code&gt; returns&lt;br&gt;
around 550 openings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trap:&lt;/strong&gt; the &lt;code&gt;/jobs&lt;/code&gt; response has no department on it at all — the keys are&lt;br&gt;
&lt;code&gt;absolute_url, data_compliance, internal_job_id, location, metadata, id,&lt;br&gt;
updated_at, requisition_id, title, company_name, first_published, language,&lt;br&gt;
application_deadline&lt;/code&gt;, and that is the whole list. If you want departments you&lt;br&gt;
have to call a second endpoint and join:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://boards-api.greenhouse.io/v1/boards/{token}/departments
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each department carries its own &lt;code&gt;jobs&lt;/code&gt; array, so you build an id → department&lt;br&gt;
map and merge. Miss this and every record silently comes back with an empty&lt;br&gt;
department, which looks like the companies just didn't fill it in.&lt;/p&gt;
&lt;h3&gt;
  
  
  Lever
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://api.lever.co/v0/postings/{token}?mode=json
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;leverdemo&lt;/code&gt; is Lever's own sandbox board and returns 388 postings, which makes&lt;br&gt;
it a good fixture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trap:&lt;/strong&gt; Lever returns &lt;code&gt;createdAt&lt;/code&gt; as &lt;strong&gt;epoch milliseconds&lt;/strong&gt;, while&lt;br&gt;
Greenhouse and Ashby return ISO 8601 strings. If you normalise the others and&lt;br&gt;
forget this one, a "posted in the last 7 days" filter doesn't error — it just&lt;br&gt;
quietly returns nothing for every Lever board, because an epoch-millisecond&lt;br&gt;
integer parsed as a year is not a date anywhere near today. Silent wrong answers&lt;br&gt;
are worse than crashes.&lt;/p&gt;
&lt;h3&gt;
  
  
  Ashby
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://api.ashbyhq.com/posting-api/job-board/{token}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Add &lt;code&gt;?includeCompensation=true&lt;/code&gt; and you get published salary ranges, which&lt;br&gt;
Ashby exposes far more consistently than the others — on Ramp's board, 115 of&lt;br&gt;
121 postings came back with a real figure, formatted like&lt;br&gt;
&lt;code&gt;$211.4K – $290.6K • Offers Equity&lt;/code&gt;. On the other five platforms salary is the&lt;br&gt;
exception rather than the rule, so if compensation data is what you are after,&lt;br&gt;
Ashby boards are where it actually lives.&lt;/p&gt;
&lt;h3&gt;
  
  
  Workable
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://apply.workable.com/api/v1/widget/accounts/{token}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This is the endpoint powering Workable's embeddable widget. It gives you&lt;br&gt;
department, employment type and a remote flag in one call.&lt;/p&gt;
&lt;h3&gt;
  
  
  Recruitee
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://{token}.recruitee.com/api/offers/
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;The trap:&lt;/strong&gt; the response contains &lt;code&gt;options_cover_letter&lt;/code&gt;. It is a boolean —&lt;br&gt;
whether the application form asks for a cover letter. I mapped it to&lt;br&gt;
&lt;code&gt;employmentType&lt;/code&gt; at one point on nothing but name-shaped optimism, and produced&lt;br&gt;
a column full of &lt;code&gt;true&lt;/code&gt;/&lt;code&gt;false&lt;/code&gt; where job types should have been. Read the&lt;br&gt;
values, not the field names.&lt;/p&gt;
&lt;h3&gt;
  
  
  SmartRecruiters
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://api.smartrecruiters.com/v1/companies/{token}/postings?limit=100&amp;amp;offset=0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two traps here, and they cost me the most time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pagination.&lt;/strong&gt; The response includes &lt;code&gt;totalFound&lt;/code&gt;, and it will happily tell you&lt;br&gt;
&lt;code&gt;4753&lt;/code&gt; while handing you 100 records. If you don't page on &lt;code&gt;offset&lt;/code&gt;, you silently&lt;br&gt;
truncate every large employer to their first 100 openings. No error, no warning.&lt;br&gt;
This is the kind of bug that survives a full test suite, because 100 records&lt;br&gt;
looks like success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case sensitivity, and identifiers that are not the company name.&lt;/strong&gt; The&lt;br&gt;
identifier is case sensitive, and it is frequently not what you would guess:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You would guess&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Live identifier&lt;/th&gt;
&lt;th&gt;Openings&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Bosch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;empty&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BoschGroup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4,753&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Ubisoft&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;empty&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Ubisoft2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;282&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Visa&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;works&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Visa&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note the failure mode: a wrong identifier returns &lt;strong&gt;HTTP 200 with an empty&lt;br&gt;
list&lt;/strong&gt;, not a 404. So "this company isn't on SmartRecruiters" and "you spelled&lt;br&gt;
it wrong" are indistinguishable from the response alone. I removed &lt;code&gt;Bosch&lt;/code&gt; from&lt;br&gt;
a set of examples once, having concluded it wasn't available, when the real&lt;br&gt;
answer was that I had the name wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  The thing nobody tells you: people don't have board tokens
&lt;/h2&gt;

&lt;p&gt;Every one of these endpoints wants an ATS board slug. Nobody has a list of ATS&lt;br&gt;
board slugs. What people actually have — sales teams, recruiters, job hunters —&lt;br&gt;
is a list of &lt;strong&gt;company domains&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So the useful move is to accept whatever someone pastes and work it out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalize_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;stripe.com, www.stripe.com and https://stripe.com/careers
    all resolve to `stripe`.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^\w+://&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^www\.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rsplit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, because dropping the TLD is a guess and not a rule, keep a fallback:&lt;br&gt;
&lt;code&gt;shield.ai&lt;/code&gt; becomes &lt;code&gt;shield&lt;/code&gt;, but the actual Lever board is &lt;code&gt;shieldai&lt;/code&gt; (433&lt;br&gt;
openings). So generate both spellings and try the second only after the first&lt;br&gt;
has missed everywhere.&lt;/p&gt;

&lt;p&gt;The cost of the fallback is one extra request on a miss. The benefit is that&lt;br&gt;
users stop having to look anything up. That trade is worth it every time.&lt;/p&gt;
&lt;h2&gt;
  
  
  One malformed record should not cost you the other 499
&lt;/h2&gt;

&lt;p&gt;Every one of these APIs will eventually hand you something you didn't expect:&lt;br&gt;
&lt;code&gt;jobs: null&lt;/code&gt; instead of an empty list, a string where a dict belongs, a null&lt;br&gt;
inside an otherwise fine array. If you build each record inside a loop with no&lt;br&gt;
isolation, one bad posting takes down the whole board.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;as_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;          &lt;span class="c1"&gt;# one bad posting, not a dead run
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jobUrl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The title/URL check matters as much as the try block. A record with no title and&lt;br&gt;
no link is not a job anyone can act on, and passing it downstream just moves the&lt;br&gt;
problem somewhere it is harder to see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detection across all six
&lt;/h2&gt;

&lt;p&gt;Given a bare company name, you can find the board by trying platforms in&lt;br&gt;
sequence and stopping at the first hit. Order matters for speed — Greenhouse and&lt;br&gt;
Lever cover the most companies, so put them first.&lt;/p&gt;

&lt;p&gt;One subtlety worth getting right: &lt;strong&gt;a valid board with zero openings is a real&lt;br&gt;
answer, not a miss.&lt;/strong&gt; A company that genuinely isn't hiring should return an&lt;br&gt;
empty list, not fall through to the next platform and eventually report "not&lt;br&gt;
found". Those are different facts and users need to tell them apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits, honestly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Only these six. Companies on Taleo, iCIMS, SuccessFactors or a hand-rolled
careers page are not covered by any of this.&lt;/li&gt;
&lt;li&gt;Private or password-protected boards are not accessible, and shouldn't be.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;department&lt;/code&gt; and &lt;code&gt;team&lt;/code&gt; are only as good as what the company filled in.&lt;/li&gt;
&lt;li&gt;Salary shows up only where the company published it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bit I'd add if you are polling this on a schedule
&lt;/h2&gt;

&lt;p&gt;If you run any of this daily, diff against the previous run rather than&lt;br&gt;
re-reading the whole board. Keep a snapshot of the ids you saw, and emit only&lt;br&gt;
the ones that appeared and the ones that vanished. Two details that turned out&lt;br&gt;
to matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Key the snapshot to the exact query.&lt;/strong&gt; Change your company list or a filter
and you should start a fresh baseline. Otherwise every job you simply stopped
asking about gets reported as newly closed, which is a wrong answer rather
than a noisy one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A job that disappeared cannot be re-fetched.&lt;/strong&gt; Report it from the snapshot —
company, title, link — and leave the rest of the fields out instead of showing
a stale copy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you'd rather not build it
&lt;/h2&gt;

&lt;p&gt;I packaged all six adapters — normalisation, the timestamp fix, the pagination,&lt;br&gt;
the domain-to-token fallback, the change detection above — as Apify Actors. You&lt;br&gt;
paste company domains and get one flat schema back, exportable as&lt;br&gt;
CSV/Excel/JSON or callable as an API.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Actor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;All six, auto-detected&lt;/td&gt;
&lt;td&gt;&lt;a href="https://apify.com/practical_ophthalmologist_iuq/career-page-job-monitor" rel="noopener noreferrer"&gt;ATS Jobs &amp;amp; Hiring Signals&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Greenhouse&lt;/td&gt;
&lt;td&gt;&lt;a href="https://apify.com/practical_ophthalmologist_iuq/greenhouse-jobs-scraper" rel="noopener noreferrer"&gt;Greenhouse Jobs Scraper&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lever&lt;/td&gt;
&lt;td&gt;&lt;a href="https://apify.com/practical_ophthalmologist_iuq/lever-jobs-scraper" rel="noopener noreferrer"&gt;Lever Jobs Scraper&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ashby&lt;/td&gt;
&lt;td&gt;&lt;a href="https://apify.com/practical_ophthalmologist_iuq/ashby-jobs-scraper" rel="noopener noreferrer"&gt;Ashby Jobs Scraper&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workable&lt;/td&gt;
&lt;td&gt;&lt;a href="https://apify.com/practical_ophthalmologist_iuq/workable-jobs-scraper" rel="noopener noreferrer"&gt;Workable Jobs Scraper&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recruitee&lt;/td&gt;
&lt;td&gt;&lt;a href="https://apify.com/practical_ophthalmologist_iuq/recruitee-jobs-scraper" rel="noopener noreferrer"&gt;Recruitee Jobs Scraper&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SmartRecruiters&lt;/td&gt;
&lt;td&gt;&lt;a href="https://apify.com/practical_ophthalmologist_iuq/smartrecruiters-jobs-scraper" rel="noopener noreferrer"&gt;SmartRecruiters Jobs Scraper&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Source for the adapters is on&lt;br&gt;
&lt;a href="https://github.com/ehddd3579-dot/career-page-job-monitor" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; if you just&lt;br&gt;
want to read how a particular platform was handled.&lt;/p&gt;

&lt;p&gt;But the endpoints above are the whole trick. If you only need one platform, a&lt;br&gt;
40-line script will do it, and now you know which four bugs to write tests for.&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webscraping</category>
      <category>hiring</category>
    </item>
  </channel>
</rss>
