<?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>The first channelId on a YouTube channel page belongs to a different channel</title>
      <dc:creator>NeverEmpty</dc:creator>
      <pubDate>Sat, 29 Aug 2026 14:42:03 +0000</pubDate>
      <link>https://dev.to/neverempty/the-first-channelid-on-a-youtube-channel-page-belongs-to-a-different-channel-o2f</link>
      <guid>https://dev.to/neverempty/the-first-channelid-on-a-youtube-channel-page-belongs-to-a-different-channel-o2f</guid>
      <description>&lt;p&gt;If you resolve a YouTube handle to a channel ID by grabbing the first &lt;code&gt;"channelId"&lt;/code&gt; in the page HTML, you are probably getting a different channel. In my sample it was wrong 15 times out of 16 — and the one time it was right is the most dangerous case of all.&lt;/p&gt;

&lt;p&gt;I found this the expensive way, after it had silently invalidated a measurement I had already published (since withdrawn).&lt;/p&gt;

&lt;h2&gt;
  
  
  The check
&lt;/h2&gt;

&lt;p&gt;For each handle I fetched &lt;code&gt;https://www.youtube.com/@name&lt;/code&gt;, pulled the first &lt;code&gt;"channelId":"UC..."&lt;/code&gt; out of the HTML, and separately pulled the ID from the page's &lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt;. Then I asked YouTube's public feed what each ID is actually called.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Handle&lt;/th&gt;
&lt;th&gt;First &lt;code&gt;"channelId"&lt;/code&gt; resolves to&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;canonical&lt;/code&gt; resolves to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@veritasium&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Veritasium &lt;strong&gt;en Français&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Veritasium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@mkbhd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;The Studio&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Marques Brownlee&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@NASA&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Learn With NASA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;NASA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@TED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;TEDx Talks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;TED&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@hikakin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;HikakinTV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HIKAKIN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@3blue1brown&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Mathologer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3Blue1Brown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@kurzgesagt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Nightshift – Kurzgesagt After Dark&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Kurzgesagt – In a Nutshell&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@Fireship&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Beyond Fireship&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fireship&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eight for eight. I then ran eight more, including the channels that came back as the &lt;em&gt;wrong&lt;/em&gt; answers above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@Computerphile   first → Numberphile                 canonical → Computerphile
@ThePrimeagen    first → The PrimeTime               canonical → ThePrimeagen
@BillieEilish    first → BillieEilishVEVO            canonical → Billie Eilish
@LinusTechTips   first → ShortCircuit                canonical → Linus Tech Tips
@beyondfireship  first → Fireship                    canonical → Beyond Fireship
@Mathologer      first → Mathologer 2                canonical → Mathologer
@nightshift_...  first → Kurzgesagt – In a Nutshell  canonical → Nightshift – …
@LearnWithNASA   first → Learn With NASA             canonical → Learn With NASA   ← same
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;15 of 16 wrong.&lt;/strong&gt; And read that last line carefully, because it is the trap inside the trap: on a channel with no featured-channels shelf, the naive method returns the right answer. Test your resolver against one of those and you will conclude your code is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worse than a loud bug
&lt;/h2&gt;

&lt;p&gt;Look at what it hands you instead. &lt;code&gt;@mkbhd&lt;/code&gt; gives you &lt;em&gt;The Studio&lt;/em&gt;, whose own description says it carries behind-the-scenes content from the MKBHD studio. &lt;code&gt;@kurzgesagt&lt;/code&gt; gives you &lt;em&gt;Nightshift – Kurzgesagt After Dark&lt;/em&gt;. &lt;code&gt;@Fireship&lt;/code&gt; gives you &lt;em&gt;Beyond Fireship&lt;/em&gt;, described as "even more highly-focused blazingly-fast coding tutorials from Fireship". &lt;code&gt;@veritasium&lt;/code&gt; gives you &lt;em&gt;Veritasium en Français&lt;/em&gt;, which calls itself an official Veritasium channel.&lt;/p&gt;

&lt;p&gt;But not always a relative: &lt;code&gt;@3blue1brown&lt;/code&gt; gives you &lt;strong&gt;Mathologer&lt;/strong&gt;, an independent channel run by a maths professor at Monash University with no organisational connection to 3Blue1Brown at all. The rule is not "you get a sibling channel". The rule is &lt;strong&gt;you get whichever channel the page happens to feature first&lt;/strong&gt;, and on a channel page that is almost always something on-topic.&lt;/p&gt;

&lt;p&gt;So nothing throws. Your rows fill up. Your row count is right. The titles look like the titles you expected. If you are aggregating across channels, you will never see it.&lt;/p&gt;

&lt;p&gt;That is exactly what happened to me. I ran a caption survey across twelve channels, verified every number in the write-up against the saved JSON, had the draft adversarially reviewed twice, and published. All of that checked whether the article matched the data. &lt;strong&gt;None of it checked whether the data matched the channels I had named.&lt;/strong&gt; It took an unrelated log line — &lt;code&gt;@veritasium → Veritasium en Français&lt;/code&gt; — to notice, hours later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the wrong ID actually lives
&lt;/h2&gt;

