<?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: NeverEmpty</title>
    <description>The latest articles on DEV Community by NeverEmpty (@neverempty).</description>
    <link>https://dev.to/neverempty</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%2F4099004%2F1bfea602-462d-41e7-96d3-da06d619424b.png</url>
      <title>DEV Community: NeverEmpty</title>
      <link>https://dev.to/neverempty</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/neverempty"/>
    <language>en</language>
    <item>
      <title>Your scraper's defaultCountry setting invents phone numbers</title>
      <dc:creator>NeverEmpty</dc:creator>
      <pubDate>Fri, 28 Aug 2026 14:09:49 +0000</pubDate>
      <link>https://dev.to/neverempty/your-scrapers-defaultcountry-setting-invents-phone-numbers-25p7</link>
      <guid>https://dev.to/neverempty/your-scrapers-defaultcountry-setting-invents-phone-numbers-25p7</guid>
      <description>&lt;p&gt;Most contact scrapers have a setting called something like &lt;code&gt;defaultCountry&lt;/code&gt; or &lt;code&gt;region&lt;/code&gt;. It looks harmless. It is the fastest way to fill a database with phone numbers that are structurally valid, pass every check you run, and belong to nobody.&lt;/p&gt;

&lt;p&gt;Here is the measurement that convinced me to delete mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  One company, 51 countries
&lt;/h2&gt;

&lt;p&gt;I crawled the contact pages of &lt;code&gt;ibm.com&lt;/code&gt; on 2026-08-28 — up to five pages per site, three were reachable, robots.txt honoured. What came back:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Email addresses&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone numbers resolved to E.164&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;115&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Distinct countries in those 115&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;51&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone-shaped strings that could &lt;strong&gt;not&lt;/strong&gt; be resolved to E.164&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 51: &lt;code&gt;AT AU BE BG CH CN CY CZ DE DK EE EG FI FR GB GP GR HK HR HU IE IL KR LK LT LU LV MO MY NC NG NL NO PF PH PK PL PT RO RS SE SG SI SK TH TR TW UA US VN ZA&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is one company's own contact pages. Not a directory, not an aggregator. The same crawl over &lt;code&gt;suse.com&lt;/code&gt; gave 11 numbers across 8 countries, &lt;code&gt;ionos.com&lt;/code&gt; 10 across 7.&lt;/p&gt;

&lt;p&gt;So the premise behind a default country — "this is a US site, so bare numbers on it are US numbers" — is wrong before you write a line of parsing code. Corporate contact pages are international by nature. That is what they are for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a default country actually does
&lt;/h2&gt;

&lt;p&gt;Take the strings that had no country code of their own and parse them as US:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;parsePhoneNumberFromString&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;libphonenumber-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;unresolvedStrings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;          &lt;span class="c1"&gt;// 52 of them&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parsePhoneNumberFromString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;US&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// valid === 18&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;18 of the 52 come back valid.&lt;/strong&gt; Not "probably valid" — &lt;code&gt;isValid()&lt;/code&gt; returns true, &lt;code&gt;.number&lt;/code&gt; gives a clean E.164 string, and every downstream check passes.&lt;/p&gt;

&lt;p&gt;Most of those 18 really are US numbers, so far so good. The problem is the ones that are not. From the &lt;code&gt;suse.com&lt;/code&gt; crawl, a Budapest number printed on the page as a local string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;parsePhoneNumberFromString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;361-489-4600&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;US&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;span class="nf"&gt;parsePhoneNumberFromString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;361-489-4600&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;US&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;      &lt;span class="c1"&gt;// '+13614894600'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;+36 1 489 4600&lt;/code&gt; is Budapest. &lt;code&gt;+1 361 489 4600&lt;/code&gt; is a real, dialable US area code. The default country did not fail loudly — it produced a different, perfectly valid number in a different country, and handed it to you as a fact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A validator cannot save you from this.&lt;/strong&gt; It validates the number you constructed, not the assumption you made. It is not lying: &lt;code&gt;+13614894600&lt;/code&gt; genuinely is a valid US number. It just is not the number that was printed on the page.&lt;/p&gt;

