hacker-newslead-generationresendnodejs

Find Show HN Leads and Email Them with Node.js

Read recent Show HN launches, extract their domains, find source-backed public contacts with ScoutLayer, and send reviewed outreach through Resend.

August 27, 2026

Show HN is a useful place to discover newly launched developer products. Each post gives you context that a generic lead database does not: what was built, how the founder describes it, when it launched, and where the product lives.

This tutorial builds a small Node.js pipeline that:

  1. reads recent Show HN stories from the official Hacker News API;
  2. keeps launches that match your product's target keywords;
  3. extracts and deduplicates their website domains;
  4. asks ScoutLayer for the public contact record behind each domain;
  5. prepares a relevant email; and
  6. hands the reviewed message to your own Resend account for delivery.

ScoutLayer does not send email. It only turns a domain into a structured public contact record. Resend is the separate delivery service used by this example.

The script defaults to preview mode. Do not turn it into an indiscriminate mailer. A public email is not permission to send irrelevant bulk outreach.

Set up the project

Use Node.js 20 or newer, then install the two dependencies:

mkdir show-hn-outreach
cd show-hn-outreach
npm init -y
npm install p-limit resend

Add "type": "module" to package.json, then set the environment variables:

export SCOUTLAYER_API_KEY="sl_your_key"
export RESEND_API_KEY="re_your_key"
export OUTREACH_FROM="Your Name <you@yourdomain.com>"
export OUTREACH_REPLY_TO="you@yourdomain.com"
export TARGET_KEYWORDS="developer,api,ai,automation,saas"

Create a ScoutLayer key from API Keys. Your Resend sender must use a domain you have verified in Resend.

Build the pipeline

Create show-hn-leads.mjs:

import pLimit from "p-limit";
import { Resend } from "resend";

const HN_API = "https://hacker-news.firebaseio.com/v0";
const SCOUTLAYER_API = "https://scoutlayer.io/api/v1";
const MAX_STORIES = 80;
const MAX_AGE_HOURS = 72;
const shouldSendWithResend = process.argv.includes("--send");

const scoutlayerKey = process.env.SCOUTLAYER_API_KEY;
const resendKey = process.env.RESEND_API_KEY;
const from = process.env.OUTREACH_FROM;
const replyTo = process.env.OUTREACH_REPLY_TO;
const keywords = (process.env.TARGET_KEYWORDS ?? "")
  .split(",")
  .map((value) => value.trim().toLowerCase())
  .filter(Boolean);

if (!scoutlayerKey) throw new Error("Missing SCOUTLAYER_API_KEY");
if (shouldSendWithResend && (!resendKey || !from || !replyTo)) {
  throw new Error(
    "RESEND_API_KEY, OUTREACH_FROM and OUTREACH_REPLY_TO are required to send",
  );
}

const resend = resendKey ? new Resend(resendKey) : null;
const limit = pLimit(8);

async function getJson(url, options) {
  const response = await fetch(url, options);
  if (!response.ok) {
    throw new Error(`${response.status} ${response.statusText}: ${url}`);
  }
  return response.json();
}

function domainFromUrl(rawUrl) {
  try {
    const url = new URL(rawUrl);
    if (!['http:', 'https:'].includes(url.protocol)) return null;
    return url.hostname.toLowerCase().replace(/^www\./, "");
  } catch {
    return null;
  }
}

function isRelevant(story) {
  if (keywords.length === 0) return true;
  const haystack = `${story.title ?? ""} ${story.text ?? ""}`.toLowerCase();
  return keywords.some((keyword) => haystack.includes(keyword));
}

function escapeHtml(value) {
  return value
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#039;");
}

async function findRecentLaunches() {
  const ids = await getJson(`${HN_API}/showstories.json`);
  const stories = await Promise.all(
    ids.slice(0, MAX_STORIES).map((id) =>
      limit(() => getJson(`${HN_API}/item/${id}.json`)),
    ),
  );

  const cutoff = Date.now() - MAX_AGE_HOURS * 60 * 60 * 1000;
  const seenDomains = new Set();

  return stories.flatMap((story) => {
    if (
      !story ||
      story.deleted ||
      story.dead ||
      story.type !== "story" ||
      !story.url ||
      story.time * 1000 < cutoff ||
      !isRelevant(story)
    ) {
      return [];
    }

    const domain = domainFromUrl(story.url);
    if (!domain || seenDomains.has(domain)) return [];
    seenDomains.add(domain);

    return [{
      id: story.id,
      title: story.title,
      productUrl: story.url,
      discussionUrl: `https://news.ycombinator.com/item?id=${story.id}`,
      domain,
    }];
  });
}