&lt;p&gt;I checked the position of that first &lt;code&gt;"channelId"&lt;/code&gt; on three pages. On all three it sat inside a &lt;code&gt;gridChannelRenderer&lt;/code&gt; in a &lt;code&gt;horizontalListRenderer&lt;/code&gt; — a featured-channels shelf — between 37% and 84% of the way through a 1.7–2.5 MB document:&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="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"horizontalListRenderer"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"gridChannelRenderer"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"channelId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"UC…"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which also explains &lt;code&gt;@LearnWithNASA&lt;/code&gt;: no such shelf on the page, so the first &lt;code&gt;"channelId"&lt;/code&gt; it finds is the channel's own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to use instead
&lt;/h2&gt;

&lt;p&gt;Five markers point at the channel itself, and in this sample &lt;strong&gt;all five agreed with each other on every channel I checked&lt;/strong&gt;:&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractChannelId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&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;const&lt;/span&gt; &lt;span class="nx"&gt;re&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sr"&gt;/&amp;lt;link&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+rel="canonical"&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+href="https:&lt;/span&gt;&lt;span class="se"&gt;\/\/&lt;/span&gt;&lt;span class="sr"&gt;www&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;youtube&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;com&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;channel&lt;/span&gt;&lt;span class="se"&gt;\/(&lt;/span&gt;&lt;span class="sr"&gt;UC&lt;/span&gt;&lt;span class="se"&gt;[\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]{22})&lt;/span&gt;&lt;span class="sr"&gt;"/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sr"&gt;/&amp;lt;meta&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+property="og:url"&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+content="https:&lt;/span&gt;&lt;span class="se"&gt;\/\/&lt;/span&gt;&lt;span class="sr"&gt;www&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;youtube&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;com&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;channel&lt;/span&gt;&lt;span class="se"&gt;\/(&lt;/span&gt;&lt;span class="sr"&gt;UC&lt;/span&gt;&lt;span class="se"&gt;[\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]{22})&lt;/span&gt;&lt;span class="sr"&gt;"/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sr"&gt;/&amp;lt;meta&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+itemprop="identifier"&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+content="&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;UC&lt;/span&gt;&lt;span class="se"&gt;[\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]{22})&lt;/span&gt;&lt;span class="sr"&gt;"/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sr"&gt;/"externalId"&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*"&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;UC&lt;/span&gt;&lt;span class="se"&gt;[\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]{22})&lt;/span&gt;&lt;span class="sr"&gt;"/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sr"&gt;/"rssUrl"&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*"&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*channel_id=&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;UC&lt;/span&gt;&lt;span class="se"&gt;[\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]{22})&lt;/span&gt;&lt;span class="sr"&gt;"/&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;re&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;m&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;m&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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// ← not a fallback to "channelId"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the important one. The temptation is to fall back to &lt;code&gt;"channelId"&lt;/code&gt; when none of the five match, on the theory that a wrong answer beats no answer. It does not. &lt;code&gt;null&lt;/code&gt; is a row you can label &lt;em&gt;"could not resolve this channel"&lt;/em&gt;. A featured channel's ID is a row that lies to you.&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;{22}&lt;/code&gt;: it is a validity check, not an over-match guard. Every pattern above is terminated by a closing quote, and &lt;code&gt;"&lt;/code&gt; is in neither &lt;code&gt;\w&lt;/code&gt; nor &lt;code&gt;-&lt;/code&gt;, so &lt;code&gt;UC[\w-]+&lt;/code&gt; would capture exactly the same characters. What &lt;code&gt;{22}&lt;/code&gt; buys you is that a token of the wrong length fails the pattern instead of being handed back as an ID. The cost is that if YouTube ever changes the format, all five stop matching and you get &lt;code&gt;null&lt;/code&gt; — which, given the rule above, is the failure I want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirming a resolution costs one request
&lt;/h2&gt;

