I run a visa agency in Bali; the other half of my week goes into Apify Actors. Both halves keep asking for the same thing: turn a topic into a list of people you can actually write to. For YouTube that means a handle in, a working business address out.
The naive version of that job reads the channel page and stops. I built it that way first. Here is what three topic searches and 30 channels say about how much that version would have found.
The run
Three queries through YouTube's own channel search — coffee roaster, van conversion, language tutor — ten channels each, 3 November 2026. No handles of my own, no curation, so the sample is whatever YouTube ranks for a plain topic phrase. YouTube Channel Email Scraper with onlyWithEmail: false, so channels without an address come back too instead of being hidden.
| Channels read | 30 of 30, zero errors |
| Run time | 40.7 s |
| Rows with an email | 17 of 30 |
| …from the channel page itself | 2 |
| …from the creator's own website | 15 |
hasHiddenEmail (address filed, not shown) | 24 of 30 |
| Hidden button and nothing readable anywhere | 9 |
| No address and no button | 4 |
| Cost | $0.34 |
Two. Out of thirty channels, two creators typed an address into a place YouTube will print. Every other contact came from the second hop: take the links the creator published, open their site, read the details off it.
That is not what my own README says either — an earlier 40-channel measurement put it at roughly 18% on the channel and 20% on the site. This sample is 7% and 50%, and the difference is who YouTube's channel search returns for a plain topic query: median size here was 3,240 subscribers, the smallest had 8, the largest 489,000, and 19 of the 30 were under 10,000. Small business channels do not fill in their About page; they have a website with a contact form and a mailto: in the footer.
Why the channel page is mostly a dead end
YouTube does have the addresses. In the channel's ytInitialData blob sits a key called signInForBusinessEmail; its presence means the creator filled in the business-inquiries field in Studio. 24 of my 30 channels had it. Reading the value needs a signed-in session and a captcha — a far more expensive kind of scraping than fetching a public page — so the Actor reports the flag and moves on.
So 24 creators are reachable in principle, YouTube prints 2, and the site hop recovers 15 of the remaining 22. Nine end up as a flag with no address — and for them the flag is the deliverable, because a null email means two different things:
-
email: null, hasHiddenEmail: false— nothing to chase. Four channels, and three of those four had no external links at all. -
email: null, hasHiddenEmail: true— a creator who wants business mail and is one manual click away. Nine channels.
Collapse those into "no email" and you throw away a third of the list. Keep the flag even if you never scrape the button.
Size does not predict any of this: 11 of 19 channels under 10k had a readable address, 6 of 11 above 10k did, and the 489,000-subscriber channel was one of the hidden-only nine.
The hop costs bandwidth, not money
The numbers the run logged for itself:
youtube: { requests: 34, direct: 32, proxied: 1, failed: 0, notFound: 0 } web: { requests: 66, fingerprint: 28, proxied: 1, failed: 3, bytes: 14305374 } Thirty-four YouTube reads — three searches and 31 channel pages — 32 of them on a direct connection, one needing a proxy retry. A channel page is a dead parse if it arrives without var ytInitialData, which is how YouTube serves its cookie wall, so that string is the success test, not the HTTP status:
const hasData = (html) => html.includes('var ytInitialData'); // 404 is different: YouTube answered and there is no such channel. A proxy will not help, // and the buyer needs "channel not found", not "page did not answer". if (res.status === 404) { stats.notFound += 1; return NOT_FOUND; } Then 66 requests to 24 creator sites — home page, plus /contact when the home page had nothing — for 14.3 MB of HTML, to produce 15 of the 17 addresses. Three site fetches failed outright and cost nothing.
It costs nothing because of how the billing is wired: $0.02 per email found, and that is the only event. Seventeen emails, $0.34, exactly. The 13 rows with no address were delivered free, with their subscriber counts, links and socials intact; searches, channel pages and the 14.3 MB are on me. If you build pay-per-result scraping, that is the shape that makes a second hop safe to attempt — be greedy on reads, charge on the outcome.
What the second hop gets wrong
Two of the 15 site addresses were not the creator's:
nicoleandedden 62,400 subs help@skool.com (site: skool.com) LearnNepaliwithBarsha 67,400 subs u003esuccess@udemy.com (site: udemy.com) Two separate bugs, both found while writing this post.
The first is a filter gap. A site address is only kept when it sits on the site's own domain or on a normal mail provider, and platform domains are denied outright:
function keepSiteEmail(email, siteDomain) { const domain = String(email).split('@')[1] || ''; if (SERVICE_DOMAINS.test(domain + '.')) return false; // platform support if (FREE_MAIL.test(domain + '.')) return true; // gmail, outlook, proton… const site = String(siteDomain || '').replace(/^www\./, ''); const base = site.split('.').slice(-2).join('.'); return Boolean(base) && (domain === site || domain.endsWith('.' + base) || domain === base); } SERVICE_DOMAINS had every site builder I could think of and not one course platform. skool.com and udemy.com sailed through because the course page is a page like any other, and help@skool.com is formally a valid address on the domain being read. Added skool, udemy, coursera, skillshare, thinkific, podia, whop, circle, buymeacoffee, ko-fi, eventbrite, patreon.
The second is worse, because it produced an address that cannot receive mail. Look at it again: u003esuccess@udemy.com. That u003e is the tail of >, the JSON escape for >. Udemy's page carries contact data inside an embedded JSON payload, my extractor ran the email regex over the raw HTML, and \ is not in the local-part character class — so the match started one character after the backslash and swallowed u003e as part of the name. Reproduced in four lines:
const html = '<p>write \\u003esuccess@udemy.com or hello@barsha.com</p>'; extractContacts(html).emails // before: [ 'u003esuccess@udemy.com', 'hello@barsha.com' ] The fix is to decode the escapes before matching, not after:
const unescapeJson = (s) => String(s).replace(/\\u([0-9a-fA-F]{4})/g, (m, hex) => { const code = parseInt(hex, 16); return code >= 32 && code < 0xd800 ? String.fromCharCode(code) : ' '; }); export function extractContacts(rawHtml) { const html = unescapeJson(rawHtml); /* … */ } // after: [ 'success@udemy.com', 'hello@barsha.com' ] Same four channels before and after the rebuild, same input:
before (0.1.26) after (0.1.28) nicoleandedden help@skool.com info@anandalivingexperiences.com LearnNepaliwithBarsha u003esuccess@udemy.com null (free row, hidden-email flag set) mkbhd business@mkbhd.com business@mkbhd.com veritasium info@snatoms.com info@snatoms.com The second line is the honest outcome: a blank, delivered free. The first is the interesting one — dropping the Skool address did not empty that row, it let the loop move to the next domain in the creator's link list and find her actual business, Ananda Living Experiences. The bad answer was not merely wrong, it was blocking the right one.
website is not the site the email came from
Every row carries both website (the first non-social link the creator published) and emailSite (the domain the address came from). They disagreed on 2 of the 15, which is the whole reason the hop tries up to three domains:
| Channel | website | emailSite |
|---|---|---|
| TheLanguageTutorPodcast | paypal.com/…cmd=_donations | thelanguagetutor.co |
| veritasium (control run) | ve42.co/YTBio | snatoms.com |
The first published link is frequently a donation page, a Steam listing, a link-in-bio or a short-link redirector, so taking only website loses both of those addresses. The hop also collapses go.creator.com-style subdomains to the registrable domain before fetching.
One field note from the same sample: instagram was filled on 17 of 30 rows, facebook on 14, tiktok on 5, linkedin on 1 and x on zero. Do not build a pipeline that assumes that last column.
Calling it
curl -s -X POST \ "https://api.apify.com/v2/acts/lergassy~youtube-channel-email-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"searchQueries":["coffee roaster","van conversion"], "maxChannelsPerQuery":10, "minSubscribers":5000, "followSiteForEmail":true, "onlyWithEmail":true}' minSubscribers matters more than it looks: topic search hands back channels with 8 subscribers, and skipped channels are free, so the filter is how a 2,000-channel job avoids becoming a list of dormant accounts. Each run also appends one free type: "notice" row with the billing summary — filter on type != "notice" or it turns up in your spreadsheet as a creator called undefined.
Where this is the wrong tool. It does not sign in, so the 9 hidden-only channels stay hidden; getting those needs a human or a browser session, and neither is cheap. The topic search is YouTube's own, with no filters on top — no country, no language, no upload recency. And it reads channels, not videos: a watch?v= link comes back as an error row on purpose. If you want the full channel profile rather than the address, YouTube Creator Contacts covers the same pages from the other direction.
These are addresses creators publish for business inquiries, which makes them fair game for relevant business mail and nothing else — honour opt-outs, and do not send 2,000 identical messages. I measure the hit rate this carefully because 57% on a real niche list beats 100% on guessed addresses every time.
And the line I keep from this run: a second hop is worth building, as long as you are as suspicious of what it finds as you are of what the first one missed.

