A spreadsheet of company websites is a starting point, not yet a usable B2B contact list. It rarely tells you which address appeared on the official site, whether the domain accepts mail, whether a phone number came from the site or a directory, or which rows need a second look. Copying a single address from each home page also misses the contact, support and legal pages where businesses often publish it.
This walkthrough takes a list of public company websites through Website Email Extractor, reviews the results, and exports a sheet that a sales operations person can actually inspect. It shows both the Apify Console route and an API route. The evidence is a five-site run made on October 23, 2026; because websites change, treat its yield as a sample rather than a promise for your list.
Prepare a list worth running
Start with the company's own domain, one per line. Remove directory listing pages, social profiles and obvious duplicates before you run anything. If your source is a Google Maps export, keep the place ID and website column together: you will want to join the results back to the source. This Actor reads websites from a Maps dataset, but it does not scrape Google Maps itself.
For a first batch, use 10 to 20 sites. Check that you are allowed to collect and use the published contact details for your purpose. A public address is not a promise that someone wants unsolicited mail. Shared mailboxes such as press@ or support@ are useful for routing and research; they are not automatically sales inboxes.
The core input is deliberately small:
{
"domains": [
"katzsdelicatessen.com",
"levainbakery.com",
"tartinebakery.com",
"stumptowncoffee.com",
"bluebottlecoffee.com"
],
"requiredContact": "email",
"maxPagesPerSite": 4
}
This is exactly the input of the run shown below. maxPagesPerSite caps how far the Actor looks beyond a site's home page. The default is eight pages; four is a reasonable first pass for a small sample. requiredContact: "email" makes phone-only and social-only rows free while still returning them for review. Remove it if phones or social profiles are useful outcomes for you.
Run it in Apify Console
Open the Store page, choose Try for free, and paste your sites into Websites or company names. In the JSON editor you can use the object above. Set a maximum cost in Apify's run options if this is a new source list. Start the run, then inspect the Output table and the run's SUMMARY record before exporting.
Look at three columns first: bestEmail, charged, and errorType (present on failed rows). bestEmail is the selected address, not the only address. emails is an array of address strings; emailDetails holds each address's source page and checks. charged tells you whether this website triggered the per-site event. errorType explains a free row that was blocked, unreachable or invalid. A contact form without an email may still be useful, but it does not become an email lead.
The output can be downloaded as CSV, Excel or JSON. The Output tab also offers a Leads CSV view and a Contact coverage report; the report is saved as CONTACT_REPORT in the run's key-value store. The coverage report separates sites read this run, sites with email, sites without email, failures and rows reused from an earlier dedupe store. That separation matters: a sheet with 100 input rows is not evidence of 100 reachable prospects.
Actual example output
Run ID y5Kktfd6mTAnRSuDE used the five-domain input above on October 23, 2026. It finished in about 7 seconds. Its SUMMARY record reported 5 websites read, 4 with email, 4 with a phone number, 5 with at least one social profile, 0 failures, and 4 charged websites. One site had nothing billable under requiredContact: "email" and was returned free.
| Input | bestEmail |
Emails found | Pages checked | charged |
|---|---|---|---|---|
| katzsdelicatessen.com | press@katzsdelicatessen.com | 1 | 4 | true |
| levainbakery.com | media@levainbakery.com | 1 | 4 | true |
| tartinebakery.com | 9th.ave@tartinebakery.com | 18 | 3 | true |
| stumptowncoffee.com | info@stumptowncoffee.com | 1 | 4 | true |
| bluebottlecoffee.com | (none) | 0 | 4 | false |
Here is the Levain Bakery row from that dataset, reduced to the fields this workflow uses (its long phone list and the pagesChecked details are omitted):
{
"input": "levainbakery.com",
"finalUrl": "https://levainbakery.com/",
"organizationName": "Levain Bakery",
"bestEmail": "media@levainbakery.com",
"bestEmailType": "role_company",
"bestEmailHasMx": true,
"emails": ["media@levainbakery.com"],
"primaryPhone": "+18779322161",
"contactFormUrl": "https://levainbakery.com/pages/get-in-touch",
"emailDetails": [
{"email": "media@levainbakery.com", "page": "https://levainbakery.com/pages/get-in-touch",
"method": "mailto", "sameDomain": true, "syntaxValid": true, "hasMx": true, "category": "role_company"}
],
"charged": true
}
The address came from the Get in touch page, not the home page, which is why reading a few contact pages per site is worth it. It is a media mailbox: useful for routing, not proof of a buyer's inbox.
The other rows show why the review step matters. Tartine's locations page produced 18 addresses, including shop mailboxes on separate Korean and manufactory domains, and the selected bestEmail was one shop's address even though info@tartinebakery.com was also found. For an account-level sheet you would probably pick the general address by hand. Blue Bottle returned four social profiles and a support help-center page but no email, so with requiredContact: "email" its row cost nothing and stays visible as "no email found".
hasMx: true says that a domain publishes mail servers. It does not prove that a particular mailbox exists or will accept your message. Four out of five is a property of this small, hand-picked sample, not an accuracy figure, and it tells you nothing about reply rates. Keep the missing-email rows in your sheet instead of quietly dropping them from your denominator.
Run the same job through the API
Apify's API uses the same Actor and JSON input. Keep your token in an environment variable; do not put it in a published script or a shared URL. This Python example starts a run, waits for its status, and downloads its dataset as JSON. It uses the same input as the run above, which was started through the same API endpoint.
import os
import time
import requests
api = "https://api.apify.com/v2"
headers = {"Authorization": f"Bearer {os.environ['APIFY_TOKEN']}"}
actor = "tidytools~website-contact-extractor"
payload = {
"domains": ["katzsdelicatessen.com", "levainbakery.com", "tartinebakery.com",
"stumptowncoffee.com", "bluebottlecoffee.com"],
"requiredContact": "email",
"maxPagesPerSite": 4,
}
response = requests.post(
f"{api}/acts/{actor}/runs",
headers=headers, json=payload, timeout=30,
)
response.raise_for_status()
run = response.json()["data"]
while run["status"] in ("READY", "RUNNING"):
time.sleep(5)
response = requests.get(f"{api}/actor-runs/{run['id']}", headers=headers, timeout=30)
response.raise_for_status()
run = response.json()["data"]
if run["status"] != "SUCCEEDED":
raise RuntimeError(f"Run {run['id']} ended as {run['status']}")
response = requests.get(
f"{api}/datasets/{run['defaultDatasetId']}/items",
headers=headers, params={"format": "json", "clean": "true"}, timeout=30,
)
response.raise_for_status()
rows = response.json()
for row in rows:
print(row.get("domain"), row.get("bestEmail"), row.get("charged"), row.get("errorType"))
For production use, save the run ID before waiting so a network interruption does not make you start and pay for the whole batch again. Fetch the dataset for that ID when the connection returns. Set Apify's maximum charge per run as another guard, and test your field mapping on a small batch before sending thousands of domains.
Turn rows into a reviewable sheet
Export one row per website first. Keep the input domain, normalized website, business name, bestEmail, all email addresses, source page, phone, social links, charged, and errorType. If your CRM needs one row per address, expand the emails array in a separate step while retaining the website key. Do not mistake 21 addresses for 21 companies: the example run covered 5 sites, of which 4 had email, and 18 of those addresses came from one bakery chain.
Review role and personal addresses separately. Check the source URL when an address looks generic or unrelated to the company. Deduplicate on normalized company website before importing to a CRM, and make it easy to remove or refresh stale contacts. Domain-level MX checking helps catch obvious dead domains but does not confirm individual delivery. If you need mailbox-level evidence, use a process designed for that and describe what it actually checks.
This workflow works especially well as a recurring enrichment step for account lists that already have official websites. It is less helpful when your starting point is only a company name with no location: a name can match several businesses. Add city or address data before asking the Actor to resolve a name, and manually inspect any low-confidence matches.
Pricing explanation
The current site-processed price in store_meta.py is $0.002 per charged website on Apify Free and Bronze plans: $2 per 1,000. Silver is $0.0018 and Gold or higher is $0.0016. There is no start event. With the default requiredContact: "any", a site is charged if at least one email, phone or social profile is found. With "email", it is charged only if an email is found. Unreachable, invalid, duplicate and no-contact rows are free.
Apify recorded site-processed: 4 for run y5Kktfd6mTAnRSuDE, so the run cost 4 × $0.002 = $0.008 at the Free/Bronze price; the Blue Bottle row was free. Had the same input used the default "any", Blue Bottle's social profiles would have made it billable: 5 × $0.002 = $0.010. That second number is a calculation, not a measured charge. A list of 1,000 sites costs at most $2.00 at Free/Bronze prices, and less when some sites have no email, so set a maximum charge before a large batch and check the Pricing tab.
If you have a domain list, try Website Email Extractor on Apify with 10 sites first. For the wider set of contact, validation and company-data options, see the TidyTools company-data directory. I built this Actor; the run above is an example of its output, not a claim that every list will produce the same yield.
Top comments (0)