async function findContact(domain) {
  const response = await fetch(
    `${SCOUTLAYER_API}/domain/${encodeURIComponent(domain)}`,
    { headers: { "X-API-Key": scoutlayerKey } },
  );

  if (response.status === 402) {
    throw new Error("ScoutLayer balance is empty");
  }
  if (!response.ok) {
    throw new Error(`ScoutLayer returned ${response.status} for ${domain}`);
  }

  return response.json();
}

function buildMessage(lead, profile) {
  const company = profile.information.name ?? lead.domain;
  const subject = `A thought about ${company}`;
  const text = [
    "Hi,",
    "",
    `I found ${lead.title} on Show HN and took a look at ${lead.domain}.`,
    "",
    "Write one specific sentence here explaining why your product is relevant to this launch.",
    "",
    "If this is not useful, reply and I will close the loop.",
    "",
    "Your Name",
  ].join("\n");

  const html = `
    <p>Hi,</p>
    <p>I found <a href="${lead.discussionUrl}">${escapeHtml(lead.title)}</a>
    on Show HN and took a look at ${escapeHtml(lead.domain)}.</p>
    <p><strong>Replace this paragraph:</strong> explain in one specific sentence
    why your product is relevant to this launch.</p>
    <p>If this is not useful, reply and I will close the loop.</p>
    <p>Your Name</p>
  `;

  return { subject, text, html };
}

async function processLead(lead) {
  const profile = await findContact(lead.domain);
  const contact = profile.best_contact;

  if (!contact) {
    return { ...lead, status: "no_working_email" };
  }

  // For outreach, keep the default conservative: use an address observed on
  // the company's own site, not a generated address.
  if (contact.email_source && contact.email_source !== "observed") {
    return { ...lead, status: "not_observed", email: contact.value };
  }

  const message = buildMessage(lead, profile);
  const preview = {
    ...lead,
    company: profile.information.name,
    email: contact.value,
    confidence: contact.confidence,
    sourceUrls: contact.source_urls,
    subject: message.subject,
  };

  if (!shouldSendWithResend) return { ...preview, status: "preview" };

  const { data, error } = await resend.emails.send(
    {
      from,
      to: contact.value,
      replyTo,
      subject: message.subject,
      text: message.text,
      html: message.html,
    },
    { idempotencyKey: `show-hn-${lead.id}-${lead.domain}` },
  );

  if (error) throw new Error(`Resend: ${error.message}`);
  return { ...preview, status: "sent", resendId: data.id };
}

const launches = await findRecentLaunches();
const results = [];

for (const lead of launches) {
  try {
    results.push(await processLead(lead));
  } catch (error) {
    results.push({
      ...lead,
      status: "error",
      error: error instanceof Error ? error.message : String(error),
    });
  }
}

console.table(
  results.map(({ domain, email, company, status }) => ({
    domain,
    company,
    email,
    status,
  })),
);

Run it in preview mode:

node show-hn-leads.mjs

Inspect each launch, source URL, contact type, and draft. Rewrite the placeholder paragraph for the small number of companies where you can explain a genuine fit. Only then tell the local script to pass the reviewed messages to Resend:

node show-hn-leads.mjs --send

Why the safeguards matter

The official Hacker News API returns up to 200 recent Show HN story IDs from showstories, while each story's details live under item/{id}. That two-step shape is why the script limits concurrency instead of firing hundreds of requests at once.

The ScoutLayer lookup contributes more than an email string. It returns the company identity, confidence, contact classification, and source_urls, allowing you to review whether an address was actually published for contact. When no working email is found, best_contact is null and the lookup costs 0 credits.

The Resend idempotency key prevents a rerun from sending the same story-domain combination twice. You should still keep your own suppression list for opt-outs and previously contacted domains.

Keep the outreach useful

Before sending any message:

  • read the Show HN post and product website;
  • explain why your product is relevant to that specific launch;
  • prefer a role or general business address when personal outreach is unnecessary;
  • identify yourself and make it easy to decline future contact;
  • comply with the rules that apply to your sender and recipient locations;
  • stop after one concise follow-up instead of building an automated sequence.

The goal is not to maximize sends. It is to turn public launch context into a small number of well-qualified conversations.

Reference the official Hacker News API, the Resend Node.js guide, and the ScoutLayer API overview when adapting the script for production.