&lt;p&gt;Sometimes the validator does catch it — &lt;code&gt;0180 0132 00049&lt;/code&gt; from the same site parses as US and comes back invalid, so that one gets thrown away. But you cannot rely on being saved by luck. The failure mode you never see is the one that stays.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape that does not lie
&lt;/h2&gt;

&lt;p&gt;Two fields, one rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;phones        E.164, only for numbers that carried their own country code
phonesLocal   the raw string, essentially as printed, when one could not be resolved
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule: &lt;strong&gt;never synthesise a country code&lt;/strong&gt;. If the page did not say which country, you do not know which country.&lt;/p&gt;

&lt;p&gt;That sounds like losing data. It is not — it is moving it. The 52 unresolved IBM strings are still in the output, still usable by code that has context you do not. A CRM that already knows the account is in Malaysia can resolve &lt;code&gt;1800-88-8558&lt;/code&gt; correctly; your crawler cannot.&lt;/p&gt;

&lt;p&gt;What you lose is the &lt;em&gt;illusion&lt;/em&gt; of 52 extra E.164 numbers. That illusion is expensive, because nobody discovers it until someone dials.&lt;/p&gt;

&lt;h2&gt;
  
  
  While you are there: emails are obfuscated on purpose
&lt;/h2&gt;

&lt;p&gt;Two patterns worth handling, both cheap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;name [at] example [dot] com&lt;/code&gt;&lt;/strong&gt; and its bracketed variants. A plain &lt;code&gt;/\S+@\S+/&lt;/code&gt; extracts nothing usable from those — &lt;code&gt;[at]&lt;/code&gt;, &lt;code&gt;(at)&lt;/code&gt; and &lt;code&gt;{at}&lt;/code&gt; forms produce no match at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare email protection.&lt;/strong&gt; The address is replaced by &lt;code&gt;&amp;lt;a href="/cdn-cgi/l/email-protection" data-cfemail="a1c4d9c0ccd1cdc4e1..."&amp;gt;&lt;/code&gt;. It is an XOR encoding: the first byte is the key, each following byte is a character XORed with it.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decodeCfEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not a security control — Cloudflare ships &lt;code&gt;email-decode.min.js&lt;/code&gt; to every browser to undo it client-side, and &lt;a href="https://developers.cloudflare.com/waf/tools/scrape-shield/email-address-obfuscation/" rel="noopener noreferrer"&gt;their docs&lt;/a&gt; note it is enabled automatically on sign-up. It is there to stop naive harvesters, and it works: six lines is the entire difference between finding a site's contact address and reporting that it has none.&lt;/p&gt;

&lt;h2&gt;
  
  
  And when the fetch fails
&lt;/h2&gt;

&lt;p&gt;The fourth site in my run was &lt;code&gt;chiyodacorp.com&lt;/code&gt;. It returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"domain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"chiyodacorp.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fetch failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"emails"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"phones"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"phonesLocal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not an absent row. Not an empty row that reads like "this company publishes no contact details". A row that says which site failed and why, so the next run can retry that one and only that one.&lt;/p&gt;

&lt;p&gt;A crawl that quietly drops what it could not reach reports a smaller, cleaner, wronger world every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Delete the default-country setting. It is not a convenience, it is a fabricator.&lt;/li&gt;
&lt;li&gt;Keep E.164 and un-resolvable strings in separate fields, and never invent the difference.&lt;/li&gt;
&lt;li&gt;Handle bracketed obfuscation and Cloudflare's XOR, or accept that you are silently missing contacts.&lt;/li&gt;
&lt;li&gt;Emit a row with a reason for every site you failed on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is hard. All of it is skipped by default, which is why so many contact datasets are confidently wrong.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance. Every count, country list and parse result above came from a live crawl executed on 2026-08-28 before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Greenhouse, Lever, Ashby, Workable: 4 job APIs, 1 dangerous bug</title>
      <dc:creator>NeverEmpty</dc:creator>
      <pubDate>Fri, 28 Aug 2026 13:35:17 +0000</pubDate>
      <link>https://dev.to/neverempty/greenhouse-lever-ashby-workable-4-job-apis-1-dangerous-bug-5b5h</link>
      <guid>https://dev.to/neverempty/greenhouse-lever-ashby-workable-4-job-apis-1-dangerous-bug-5b5h</guid>
      <description>&lt;p&gt;Most companies running Greenhouse, Lever, Ashby or Workable publish their open roles on a public JSON endpoint. No API key, no OAuth, no browser.&lt;/p&gt;

