Keencrest guides

Find emails from a website list in Google Sheets (Apps Script)

By Keencrest · 2026-11-01

Ready-made template: Apps Script file (Google Sheets domains in, emails written back, joined on domain). All three variants are on the templates page.

No add-on is needed. A short Apps Script reads the websites in column A, sends them in one request and writes the email and a status back. We ran this exact function, with Apps Script's UrlFetchApp and SpreadsheetApp stood in for by a small test harness, against the live Actor.

Use your own token. Create an API token in Apify under Settings, Integrations, and put it in the tool's secret or credential store. Never paste it into a shared sheet or a public workflow. The examples say YOUR_APIFY_TOKEN; replace it. Sending the token in the Authorization: Bearer header is safer than putting it in the URL, because URLs end up in logs.

Set up

  1. In your sheet put a header in row 1 and the websites from row 2 down in column A. Columns B and C will receive the email and status.
  2. Open Extensions, Apps Script. In Project Settings, Script properties, add a property named APIFY_TOKEN with your token. This keeps the token out of the sheet and the code.
  3. Paste the script, save, run enrichEmails once and approve the permissions (it needs the spreadsheet and external requests).

The script

function enrichEmails() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const token = PropertiesService.getScriptProperties().getProperty("APIFY_TOKEN");
  const last = sheet.getLastRow();
  const sites = sheet.getRange(2, 1, last - 1, 1).getValues()
    .map(r => String(r[0]).trim()).filter(String);
  const url = "https://api.apify.com/v2/acts/datagleaner~website-contact-details-scraper/run-sync-get-dataset-items" +
    "?maxTotalChargeUsd=1&fields=domain,bestEmail,status";
  const res = UrlFetchApp.fetch(url, {
    method: "post",
    contentType: "application/json",
    headers: { Authorization: "Bearer " + token },
    payload: JSON.stringify({ websites: sites, maxPagesPerSite: 4 }),
    muteHttpExceptions: true
  });
  if (res.getResponseCode() !== 201) throw new Error(res.getResponseCode() + " " + res.getContentText());
  const byDomain = {};
  JSON.parse(res.getContentText()).forEach(i => byDomain[i.domain] = i);
  const out = sites.map(s => {
    const d = s.replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0];
    const i = byDomain[d] || {};
    return [i.bestEmail || "", i.status || "missing"];
  });
  sheet.getRange(2, 2, out.length, 2).setValues(out);
}

What it wrote

For column A containing basecamp.com, https://mozilla.org and sitemaps.org, the script wrote:

B (email)C (status)
jason@basecamp.comok
trademark-permissions@mozilla.comok
noContacts

The success code is 201, not 200, which is why the check tests for 201. Rows are matched by domain because the response order does not follow the input order.

Limits

What comes back

A JSON array with one object per site. Output order is not guaranteed to match input order (in our test, sitemaps.org came back before mozilla.org), so always match rows on domain, never on position. A real response for three sites, with fields=domain,bestEmail,status,failureReason:

[
  {"domain": "basecamp.com",  "bestEmail": "jason@basecamp.com", "status": "ok", "failureReason": null},
  {"domain": "sitemaps.org",  "bestEmail": null, "status": "noContacts", "failureReason": "no_contact_found"},
  {"domain": "mozilla.org",   "bestEmail": "trademark-permissions@mozilla.com", "status": "ok", "failureReason": null}
]

Without fields each object is large (around 60 keys: every email with the page it was found on and a kind, phones, 14 social networks, contact-form URLs, pages crawled). Ask only for what you need. A site that publishes no email still returns a row, with bestEmail: null. Junk input such as "not a site" returns a row with status: "invalidUrl" instead of failing the call.

What it costs

The Actor charges $0.002 for each site where an email is found and nothing for sites without one. 1,000 sites with 600 hits is about $1.20, and a 4-site test is under a cent. Apify's free plan includes monthly credit. The maxTotalChargeUsd query parameter in the examples is a hard cap on what one call can spend: the run stops when it is reached, so a typo cannot cost more than you set. Expect an email on roughly half to two-thirds of small-business sites and less on large brands; in our five-site test three sites gave one.

Try it: Website Contact Details Scraper on Apify. $0.002 per site with an email, nothing for sites without. The Apify free plan includes monthly credit, so a test run costs nothing out of pocket.

Related guides

All Keencrest guides