&lt;p&gt;Whatever you extract, the public feed will tell you what it is:&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;feed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&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="s2"&gt;`https://www.youtube.com/feeds/videos.xml?channel_id=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;text&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;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;title&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;([\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?)&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;title&amp;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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; → &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// this line is the entire fix&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; in that XML is the channel's own name. Print it, compare it to what you asked for. It is one line, and it would have stopped me publishing an article about the wrong channels.&lt;/p&gt;

&lt;p&gt;That feed is also where the videos are — up to the 15 most recent, as &lt;code&gt;&amp;lt;yt:videoId&amp;gt;&lt;/code&gt; entries — so if resolving the channel was a step towards listing its uploads, you are already there.&lt;/p&gt;

&lt;h2&gt;
  
  
  About the sample
&lt;/h2&gt;

&lt;p&gt;Sixteen channels, all of them ones I picked by hand, all large, all fetched on 2026-08-29 from one IP with &lt;code&gt;hl=en&amp;amp;gl=US&lt;/code&gt;. That is enough to show the naive method is unreliable and that the five markers agree with each other; it is not enough to tell you the exact failure rate on channels unlike these.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;The first &lt;code&gt;"channelId"&lt;/code&gt; in a YouTube channel page usually belongs to a different channel — 15 of 16 in my sample.&lt;/li&gt;
&lt;li&gt;It sits in the page's featured-channels shelf, so the wrong channel is on-topic and looks right.&lt;/li&gt;
&lt;li&gt;Sometimes it is the same creator's other channel; sometimes it is an unrelated channel they feature. Do not assume a relationship.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A channel with no featured shelf makes the naive method look correct.&lt;/strong&gt; Do not validate your resolver on one.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;canonical&lt;/code&gt;, &lt;code&gt;og:url&lt;/code&gt;, &lt;code&gt;itemprop="identifier"&lt;/code&gt;, &lt;code&gt;"externalId"&lt;/code&gt; or &lt;code&gt;"rssUrl"&lt;/code&gt;. Return &lt;code&gt;null&lt;/code&gt; rather than falling back.&lt;/li&gt;
&lt;li&gt;Print the resolved channel's own name once. Verifying the &lt;em&gt;numbers&lt;/em&gt; is not the same as verifying the &lt;em&gt;subject&lt;/em&gt;, and only one of those catches this.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this came from
&lt;/h2&gt;

&lt;p&gt;I hit this while building &lt;a href="https://apify.com/neverempty/youtube-channel-transcripts" rel="noopener noreferrer"&gt;YouTube Channel Transcript Scraper&lt;/a&gt;, which takes a channel handle and returns transcripts for its recent videos. Resolution now uses the five markers above, and a channel it cannot resolve produces a row saying which one and why, rather than quietly substituting another.&lt;/p&gt;

&lt;p&gt;Re-running the survey that started all this, against the correct channels: 180 videos across 12 channels produced &lt;strong&gt;163 with a caption track, 15 with none at all, and 2 that could not be played&lt;/strong&gt; — three outcomes, not two. Of the 163, &lt;strong&gt;120 (73.6%) offered only auto-generated captions&lt;/strong&gt;. Human captions cluster hard: six of the twelve channels had none across their last 15 uploads, while Marques Brownlee (12) and NASA (11) accounted for over half of the 43 that did.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance. Every handle, ID, channel name, position and ratio above came from live requests executed on 2026-08-29 before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <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;h2&gt;
  
  
  The crawler these numbers came from
&lt;/h2&gt;

&lt;p&gt;The crawl above ran on &lt;a href="https://apify.com/neverempty/website-contacts" rel="noopener noreferrer"&gt;website-contacts&lt;/a&gt;, which has no default-country setting for the reason described here, and keeps E.164 and un-resolvable strings in separate fields. &lt;a href="https://apify.com/neverempty/website-contacts/examples/emails-and-phones-from-a-list-of-websites" rel="noopener noreferrer"&gt;Worked example: emails and phones from a list of websites&lt;/a&gt;.&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;h2&gt;
  
  
  If you would rather not build it
&lt;/h2&gt;

&lt;p&gt;I keep the wrapper described above as a public Actor. It applies the same three-outcome rule and the field names in this post:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://apify.com/neverempty/ats-jobs-api" rel="noopener noreferrer"&gt;ats-jobs-api&lt;/a&gt; — all four boards behind one input&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/neverempty/ats-board-finder" rel="noopener noreferrer"&gt;ats-board-finder&lt;/a&gt; — give it a company domain, it tells you which ATS the company runs (&lt;a href="https://apify.com/neverempty/ats-board-finder/examples/which-ats-does-a-company-use" rel="noopener noreferrer"&gt;worked example&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Single-vendor runs, if you only need one: &lt;a href="https://apify.com/neverempty/ats-jobs-api/examples/greenhouse-jobs-for-one-company" rel="noopener noreferrer"&gt;Greenhouse&lt;/a&gt; · &lt;a href="https://apify.com/neverempty/ats-jobs-api/examples/lever-jobs-for-one-company" rel="noopener noreferrer"&gt;Lever&lt;/a&gt; · &lt;a href="https://apify.com/neverempty/ats-jobs-api/examples/ashby-jobs-for-one-company" rel="noopener noreferrer"&gt;Ashby&lt;/a&gt; · &lt;a href="https://apify.com/neverempty/ats-jobs-api/examples/workable-jobs-for-one-company" rel="noopener noreferrer"&gt;Workable&lt;/a&gt; · &lt;a href="https://apify.com/neverempty/ats-jobs-api/examples/remote-jobs-from-ats-boards" rel="noopener noreferrer"&gt;remote roles across all four&lt;/a&gt;&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>