&lt;p&gt;Here they are, with the counts I measured on 2026-08-28. Every number below came back from a live request while writing this — and job counts move daily, so yours will differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four endpoints
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Greenhouse&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://boards-api.greenhouse.io/v1/boards/stripe/jobs"&lt;/span&gt;
&lt;span class="c"&gt;# → 200, jobs[] with 578 entries&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://boards-api.greenhouse.io/v1/boards/monzo/jobs"&lt;/span&gt;
&lt;span class="c"&gt;# → 200, jobs[] with 66 entries&lt;/span&gt;

&lt;span class="c"&gt;# Lever&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.lever.co/v0/postings/matchgroup?mode=json"&lt;/span&gt;
&lt;span class="c"&gt;# → 200, a bare array with 71 entries&lt;/span&gt;

&lt;span class="c"&gt;# Ashby&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.ashbyhq.com/posting-api/job-board/linear"&lt;/span&gt;
&lt;span class="c"&gt;# → 200, jobs[] with 29 entries&lt;/span&gt;

&lt;span class="c"&gt;# Workable&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://apply.workable.com/api/v1/widget/accounts/lyst?details=true"&lt;/span&gt;
&lt;span class="c"&gt;# → 200, jobs[] with 8 entries&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three of the four are documented: &lt;a href="https://developers.greenhouse.io/job-board.html" rel="noopener noreferrer"&gt;Greenhouse&lt;/a&gt;, &lt;a href="https://github.com/lever/postings-api" rel="noopener noreferrer"&gt;Lever&lt;/a&gt;, &lt;a href="https://developers.ashbyhq.com/reference/introduction" rel="noopener noreferrer"&gt;Ashby&lt;/a&gt;. The Workable one is different — that is the endpoint their embeddable careers widget calls, not the documented Workable API (which needs a Bearer token). It works today and it is public, but treat it as something that can change without a changelog.&lt;/p&gt;

&lt;p&gt;Two container shapes, four vocabularies. Greenhouse, Ashby and Workable all wrap the list in &lt;code&gt;jobs&lt;/code&gt;; Lever returns a bare array. Inside, nothing lines up — the job title is &lt;code&gt;title&lt;/code&gt; in three of them and &lt;strong&gt;&lt;code&gt;text&lt;/code&gt;&lt;/strong&gt; in Lever. Greenhouse gives you &lt;code&gt;absolute_url&lt;/code&gt; and &lt;code&gt;internal_job_id&lt;/code&gt;, Ashby gives &lt;code&gt;employmentType&lt;/code&gt; and &lt;code&gt;secondaryLocations&lt;/code&gt;, Lever gives &lt;code&gt;categories&lt;/code&gt; and &lt;code&gt;hostedUrl&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that silently breaks
&lt;/h2&gt;

&lt;p&gt;Here is what a naive integration does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({}));&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jobs&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;   &lt;span class="c1"&gt;// ← this is the bug&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that against a company that does not use Greenhouse and you get &lt;code&gt;[]&lt;/code&gt;. Run it against a company that has zero open roles right now and you also get &lt;code&gt;[]&lt;/code&gt;. Run it while the endpoint is throwing a 502 and you get &lt;code&gt;[]&lt;/code&gt; again. Point it at a Lever board and you get &lt;code&gt;[]&lt;/code&gt; every single time, forever, because Lever's response has no &lt;code&gt;jobs&lt;/code&gt; key at all.&lt;/p&gt;

&lt;p&gt;Four completely different situations, one identical output. Downstream, a week later, someone asks why a client "stopped hiring" — and the answer is that nobody was ever hiring there on that ATS.&lt;/p&gt;

