Most contact enrichment jobs do not begin with a person's name. They begin with a list of websites exported from a directory, a customer table, a marketplace, or a research spreadsheet.
This tutorial turns an input CSV into a reviewable output file containing:
- the normalized company name;
- the best working public email;
- all public emails and phones found;
- official social profiles;
- contact counts and confidence;
- source URLs; and
- an explicit status for domains that fail or publish no working email.
The important parts are not the single API request. They are bounded concurrency, retry behavior, preserving the original rows, and recording partial failures instead of losing a long-running job.
Prepare the input
Create domains.csv with a domain column. Any additional columns are preserved:
domain,segment,owner
linear.app,developer-tools,Ana
resend.com,email-infrastructure,Sam
example.com,test,Riley
Create the project and install a real CSV parser plus a concurrency limiter:
mkdir contact-enrichment
cd contact-enrichment
npm init -y
npm install csv-parse csv-stringify p-limit
Add "type": "module" to package.json, then export your key:
export SCOUTLAYER_API_KEY="sl_your_key"
Write the enrichment script
Create enrich-domains.mjs:
import { readFile, writeFile } from "node:fs/promises";
import { parse } from "csv-parse/sync";
import { stringify } from "csv-stringify/sync";
import pLimit from "p-limit";
const API_BASE = "https://scoutlayer.io/api/v1";
const CONCURRENCY = 5;
const MAX_ATTEMPTS = 3;
const apiKey = process.env.SCOUTLAYER_API_KEY;
if (!apiKey) throw new Error("Missing SCOUTLAYER_API_KEY");
function normalizeDomain(value) {
const raw = String(value ?? "").trim().toLowerCase();
if (!raw) return null;
try {
const url = new URL(raw.includes("://") ? raw : `https://${raw}`);
return url.hostname.replace(/^www\./, "") || null;
} catch {
return null;
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function lookupDomain(domain) {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
const response = await fetch(
`${API_BASE}/domain/${encodeURIComponent(domain)}`,
{ headers: { "X-API-Key": apiKey } },
);
if (response.ok) return response.json();
if (response.status === 402) {
throw new Error("insufficient_credits");
}
const retryable = response.status === 429 || response.status >= 500;
if (!retryable || attempt === MAX_ATTEMPTS) {
throw new Error(`http_${response.status}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const delay = Number.isFinite(retryAfter)
? retryAfter * 1000
: 500 * 2 ** (attempt - 1) + Math.random() * 250;
await sleep(delay);
}
}
function flattenResult(row, domain, result) {
const best = result.best_contact;
return {
...row,
domain,
enrichment_status: best ? "contact_found" : "no_working_email",
company_name: result.information.name ?? "",
legal_name: result.information.legal_name ?? "",
website: result.information.website ?? "",
best_email: best?.value ?? "",
best_email_type: best?.type ?? "",
best_email_confidence: best?.confidence ?? "",
all_emails: result.emails.map((item) => item.email).join(" | "),
phones: result.phones.map((item) => item.phone).join(" | "),
socials: result.socials.map((item) => item.url).join(" | "),
people: result.people
.map((person) => [person.name, person.role].filter(Boolean).join(" — "))
.join(" | "),
source_urls: best?.source_urls.join(" | ") ?? "",
email_count: result.summary.email_count,
phone_count: result.summary.phone_count,
social_count: result.summary.social_count,
processing_ms: result.meta.processing_ms,
credits_consumed: result.meta.credits_consumed,
enrichment_error: "",
};
}
function errorRow(row, domain, error) {
return {
...row,
domain: domain ?? row.domain ?? "",
enrichment_status: domain ? "error" : "invalid_domain",
company_name: "",
legal_name: "",
website: "",
best_email: "",
best_email_type: "",
best_email_confidence: "",
all_emails: "",
phones: "",
socials: "",
people: "",
source_urls: "",
email_count: "",
phone_count: "",
social_count: "",
processing_ms: "",
credits_consumed: "",
enrichment_error: error,
};
}
const input = await readFile("domains.csv", "utf8");
const rows = parse(input, {
columns: true,
bom: true,
skip_empty_lines: true,
trim: true,
});
const limit = pLimit(CONCURRENCY);
let completed = 0;
const tasks = rows.map((row) =>
limit(async () => {
const domain = normalizeDomain(row.domain);
if (!domain) return errorRow(row, null, "invalid_domain");
try {
const result = await lookupDomain(domain);
return flattenResult(row, domain, result);
} catch (error) {
return errorRow(
row,
domain,
error instanceof Error ? error.message : String(error),
);
} finally {
completed += 1;
process.stdout.write(`\rEnriched ${completed}/${rows.length}`);
}
}),
);
const enriched = await Promise.all(tasks);
await writeFile(
"domains-enriched.csv",
stringify(enriched, { header: true }),
"utf8",
);
console.log("\nWrote domains-enriched.csv");
Run it:
node enrich-domains.mjs
Why this version is safe to rerun
The script never edits the source file. It reads domains.csv and writes a separate domains-enriched.csv, preserving every original column alongside the enrichment fields.
It also distinguishes four outcomes:
contact_found— ScoutLayer returned a workingbest_contact;no_working_email— the site was processed but no working email was found;invalid_domain— the input could not be normalized into a hostname;error— the request failed after retry handling.
That distinction matters. An empty email cell should not make you guess whether the website had no public email, the input was malformed, or the API was temporarily unavailable.
Tune concurrency instead of removing it
Five concurrent lookups is a conservative starting point for a local script. Increasing concurrency can finish a large file faster, but it also increases burst traffic and makes rate-limit responses more likely.
The retry loop only retries 429 and server-side failures. It does not retry authentication errors, invalid requests, or 402 Payment Required, because waiting will not fix those conditions. Exponential delay plus jitter prevents every failed task from retrying at the same instant.
For a scheduled production job, add checkpointing: write completed rows to a database or append-only file as they finish. Promise.all keeps the complete result set in memory, which is reasonable for a modest CSV but not for millions of rows.
Review the evidence before using the data
best_email is the easiest field to consume, but keep best_email_type, best_email_confidence, and source_urls beside it. Those fields let a reviewer distinguish a general company inbox from a person-specific address and inspect the public page behind the result.
The credits_consumed field also makes cost visible per row. ScoutLayer charges 1 credit when a working best_contact is returned and 0 when it is null, so the output file can be reconciled with usage without estimating from the input row count.
If you do not want to maintain this script, the signed-in ScoutLayer dashboard accepts up to 100 domains and exports CSV or JSON. Use the script when you need repeatable processing, custom columns, or integration with a larger data pipeline.
Create an API key, inspect the DomainResult API, or test one domain in the free Playground.