&lt;p&gt;I measured this while checking companies for this post:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;boards-api.greenhouse.io/v1/boards/stripe/jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200, 578 jobs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;boards-api.greenhouse.io/v1/boards/ramp/jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;404&lt;/strong&gt; &lt;code&gt;{"status":404,"error":"Job not found"}&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;api.lever.co/v0/postings/netlify?mode=json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;404&lt;/strong&gt; &lt;code&gt;{"ok":false,"error":"Document not found"}&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;api.lever.co/v0/postings/plaid?mode=json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;404&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those 404s do not mean those companies are not hiring. They mean the guess about which ATS they use, or the guess about their board slug, was wrong.&lt;/p&gt;

&lt;p&gt;So the minimum honest shape is four outcomes, not one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;res&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="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fetch-failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-board-on-this-ats&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fetch-failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;list&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="nx"&gt;list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pickList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;        &lt;span class="c1"&gt;// vendor-specific: jobs[] or bare array&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;not-json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;   &lt;span class="c1"&gt;// 200 + an HTML error page&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;board-exists-but-zero-open-roles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;state&lt;/code&gt; costs you one string per row and removes an entire class of "the data looked fine" incidents.&lt;/p&gt;

&lt;h2&gt;
  
  
  The harder problem: which board belongs to whom
&lt;/h2&gt;

&lt;p&gt;Guessing a slug from a domain name works often enough to be dangerous. Here is a real collision, both measured today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.ashbyhq.com/posting-api/job-board/notion"&lt;/span&gt;
&lt;span class="c"&gt;# → 200, 134 jobs, first one: "Software Engineer, Developer Platform"&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://apply.workable.com/api/v1/widget/accounts/notion?details=true"&lt;/span&gt;
&lt;span class="c"&gt;# → 200, "name": "Notion", 0 jobs&lt;/span&gt;
&lt;span class="c"&gt;#   description: "We're a luxury lifestyle agency based in Shoreditch..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same slug, two entirely unrelated organisations, both returning HTTP 200. If your resolver tries Workable first and stops at the first 200, you have just labelled a London lifestyle agency as the productivity company.&lt;/p&gt;

&lt;p&gt;It gets sillier. &lt;code&gt;apply.workable.com/api/v1/widget/accounts/a&lt;/code&gt; — a single letter — returns 200 with &lt;code&gt;"name": "a"&lt;/code&gt;. A slug existing proves nothing about who owns it. (For contrast, &lt;code&gt;zzqqxxnotarealcompany&lt;/code&gt; returns 404 on both Workable and Greenhouse, so the 200s above are real registrations, not a catch-all.)&lt;/p&gt;

&lt;p&gt;The only thing that settles ownership is the company's own site. If &lt;code&gt;notion.so&lt;/code&gt; links to that board, it is theirs. If it does not, what you have is a lead, not a fact — and it should be labelled that way rather than merged into the same column as verified results.&lt;/p&gt;

&lt;p&gt;There is a second trap in the other direction. Investor portfolio pages, accelerator sites and job aggregators link to boards that belong to &lt;em&gt;other&lt;/em&gt; companies. Crawl an investor's site, follow every board link, and you will end up attributing their portfolio companies' job boards to the investor. Ask "does this page list other companies' jobs?" before you attribute anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalising the shapes into one
&lt;/h2&gt;

&lt;p&gt;A usable row needs at minimum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;source        greenhouse | lever | ashby | workable
company       the domain you started from
boardSlug     the slug that actually worked
jobId         vendor id, kept as a string
title         Lever calls this `text`
location      null when the vendor does not give one — not ""
remote        true | false | null   (null when unknown, not false)
department
url           the public apply URL
postedAt      ISO 8601, not the vendor's raw format
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;null&lt;/code&gt; rule matters more than it looks. If "unknown" and "false" collapse into the same value, every downstream filter for on-site roles quietly includes the ones you simply had no data for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule, if you build your own
&lt;/h2&gt;

&lt;p&gt;Whatever you wrap these endpoints in, keep the three outcomes separate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;the board does not exist on this ATS&lt;/strong&gt; (404)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;the board exists and has zero open roles right now&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the request failed&lt;/strong&gt; (timeout, 5xx, HTML where JSON was expected)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Collapsing them into one empty array is the bug that costs you a week later, not today. The endpoints at the top of this post are the whole integration otherwise — no key, no proxy, no browser.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance. Every endpoint, count, status code and error body in this post was executed live before publishing, on 2026-08-28.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
